Agent 2.0 exdev 서버 배포 스택

- server-dev start/stop/deploy 및 Gitea push 자동 배포
- local-dev 로컬 개발 환경

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Macbook
2026-07-21 22:57:30 +09:00
commit 4b86b2a660
344 changed files with 45787 additions and 0 deletions
@@ -0,0 +1,33 @@
"""
RAG 챗봇 핸들러 모듈
──────────────────
기능별로 분리된 핸들러 컴포넌트
"""
from .config import Config
from .query_rewriter import QueryRewriter
from .search_handler import SearchHandler
from .prompt_builder import PromptBuilder
from .llm_handler import LLMHandler
from .response_handler import ResponseHandler
from .intent_detector import IntentDetector, Intent
from .greeting_handler import GreetingHandler
from .emotion_detector import EmotionDetector, Emotion
from .emotion_handler import EmotionHandler
from .suggestion_handler import SuggestionHandler
__all__ = [
"Config",
"QueryRewriter",
"SearchHandler",
"PromptBuilder",
"LLMHandler",
"ResponseHandler",
"IntentDetector",
"Intent",
"GreetingHandler",
"EmotionDetector",
"Emotion",
"EmotionHandler",
"SuggestionHandler"
]
@@ -0,0 +1,92 @@
"""
설정 관리 모듈
────────────
환경 변수 기반 설정 관리
"""
import os
from dataclasses import dataclass
from typing import Optional
@dataclass
class Config:
"""RAG 시스템 설정"""
# 벡터 검색 설정
top_k: int
threshold: float
threshold_rewrite: float
hybrid_search_enabled: bool
sparse_top_k: int
hybrid_merge_top_k: int
# 재랭킹 설정
rerank_candidates: int
rerank_batch_size: int
top_n_for_llm: int
# 리랭커 1위 점수 미만 → 제안 문구·Full Context Rewriting (Qwen3-Reranker sigmoid 0~1)
low_confidence_threshold: float
# low 이상이면 medium, 이상이면 high (MongoDB answer_confidence)
high_confidence_threshold: float
# LLM 설정
llm_max_tokens: int
# Query Rewriting 설정
query_rewrite_enabled: bool
# 대화 이력 설정
chat_history_enabled: bool
chat_history_limit: int
chat_history_hours: int
chat_history_always_include: bool
@classmethod
def from_env(cls, chat_history_enabled: bool = False) -> "Config":
"""환경 변수에서 설정 로드"""
return cls(
# 벡터 검색
top_k=int(os.getenv("FAISS_TOP_K", "30")),
threshold=float(os.getenv("FAISS_THRESHOLD", "0.55")),
threshold_rewrite=float(os.getenv("FAISS_THRESHOLD_REWRITE", "0.50")),
hybrid_search_enabled=os.getenv("HYBRID_SEARCH_ENABLED", "false").lower() == "true",
sparse_top_k=int(os.getenv("SPARSE_TOP_K", "30")),
hybrid_merge_top_k=int(os.getenv("HYBRID_MERGE_TOP_K", "40")),
# 재랭킹
rerank_candidates=int(os.getenv("RERANK_CANDIDATES", "20")),
rerank_batch_size=int(os.getenv("RERANK_BATCH_SIZE", "16")),
top_n_for_llm=int(os.getenv("TOP_N_FOR_LLM", "5")),
low_confidence_threshold=float(os.getenv("LOW_CONFIDENCE_THRESHOLD", "0.65")),
high_confidence_threshold=float(os.getenv("HIGH_CONFIDENCE_THRESHOLD", "0.75")),
# LLM
llm_max_tokens=int(os.getenv("LLM_MAX_TOKENS", "2048")),
# Query Rewriting
query_rewrite_enabled=os.getenv("QUERY_REWRITE_ENABLED", "true").lower() == "true",
# 대화 이력
chat_history_enabled=chat_history_enabled,
chat_history_limit=int(os.getenv("CHAT_HISTORY_LIMIT", "10")),
chat_history_hours=int(os.getenv("CHAT_HISTORY_HOURS", "24")),
chat_history_always_include=os.getenv("CHAT_HISTORY_ALWAYS_INCLUDE", "true").lower() == "true"
)
def print_summary(self):
"""설정 요약 출력"""
print(f"[Config] Query Rewriting: {'활성화' if self.query_rewrite_enabled else '비활성화'}")
print(f"[Config] FAISS Threshold: 1차={self.threshold}, 2차(Rewrite)={self.threshold_rewrite}")
print(
f"[Config] Hybrid Search: {'활성화' if self.hybrid_search_enabled else '비활성화'} "
f"(sparse_top_k={self.sparse_top_k}, merge_top_k={self.hybrid_merge_top_k})"
)
print(
f"[Config] Reranker 신뢰도: low<{self.low_confidence_threshold}, "
f"high>={self.high_confidence_threshold}"
)
print(f"[Config] 대화 이력: {'활성화' if self.chat_history_enabled else '비활성화'}")
if self.chat_history_enabled:
print(f"[Config] - 최근 {self.chat_history_limit}개 / {self.chat_history_hours}시간")
print(f"[Config] - 정상 질문 포함: {'예' if self.chat_history_always_include else '아니오'}")
@@ -0,0 +1,169 @@
"""
감정 분석 모듈
──────────────
사용자 질문의 감정 상태를 세밀하게 분석
"""
from typing import Dict, List, Optional
from dataclasses import dataclass
@dataclass
class Emotion:
"""감정 분석 결과"""
primary: str # 주요 감정: angry, frustrated, satisfied, confused, worried, neutral
intensity: float # 강도: 0.0 (약함) ~ 1.0 (강함)
confidence: float # 신뢰도: 0.0 ~ 1.0
matched_keywords: List[str] # 매칭된 키워드
def needs_empathy(self) -> bool:
"""공감이 필요한 감정인지 (부정적 감정)"""
return self.primary in ["angry", "frustrated", "worried"]
def is_positive(self) -> bool:
"""긍정적 감정인지"""
return self.primary == "satisfied"
def needs_clarification(self) -> bool:
"""명확한 설명이 필요한지 (혼란)"""
return self.primary == "confused"
class EmotionDetector:
"""사용자 감정 분석기"""
# 감정별 키워드 및 강도
EMOTION_PATTERNS = {
"angry": {
"keywords": {
# (키워드, 강도)
"화나": 0.9, "화남": 0.9, "분노": 1.0, "열받": 0.9,
"짜증": 0.7, "짜증나": 0.8, "짜증남": 0.8,
"불만": 0.6, "불쾌": 0.7, "기분 나쁘": 0.6,
"최악": 0.9, "엉망": 0.7, "터무니": 0.8,
"너무해": 0.8, "심각": 0.7, "문제": 0.5,
"불친절": 0.7, "무례": 0.8, "실망": 0.6
},
"description": "화남/분노"
},
"frustrated": {
"keywords": {
"답답": 0.8, "막막": 0.7, "곤란": 0.6,
"어렵": 0.5, "힘들": 0.6, "난감": 0.7,
"복잡": 0.5, "이해가 안": 0.6, "잘 모르": 0.5,
"왜 안": 0.6, "계속": 0.4, "여전": 0.5,
"해결이 안": 0.7, "안 되": 0.6
},
"description": "답답함/막막함"
},
"satisfied": {
"keywords": {
"감사": 0.8, "고마워": 0.8, "고맙": 0.8,
"좋": 0.7, "훌륭": 0.9, "최고": 0.9,
"도움": 0.7, "해결": 0.8, "완벽": 0.9,
"잘": 0.6, "쉽": 0.6, "편리": 0.7,
"만족": 0.9, "훌륭": 0.9
},
"description": "만족/긍정"
},
"confused": {
"keywords": {
"모르겠": 0.8, "헷갈": 0.9, "혼란": 0.9,
"무슨": 0.6, "뭐가": 0.6,
"이해가": 0.6, "뜻이": 0.5, "의미": 0.5,
"차이": 0.5, "구별": 0.6, "잘 안": 0.6
},
"description": "혼란/이해 부족"
},
"worried": {
"keywords": {
"걱정": 0.8, "불안": 0.9, "염려": 0.7,
"겁나": 0.8, "무섭": 0.7, "두렵": 0.7,
"조심": 0.5, "주의": 0.5, "위험": 0.6,
"문제가": 0.6, "괜찮": 0.5
},
"description": "걱정/불안"
}
}
def detect(self, query: str, ts: Optional[str] = None) -> Emotion:
"""감정 분석
Args:
query: 사용자 질문
ts: 타임스탬프 (로깅용)
Returns:
Emotion 객체
"""
query_lower = query.lower().strip()
# 빈 문자열 체크
if not query_lower:
return Emotion(
primary="neutral",
intensity=0.0,
confidence=1.0,
matched_keywords=[]
)
# 각 감정별 점수 계산
emotion_scores = {}
for emotion_name, config in self.EMOTION_PATTERNS.items():
matched = []
total_intensity = 0.0
for keyword, intensity in config["keywords"].items():
if keyword in query_lower:
matched.append(keyword)
total_intensity += intensity
if matched:
# 평균 강도 계산
avg_intensity = total_intensity / len(matched)
# 매칭 개수와 강도를 결합한 점수
score = len(matched) * avg_intensity
emotion_scores[emotion_name] = {
"score": score,
"intensity": min(1.0, avg_intensity),
"matched": matched
}
# 매칭된 감정이 없으면 중립
if not emotion_scores:
return Emotion(
primary="neutral",
intensity=0.0,
confidence=1.0,
matched_keywords=[]
)
# 가장 높은 점수의 감정 선택
best_emotion = max(emotion_scores.items(), key=lambda x: x[1]["score"])
emotion_name = best_emotion[0]
emotion_data = best_emotion[1]
# 신뢰도 계산 (매칭 개수와 강도 기반)
confidence = min(0.95, 0.6 + (emotion_data["score"] * 0.1))
result = Emotion(
primary=emotion_name,
intensity=emotion_data["intensity"],
confidence=confidence,
matched_keywords=emotion_data["matched"]
)
if ts:
print(f"[EmotionDetector] {ts} 감정 분석: {result.primary} "
f"(강도: {result.intensity:.2f}, 신뢰도: {result.confidence:.2f}, "
f"키워드: {result.matched_keywords})")
return result
def get_emotion_summary(self, emotion: Emotion) -> str:
"""감정 요약 문자열"""
description = self.EMOTION_PATTERNS.get(emotion.primary, {}).get("description", emotion.primary)
intensity_label = "강함" if emotion.intensity > 0.7 else "중간" if emotion.intensity > 0.4 else "약함"
return f"{description} ({intensity_label})"
@@ -0,0 +1,171 @@
"""
감정별 공감 응답 핸들러
──────────────────────
감정에 따른 공감 메시지 및 프롬프트 커스터마이징
"""
from typing import Optional
class EmotionHandler:
"""감정별 공감 응답 생성기"""
# 감정별 공감 프리픽스
EMPATHY_PREFIXES = {
"angry": [
"고객님의 불편한 경험에 대해 진심으로 사과드립니다. 😔",
"불편을 드려 정말 죄송합니다. 😔",
"고객님의 화나신 마음을 충분히 이해합니다. 😔"
],
"frustrated": [
"답답하셨겠습니다. 😓",
"막막하셨을 것 같습니다. 😓",
"불편하셨겠네요. 저희가 더 명확히 안내드리겠습니다. 😓"
],
"satisfied": [
"도움이 되셨다니 정말 기쁩니다! 😊",
"만족하셨다니 다행입니다! 😊",
"고객님께 도움을 드릴 수 있어 기쁩니다! 😊"
],
"confused": [
"이해하기 어려우셨군요. 제가 더 명확히 설명드리겠습니다. 🤔",
"헷갈리셨을 것 같네요. 차근차근 설명드리겠습니다. 🤔",
"복잡하게 느껴지셨나봅니다. 쉽게 풀어서 설명드릴게요. 🤔"
],
"worried": [
"걱정되시는 부분이 있으시군요. 안심하셔도 됩니다. 😌",
"염려하지 않으셔도 괜찮습니다. 자세히 안내드릴게요. 😌",
"불안하셨겠습니다. 정확한 정보를 드리겠습니다. 😌"
],
"neutral": []
}
# 감정별 시스템 프롬프트 추가 지시사항
EMOTION_INSTRUCTIONS = {
"angry": (
"\n【감정 고려사항 - 화난 고객】\n"
"⚠️ 고객이 매우 화가 난 상태입니다:\n"
"1. 먼저 진심으로 사과하세요.\n"
"2. 고객의 감정을 인정하고 공감하세요.\n"
"3. 구체적인 해결책을 빠르게 제시하세요.\n"
"4. 추가 불편을 드리지 않도록 명확하고 간결하게 답변하세요.\n"
"5. 필요시 상담원 연결이나 콜센터 안내를 제안하세요."
),
"frustrated": (
"\n【감정 고려사항 - 답답한 고객】\n"
"💡 고객이 문제 해결에 어려움을 겪고 있습니다:\n"
"1. 고객의 답답함에 공감하세요.\n"
"2. 복잡한 설명보다는 단계별로 쉽게 설명하세요.\n"
"3. 즉시 실행 가능한 해결 방법을 제시하세요.\n"
"4. 추가 도움이 필요한지 물어보세요."
),
"satisfied": (
"\n【감정 고려사항 - 만족한 고객】\n"
"😊 고객이 긍정적인 상태입니다:\n"
"1. 긍정적인 톤을 유지하세요.\n"
"2. 추가로 도움이 될 만한 정보를 제안하세요.\n"
"3. 다른 질문이 있는지 자연스럽게 물어보세요."
),
"confused": (
"\n【감정 고려사항 - 혼란스러운 고객】\n"
"🤔 고객이 개념 이해에 어려움을 겪고 있습니다:\n"
"1. 전문 용어를 피하고 쉬운 말로 설명하세요.\n"
"2. 예시를 들어 설명하세요.\n"
"3. 단계를 나눠서 차근차근 설명하세요.\n"
"4. 추가 질문을 환영하는 분위기를 만드세요."
),
"worried": (
"\n【감정 고려사항 - 걱정하는 고객】\n"
"😌 고객이 불안해하거나 걱정하고 있습니다:\n"
"1. 먼저 안심시키세요.\n"
"2. 정확하고 신뢰할 수 있는 정보를 제공하세요.\n"
"3. 예방 방법이나 주의사항을 함께 안내하세요.\n"
"4. 문제가 없음을 명확히 전달하세요."
),
"neutral": ""
}
def get_empathy_prefix(self, emotion_name: str, intensity: float) -> Optional[str]:
"""감정에 맞는 공감 프리픽스 반환
Args:
emotion_name: 감정 이름
intensity: 감정 강도 (0.0 ~ 1.0)
Returns:
공감 메시지 또는 None
"""
prefixes = self.EMPATHY_PREFIXES.get(emotion_name, [])
if not prefixes:
return None
# 강도에 따라 선택
if intensity > 0.7:
# 강한 감정 → 첫 번째 (가장 강한 공감)
return prefixes[0]
elif intensity > 0.4:
# 중간 감정 → 두 번째
return prefixes[min(1, len(prefixes) - 1)]
else:
# 약한 감정 → 마지막 (가장 약한 공감)
return prefixes[-1]
def get_emotion_instruction(self, emotion_name: str) -> str:
"""감정별 시스템 프롬프트 추가 지시사항"""
return self.EMOTION_INSTRUCTIONS.get(emotion_name, "")
def should_add_empathy(self, emotion_name: str, intensity: float, confidence: float) -> bool:
"""공감 메시지를 추가해야 하는지 판단
Args:
emotion_name: 감정 이름
intensity: 감정 강도
confidence: 신뢰도
Returns:
공감 메시지 추가 여부
"""
# 중립 감정은 공감 불필요
if emotion_name == "neutral":
return False
# 신뢰도가 낮으면 공감 추가 안함
if confidence < 0.6:
return False
# 부정적 감정 (angry, frustrated, worried)은 강도 상관없이 공감
if emotion_name in ["angry", "frustrated", "worried"]:
return True
# 긍정적/혼란 감정은 강도가 충분히 높을 때만
return intensity > 0.5
def enhance_answer_with_empathy(
self,
answer: str,
emotion_name: str,
intensity: float,
confidence: float
) -> str:
"""답변에 공감 메시지 추가
Args:
answer: 원본 답변
emotion_name: 감정 이름
intensity: 감정 강도
confidence: 신뢰도
Returns:
공감 메시지가 추가된 답변
"""
if not self.should_add_empathy(emotion_name, intensity, confidence):
return answer
empathy_prefix = self.get_empathy_prefix(emotion_name, intensity)
if not empathy_prefix:
return answer
# 공감 메시지를 답변 앞에 추가
return f"{empathy_prefix}\n\n{answer}"
@@ -0,0 +1,132 @@
"""
인사/종료 응답 핸들러
──────────────────────
인사, 종료 의도에 대한 특별 응답 생성
"""
from typing import List, Dict, Any, Optional
class GreetingHandler:
"""인사 및 종료 응답 생성기"""
# 퀵 리플라이 템플릿
GREETING_QUICK_REPLIES = [
"통행료 조회",
"하이패스 문의",
"환불 신청",
"휴게소 안내"
]
FAREWELL_QUICK_REPLIES = [
"추가 문의하기",
"처음으로",
"상담 종료"
]
def generate_greeting_response(
self,
query: str,
matched_keywords: List[str],
bot_id: Optional[str] = None
) -> Dict[str, Any]:
"""인사 응답 생성
Args:
query: 원본 질문
matched_keywords: 매칭된 키워드
bot_id: 봇 ID
Returns:
응답 딕셔너리 (answer, quick_replies 포함)
"""
answer = (
"안녕하세요! 한국도로공사 채팅상담 챗봇입니다. 😊\n\n"
"고속도로 이용과 관련하여 궁금하신 점을 편하게 물어보세요.\n\n"
"📌 자주 묻는 질문\n"
"• 통행료 조회 및 환불\n"
"• 하이패스 발급 및 사용법\n"
"• 휴게소 및 편의시설 안내\n"
"• 고속도로 소음/환경 민원\n\n"
"무엇을 도와드릴까요?"
)
return {
"answer": answer,
"matched_questions": [],
"scores": [],
"num_references": 0,
"botId": bot_id,
"intent": "greeting",
"quick_replies": self.GREETING_QUICK_REPLIES,
"rerank_info": {
"used": False,
"detail": "Greeting intent detected"
}
}
def generate_farewell_response(
self,
query: str,
matched_keywords: List[str],
bot_id: Optional[str] = None
) -> Dict[str, Any]:
"""종료 인사 응답 생성"""
# 감사 표현이 있는지 확인
thanks_keywords = ["감사", "고마워", "고맙", "도움"]
has_thanks = any(kw in query for kw in thanks_keywords)
if has_thanks:
answer = (
"도움이 되셨다니 기쁩니다! 😊\n\n"
"한국도로공사를 이용해 주셔서 감사합니다.\n"
"궁금하신 점이 더 있으시면 언제든지 다시 찾아주세요.\n\n"
"안전운전 하세요! 🚗"
)
else:
answer = (
"상담을 종료하시겠습니까?\n\n"
"추가로 궁금하신 사항이 있으시면\n"
"언제든지 질문해 주세요.\n\n"
"한국도로공사를 이용해 주셔서 감사합니다. 😊"
)
return {
"answer": answer,
"matched_questions": [],
"scores": [],
"num_references": 0,
"botId": bot_id,
"intent": "farewell",
"quick_replies": self.FAREWELL_QUICK_REPLIES,
"rerank_info": {
"used": False,
"detail": "Farewell intent detected"
}
}
def generate_response(
self,
intent_name: str,
query: str,
matched_keywords: List[str],
bot_id: Optional[str] = None
) -> Dict[str, Any]:
"""의도에 따른 응답 생성 (greeting, farewell만)
Args:
intent_name: "greeting" 또는 "farewell"
query: 원본 질문
matched_keywords: 매칭된 키워드
bot_id: 봇 ID
Returns:
응답 딕셔너리
"""
if intent_name == "greeting":
return self.generate_greeting_response(query, matched_keywords, bot_id)
elif intent_name == "farewell":
return self.generate_farewell_response(query, matched_keywords, bot_id)
else:
# complaint는 더 이상 여기서 처리하지 않음 (RAG 파이프라인으로)
raise ValueError(f"Unknown intent: {intent_name}")
@@ -0,0 +1,115 @@
"""
의도 감지 모듈
──────────────
사용자 질문의 의도를 분류 (인사, 종료, 불만, 일반 등)
"""
from typing import Dict, List, Optional
from dataclasses import dataclass
@dataclass
class Intent:
"""의도 분류 결과"""
name: str # "greeting", "farewell", "general"
confidence: float # 0.0 ~ 1.0
matched_keywords: List[str] # 매칭된 키워드
def is_special(self) -> bool:
"""특별 처리가 필요한 의도인지 (인사, 종료만)"""
return self.name in ["greeting", "farewell"]
class IntentDetector:
"""사용자 의도 감지기"""
# 의도별 키워드 패턴
INTENT_PATTERNS = {
"greeting": {
"keywords": [
"안녕", "안녕하세요", "안녕하십니까",
"처음", "반가", "반갑습니다",
"hi", "hello", "hey",
"처음 뵙겠습니다", "처음입니다"
],
"priority": 1 # 우선순위 (낮을수록 높음)
},
"farewell": {
"keywords": [
"감사", "고마워", "고맙습니다", "감사합니다",
"잘됐", "해결", "알겠", "알았",
"끝", "종료", "그만",
"bye", "goodbye", "끝내", "닫기",
"도움 됐", "도움됐", "충분"
],
"priority": 2
}
# complaint 제거: 감정 분석(EmotionDetector)으로만 처리
}
def detect(self, query: str, ts: Optional[str] = None) -> Intent:
"""의도 감지
Args:
query: 사용자 질문
ts: 타임스탬프 (로깅용)
Returns:
Intent 객체
"""
query_lower = query.lower().strip()
# 빈 문자열 체크
if not query_lower:
return Intent(name="general", confidence=1.0, matched_keywords=[])
# 각 의도별 매칭 점수 계산
intent_scores = {}
for intent_name, config in self.INTENT_PATTERNS.items():
matched = []
for keyword in config["keywords"]:
if keyword.lower() in query_lower:
matched.append(keyword)
if matched:
# 매칭된 키워드 개수와 우선순위를 고려한 점수
score = len(matched) * (1.0 / config["priority"])
intent_scores[intent_name] = {
"score": score,
"matched": matched,
"priority": config["priority"]
}
# 매칭된 의도가 없으면 일반 질문
if not intent_scores:
return Intent(name="general", confidence=1.0, matched_keywords=[])
# 가장 높은 점수의 의도 선택
best_intent = max(intent_scores.items(), key=lambda x: x[1]["score"])
intent_name = best_intent[0]
intent_data = best_intent[1]
# 신뢰도 계산 (0.7 ~ 0.95)
confidence = min(0.95, 0.7 + (intent_data["score"] * 0.1))
result = Intent(
name=intent_name,
confidence=confidence,
matched_keywords=intent_data["matched"]
)
if ts:
print(f"[IntentDetector] {ts} 의도 감지: {result.name} (신뢰도: {result.confidence:.2f}, 키워드: {result.matched_keywords})")
return result
def is_greeting(self, query: str) -> bool:
"""인사 여부 판단 (간단한 헬퍼)"""
intent = self.detect(query)
return intent.name == "greeting"
def is_farewell(self, query: str) -> bool:
"""종료 인사 여부 판단"""
intent = self.detect(query)
return intent.name == "farewell"
@@ -0,0 +1,132 @@
"""
LLM 핸들러 모듈
─────────────
LLM 답변 생성 및 후처리
"""
import re
from typing import Optional, List, Dict
class LLMHandler:
"""LLM 답변 생성 핸들러"""
def __init__(self, llm_client, config):
self.llm_client = llm_client
self.config = config
def generate_answer(
self,
system_prompt: str,
user_prompt: str,
ts: str,
max_tokens: Optional[int] = None,
temperature: float = 0.3
) -> str:
"""LLM 답변 생성 (기존 방식 - 하위 호환성)
Args:
system_prompt: 시스템 프롬프트
user_prompt: 사용자 프롬프트
ts: 타임스탬프 (로깅용)
max_tokens: 최대 토큰 수 (기본값: config에서 가져옴)
temperature: 온도 파라미터
Returns:
생성된 답변 텍스트
"""
if max_tokens is None:
max_tokens = self.config.llm_max_tokens
try:
print(f"[LLMHandler] {ts} LLM 답변 생성 중... (max_tokens={max_tokens})")
response = self.llm_client.chat_completion(
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt}
],
max_tokens=max_tokens,
temperature=temperature
)
# <think> 태그 제거
formatted = self._remove_think_tags(response)
print(f"[LLMHandler] {ts} LLM 답변 생성 완료 (길이: {len(formatted)}자)")
return formatted
except Exception as e:
print(f"[LLMHandler] {ts} ❌ LLM 답변 생성 실패: {e}")
raise
def generate_answer_from_messages(
self,
messages: List[Dict[str, str]],
ts: str,
max_tokens: Optional[int] = None,
temperature: float = 0.3
) -> str:
"""LLM 답변 생성 (Messages Format - 표준 방식)
Args:
messages: [{"role": "system|user|assistant", "content": "..."}]
ts: 타임스탬프 (로깅용)
max_tokens: 최대 토큰 수
temperature: 온도 파라미터
Returns:
생성된 답변 텍스트
"""
if max_tokens is None:
max_tokens = self.config.llm_max_tokens
try:
print(f"[LLMHandler] {ts} LLM 답변 생성 중... (messages={len(messages)}개, max_tokens={max_tokens})")
response = self.llm_client.chat_completion(
messages=messages,
max_tokens=max_tokens,
temperature=temperature
)
# <think> 태그 제거
formatted = self._remove_think_tags(response)
print(f"[LLMHandler] {ts} LLM 답변 생성 완료 (길이: {len(formatted)}자)")
return formatted
except Exception as e:
print(f"[LLMHandler] {ts} ❌ LLM 답변 생성 실패: {e}")
raise
def generate_fallback_answer(self, best_match: dict) -> str:
"""폴백 답변 생성 (LLM 실패 시)
Args:
best_match: 최상위 매칭 결과
Returns:
폴백 답변
"""
return (
"고객님의 질문은 다음과 같다고 생각됩니다.\n\n"
f"{best_match['q']}\n\n"
"이에 대한 답변을 드리겠습니다.\n\n"
f"{best_match['a']}"
)
def generate_default_guidance(self) -> str:
"""기본 질문 유도 답변 (LLM 실패 시)"""
return (
"죄송합니다. 고객님의 질문과 관련된 정보를 찾을 수 없습니다.\n\n"
"한국도로공사는 고속도로 이용과 관련된 상담을 제공하고 있습니다.\n"
"통행료, Hi-pass, 환불, 소음 민원, 시설물 이용 등에 대해 궁금하신 사항이 있으시다면 질문해 주세요.\n\n"
"또는 한국도로공사 콜센터(1588-2504)로 문의하시면 자세한 안내를 받으실 수 있습니다."
)
def _remove_think_tags(self, text: str) -> str:
"""<think> 태그 제거"""
if "<think>" in text and "</think>" in text:
return re.sub(r'<think>.*?</think>\s*', '', text, flags=re.DOTALL).strip()
return text
@@ -0,0 +1,399 @@
"""
프롬프트 빌더 모듈
────────────────
LLM 프롬프트 생성 로직
"""
from typing import List, Dict, Any, Optional
class PromptBuilder:
"""LLM 프롬프트 생성기"""
# 시스템 프롬프트 (일반 답변)
SYSTEM_PROMPT = (
"당신은 한국도로공사 채팅상담 챗봇입니다.\n"
"한국도로공사의 고객 문의에 답변하는 전문 상담원으로서 행동하세요.\n"
"\n"
"【대화 이력 활용】\n"
"1. 이전 대화는 현재 질문에 지시어가 있거나 주제가 명확히 이어질 때만 참고하세요.\n"
"2. '그럼', '그거', '그건', '아까', '방금', '그때' 등의 지시어가 있다면:\n"
" - 이전 대화에서 언급된 주제를 파악하세요.\n"
" - 해당 주제와 현재 질문을 연결하여 답변하세요.\n"
"3. 이전 대화와 현재 질문이 같은 주제라면 자연스럽게 이어서 답변하세요.\n"
"4. 현재 질문이 인사, 감사, 종료, 새 주제라면 이전 대화와 억지로 연결하지 말고 현재 질문만 기준으로 답변하세요.\n"
"5. 이전 대화가 없거나 무관하다면 현재 질문만 기준으로 답변하세요.\n"
"\n"
"【답변 작성 원칙】\n"
"1. 제공된 참고자료 중 고객의 상황에 일반적으로 적용 가능한 내용만 사용하세요.\n"
"2. 특정 개인, 특정 지역, 특수한 상황에만 해당하는 내용은 제외하세요.\n"
"3. 일반적인 정책, 절차, 규정에 관한 내용을 우선적으로 활용하세요.\n"
"4. 참고자료가 고객의 질문과 직접적으로 관련이 없거나 특수 사례만 있다면,\n"
" '관련 정보를 정확히 안내드리기 어렵습니다. 한국도로공사 콜센터(1588-2504)로 문의해 주시면 자세히 안내드리겠습니다.'라고 답변하세요.\n"
"5. 참고자료에 없는 내용은 추측하지 마세요.\n"
"\n"
"【특수 케이스 식별 기준 - 다음 내용이 포함된 참고자료는 제외】\n"
"❌ 특정 인명, 차량번호, 계좌번호, 주민등록번호 등 개인정보\n"
"❌ '00아파트', '00지역 주민만', '특정 구간 한정' 등 특정 지역 한정\n"
"❌ '2023년 특별 이벤트', '한시적 조치', '임시 운영' 등 기간 한정\n"
"❌ '귀하의 경우', '고객님만', '해당 건에 한해' 등 개별 맞춤 답변\n"
"❌ '예외적으로', '특별히', '이번 건에 한해서만' 등 특수 조건\n"
"❌ 과거 민원 처리 결과나 개별 사례의 구체적 내용\n"
"\n"
"【용어 및 형식】\n"
"- 답변 시 'KEC' 대신 '한국도로공사'라는 명칭을 사용하세요.\n"
"- 답변은 한국어로 작성하고, 존댓말을 사용하세요.\n"
"- 간결하고 명확하게 답변하세요.\n"
"- 카카오톡 챗봇 응답이므로 3~4문장 이내로 답변하세요.\n"
"- 인사말과 마무리 인사는 생략하세요.\n"
"- 최종 답변에서 '참고자료', '제공된 자료', '주어진 자료', '자료를 확인한 결과' 같은 내부 근거 표현을 사용하지 마세요.\n"
"- 개인정보나 특수 사례가 포함된 참고자료는 절대 언급하지 마세요."
)
DOMAIN_DATA_PRIORITY_PROMPT = (
"\n\n"
"【DB 조회 결과 우선 원칙】\n"
"1. 【DB 조회 결과】가 제공된 경우, 이 데이터는 시스템이 실시간으로 조회한 정확한 결과입니다.\n"
"2. DB 조회 결과가 고객 질문에 답할 수 있으면, 참고자료 유무와 관계없이 DB 조회 결과를 최우선으로 사용하세요.\n"
"3. 참고자료는 DB 조회 결과를 보완하는 용도로만 사용하고, DB 조회 결과와 충돌하면 DB 조회 결과를 따르세요.\n"
"4. DB 조회 결과에 없는 세부 내용은 추측하지 말고, 제공된 값만 자연스럽게 설명하세요."
)
def _format_reference(self, index: int, ref: Dict[str, Any], score: Any = None) -> str:
"""참고자료를 LLM 프롬프트용 텍스트로 변환"""
source = ref.get("source") or "unknown"
category = ref.get("category")
url = ref.get("url")
lines = [
f"[참고자료 {index}]",
f"출처: {source}",
]
if category:
lines.append(f"분류: {category}")
if score is not None:
lines.append(f"점수: {score}")
lines.extend([
f"질문: {ref['q']}",
f"답변: {ref['a']}",
])
if url:
lines.append(f"공식 FAQ URL: {url}")
lines.append("주의: 이 URL은 고객에게 상세 원문 확인 링크로 제공할 수 있습니다.")
return "\n".join(lines) + "\n"
# 시스템 프롬프트 (질문 유도)
GUIDANCE_SYSTEM_PROMPT = (
"당신은 한국도로공사 채팅상담 챗봇입니다.\n"
"한국도로공사 관련 문의를 돕는 챗봇이라는 역할 안에서만 답변하세요.\n"
"\n"
"【상황】\n"
"고객 질문에 대해 검색 가능한 DB/벡터 자료에서 정확한 근거를 찾지 못한 상황입니다.\n"
"\n"
"【중요: 절대 금지 사항】\n"
"❌ 한국도로공사 업무 정보, 제도, 요금, 정책, 운영 현황을 임의로 만들어내지 마세요.\n"
"❌ 추측하거나 가정하여 구체적인 정보를 답변하지 마세요.\n"
"❌ 새로운 질문을 추천하거나 다른 질문을 유도하지 마세요.\n"
"❌ '참고자료', '제공된 자료', '주어진 자료' 같은 내부 표현을 사용하지 마세요.\n"
"\n"
"【답변 원칙】\n"
"1. '넌 누구야?', '자기소개해줘', '뭐 하는 챗봇이야?' 같은 챗봇 정체성/가벼운 질문에는 짧게 답변하세요.\n"
"2. 업무 정보에 대한 질문인데 정확한 근거가 없으면, 현재 확인 가능한 정보가 없다고 안내하고 한국도로공사 콜센터(1588-2504)로 문의하도록 안내하세요.\n"
"3. 이전 대화는 현재 질문에 지시어가 있거나 주제가 명확히 이어질 때만 참고하세요.\n"
"4. 현재 질문이 인사, 감사, 종료, 새 주제이거나 이전 대화와 무관한 경우:\n"
" - 이전 대화와 억지로 연결하지 말고 현재 질문만 기준으로 답변하세요.\n"
"\n"
"【용어 및 형식】\n"
"- 정중하고 친절한 톤을 유지하세요.\n"
"- 답변은 한국어로 작성하고, 존댓말을 사용하세요.\n"
"- 간결하게 2~4문장으로 답변하세요.\n"
"- 인사말과 마무리 인사는 생략하세요.\n"
"- 콜센터 안내 시: 한국도로공사 콜센터(1588-2504)"
)
def build_answer_prompt_messages(
self,
original_query: str,
rewritten_query: Optional[str],
references: List[Dict[str, Any]],
scores: List[float],
conversation_history: List[Dict[str, str]] = None,
emotion_instruction: Optional[str] = None,
emotion_name: Optional[str] = None,
domain_data: Optional[Dict[str, Any]] = None
) -> List[Dict[str, str]]:
"""일반 답변 프롬프트 생성 (Messages Format)
Args:
original_query: 원본 질문
rewritten_query: 재작성된 질문 (없으면 None)
references: 참고자료 리스트
scores: 참고자료 점수
conversation_history: 대화 이력 (messages format)
emotion_instruction: 감정별 추가 지시사항
emotion_name: 감정 이름 (angry, frustrated, worried 등)
domain_data: chatbotApi 도메인 서비스 DB 조회 결과 (있으면 프롬프트에 포함)
Returns:
messages 리스트
"""
has_domain_data = self._has_usable_domain_data(domain_data)
# 시스템 프롬프트에 domainData/감정 지시사항 추가
system_prompt = self.SYSTEM_PROMPT
if has_domain_data:
system_prompt = system_prompt + self.DOMAIN_DATA_PRIORITY_PROMPT
if emotion_instruction:
system_prompt = system_prompt + emotion_instruction
messages = [
{"role": "system", "content": system_prompt}
]
# 대화 이력 추가 (messages format)
if conversation_history:
messages.extend(conversation_history)
# 현재 질문 구성
current_query_parts = []
# 감정 정보 추가 (부정적 감정인 경우)
if emotion_name and emotion_name in ["angry", "frustrated", "worried"]:
emotion_labels = {
"angry": "화난/불만",
"frustrated": "답답한/짜증난",
"worried": "걱정되는/불안한"
}
current_query_parts.extend([
f"【⚠️ 고객 감정 상태: {emotion_labels.get(emotion_name, emotion_name)}】",
"고객이 부정적인 감정을 느끼고 있습니다.",
"이전 대화나 현재 상황에서 무엇이 불편했는지 파악하고,",
"그 점에 대해 구체적으로 공감한 후 실질적인 해결책을 제시하세요.",
""
])
if rewritten_query:
# Query Rewriting이 적용된 경우
current_query_parts.extend([
"【원본 질문】",
original_query,
"",
"【맥락 기반 재작성 질문】",
rewritten_query,
"(이전 대화를 참고하여 재작성된 질문입니다)",
""
])
else:
# Query Rewriting이 없는 경우 원본 질문 명시
current_query_parts.extend([
"【고객 질문】",
original_query,
""
])
# DB 조회 결과 추가 (domain_data가 있을 때만, 참고자료보다 앞에 위치)
if has_domain_data:
summary = domain_data.get("llmSummary")
if summary is not None and str(summary).strip():
current_query_parts.extend([
"【DB 조회 결과】",
"(아래는 시스템 요약(llmSummary)입니다. 참고자료보다 우선하여 활용하세요.)",
str(summary).strip(),
""
])
else:
db_parts = []
for key, value in domain_data.items():
if key not in ("status", "statusMsg", "errorMsg") and value is not None:
db_parts.append(f"- {key}: {value}")
if db_parts:
current_query_parts.extend([
"【DB 조회 결과】",
"(아래는 시스템에서 조회한 정확한 데이터입니다. 참고자료보다 우선하여 활용하세요.)",
"\n".join(db_parts),
""
])
# 참고자료 추가
context_parts = []
for i, (ref, score) in enumerate(zip(references, scores), 1):
context_parts.append(self._format_reference(i, ref, score))
if context_parts:
current_query_parts.extend([
"【참고자료】",
"\n".join(context_parts),
"",
"【답변 지시사항】"
])
else:
current_query_parts.extend([
"【참고자료】",
"제공된 참고자료가 없습니다.",
"",
"【답변 지시사항】"
])
if emotion_name in ["angry", "frustrated", "worried"]:
current_query_parts.append(
f"⚠️ 고객이 {emotion_labels.get(emotion_name, emotion_name)} 상태입니다. "
"답변 시작 부분에 무엇이 불편했는지 구체적으로 언급하며 공감하고, "
"그 후 명확한 해결 방법을 제시하세요."
)
if rewritten_query:
current_query_parts.append("- 원본 질문은 간단하지만, 재작성된 질문의 의도를 파악하여 답변하세요.")
current_query_parts.append("- 답변 시에는 사용자가 실제로 물어본 질문에 대해 자연스럽게 답변하세요.")
if conversation_history:
current_query_parts.append("- 이전 대화는 현재 질문에 지시어가 있거나 주제가 명확히 이어질 때만 참고하세요.")
current_query_parts.append("- 현재 질문이 인사, 감사, 종료, 새 주제라면 이전 대화와 억지로 연결하지 말고 현재 질문만 기준으로 답변하세요.")
if has_domain_data:
current_query_parts.append("- 【DB 조회 결과】를 최우선 근거로 고객의 질문에 답변해주세요.")
current_query_parts.append("- 참고자료가 없거나 질문과 무관해도, DB 조회 결과가 질문에 답할 수 있으면 콜센터 안내로 대체하지 마세요.")
current_query_parts.append("- 참고자료는 DB 조회 결과를 보완할 때만 사용하고, DB 조회 결과와 충돌하면 DB 조회 결과를 따르세요.")
if domain_data and domain_data.get("msgMap"):
current_query_parts.append("- 휴게소 메뉴 목록은 전체를 모두 나열하지 말고, 대표 메뉴 3~5개만 짧게 언급한 뒤 상세 목록은 시스템 응답의 메뉴 목록을 확인하도록 안내하세요.")
current_query_parts.append("- 메뉴가 많거나 방향/매장이 여러 개인 경우에도 답변 본문은 5문장 이내로 간결하게 작성하세요.")
current_query_parts.append("- 공식 FAQ URL이 있는 참고자료가 관련 있다면, 답변 본문에는 간단히 안내하고 시스템 응답의 링크 버튼으로 상세 확인을 유도할 수 있습니다.")
else:
current_query_parts.append("- 위 참고자료를 바탕으로 고객의 질문에 답변해주세요.")
current_query_parts.append("- 참고자료가 질문과 무관하거나 특수 사례만 있다면 콜센터로 안내하세요.")
current_query_parts.append("- 공식 FAQ URL이 있는 참고자료가 관련 있다면, 답변 본문에는 간단히 안내하고 시스템 응답의 링크 버튼으로 상세 확인을 유도할 수 있습니다.")
current_query_parts.append("- 카카오톡 챗봇 응답이므로 3~4문장 이내로 답변하세요.")
current_query_parts.append("- 인사말과 마무리 인사는 생략하세요.")
current_query_parts.append("- 최종 답변에서 '참고자료', '제공된 자료', '주어진 자료', '자료를 확인한 결과' 같은 내부 근거 표현을 사용하지 마세요.")
# 현재 질문 추가
messages.append({
"role": "user",
"content": "\n".join(current_query_parts)
})
return messages
def _has_usable_domain_data(self, domain_data: Optional[Dict[str, Any]]) -> bool:
if not domain_data:
return False
status = domain_data.get("status")
if status is False:
return False
if isinstance(status, str) and status.lower() == "false":
return False
return True
def build_answer_prompt(
self,
original_query: str,
rewritten_query: Optional[str],
references: List[Dict[str, Any]],
scores: List[float],
conversation_context: Optional[str] = None,
emotion_instruction: Optional[str] = None
) -> tuple[str, str]:
"""일반 답변 프롬프트 생성
Args:
original_query: 원본 질문
rewritten_query: 재작성된 질문 (없으면 None)
references: 참고자료 리스트
scores: 참고자료 점수
conversation_context: 대화 이력 텍스트
emotion_instruction: 감정별 추가 지시사항
Returns:
(system_prompt, user_prompt)
"""
# 시스템 프롬프트에 감정 지시사항 추가
system_prompt = self.SYSTEM_PROMPT
if emotion_instruction:
system_prompt = system_prompt + emotion_instruction
user_prompt_parts = []
# 이전 대화 이력
if conversation_context:
user_prompt_parts.append("【이전 대화 이력】")
user_prompt_parts.append("(현재 질문과 명확히 이어지는 경우에만 참고하세요)")
user_prompt_parts.append(conversation_context)
user_prompt_parts.append("")
# 질문 (Query Rewriting 적용 여부에 따라)
if rewritten_query:
user_prompt_parts.extend([
"【사용자의 현재 질문】",
original_query,
"",
"【맥락을 고려한 완전한 질문】",
rewritten_query,
"(이전 대화를 참고하여 재작성된 질문입니다. 이 질문에 대해 답변해주세요.)",
"",
])
else:
user_prompt_parts.extend([
"【현재 질문】",
f"고객 질문: {original_query}",
"",
])
# 참고자료
context_parts = []
for i, (ref, score) in enumerate(zip(references, scores), 1):
context_parts.append(self._format_reference(i, ref, score))
user_prompt_parts.extend([
"【참고자료】",
"\n".join(context_parts),
"",
"【지시사항】"
])
# 추가 지시사항
if rewritten_query:
user_prompt_parts.append("- 사용자의 현재 질문은 간단하지만, 재작성된 완전한 질문의 의도를 파악하여 답변하세요.")
user_prompt_parts.append("- 답변 시에는 사용자가 실제로 물어본 현재 질문에 대해 자연스럽게 답변하세요.")
if conversation_context:
user_prompt_parts.append("- 이전 대화는 현재 질문에 지시어가 있거나 주제가 명확히 이어질 때만 참고하세요.")
user_prompt_parts.append("- 현재 질문이 인사, 감사, 종료, 새 주제라면 이전 대화와 억지로 연결하지 말고 현재 질문만 기준으로 답변하세요.")
user_prompt_parts.append("- 위 참고자료를 바탕으로 고객의 질문에 답변해주세요.")
user_prompt_parts.append("- 참고자료가 질문과 무관하거나 특수 사례만 있다면 콜센터로 안내하세요.")
user_prompt_parts.append("- 공식 FAQ URL이 있는 참고자료가 관련 있다면, 답변 본문에는 간단히 안내하고 시스템 응답의 링크 버튼으로 상세 확인을 유도할 수 있습니다.")
user_prompt_parts.append("- 최종 답변에서 '참고자료', '제공된 자료', '주어진 자료', '자료를 확인한 결과' 같은 내부 근거 표현을 사용하지 마세요.")
return system_prompt, "\n".join(user_prompt_parts)
def build_guidance_prompt(
self,
original_query: str,
conversation_context: Optional[str] = None
) -> tuple[str, str]:
"""질문 유도 프롬프트 생성
Args:
original_query: 원본 질문
conversation_context: 대화 이력 텍스트
Returns:
(system_prompt, user_prompt)
"""
user_prompt_parts = []
# 이전 대화 이력
if conversation_context:
user_prompt_parts.append(conversation_context)
user_prompt_parts.append("")
# 현재 상황
user_prompt_parts.extend([
"【현재 상황】",
f"고객 질문: {original_query}",
"",
"위 질문에 대해 검색 가능한 DB/벡터 자료에서 정확한 근거를 찾지 못했습니다.",
"",
"【지시사항】",
"- 챗봇 정체성이나 가벼운 질문이면 한국도로공사 채팅상담 챗봇이라는 역할 안에서 짧게 답변하세요.",
"- 업무 정보에 대한 질문이면 현재 정확한 정보를 확인하기 어렵다고 안내하고 콜센터로 연결하세요.",
"- 임의로 새로운 정보를 만들어내지 마세요.",
"- 새로운 질문을 추천하거나 다른 질문을 유도하지 마세요."
])
return self.GUIDANCE_SYSTEM_PROMPT, "\n".join(user_prompt_parts)
@@ -0,0 +1,245 @@
"""
Query Rewriting 모듈
──────────────────
대화 이력 기반 질문 재작성
"""
import re
from typing import Optional, List, Dict, Any
from handlers.suggestion_handler import strip_suggestion_block
class QueryRewriter:
"""대화 이력을 활용한 질문 재작성"""
NO_REWRITE_TOKEN = "__NO_REWRITE__"
CONTEXT_CUE_KEYWORDS = [
"그럼", "그거", "그건", "그게", "그걸", "그곳", "거기",
"아까", "방금", "이어서", "계속", "위 내용", "위에",
"앞에서", "앞서", "이전", "그 요금", "그 휴게소", "해당"
]
FOLLOW_UP_PATTERNS = [
r".*(얼마|몇\s*원|가격|비용|요금).*",
r".*(어디|어디서|위치|장소).*",
r".*(어떻게|방법|절차|신청|구매|사\?|사요|사나요|살|등록|해지|취소).*",
r".*(가능|돼|되나|되나요|필요|있어|없어).*",
r".*(언제|몇\s*시|시간|기간).*",
r".*(왜|이유).*"
]
def __init__(self, llm_client, embed_client):
self.llm_client = llm_client
self.embed_client = embed_client
self.system_prompt = (
"당신은 질문 재작성 전문가입니다.\n"
"현재 질문이 이전 대화와 자연스럽게 이어지는 후속 질문인지 먼저 판단하세요.\n"
"이어지는 후속 질문이면 이전 대화를 참고하여 완전한 질문으로 재작성하세요.\n"
f"이어지지 않는 새 질문이면 정확히 {self.NO_REWRITE_TOKEN}만 출력하세요.\n"
"\n"
"【재작성 원칙】\n"
"1. '그럼', '그거', '그건', '아까', '이거' 등의 지시어를 구체적인 명사로 교체하세요.\n"
"2. '얼마야?', '어디서 사?', '어떻게 해?'처럼 짧은 후속 질문은 이전 대화 주제와 연결하세요.\n"
f"3. 현재 질문이 인사, 감사, 종료, 새 주제이면 이전 대화와 억지로 연결하지 말고 {self.NO_REWRITE_TOKEN}만 출력하세요.\n"
"4. 후속 질문으로 판단한 경우에만 질문의 의도를 유지하면서 완전한 문장으로 만드세요.\n"
f"5. 출력은 재작성된 질문 한 문장 또는 {self.NO_REWRITE_TOKEN}만 허용됩니다.\n"
"\n"
"【예시】\n"
"이전 대화: '하이패스 단말기가 뭔가요?'\n"
"현재 질문: '그럼 어디서 사나요?'\n"
"재작성: '하이패스 단말기는 어디서 구매할 수 있나요?'\n"
"이전 대화: '하이패스 단말기에 대해 알려줘'\n"
"현재 질문: '얼마야?'\n"
"재작성: '하이패스 단말기 가격은 얼마인가요?'\n"
"이전 대화: '통행요금 조회해줘'\n"
"현재 질문: '안녕'\n"
f"재작성: {self.NO_REWRITE_TOKEN}\n"
"이전 대화: '하이패스 단말기에 대해 알려줘'\n"
"현재 질문: '동김천 휴게소 메뉴 알려줘'\n"
f"재작성: {self.NO_REWRITE_TOKEN}\n"
)
def format_history(self, history: List[Dict[str, Any]]) -> str:
"""대화 이력을 텍스트로 포맷팅 (질문 + 답변 전문, 추천 블록 제외)"""
lines = []
for h in history:
body = strip_suggestion_block(h.get("ai_response"))
lines.append(f"고객: {h['user_query']}\n상담원: {body}")
return "\n".join(lines)
def has_context_cue(self, query: str) -> bool:
"""현재 질문이 이전 대화 참조가 필요한 후속 질문인지 판단"""
text = (query or "").strip()
normalized = text.replace(" ", "").lower()
if any(keyword.replace(" ", "").lower() in normalized for keyword in self.CONTEXT_CUE_KEYWORDS):
return True
compact = re.sub(r"\s+", "", text)
is_short_question = len(compact) <= 20
if not is_short_question:
return False
return any(re.match(pattern, text) for pattern in self.FOLLOW_UP_PATTERNS)
def _postprocess_rewrite(self, rewritten_query: str, original_query: str, ts: str, label: str) -> Optional[str]:
rewritten_query = self._remove_think_tags(rewritten_query).strip()
if not rewritten_query:
return None
if self.NO_REWRITE_TOKEN in rewritten_query:
print(f"[QueryRewriter] {ts} ⏭️ {label} 생략: 이전 대화와 이어지지 않음")
return None
if rewritten_query == original_query:
print(f"[QueryRewriter] {ts} ⏭️ {label} 생략: 재작성 결과가 원문과 동일")
return None
return rewritten_query
def rewrite_query_with_full_context(
self,
original_query: str,
history: List[Dict[str, Any]],
ts: str
) -> Optional[str]:
"""질문 재작성 (이전 질문 + 답변 + 현재 질문 모두 활용)
Args:
original_query: 원본 질문
history: 대화 이력 (질문 + 답변)
ts: 타임스탬프 (로깅용)
Returns:
재작성된 질문 또는 None (실패 시)
"""
if not history:
return None
try:
print(f"[QueryRewriter] {ts} 🔄 Full Context Rewriting 시도 (이력 {len(history)}개)")
# 대화 이력 포맷팅 (질문 + 답변)
history_text = self.format_history(history)
# 확장된 프롬프트 (답변 포함한 맥락 활용)
cue_hint = "있음" if self.has_context_cue(original_query) else "없음"
enhanced_system_prompt = (
"당신은 질문 재작성 전문가입니다.\n"
"현재 질문이 이전 대화와 자연스럽게 이어지는 후속 질문인지 먼저 판단하세요.\n"
"이어지는 후속 질문이면 이전 대화 내용(질문과 답변 모두)을 참고하여 완전한 질문으로 재작성하세요.\n"
f"이어지지 않는 새 질문이면 정확히 {self.NO_REWRITE_TOKEN}만 출력하세요.\n"
"\n"
"【재작성 원칙】\n"
"1. 후속 질문으로 판단한 경우에만 이전 답변에서 설명된 개념이나 용어를 활용하세요.\n"
"2. '그럼', '그거', '그건', '아까', '이거' 등의 지시어를 구체적인 명사로 교체하세요.\n"
"3. '얼마야?', '어디서 사?', '어떻게 해?'처럼 짧은 후속 질문은 이전 대화 주제와 연결하세요.\n"
f"4. 현재 질문이 인사, 감사, 종료, 새 주제이면 이전 대화와 억지로 연결하지 말고 {self.NO_REWRITE_TOKEN}만 출력하세요.\n"
"5. 후속 질문으로 판단한 경우에만 질문의 의도를 유지하면서 완전한 문장으로 만드세요.\n"
f"6. 출력은 재작성된 질문 한 문장 또는 {self.NO_REWRITE_TOKEN}만 허용됩니다.\n"
"\n"
"【예시】\n"
"이전 대화:\n"
"고객: '하이패스가 뭐야?'\n"
"상담원: '하이패스는 전자식 통행료 결제 시스템입니다. 단말기를 차량에 부착하면...'\n"
"\n"
"현재 질문: '그럼 어디서 사?'\n"
"재작성: '하이패스 단말기는 어디서 구매할 수 있나요?'\n"
"이전 대화:\n"
"고객: '하이패스 단말기에 대해 알려줘'\n"
"상담원: '하이패스 단말기는 차량에 부착해 통행료를 자동 결제하는 장치입니다...'\n"
"\n"
"현재 질문: '얼마야?'\n"
"재작성: '하이패스 단말기 가격은 얼마인가요?'\n"
"이전 대화:\n"
"고객: '통행요금 조회해줘'\n"
"상담원: '출발 IC와 도착 IC를 알려주세요...'\n"
"\n"
"현재 질문: '안녕'\n"
f"재작성: {self.NO_REWRITE_TOKEN}\n"
)
user_prompt = (
f"【이전 대화】\n{history_text}\n\n"
f"【현재 질문】\n{original_query}\n\n"
f"【후속 질문 힌트】\n패턴 기반 후속 질문 후보: {cue_hint}\n\n"
f"이전 대화와 자연스럽게 이어지는 후속 질문이면 완전한 질문으로 재작성하고, 이어지지 않으면 {self.NO_REWRITE_TOKEN}만 출력하세요."
)
# LLM 호출
rewritten_query = self.llm_client.chat_completion(
messages=[
{"role": "system", "content": enhanced_system_prompt},
{"role": "user", "content": user_prompt}
],
max_tokens=1000,
temperature=0.1
).strip()
rewritten_query = self._postprocess_rewrite(
rewritten_query, original_query, ts, "Full Context Rewriting"
)
if not rewritten_query:
return None
print(f"[QueryRewriter] {ts} ✅ Full Context Rewriting 완료: '{original_query}' → '{rewritten_query}'")
return rewritten_query
except Exception as e:
print(f"[QueryRewriter] {ts} ❌ Full Context Rewriting 실패: {e}")
return None
def rewrite_query(self, original_query: str, history: List[Dict[str, Any]], ts: str) -> Optional[str]:
"""질문 재작성
Args:
original_query: 원본 질문
history: 대화 이력
ts: 타임스탬프 (로깅용)
Returns:
재작성된 질문 또는 None (실패 시)
"""
if not history:
return None
try:
print(f"[QueryRewriter] {ts} 🔄 Query Rewriting 시도 (대화 이력 {len(history)}개)")
# 대화 이력 포맷팅
history_text = self.format_history(history)
cue_hint = "있음" if self.has_context_cue(original_query) else "없음"
# 프롬프트 구성
user_prompt = (
f"【이전 대화】\n{history_text}\n\n"
f"【현재 질문】\n{original_query}\n\n"
f"【후속 질문 힌트】\n패턴 기반 후속 질문 후보: {cue_hint}\n\n"
f"이전 대화와 자연스럽게 이어지는 후속 질문이면 완전한 질문으로 재작성하고, 이어지지 않으면 {self.NO_REWRITE_TOKEN}만 출력하세요."
)
# LLM 호출
rewritten_query = self.llm_client.chat_completion(
messages=[
{"role": "system", "content": self.system_prompt},
{"role": "user", "content": user_prompt}
],
max_tokens=1000,
temperature=0.1
).strip()
rewritten_query = self._postprocess_rewrite(
rewritten_query, original_query, ts, "Query Rewriting"
)
if not rewritten_query:
return None
print(f"[QueryRewriter] {ts} ✅ Query Rewriting 완료: '{original_query}' → '{rewritten_query}'")
return rewritten_query
except Exception as e:
print(f"[QueryRewriter] {ts} ❌ Query Rewriting 실패: {e}")
return None
def _remove_think_tags(self, text: str) -> str:
"""<think> 태그 제거"""
if "<think>" in text and "</think>" in text:
return re.sub(r'<think>.*?</think>\s*', '', text, flags=re.DOTALL).strip()
return text
@@ -0,0 +1,202 @@
"""
응답 핸들러 모듈
──────────────
응답 생성, 저장, 로깅 처리
"""
import json
from pathlib import Path
from typing import List, Dict, Any, Optional
class ResponseHandler:
"""응답 처리 및 저장 핸들러"""
def __init__(self, data_dir: Path, chat_manager=None, config=None):
self.data_dir = data_dir
self.chat_manager = chat_manager
self.config = config
def build_references(self, top_results: List[Dict], scores: List[float]) -> List[Dict[str, Any]]:
"""클라이언트 응답/로그용 참고자료 메타데이터 구성"""
references = []
for idx, result in enumerate(top_results):
references.append({
"question": result.get("q"),
"answer": result.get("a"),
"score": scores[idx] if idx < len(scores) else None,
"category": result.get("category"),
"source": result.get("source"),
"source_id": result.get("source_id"),
"quality": result.get("quality"),
"url": result.get("url"),
})
return references
def extract_faq_urls(self, top_results: List[Dict]) -> List[str]:
"""상위 참고자료에서 중복 없는 URL 목록 추출"""
urls = []
seen = set()
for result in top_results:
url = result.get("url")
if url and url not in seen:
urls.append(url)
seen.add(url)
return urls
def log_failed_query(self, query: str, ts: str):
"""실패한 질문 로깅"""
try:
fail_path = self.data_dir / "qa_failed.jsonl"
fail_path.parent.mkdir(parents=True, exist_ok=True)
with fail_path.open("a", encoding="utf-8") as fout:
rec = {"q": query, "ts": ts}
json.dump(rec, fout, ensure_ascii=False)
fout.write("\n")
except Exception as e:
print(f"[ResponseHandler] 실패 로깅 오류: {e}")
def log_success(
self,
original_query: str,
rewritten_query: Optional[str],
top_results: List[Dict],
scores: List[float],
answer: str,
ts: str
):
"""성공한 질문 로깅"""
try:
succ_path = self.data_dir / "qa_successed.jsonl"
succ_path.parent.mkdir(parents=True, exist_ok=True)
with succ_path.open("a", encoding="utf-8") as fout:
rec = {
"user_q": original_query,
"top_matches": [{"q": r["q"], "score": s} for r, s in zip(top_results, scores)],
"llm_answer": answer,
"ts": ts
}
if rewritten_query:
rec["rewritten_q"] = rewritten_query
json.dump(rec, fout, ensure_ascii=False)
fout.write("\n")
except Exception as e:
print(f"[ResponseHandler] 성공 로깅 오류: {e}")
def save_to_mongodb(
self,
bot_id: Optional[str],
user_query: str,
ai_response: str,
matched_questions: List[str],
scores: List[float],
metadata: Dict[str, Any],
ts: str
) -> bool:
"""MongoDB에 대화 저장
Returns:
저장 성공 여부
"""
if not self.chat_manager:
print(f"[ResponseHandler] {ts} ⏭️ MongoDB 비활성화 → 저장 스킵")
return False
try:
print(f"[ResponseHandler] {ts} ✍️ MongoDB 저장 중... (bot_id={bot_id})")
result_id = self.chat_manager.save_conversation(
bot_id=bot_id,
user_query=user_query,
ai_response=ai_response,
matched_questions=matched_questions,
scores=scores,
metadata=metadata
)
print(f"[ResponseHandler] {ts} ✅ MongoDB 저장 성공! doc_id={result_id}")
return True
except Exception as e:
import traceback
print(f"[ResponseHandler] {ts} ❌ MongoDB 저장 실패!")
print(f"[ResponseHandler] {ts} 오류: {e}")
traceback.print_exc()
return False
def print_console_log(
self,
original_query: str,
rewritten_query: Optional[str],
top_results: List[Dict],
scores: List[float],
answer: str,
ts: str
):
"""콘솔 로그 출력"""
try:
preview = answer.replace("\n", " ")[:120]
top_match = top_results[0]["q"] if top_results else "N/A"
top_score = scores[0] if scores else None
query_log = f"'{original_query}'"
if rewritten_query:
query_log = f"'{original_query}' → '{rewritten_query}'"
print(f"[ResponseHandler] {ts} query={query_log}")
print(f"[ResponseHandler] {ts} top_match='{top_match}' score={top_score}")
print(f"[ResponseHandler] {ts} answer_preview='{preview}'")
except Exception as e:
print(f"[ResponseHandler] 콘솔 로그 오류: {e}")
def build_response(
self,
answer: str,
matched_questions: List[str],
scores: List[float],
bot_id: Optional[str],
rerank_info: Dict[str, Any],
references: List[Dict[str, Any]] = None,
faq_urls: List[str] = None,
faiss_scores: List[float] = None
) -> Dict[str, Any]:
"""최종 응답 생성"""
if faiss_scores is None:
faiss_scores = []
if references is None:
references = []
if faq_urls is None:
faq_urls = []
return {
"answer": answer,
"matched_questions": matched_questions,
"scores": scores,
"num_references": len(matched_questions),
"botId": bot_id,
"references": references,
"faq_urls": faq_urls,
"rerank_info": rerank_info
}
def build_no_match_response(
self,
answer: str,
bot_id: Optional[str],
top_k: int
) -> Dict[str, Any]:
"""매칭 실패 응답 생성"""
return {
"answer": answer,
"matched_questions": [],
"scores": [],
"num_references": 0,
"botId": bot_id,
"references": [],
"faq_urls": [],
"rerank_info": {
"used": False,
"faiss_top_k": top_k,
"rerank_top_n": 0,
"faiss_scores": [],
"rerank_scores": [],
"detail": "No matching documents found (threshold not met)"
}
}
@@ -0,0 +1,165 @@
"""
검색 핸들러 모듈
──────────────
벡터 검색 및 재랭킹 처리
"""
import numpy as np
from typing import List, Dict, Any, Optional, Tuple
def _format_rerank_document(candidate: Dict[str, Any]) -> str:
"""리랭커용 FAQ passage — 질문과 답변 전체."""
q = str(candidate.get("q") or candidate.get("question") or "").strip()
a = str(candidate.get("a") or candidate.get("answer") or "").strip()
if q and a:
return f"질문: {q}\n답변: {a}"
return q or a
class SearchHandler:
"""벡터 검색 및 재랭킹 핸들러"""
def __init__(self, vector_store, embed_client, rerank_client, config):
self.vector_store = vector_store
self.embed_client = embed_client
self.rerank_client = rerank_client
self.config = config
self.last_search_info: Dict[str, Any] = {}
def embed_query(self, query: str, ts: str) -> Optional[np.ndarray]:
"""질문 임베딩
Args:
query: 검색 질문
ts: 타임스탬프 (로깅용)
Returns:
임베딩 벡터 또는 None (실패 시)
"""
self._last_query_text = query or ""
try:
embeddings = self.embed_client.embed([query], normalize=True, is_query=True)
return np.array(embeddings[0], dtype="float32")
except Exception as e:
print(f"[SearchHandler] {ts} ❌ 임베딩 실패: {e}")
return None
def search(self, query_vec: np.ndarray, threshold: float, ts: str) -> List[Dict[str, Any]]:
"""벡터 검색
Args:
query_vec: 질문 임베딩 벡터
threshold: 유사도 임계값
ts: 타임스탬프 (로깅용)
Returns:
검색 결과 리스트
"""
try:
if (
getattr(self.config, "hybrid_search_enabled", False)
and hasattr(self.vector_store, "hybrid_search")
):
results = self.vector_store.hybrid_search(
query_vec,
getattr(self, "_last_query_text", ""),
top_k=self.config.top_k,
threshold=threshold,
sparse_top_k=getattr(self.config, "sparse_top_k", 30),
merge_top_k=getattr(self.config, "hybrid_merge_top_k", 40),
)
self.last_search_info = {
"searchMode": "hybrid_sparse",
"candidateCount": len(results),
}
else:
results = self.vector_store.search(
query_vec,
top_k=self.config.top_k,
threshold=threshold
)
self.last_search_info = {
"searchMode": "vector",
"candidateCount": len(results),
}
return results
except Exception as e:
print(f"[SearchHandler] {ts} ❌ 벡터 검색 실패: {e}")
self.last_search_info = {"searchMode": "error", "error": str(e)}
return []
def rerank(self, query: str, candidates: List[Dict[str, Any]], ts: str) -> Tuple[List[Dict[str, Any]], List[float], bool, Optional[Dict]]:
"""재랭킹
Args:
query: 검색 질문
candidates: 후보 문서 리스트
ts: 타임스탬프 (로깅용)
Returns:
(상위 N개 결과, 점수 리스트, 재랭킹 사용 여부, 재랭킹 정보)
"""
if not candidates:
print(f"[SearchHandler] {ts} 재랭킹 건너뜀: 후보 문서 없음")
return [], [], False, {"detail": "No candidates to rerank"}
# 재랭킹 후보 제한
max_rerank = min(self.config.rerank_candidates, len(candidates))
rerank_candidates = candidates[:max_rerank]
print(f"[SearchHandler] {ts} 재랭킹 시작 (candidates={len(candidates)} → 상위 {max_rerank}개)")
try:
# TEI Reranker API 호출 (질문+답변 전체를 passage로 전달)
documents = [_format_rerank_document(c) for c in rerank_candidates]
print(f"[SearchHandler] {ts} 리랭커 호출: query={query[:50]}..., documents={len(documents)}개 (Q+A)")
rerank_results = self.rerank_client.rerank(
query=query,
documents=documents,
return_documents=False
)
print(f"[SearchHandler] {ts} 리랭커 응답: {len(rerank_results) if rerank_results else 0}개 결과")
if not rerank_results:
print(f"[SearchHandler] {ts} 재랭킹 결과 없음 → FAISS 상위 결과 사용")
top_n_results = rerank_candidates[:self.config.top_n_for_llm]
top_scores = [None] * len(top_n_results)
return top_n_results, top_scores, False, None
# score 기준 정렬 후 상위 N개
sorted_results = sorted(rerank_results, key=lambda x: x["score"], reverse=True)
top_n_indices = [r["index"] for r in sorted_results[:self.config.top_n_for_llm]]
top_n_results = [rerank_candidates[idx] for idx in top_n_indices]
top_scores = [
round(sorted_results[i]["score"], 4) if i < len(sorted_results) else None
for i in range(len(top_n_results))
]
# 순위 변화 계산
rank_changes = [idx - i for i, idx in enumerate(top_n_indices)]
def _question_of(candidate: Dict[str, Any]) -> str:
return str(candidate.get("q") or candidate.get("question") or "")
rerank_info = {
"used": True,
"original_top_question": _question_of(rerank_candidates[0])[:50] + "...",
"reranked_top_question": _question_of(top_n_results[0])[:50] + "...",
"rank_changes": rank_changes
}
print(f"[SearchHandler] {ts} 재랭킹 완료: 상위 {len(top_n_results)}개, 최고 점수={top_scores[0]}")
if rank_changes[0] != 0:
print(f"[SearchHandler] {ts} 순위 변화: FAISS #{top_n_indices[0]+1} → Rerank #1")
return top_n_results, top_scores, True, rerank_info
except Exception as e:
print(f"[SearchHandler] {ts} ❌ 재랭킹 실패: {e}")
print(f"[SearchHandler] {ts} FAISS 상위 결과로 대체")
top_n_results = candidates[:self.config.top_n_for_llm]
top_scores = [None] * len(top_n_results)
return top_n_results, top_scores, False, None
@@ -0,0 +1,193 @@
"""
제안 핸들러 모듈
──────────────
낮은 신뢰도 시 대안 질문 제안
"""
from typing import List, Dict, Any, Optional
SUGGESTION_BLOCK_MARKER = "💡 혹시 이런 것을 찾으셨나요?"
SUGGESTION_BLOCK_MARKER_ALT = "혹시 이런 것을 찾으셨나요?"
def strip_suggestion_block(text: Optional[str]) -> str:
"""LLM 이력용: 추천 블록(및 LLM 모방 블록) 제거, FAQ 본문만 반환"""
if not text:
return text or ""
indices = []
for marker in (SUGGESTION_BLOCK_MARKER, SUGGESTION_BLOCK_MARKER_ALT):
idx = text.find(marker)
if idx != -1:
indices.append(idx)
if not indices:
return text
return text[: min(indices)].rstrip()
def has_suggestion_block(text: Optional[str]) -> bool:
if not text:
return False
return any(marker in text for marker in (SUGGESTION_BLOCK_MARKER, SUGGESTION_BLOCK_MARKER_ALT))
class SuggestionHandler:
"""낮은 신뢰도 답변에 대한 대안 질문 제안"""
# 신뢰도 임계값 (기본값 — Config.from_env / LOW_CONFIDENCE_THRESHOLD 로 override)
DEFAULT_LOW_CONFIDENCE_THRESHOLD = 0.65
DEFAULT_HIGH_CONFIDENCE_THRESHOLD = 0.75
def __init__(
self,
low_confidence_threshold: float = DEFAULT_LOW_CONFIDENCE_THRESHOLD,
high_confidence_threshold: float = DEFAULT_HIGH_CONFIDENCE_THRESHOLD,
):
"""
Args:
low_confidence_threshold: 낮은 신뢰도 기준 (기본 0.65, env LOW_CONFIDENCE_THRESHOLD)
high_confidence_threshold: 높은 신뢰도 기준 (기본 0.75, env HIGH_CONFIDENCE_THRESHOLD)
"""
self.low_confidence_threshold = low_confidence_threshold
self.high_confidence_threshold = high_confidence_threshold
def is_low_confidence(self, top_score: Optional[float]) -> bool:
"""신뢰도가 낮은지 판단
Args:
top_score: 최상위 검색 결과의 점수
Returns:
낮은 신뢰도 여부
"""
if top_score is None:
return True
return top_score < self.low_confidence_threshold
def generate_suggestions(
self,
top_results: List[Dict[str, Any]],
top_scores: List[float],
max_suggestions: int = 3
) -> Optional[str]:
"""대안 질문 제안 생성
Args:
top_results: 검색 결과 리스트
top_scores: 검색 점수 리스트
max_suggestions: 최대 제안 개수 (기본 3개)
Returns:
제안 문자열 또는 None
"""
if not top_results or not top_scores:
return None
# 최상위 점수가 충분히 높으면 제안 불필요
if not self.is_low_confidence(top_scores[0]):
return None
# 1위 포함 상위 max_suggestions개 질문 제안 (번호 1부터)
suggestion_count = min(max_suggestions, len(top_results))
if suggestion_count <= 0:
return None
suggestions = []
for i in range(suggestion_count):
question = top_results[i]["q"]
score = top_scores[i] if i < len(top_scores) else None
suggestions.append({
"index": i + 1,
"question": question,
"score": score,
})
if not suggestions:
return None
# 제안 텍스트 생성
suggestion_text = f"\n\n{SUGGESTION_BLOCK_MARKER}\n"
for suggestion in suggestions:
# 점수가 있으면 신뢰도 표시 (선택적)
# suggestion_text += f"{suggestion['index']}. {suggestion['question']} (유사도: {suggestion['score']:.2f})\n"
suggestion_text += f"{suggestion['index']}. {suggestion['question']}\n"
suggestion_text += "\n위 질문 중 하나를 선택하시면 정확한 답변을 드리겠습니다."
return suggestion_text
def enhance_answer_with_suggestions(
self,
answer: str,
top_results: List[Dict[str, Any]],
top_scores: List[float],
max_suggestions: int = 3
) -> str:
"""답변에 제안 추가
Args:
answer: 원본 답변
top_results: 검색 결과
top_scores: 검색 점수
max_suggestions: 최대 제안 개수
Returns:
제안이 추가된 답변 (또는 원본)
"""
if has_suggestion_block(answer):
return answer
suggestions = self.generate_suggestions(top_results, top_scores, max_suggestions)
if suggestions:
return answer + suggestions
return answer
def get_confidence_level(self, score: Optional[float]) -> str:
"""점수를 신뢰도 레벨로 변환
Args:
score: 검색 점수
Returns:
"high", "medium", "low" 중 하나
"""
if score is None:
return "low"
if score >= self.high_confidence_threshold:
return "high"
elif score >= self.low_confidence_threshold:
return "medium"
else:
return "low"
def should_show_confidence_warning(self, top_score: Optional[float]) -> bool:
"""신뢰도 경고를 표시해야 하는지
Args:
top_score: 최상위 점수
Returns:
경고 표시 여부
"""
if top_score is None:
return True
# 매우 낮은 신뢰도 (0.50 미만)
return top_score < 0.50
def generate_confidence_warning(self) -> str:
"""신뢰도 경고 메시지 생성"""
return (
"\n\n⚠️ **정확도 안내**\n"
"질문과 정확히 일치하는 정보를 찾기 어려웠습니다.\n"
"더 구체적으로 질문하시거나, 아래 옵션을 참고해 주세요.\n"
"정확한 답변이 필요하시면 한국도로공사 콜센터(1588-2504)로 문의해 주세요."
)