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,582 @@
"""
admin_service.py
────────────────────────────────────────────
벡터 DB(Qdrant) 큐레이션 어드민 API + 단일 페이지 웹 UI.
기능:
- 검색(semantic): 질문 임베딩 → 벡터 검색
- 목록(browse): 페이지네이션 조회
- 단건 조회 / 삭제 / 일괄 삭제
- 추가: 질문/답변 텍스트 → 임베딩 → 결정적 ID로 upsert
실시간 /ask 서비스(run_service_qa.py)와 같은 모듈/벡터스토어를 공유하되,
별도 프로세스(컨테이너)로 분리해 운영한다. Qdrant 전용 기능을 사용한다.
"""
import os
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional, List, Dict, Any
from urllib.parse import unquote
import numpy as np
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import HTMLResponse
from pydantic import BaseModel, Field
from api_clients import TEIEmbeddingClient
from vector_store import get_vector_store, VECTOR_STORE, make_point_id
app = FastAPI(title="RAG 벡터DB 큐레이션 어드민")
# 별도 origin(다른 포트/도메인)의 프론트엔드가 호출할 수 있도록 CORS 허용.
# 내부망 도구이므로 기본 전체 허용. 필요 시 ADMIN_CORS_ORIGINS="http://host:port,..." 로 제한.
_cors = os.getenv("ADMIN_CORS_ORIGINS", "*")
_origins = ["*"] if _cors.strip() == "*" else [o.strip() for o in _cors.split(",") if o.strip()]
app.add_middleware(
CORSMiddleware,
allow_origins=_origins,
allow_methods=["*"],
allow_headers=["*"],
)
DATA_DIR = Path(os.getenv("DATA_DIR", "/app/data"))
def _now_iso() -> str:
"""등록/수정 시각 기본값 (UTC ISO-8601)"""
return datetime.now(timezone.utc).isoformat()
print("[Admin] 초기화 시작...")
embed_client = TEIEmbeddingClient()
vector_store = get_vector_store()
vector_store.load(str(DATA_DIR))
print(f"[Admin] 벡터 스토어({VECTOR_STORE}) 로드 완료: {vector_store.count()}개")
if VECTOR_STORE != "qdrant":
print("[Admin] ⚠️ 경고: 어드민의 조회/삭제/추가 기능은 Qdrant에서만 동작합니다 "
f"(현재 VECTOR_STORE={VECTOR_STORE}).")
else:
vector_store.ensure_admin_payload_indexes()
# ───────────────────────────────────────────
# 요청/응답 모델
# ───────────────────────────────────────────
class SearchRequest(BaseModel):
query: str = Field(..., min_length=1, max_length=500)
top_k: int = Field(20, ge=1, le=100)
threshold: Optional[float] = None
category: Optional[str] = None # 정확일치 필터
source: Optional[str] = None # 정확일치 필터
class AddRequest(BaseModel):
q: str = Field(..., min_length=1)
a: str = Field(..., min_length=1)
category: Optional[str] = None
url: Optional[str] = None
source: str = "admin_manual"
source_id: Optional[str] = None
source_created_at: Optional[str] = None
class UpdateRequest(BaseModel):
"""전달된 필드만 수정. q가 바뀌면 재임베딩한다."""
q: Optional[str] = None
a: Optional[str] = None
category: Optional[str] = None
url: Optional[str] = None
source_created_at: Optional[str] = None
class KeywordSearchRequest(BaseModel):
"""DB 전체 대상 문자열 포함(부분일치) 검색"""
keyword: str = Field(..., min_length=1, max_length=200)
field: str = Field("both", pattern="^(both|q|a)$") # 검색 대상 필드
category: Optional[str] = None
source: Optional[str] = None
skip: int = Field(0, ge=0)
limit: int = Field(50, ge=1, le=200)
class DeleteBatchRequest(BaseModel):
ids: List[str] = Field(..., min_items=1)
def _row(item: Dict[str, Any], score: Optional[float] = None) -> Dict[str, Any]:
"""검색/조회 결과를 UI 친화 형태로 변환"""
meta = item.get("meta", {}) or {}
row = {
"id": item.get("id"),
"q": meta.get("q"),
"a": meta.get("a"),
"category": meta.get("category"),
"source": meta.get("source"),
"source_id": meta.get("source_id"),
"url": meta.get("url"),
"source_created_at": meta.get("source_created_at"),
"indexed_at": meta.get("indexed_at"),
"updated_at": meta.get("updated_at"),
}
if score is not None:
row["score"] = round(float(score), 4)
return row
def _require_qdrant():
if VECTOR_STORE != "qdrant":
raise HTTPException(
status_code=400,
detail="이 기능은 Qdrant 벡터스토어에서만 지원됩니다. VECTOR_STORE=qdrant 로 실행하세요.",
)
# ───────────────────────────────────────────
# API
# ───────────────────────────────────────────
@app.get("/api/stats")
def stats():
return {"vector_store": VECTOR_STORE, "count": vector_store.count()}
def _normalize_query_param(value: Optional[str]) -> Optional[str]:
"""쿼리 파라미터 이중 URL 인코딩 복구 (Java RestTemplate + toUriString 조합 대응)."""
if value is None:
return None
v = value.strip()
if not v:
return v
for _ in range(3):
decoded = unquote(v)
if decoded == v:
break
v = decoded
return v
def _filter_dict(category: Optional[str], source: Optional[str]) -> Optional[Dict[str, Any]]:
f: Dict[str, Any] = {}
if category:
cat = (_normalize_query_param(category) or category).strip()
if cat == "미분류":
f["category"] = "__EMPTY__"
else:
f["category"] = cat
if source:
src = (_normalize_query_param(source) or source).strip()
if src:
f["source"] = src
return f or None
@app.post("/api/search")
def search(req: SearchRequest):
vecs = embed_client.embed([req.query], normalize=True, is_query=True)
if not vecs:
raise HTTPException(status_code=502, detail="임베딩 실패")
query_vec = np.array(vecs[0], dtype="float32")
results = vector_store.search(
query_vec,
top_k=req.top_k,
threshold=req.threshold,
filter_dict=_filter_dict(req.category, req.source),
)
return {"count": len(results), "items": [_row(r, r.get("score")) for r in results]}
@app.post("/api/keyword-search")
def keyword_search(req: KeywordSearchRequest):
"""문자열 포함(부분일치) 검색 — 의미 검색과 달리 키워드가 실제 포함된 항목만 반환"""
_require_qdrant()
fields = {"both": ("q", "a"), "q": ("q",), "a": ("a",)}[req.field]
items, total, exhausted = vector_store.keyword_search(
req.keyword,
fields=fields,
filter_dict=_filter_dict(req.category, req.source),
skip=req.skip,
limit=req.limit,
)
return {
"count": len(items),
"total": total,
"exhausted": exhausted,
"items": [_row(it) for it in items],
}
@app.get("/api/categories")
def categories():
"""등록된 category 목록 + 건수 (어드민 분류 필터/트리용)"""
_require_qdrant()
counts = vector_store.distinct_payload_values("category")
items = [{"category": k, "count": v} for k, v in sorted(counts.items())]
return {"count": len(items), "items": items}
@app.get("/api/points")
def list_points(
limit: int = 50,
offset: Optional[str] = None,
page: Optional[int] = None,
size: Optional[int] = None,
category: Optional[str] = None,
source: Optional[str] = None,
):
_require_qdrant()
fd = _filter_dict(category, source)
if page is not None:
pg = max(0, page)
sz = max(1, min(size or 20, 200))
items, total = vector_store.list_points_page(pg, sz, fd)
return {
"page": pg,
"size": sz,
"total": total,
"items": [_row(it) for it in items],
}
items, next_offset = vector_store.list_points(
limit=limit, offset=offset, filter_dict=fd
)
return {
"count": len(items),
"items": [_row(it) for it in items],
"next_offset": next_offset,
}
@app.get("/api/points/{point_id}")
def get_point(point_id: str):
_require_qdrant()
item = vector_store.get_by_id(point_id)
if not item:
raise HTTPException(status_code=404, detail="해당 ID의 항목이 없습니다.")
return _row(item)
@app.put("/api/points/{point_id}")
def update_point(point_id: str, req: UpdateRequest):
"""
기존 항목 수정. 전달된 필드만 갱신한다.
- q 기반 결정적 ID → q 변경 시 새 ID로 upsert 후 옛 ID 삭제
- 추가/업로드와 동일: 더 최신 source_created_at이 이미 있으면 스킵
"""
_require_qdrant()
existing = vector_store.get_by_id(point_id)
if not existing:
raise HTTPException(status_code=404, detail="해당 ID의 항목이 없습니다.")
meta: Dict[str, Any] = dict(existing.get("meta") or {})
if req.q is not None:
meta["q"] = req.q.strip()
if req.a is not None:
meta["a"] = req.a
if req.category is not None:
meta["category"] = req.category
if req.url is not None:
meta["url"] = req.url
if req.source_created_at is not None:
meta["source_created_at"] = req.source_created_at.strip() or None
if not meta.get("q") or not meta.get("a"):
raise HTTPException(status_code=400, detail="q와 a는 비울 수 없습니다.")
if not meta.get("source_created_at") or not str(meta.get("source_created_at")).strip():
raise HTTPException(status_code=400, detail="source_created_at은 필수입니다.")
meta["source_created_at"] = str(meta["source_created_at"]).strip()
# 질문 재임베딩 (q가 안 바뀌었어도 일관성을 위해 항상 재임베딩)
vecs = embed_client.embed([meta["q"]], normalize=True, is_query=False)
if not vecs:
raise HTTPException(status_code=502, detail="임베딩 실패")
new_id = make_point_id(meta)
vectors = np.array([vecs[0]], dtype="float32")
if hasattr(vector_store, "upsert_vectors"):
stats = vector_store.upsert_vectors(vectors, [meta], skip_if_older=True)
if stats.get("skipped"):
return {
"skipped": True,
"reason": stats.get("last_skip_reason") or "older_source_created_at",
"id": stats.get("last_id") or new_id,
}
else:
meta.setdefault("indexed_at", _now_iso())
meta["updated_at"] = _now_iso()
vector_store.add_vectors(vectors, [meta], skip_if_older=True)
# ID가 바뀐 경우(질문 변경) 옛 항목 제거
if new_id != point_id:
vector_store.delete_by_id(point_id)
# 레거시(source_id 기반) ID로 남아 있는 동일 q 항목 정리
if hasattr(vector_store, "find_by_exact_q"):
legacy = vector_store.find_by_exact_q(meta["q"])
if legacy and str(legacy.get("id")) not in (str(new_id), str(point_id)):
vector_store.delete_by_id(str(legacy["id"]))
return {"updated": new_id, "moved": new_id != point_id, "skipped": False}
@app.delete("/api/points/{point_id}")
def delete_point(point_id: str):
_require_qdrant()
if not vector_store.get_by_id(point_id):
raise HTTPException(status_code=404, detail="해당 ID의 항목이 없습니다.")
vector_store.delete_by_id(point_id)
return {"deleted": point_id}
@app.post("/api/points/delete-batch")
def delete_batch(req: DeleteBatchRequest):
_require_qdrant()
vector_store.delete_by_ids(req.ids)
return {"deleted": len(req.ids)}
@app.post("/api/points")
def add_point(req: AddRequest):
_require_qdrant()
if not req.source_created_at or not req.source_created_at.strip():
raise HTTPException(status_code=400, detail="source_created_at은 필수입니다.")
# 문서 임베딩(is_query=False) → ingest와 동일 방식
vecs = embed_client.embed([req.q], normalize=True, is_query=False)
if not vecs:
raise HTTPException(status_code=502, detail="임베딩 실패")
source_id = req.source_id or f"manual_{os.urandom(6).hex()}"
now = _now_iso()
meta: Dict[str, Any] = {
"q": req.q.strip(),
"a": req.a,
"category": req.category,
"source": req.source,
"source_id": source_id,
"url": req.url,
"source_created_at": req.source_created_at.strip(),
"indexed_at": now,
"updated_at": now,
}
point_id = make_point_id(meta)
vectors = np.array([vecs[0]], dtype="float32")
if hasattr(vector_store, "upsert_vectors"):
stats = vector_store.upsert_vectors(vectors, [meta], skip_if_older=True)
if stats.get("skipped"):
return {
"skipped": True,
"reason": stats.get("last_skip_reason") or "older_source_created_at",
"id": stats.get("last_id") or point_id,
}
action = stats.get("last_action") or "inserted"
return {
"created": stats.get("last_id") or point_id,
"source_id": source_id,
"action": action,
"skipped": False,
}
vector_store.add_vectors(vectors, [meta], skip_if_older=True)
return {"created": point_id, "source_id": source_id, "skipped": False}
# ───────────────────────────────────────────
# 웹 UI (빌드 불필요 단일 페이지)
# ───────────────────────────────────────────
@app.get("/", response_class=HTMLResponse)
def index():
return HTML_PAGE
HTML_PAGE = """<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>벡터DB 큐레이션 어드민</title>
<style>
:root { --bd:#e2e8f0; --pri:#2563eb; --bg:#f8fafc; --txt:#0f172a; --muted:#64748b; --danger:#dc2626; }
* { box-sizing:border-box; }
body { margin:0; font-family:'Segoe UI',-apple-system,system-ui,sans-serif; background:var(--bg); color:var(--txt); }
header { background:#fff; border-bottom:1px solid var(--bd); padding:16px 24px; display:flex; align-items:center; gap:16px; position:sticky; top:0; z-index:10; }
header h1 { font-size:18px; margin:0; }
.stat { margin-left:auto; color:var(--muted); font-size:14px; }
main { max-width:1100px; margin:0 auto; padding:24px; }
.tabs { display:flex; gap:8px; margin-bottom:16px; }
.tab { padding:8px 16px; border:1px solid var(--bd); border-radius:8px; background:#fff; cursor:pointer; font-size:14px; }
.tab.active { background:var(--pri); color:#fff; border-color:var(--pri); }
.panel { display:none; background:#fff; border:1px solid var(--bd); border-radius:12px; padding:20px; }
.panel.active { display:block; }
.row { display:flex; gap:8px; align-items:center; flex-wrap:wrap; }
input, textarea, select { width:100%; padding:9px 12px; border:1px solid var(--bd); border-radius:8px; font-size:14px; font-family:inherit; }
textarea { min-height:90px; resize:vertical; }
label { display:block; font-size:13px; color:var(--muted); margin:10px 0 4px; }
button { padding:9px 16px; border:none; border-radius:8px; background:var(--pri); color:#fff; cursor:pointer; font-size:14px; }
button.secondary { background:#fff; color:var(--txt); border:1px solid var(--bd); }
button.danger { background:var(--danger); }
button:disabled { opacity:.5; cursor:not-allowed; }
.card { border:1px solid var(--bd); border-radius:10px; padding:14px; margin-top:12px; }
.card .meta { font-size:12px; color:var(--muted); margin-bottom:6px; display:flex; gap:10px; flex-wrap:wrap; }
.card .q { font-weight:600; margin-bottom:6px; }
.card .a { font-size:14px; color:#334155; white-space:pre-wrap; }
.badge { background:#eff6ff; color:var(--pri); border-radius:6px; padding:1px 8px; font-size:12px; }
.toolbar { display:flex; gap:8px; margin-top:10px; }
.empty { color:var(--muted); text-align:center; padding:30px; }
.toast { position:fixed; bottom:20px; left:50%; transform:translateX(-50%); background:#0f172a; color:#fff; padding:10px 18px; border-radius:8px; opacity:0; transition:.2s; font-size:14px; }
.toast.show { opacity:1; }
code { background:#f1f5f9; padding:1px 6px; border-radius:4px; font-size:12px; }
</style>
</head>
<body>
<header>
<h1>🗂️ 벡터DB 큐레이션 어드민</h1>
<div class="stat" id="stat">로딩...</div>
</header>
<main>
<div class="tabs">
<div class="tab active" data-tab="search">의미검색</div>
<div class="tab" data-tab="keyword">키워드검색</div>
<div class="tab" data-tab="browse">목록</div>
<div class="tab" data-tab="add">추가</div>
</div>
<!-- 의미 검색 -->
<section class="panel active" id="panel-search">
<div class="row">
<input id="q" placeholder="질문을 입력하세요 (뜻이 비슷한 항목 검색 = 챗봇과 동일)" onkeydown="if(event.key==='Enter')doSearch()"/>
<button onclick="doSearch()">검색</button>
</div>
<div id="search-results"></div>
</section>
<!-- 키워드(문자열 포함) 검색 -->
<section class="panel" id="panel-keyword">
<div class="row">
<input id="kw" placeholder="포함된 문자열 검색 (예: 1588-2504, 부가통행료)" onkeydown="if(event.key==='Enter')doKeyword()"/>
<select id="kw-field" style="max-width:140px">
<option value="both">질문+답변</option>
<option value="q">질문만</option>
<option value="a">답변만</option>
</select>
<button onclick="doKeyword()">검색</button>
</div>
<div id="kw-results"></div>
</section>
<!-- 목록 -->
<section class="panel" id="panel-browse">
<div class="row">
<button class="secondary" onclick="loadList(true)">처음부터</button>
<button class="secondary" id="more-btn" onclick="loadList(false)">더 보기</button>
</div>
<div id="list-results"></div>
</section>
<!-- 추가 -->
<section class="panel" id="panel-add">
<label>질문 (q) *</label>
<input id="add-q" placeholder="예: 하이패스 단말기는 어디서 구입하나요?"/>
<label>답변 (a) *</label>
<textarea id="add-a" placeholder="답변 내용"></textarea>
<div class="row">
<div style="flex:1"><label>분류 (category)</label><input id="add-cat" placeholder="예: 하이패스 이용"/></div>
<div style="flex:1"><label>URL</label><input id="add-url" placeholder="https://..."/></div>
</div>
<div class="toolbar"><button onclick="doAdd()">벡터DB에 추가</button></div>
<p style="color:var(--muted);font-size:13px">추가 시 질문이 임베딩되어 검색 대상이 됩니다. 같은 항목을 다시 추가하면 덮어쓰기됩니다.</p>
</section>
</main>
<div class="toast" id="toast"></div>
<script>
const $ = (s) => document.querySelector(s);
let listOffset = null;
function toast(msg){ const t=$("#toast"); t.textContent=msg; t.classList.add("show"); setTimeout(()=>t.classList.remove("show"),2200); }
function esc(s){ return (s??"").toString().replace(/[&<>]/g, c=>({'&':'&amp;','<':'&lt;','>':'&gt;'}[c])); }
document.querySelectorAll(".tab").forEach(t=>t.onclick=()=>{
document.querySelectorAll(".tab").forEach(x=>x.classList.remove("active"));
document.querySelectorAll(".panel").forEach(x=>x.classList.remove("active"));
t.classList.add("active");
$("#panel-"+t.dataset.tab).classList.add("active");
if(t.dataset.tab==="browse") loadList(true);
});
async function refreshStat(){
try{ const r=await fetch("api/stats"); const d=await r.json(); $("#stat").textContent=`벡터 ${d.count.toLocaleString()}개 · ${d.vector_store}`; }
catch(e){ $("#stat").textContent="상태 조회 실패"; }
}
function cardHtml(it, showScore){
const score = showScore && it.score!=null ? `<span class="badge">score ${it.score}</span>` : "";
return `<div class="card" data-id="${esc(it.id)}">
<div class="meta">${score}<span>출처: ${esc(it.source)||'-'}</span><span>분류: ${esc(it.category)||'-'}</span><span>id: <code>${esc(it.id)}</code></span></div>
<div class="q">Q. ${esc(it.q)}</div>
<div class="a">A. ${esc(it.a)}</div>
${it.url?`<div class="meta"><a href="${esc(it.url)}" target="_blank">${esc(it.url)}</a></div>`:""}
<div class="toolbar"><button class="danger" onclick="del('${esc(it.id)}')">삭제</button></div>
</div>`;
}
async function doSearch(){
const q=$("#q").value.trim(); if(!q) return;
$("#search-results").innerHTML="<div class='empty'>검색 중...</div>";
try{
const r=await fetch("api/search",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({query:q,top_k:20})});
const d=await r.json();
$("#search-results").innerHTML = d.items.length ? d.items.map(it=>cardHtml(it,true)).join("") : "<div class='empty'>결과가 없습니다.</div>";
}catch(e){ $("#search-results").innerHTML="<div class='empty'>검색 실패</div>"; }
}
async function doKeyword(){
const kw=$("#kw").value.trim(); if(!kw) return;
$("#kw-results").innerHTML="<div class='empty'>검색 중...</div>";
try{
const r=await fetch("api/keyword-search",{method:"POST",headers:{"Content-Type":"application/json"},
body:JSON.stringify({keyword:kw,field:$("#kw-field").value,limit:100})});
const d=await r.json();
const note=`<div class="meta" style="margin-bottom:8px">스캔 ${d.scanned}건 · 매칭 ${d.count}건${d.exhausted?'':' (상위 일부만 표시)'}</div>`;
$("#kw-results").innerHTML = note + (d.items.length ? d.items.map(it=>cardHtml(it,false)).join("") : "<div class='empty'>포함된 항목이 없습니다.</div>");
}catch(e){ $("#kw-results").innerHTML="<div class='empty'>검색 실패</div>"; }
}
async function loadList(reset){
if(reset){ listOffset=null; $("#list-results").innerHTML=""; }
try{
const url = "api/points?limit=20" + (listOffset?("&offset="+encodeURIComponent(listOffset)):"");
const r=await fetch(url); const d=await r.json();
if(reset && !d.items.length){ $("#list-results").innerHTML="<div class='empty'>등록된 항목이 없습니다.</div>"; }
else { $("#list-results").insertAdjacentHTML("beforeend", d.items.map(it=>cardHtml(it,false)).join("")); }
listOffset = d.next_offset;
$("#more-btn").disabled = !listOffset;
}catch(e){ toast("목록 조회 실패"); }
}
async function del(id){
if(!confirm("이 항목을 벡터DB에서 삭제할까요?")) return;
try{
const r=await fetch("api/points/"+encodeURIComponent(id),{method:"DELETE"});
if(!r.ok) throw 0;
document.querySelector(`.card[data-id="${CSS.escape(id)}"]`)?.remove();
toast("삭제되었습니다."); refreshStat();
}catch(e){ toast("삭제 실패"); }
}
async function doAdd(){
const q=$("#add-q").value.trim(), a=$("#add-a").value.trim();
if(!q||!a){ toast("질문과 답변은 필수입니다."); return; }
try{
const r=await fetch("api/points",{method:"POST",headers:{"Content-Type":"application/json"},
body:JSON.stringify({q,a,category:$("#add-cat").value.trim()||null,url:$("#add-url").value.trim()||null})});
if(!r.ok) throw 0;
$("#add-q").value=""; $("#add-a").value=""; $("#add-cat").value=""; $("#add-url").value="";
toast("추가되었습니다."); refreshStat();
}catch(e){ toast("추가 실패"); }
}
refreshStat();
</script>
</body>
</html>"""
@@ -0,0 +1,907 @@
"""
OpenAI-style tool calling agent loop.
"""
import json
import os
import re
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional
from agent.pending_store import AgentPendingStore
from agent.tool_executor import ToolExecutor
AGENT_SYSTEM_PROMPT = """당신은 한국도로공사 AI 챗봇 에이전트입니다.
사용자 질문에 답하기 위해 제공된 tool을 적절히 호출하세요.
규칙:
1. 실시간 DB/운영 데이터(통행료, 미납, IC전화, 도로정체, 휴게소 주유·음식·매장 등)는 해당 domain tool을 사용하세요.
2. 일반 FAQ/절차/안내는 rag_search tool로 지식베이스를 검색하세요.
3. 필수 정보가 부족하면 ask_user tool로 사용자에게 되물으세요.
4. tool 결과를 바탕으로 한국어로 정확하고 친절하게 최종 답변을 작성하세요.
5. 반드시 tool(rag_search 또는 domain tool)로 얻은 결과에 있는 내용만 사용하세요.
tool 결과에 없는 제도·요금·정책·수치·날짜는 절대 추측하거나 만들어내지 마세요.
6. 업무·정보성 질문은 반드시 먼저 적절한 tool을 호출하세요. tool 없이 임의로 답변하지 마세요.
- 이전 대화 이력에 비슷한 내용이 있어 보여도, 지식·제도·정책·요금 등 정보성 질문이면 매 턴마다 다시 rag_search(또는 domain tool)를 호출하세요.
- 대화 이력만 근거로 사실 답변을 재생성하지 마세요. 근거는 항상 이번 턴 tool 결과에서 가져와야 합니다.
7. tool 결과에서 근거를 찾지 못하면, 정확한 정보를 확인하기 어렵다고 안내하고 한국도로공사 콜센터(1588-2504)로 문의하도록 하세요.
8. 이전 턴 pending intent가 있고 사용자가 누락 파라미터만 짧게 답한 경우, pending intent의 파라미터로 해석하세요.
"""
class AgentService:
"""Runs LLM tool-calling loop with local RAG + remote domain tools."""
MAX_ROUNDS = int(os.getenv("AGENT_MAX_ROUNDS", "6"))
def __init__(
self,
llm_client,
tool_executor: ToolExecutor,
config,
pending_store: Optional[AgentPendingStore] = None,
prompt_builder=None,
llm_handler=None,
intent_detector=None,
greeting_handler=None,
emotion_detector=None,
emotion_handler=None,
suggestion_handler=None,
chat_manager=None,
response_handler=None,
query_rewriter=None,
):
self.llm_client = llm_client
self.tool_executor = tool_executor
self.config = config
self.pending_store = pending_store or AgentPendingStore()
self.prompt_builder = prompt_builder
self.llm_handler = llm_handler
# Legacy parity 핸들러 (선택 주입)
self.intent_detector = intent_detector
self.query_rewriter = query_rewriter
self.greeting_handler = greeting_handler
self.emotion_detector = emotion_detector
self.emotion_handler = emotion_handler
self.suggestion_handler = suggestion_handler
self.chat_manager = chat_manager
self.response_handler = response_handler
def chat(
self,
query: str,
bot_id: Optional[str] = None,
*,
pending_intent_type: Optional[str] = None,
pending_params: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
self.tool_executor.reset_state()
ts = datetime.now(timezone.utc).isoformat()
stored_pending = self.pending_store.get(bot_id)
effective_pending_type = pending_intent_type or (
stored_pending.get("pendingIntentType") if stored_pending else None
)
effective_pending_params = pending_params or (
stored_pending.get("pendingParams") if stored_pending else None
) or {}
had_pending = effective_pending_type is not None
# ⓪ 인사/종료 특별의도 선처리 (Legacy /ask와 동일). pending 진행 중에는 건너뜀.
if not had_pending:
greeting_response = self._handle_special_intent(query, bot_id, ts)
if greeting_response is not None:
return greeting_response
# 대화 이력 (멀티턴 맥락) — tool 선택·슬롯필링에 활용
conversation_messages = self._load_history_messages(bot_id, ts)
tools = self.tool_executor.all_tools()
if len(tools) <= 2:
print(
"[AgentService] WARN: remote domain tools unavailable "
f"(only {len(tools)} tools: rag_search, ask_user)"
)
system_content = AGENT_SYSTEM_PROMPT
if effective_pending_type:
system_content += (
"\n\n【이전 턴 pending】\n"
f"- intent: {effective_pending_type}\n"
f"- 이미 수집된 파라미터: {json.dumps(effective_pending_params, ensure_ascii=False)}\n"
"- 사용자의 이번 발화가 누락 슬롯을 채우는 답이면 해당 intent tool을 재호출하세요."
)
messages: List[Dict[str, Any]] = [
{"role": "system", "content": system_content},
]
if conversation_messages:
messages.extend(conversation_messages)
messages.append({"role": "user", "content": query})
final_content = ""
for round_idx in range(self.MAX_ROUNDS):
try:
message = self.llm_client.chat_completion_message(
messages=messages,
tools=tools,
max_tokens=min(self.config.llm_max_tokens, 1024),
temperature=0.2,
)
except Exception as exc:
print(f"[AgentService] {ts} tool 선택 LLM 호출 실패 → 안전망 진행: {exc}")
break
tool_calls = message.get("tool_calls") or []
content = (message.get("content") or "").strip()
if not tool_calls:
parsed = self._parse_json_tool_call(content)
if parsed:
tool_calls = [parsed]
else:
final_content = self._strip_think_tags(content)
break
messages.append(
{
"role": "assistant",
"content": content or None,
"tool_calls": tool_calls,
}
)
for call in tool_calls:
fn = call.get("function") or {}
tool_name = fn.get("name") or call.get("name")
raw_args = fn.get("arguments") or call.get("arguments") or "{}"
arguments = raw_args if isinstance(raw_args, dict) else json.loads(raw_args)
if tool_name == "ask_user":
question = arguments.get("question") or "조회에 필요한 정보를 조금 더 알려주세요."
# intentType 누락 시: 이번 턴에 시도한 domain tool로 폴백 → 멀티턴 슬롯필링 유지
intent_for_pending = (
arguments.get("intentType")
or arguments.get("pendingIntentType")
or effective_pending_type
or self._last_domain_intent_from_trace()
)
return self._finalize_clarify(
bot_id=bot_id,
answer=question,
intent_type=intent_for_pending,
params=effective_pending_params,
missing_params=arguments.get("missingParams"),
user_query=query,
ts=ts,
)
tool_result_raw = self.tool_executor.execute(
tool_name,
arguments,
bot_id=bot_id,
user_input=query,
)
tool_result = json.loads(tool_result_raw) if isinstance(tool_result_raw, str) else tool_result_raw
if tool_name not in ("rag_search", "ask_user") and isinstance(tool_result, dict):
if tool_result.get("needsClarification"):
question = tool_result.get("clarificationQuestion") or "추가 정보가 필요합니다."
return self._finalize_clarify(
bot_id=bot_id,
answer=question,
intent_type=tool_result.get("intentType"),
fetch_owner=tool_result.get("fetchOwner"),
ui_type=tool_result.get("uiType"),
domain_data=tool_result.get("domainData"),
params=tool_result.get("params"),
ic_candidates=tool_result.get("icCandidates"),
missing_params=tool_result.get("missingParams"),
user_query=query,
ts=ts,
)
if tool_result.get("status") is False:
msg = (
tool_result.get("statusMsg")
or tool_result.get("error")
or "요청을 처리하지 못했습니다."
)
return self._build_response(
answer=msg,
route_type="agent",
references=[],
faq_urls=[],
)
messages.append(
{
"role": "tool",
"tool_call_id": call.get("id") or f"call_{round_idx}_{tool_name}",
"name": tool_name,
"content": tool_result_raw if isinstance(tool_result_raw, str) else json.dumps(tool_result, ensure_ascii=False),
}
)
continue
if not final_content:
final_content = "죄송합니다. 답변을 생성하지 못했습니다."
domain_payload = self.tool_executor.last_domain_result or {}
rag_payload = self.tool_executor.last_rag_result or {}
# 사용 가능한 domain 결과 수집 (복합 질의 시 여러 tool 결과 누적)
usable_domain_results = [
d
for d in (self.tool_executor.domain_results or [])
if self._has_usable_domain_data(d.get("domainData"))
]
if not usable_domain_results and self._has_usable_domain_data(domain_payload.get("domainData")):
usable_domain_results = [domain_payload]
has_rag = bool(rag_payload.get("status") and rag_payload.get("references"))
# [안전망] 어떤 경우라도 정보성 질문은 Qdrant를 조회한다.
# LLM이 대화이력만 보고 rag_search를 스킵하면 동일 질문에 답이 달라지는 문제가 발생하므로,
# domain 근거가 없고 rag_search가 한 번도 실행되지 않았다면 강제로 Qdrant를 조회한다.
# (domain tool이 데이터를 가져온 경우엔 그 자체가 근거이므로 강제 조회하지 않는다.)
#
# 단, pending(슬롯필링) 진행 중에는 이 안전망을 건너뛴다:
# - 강제 rag가 rag_payload.status=True를 만들면 아래 stale-pending 정리가 빈 결과에도 발동해
# 슬롯 채우는 중인 유효 pending이 지워진다.
# - domain 가드가 params={}로 clarify를 반환하면 누적된 pending 파라미터가 덮어써진다.
# pending 중 LLM이 tool 재호출에 실패하면 guidance로 폴백하되 pending은 그대로 보존한다.
if not had_pending and not usable_domain_results and self.tool_executor.last_rag_result is None:
# 강제 rag 전에 명백한 domain 의도(요금 계산/미납 조회 등)면 FAQ 대신 되물어 정확 흐름으로 유도.
# FAQ성 질문(할인/방법/절차 등)은 가드하지 않아 기존 rag 경로를 유지한다(다자녀할인 등 회귀 방지).
domain_guard = self._domain_intent_guard(query)
if domain_guard is not None:
print(
f"[AgentService] {ts} 강제 rag 대신 domain 되물음: "
f"intent={domain_guard['intent_type']}"
)
return self._finalize_clarify(
bot_id=bot_id,
answer=domain_guard["message"],
intent_type=domain_guard["intent_type"],
params={},
user_query=query,
ts=ts,
)
self._force_rag_search(query, bot_id, ts)
rag_payload = self.tool_executor.last_rag_result or {}
has_rag = bool(rag_payload.get("status") and rag_payload.get("references"))
# 감정 분석 (부정 감정 시 공감 톤 지시) — Legacy /ask parity
emotion_instruction, emotion_name = self._detect_emotion(query, ts)
grounded_used = False
# 환각 방지: 최종 답변은 반드시 tool/qdrant 근거로만 생성한다.
# 근거가 있으면 PromptBuilder로 재작성, 근거가 없으면 guidance(콜센터 안내)로 폴백.
guidance_used = False
if usable_domain_results:
combined_domain_data = self._combine_domain_data(usable_domain_results)
grounded = self._generate_grounded_answer(
query=query,
rag_payload=rag_payload,
domain_data=combined_domain_data,
conversation_history=conversation_messages,
emotion_instruction=emotion_instruction,
emotion_name=emotion_name,
)
if grounded:
final_content = grounded
grounded_used = True
elif has_rag:
# rag-only(FAQ) 경로도 Legacy/Admin과 동일한 PromptBuilder로 최종 답변 생성
grounded = self._generate_grounded_answer(
query=query,
rag_payload=rag_payload,
domain_data=None,
conversation_history=conversation_messages,
emotion_instruction=emotion_instruction,
emotion_name=emotion_name,
)
if grounded:
final_content = grounded
grounded_used = True
else:
# tool/qdrant 근거 없음 → agent LLM 자유 답변 폐기, 안내 프롬프트로 폴백
guidance = self._guidance_answer(query, bot_id, conversation_messages)
if guidance:
final_content = guidance
guidance_used = True
references = self._resolve_references(domain_payload, rag_payload)
faq_urls = self._extract_faq_urls(references)
# 낮은 신뢰도 시 대안 질문 제안(💡) — rag 근거가 있을 때만
if grounded_used and has_rag:
final_content = self._apply_suggestions(final_content, rag_payload)
if domain_payload.get("intentType"):
self.pending_store.clear(bot_id)
elif (
had_pending
and rag_payload.get("status")
and not domain_payload.get("intentType")
):
# 무관 FAQ 등 rag_search만으로 답한 경우 stale pending 제거
self.pending_store.clear(bot_id)
history_metadata = {
"type": "no_match" if guidance_used else "agent",
"routeType": self._resolve_route_type(domain_payload),
"intentType": domain_payload.get("intentType"),
"num_references": len(references),
"searchMode": rag_payload.get("searchMode"),
"candidateCount": rag_payload.get("candidateCount"),
}
if guidance_used:
# chatbotAdmin 통계가 guidance를 정상(success)으로 보지 않도록 명시한다.
history_metadata.update(
{
"answer_confidence": "low",
"reason": "no_grounding",
"statusMsg": "no_match",
"searchMode": rag_payload.get("searchMode"),
"keywordRetryUsed": rag_payload.get("keywordRetryUsed"),
"keywordRetryAccepted": rag_payload.get("keywordRetryAccepted"),
}
)
# 대화 이력 저장 (멀티턴 컨텍스트 유지) — Legacy /ask parity
self._save_history(
bot_id=bot_id,
user_query=query,
answer=final_content,
references=references,
metadata=history_metadata,
ts=ts,
)
return self._build_response(
answer=final_content,
route_type=self._resolve_route_type(domain_payload),
intent_type=domain_payload.get("intentType"),
fetch_owner=domain_payload.get("fetchOwner"),
ui_type=domain_payload.get("uiType"),
domain_data=domain_payload.get("domainData"),
params=domain_payload.get("params"),
references=references,
faq_urls=faq_urls,
)
def _finalize_clarify(
self,
*,
bot_id: Optional[str],
answer: str,
intent_type: Optional[str] = None,
fetch_owner: Optional[str] = None,
ui_type: Optional[str] = None,
domain_data: Optional[Dict[str, Any]] = None,
params: Optional[Dict[str, Any]] = None,
ic_candidates: Optional[List[Any]] = None,
missing_params: Optional[List[Any]] = None,
user_query: Optional[str] = None,
ts: Optional[str] = None,
) -> Dict[str, Any]:
if intent_type:
self.pending_store.save(
bot_id,
pending_intent_type=intent_type,
pending_params=params or {},
missing_params=missing_params,
)
# clarify(되물음) 턴도 대화 이력에 저장 → 멀티턴 맥락/재작성기가 참조 가능
if user_query is not None:
self._save_history(
bot_id=bot_id,
user_query=user_query,
answer=answer,
references=[],
metadata={"type": "clarify", "intentType": intent_type},
ts=ts or datetime.now(timezone.utc).isoformat(),
)
return self._build_response(
answer=answer,
route_type="clarify",
intent_type=intent_type,
fetch_owner=fetch_owner,
ui_type=ui_type,
domain_data=domain_data,
params=params,
needs_clarification=True,
clarification_question=answer,
ic_candidates=ic_candidates,
pending_intent_type=intent_type,
missing_params=missing_params,
)
def _resolve_references(
self,
domain_payload: Dict[str, Any],
rag_payload: Dict[str, Any],
) -> List[Any]:
domain_refs = domain_payload.get("references")
if isinstance(domain_refs, list) and domain_refs:
return domain_refs
rag_refs = rag_payload.get("references")
if isinstance(rag_refs, list):
return rag_refs
return []
def _extract_faq_urls(self, references: List[Any]) -> List[str]:
urls: List[str] = []
seen = set()
for ref in references:
if not isinstance(ref, dict):
continue
url = ref.get("url")
if url and url not in seen:
seen.add(url)
urls.append(url)
if len(urls) >= 3:
break
return urls
def _resolve_route_type(self, domain_payload: Dict[str, Any]) -> str:
fetch_owner = domain_payload.get("fetchOwner")
if fetch_owner == "WEB":
return "web_domain"
if domain_payload.get("intentType"):
return "domain"
return "agent"
def _has_usable_domain_data(self, domain_data: Optional[Dict[str, Any]]) -> bool:
if self.prompt_builder:
return self.prompt_builder._has_usable_domain_data(domain_data)
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 _rag_refs_to_prompt_format(
self,
rag_payload: Dict[str, Any],
) -> tuple[List[Dict[str, Any]], List[float]]:
references: List[Dict[str, Any]] = []
scores: List[float] = []
for ref in rag_payload.get("references") or []:
if not isinstance(ref, dict):
continue
references.append(
{
"q": ref.get("q") or ref.get("question") or "",
"a": ref.get("a") or ref.get("answer") or "",
"url": ref.get("url"),
"category": ref.get("category"),
"source": "faq",
}
)
score = ref.get("score")
scores.append(float(score) if score is not None else 0.0)
return references, scores
def _generate_grounded_answer(
self,
*,
query: str,
rag_payload: Dict[str, Any],
domain_data: Optional[Dict[str, Any]] = None,
conversation_history: Optional[List[Dict[str, str]]] = None,
emotion_instruction: Optional[str] = None,
emotion_name: Optional[str] = None,
) -> Optional[str]:
"""Legacy /ask와 동일한 PromptBuilder로 최종 문장을 생성.
- 도메인 tool 성공: domain_data(llmSummary 포함)를 【DB 조회 결과】 컨텍스트로 사용
- rag-only(FAQ): domain_data=None, FAQ references만으로 답변 (Admin/Legacy와 동일 경로)
- 대화 이력/감정 지시사항을 함께 반영 (Legacy parity)
"""
if not self.prompt_builder or not self.llm_handler:
return None
references, scores = self._rag_refs_to_prompt_format(rag_payload)
messages = self.prompt_builder.build_answer_prompt_messages(
original_query=query,
rewritten_query=None,
references=references,
scores=scores,
conversation_history=conversation_history or [],
emotion_instruction=emotion_instruction,
emotion_name=emotion_name,
domain_data=domain_data,
)
ts = datetime.now(timezone.utc).isoformat()
try:
return self.llm_handler.generate_answer_from_messages(messages, ts)
except Exception as exc:
print(f"[AgentService] {ts} 최종 LLM 답변 실패, 폴백 사용: {exc}")
if domain_data:
return self._domain_fallback_answer(domain_data)
return None
def _combine_domain_data(
self,
domain_results: List[Dict[str, Any]],
) -> Optional[Dict[str, Any]]:
"""복합 질의 시 여러 domain 결과의 llmSummary/필드를 프롬프트용으로 병합."""
usable = [d.get("domainData") for d in domain_results if d.get("domainData")]
if not usable:
return None
if len(usable) == 1:
return usable[0]
summaries: List[str] = []
merged_fields: Dict[str, Any] = {"status": True}
for dd in usable:
summary = dd.get("llmSummary")
if summary is not None and str(summary).strip():
summaries.append(str(summary).strip())
for key, value in dd.items():
if key in ("status", "statusMsg", "errorMsg", "llmSummary"):
continue
if value is not None and key not in merged_fields:
merged_fields[key] = value
if summaries:
merged_fields["llmSummary"] = "\n\n".join(summaries)
return merged_fields
# 강제 rag 폴백 시 명백한 domain 의도만 되묻기로 유도(고정밀). FAQ성 질문은 가드하지 않음.
_FAQ_HINT_TOKENS = (
"할인", "감면", "방법", "절차", "어떻게", "안내", "신청", "등록",
"해지", "종류", "자격", "대상", "무엇", "인가요", "되나요", "가능",
)
_CAR_NO_RE = re.compile(r"\d{2,3}[가-힣]\d{4}")
_ROUTE_RE = re.compile(r"(에서|부터).{0,15}(까지)")
def _last_domain_intent_from_trace(self) -> Optional[str]:
"""이번 턴 tool_trace에서 마지막으로 시도한 domain tool명(=intentType) 반환.
LLM이 ask_user를 intentType 없이 호출했을 때 pending 저장용 폴백으로 사용.
rag_search/ask_user는 domain intent가 아니므로 제외한다.
"""
try:
for entry in reversed(self.tool_executor.tool_trace or []):
name = entry.get("tool")
if name and name not in ("rag_search", "ask_user"):
return name
except Exception:
pass
return None
def _domain_intent_guard(self, query: str) -> Optional[Dict[str, str]]:
"""강제 rag 직전, 명백한 domain 의도면 rag(FAQ) 대신 되물음으로 유도.
- FAQ성 표현(할인/방법/절차 등)이 있으면 가드하지 않는다 → 기존 rag 경로 유지(회귀 방지).
- 전체 차량번호 패턴 → 미납/환불 조회 의도.
- 'A에서 B까지' 경로 + '얼마' → 통행요금 조회 의도.
"""
text = (query or "").strip()
if not text:
return None
if any(tok in text for tok in self._FAQ_HINT_TOKENS):
return None
compact = re.sub(r"\s+", "", text)
if self._CAR_NO_RE.search(compact):
return {
"intent_type": "FARE_UNPAID",
"message": (
"챗봇에서 미납 통행료 조회가 가능합니다. "
"조회하려면 전체 차량번호를 입력해 주세요. 예: 12가3456 미납 조회"
),
}
if self._ROUTE_RE.search(text) and "얼마" in text:
return {
"intent_type": "FARE_SEARCH",
"message": (
"통행요금 조회를 위해 출발 IC와 도착 IC를 알려주세요. "
"예: 판교에서 신갈까지"
),
}
return None
def _contextualize_search_query(
self, query: str, bot_id: Optional[str], ts: str
) -> str:
"""후속 질문('얼마야?' 등)은 대화 이력을 반영해 완결형 검색어로 재작성.
강제 rag는 LLM이 인자를 만들지 않고 원문 발화를 그대로 검색하므로,
맥락이 필요한 후속 질문은 여기서 재작성해 검색 리콜을 보전한다.
재작성이 불필요(새 주제)하면 원문을 그대로 사용한다.
"""
if not self.query_rewriter or not self.chat_manager or not bot_id:
return query
if not getattr(self.config, "query_rewrite_enabled", True):
return query
try:
history = self.chat_manager.get_recent_history(
bot_id=bot_id,
hours=getattr(self.config, "chat_history_hours", 24),
limit=getattr(self.config, "chat_history_limit", 10),
)
if not history:
return query
rewritten = self.query_rewriter.rewrite_query(query, history, ts)
if rewritten:
print(f"[AgentService] {ts} 강제 rag 검색어 맥락화: '{query}' → '{rewritten}'")
return rewritten
except Exception as exc:
print(f"[AgentService] {ts} 검색어 맥락화 실패(원문 사용): {exc}")
return query
def _force_rag_search(self, query: str, bot_id: Optional[str], ts: str) -> None:
"""LLM이 tool을 스킵해도 정보성 질문은 Qdrant를 반드시 조회한다(일관성/환각방지 안전망)."""
try:
search_query = self._contextualize_search_query(query, bot_id, ts)
print(f"[AgentService] {ts} 근거 없음 → rag_search 강제 실행: {search_query}")
self.tool_executor.execute(
"rag_search", {"query": search_query}, user_input=query
)
except Exception as exc:
print(f"[AgentService] {ts} 강제 rag_search 실패: {exc}")
def _guidance_answer(
self,
query: str,
bot_id: Optional[str],
conversation_messages: Optional[List[Dict[str, str]]] = None,
) -> Optional[str]:
"""근거를 찾지 못했을 때 Legacy /ask no-match와 동일한 안내(콜센터) 답변 생성."""
if not self.prompt_builder or not self.llm_handler:
return None
ts = datetime.now(timezone.utc).isoformat()
conversation_context = self._history_messages_to_text(conversation_messages)
try:
system_prompt, user_prompt = self.prompt_builder.build_guidance_prompt(
query, conversation_context
)
return self.llm_handler.generate_answer(system_prompt, user_prompt, ts)
except Exception as exc:
print(f"[AgentService] {ts} guidance 답변 실패, 기본 안내 사용: {exc}")
return (
"문의하신 내용은 현재 정확한 정보를 확인하기 어렵습니다.\n"
"정확한 확인이 필요한 경우 한국도로공사 콜센터(1588-2504)로 문의해 주세요."
)
# ── Legacy parity helpers ─────────────────────────────────
def _handle_special_intent(
self,
query: str,
bot_id: Optional[str],
ts: str,
) -> Optional[Dict[str, Any]]:
"""인사/종료 등 특별의도를 Legacy /ask와 동일하게 고정 응답으로 처리."""
if not self.intent_detector or not self.greeting_handler:
return None
try:
intent = self.intent_detector.detect(query, ts)
except Exception as exc:
print(f"[AgentService] {ts} intent 감지 실패: {exc}")
return None
if not getattr(intent, "is_special", None) or not intent.is_special():
return None
try:
greeting = self.greeting_handler.generate_response(
intent_name=intent.name,
query=query,
matched_keywords=getattr(intent, "matched_keywords", []),
bot_id=bot_id,
)
except Exception as exc:
print(f"[AgentService] {ts} 특별의도 응답 생성 실패: {exc}")
return None
answer = greeting.get("answer") or ""
self._save_history(
bot_id=bot_id,
user_query=query,
answer=answer,
references=[],
metadata={"type": "special_intent", "intent": intent.name},
ts=ts,
)
return self._build_response(
answer=answer,
route_type="greeting",
references=[],
faq_urls=[],
quick_replies=greeting.get("quick_replies"),
)
def _load_history_messages(self, bot_id: Optional[str], ts: str) -> List[Dict[str, str]]:
"""MongoDB 대화 이력을 messages 포맷으로 로드 (멀티턴 컨텍스트)."""
if not self.chat_manager or not bot_id:
return []
if not getattr(self.config, "chat_history_always_include", True):
return []
try:
messages = self.chat_manager.get_messages_for_llm(
bot_id=bot_id,
hours=getattr(self.config, "chat_history_hours", 24),
max_conversations=getattr(self.config, "chat_history_limit", 10),
)
return messages or []
except Exception as exc:
print(f"[AgentService] {ts} 대화 이력 조회 실패: {exc}")
return []
def _history_messages_to_text(
self,
conversation_messages: Optional[List[Dict[str, str]]],
) -> Optional[str]:
if not conversation_messages:
return None
lines = []
for msg in conversation_messages:
role = msg.get("role")
content = (msg.get("content") or "").strip()
if not content:
continue
speaker = "고객" if role == "user" else "상담원"
lines.append(f"{speaker}: {content}")
if not lines:
return None
return "【이전 대화 이력】\n" + "\n".join(lines)
def _detect_emotion(self, query: str, ts: str) -> tuple[Optional[str], Optional[str]]:
"""감정 분석 → (emotion_instruction, emotion_name). Legacy /ask parity."""
if not self.emotion_detector or not self.emotion_handler:
return None, None
try:
emotion = self.emotion_detector.detect(query, ts)
instruction = self.emotion_handler.get_emotion_instruction(emotion.primary)
return (instruction or None), emotion.primary
except Exception as exc:
print(f"[AgentService] {ts} 감정 분석 실패: {exc}")
return None, None
def _apply_suggestions(self, answer: str, rag_payload: Dict[str, Any]) -> str:
"""낮은 신뢰도 시 대안 질문 제안(💡) 추가. Legacy /ask parity."""
if not self.suggestion_handler:
return answer
top_results, top_scores = self._rag_refs_to_prompt_format(rag_payload)
if not top_results:
return answer
try:
return self.suggestion_handler.enhance_answer_with_suggestions(
answer=answer,
top_results=top_results,
top_scores=top_scores,
max_suggestions=3,
)
except Exception as exc:
print(f"[AgentService] 제안 문구 생성 실패: {exc}")
return answer
def _save_history(
self,
*,
bot_id: Optional[str],
user_query: str,
answer: str,
references: List[Any],
metadata: Dict[str, Any],
ts: str,
) -> None:
"""Agent 응답을 MongoDB 대화 이력에 저장 (멀티턴 유지)."""
if not self.response_handler or not bot_id:
return
try:
matched_questions = []
scores = []
for ref in references or []:
if not isinstance(ref, dict):
continue
q = ref.get("question") or ref.get("q")
if q:
matched_questions.append(q)
score = ref.get("score")
if score is not None:
scores.append(score)
self.response_handler.save_to_mongodb(
bot_id=bot_id,
user_query=user_query,
ai_response=answer,
matched_questions=matched_questions,
scores=scores,
metadata=metadata,
ts=ts,
)
except Exception as exc:
print(f"[AgentService] {ts} 대화 이력 저장 실패: {exc}")
def _domain_fallback_answer(self, domain_data: Dict[str, Any]) -> str:
status_msg = domain_data.get("statusMsg")
if status_msg is not None and str(status_msg).strip():
return str(status_msg).strip()
summary = domain_data.get("llmSummary")
if summary is not None and str(summary).strip():
lines = [
line.strip()
for line in str(summary).splitlines()
if line.strip() and "안내하세요" not in line
]
if lines:
return "\n".join(lines)
return "조회 결과를 안내드리지 못했습니다. 잠시 후 다시 시도해 주세요."
def _build_response(
self,
*,
answer: str,
route_type: str,
intent_type: Optional[str] = None,
fetch_owner: Optional[str] = None,
ui_type: Optional[str] = None,
domain_data: Optional[Dict[str, Any]] = None,
params: Optional[Dict[str, Any]] = None,
references: Optional[List[Any]] = None,
faq_urls: Optional[List[str]] = None,
needs_clarification: bool = False,
clarification_question: Optional[str] = None,
ic_candidates: Optional[List[Any]] = None,
pending_intent_type: Optional[str] = None,
missing_params: Optional[List[Any]] = None,
quick_replies: Optional[List[Any]] = None,
) -> Dict[str, Any]:
return {
"status": True,
"routeType": route_type,
"answer": answer,
"llmAnswer": answer,
"intentType": intent_type,
"fetchOwner": fetch_owner,
"uiType": ui_type,
"domainData": domain_data,
"params": params or {},
"faqUrls": faq_urls or [],
"references": references or [],
"needsClarification": needs_clarification,
"clarificationQuestion": clarification_question,
"pendingIntentType": pending_intent_type,
"missingParams": missing_params,
"icCandidates": ic_candidates,
"quickReplies": quick_replies or [],
"toolTrace": self.tool_executor.tool_trace,
"reason": "agent_tool_calling",
}
def _parse_json_tool_call(self, content: str) -> Optional[Dict[str, Any]]:
if not content:
return None
match = re.search(r"\{[\s\S]*\}", content)
if not match:
return None
try:
payload = json.loads(match.group(0))
except json.JSONDecodeError:
return None
tool_name = payload.get("tool") or payload.get("name")
if not tool_name:
return None
arguments = payload.get("arguments") or payload.get("params") or {}
return {
"id": "parsed_json_call",
"type": "function",
"function": {
"name": tool_name,
"arguments": json.dumps(arguments, ensure_ascii=False),
},
}
def _strip_think_tags(self, text: str) -> str:
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,55 @@
"""
chatbotApi tool registry / execution HTTP client.
"""
import os
from typing import Any, Dict, List, Optional
import httpx
CHATBOT_API_BASE_URL = os.getenv(
"CHATBOT_API_BASE_URL",
os.getenv("TOOL_API_BASE_URL", "http://127.0.0.1:8086/api"),
).rstrip("/")
INTERNAL_TOOL_API_KEY = os.getenv("INTERNAL_TOOL_API_KEY", "")
TOOL_TIMEOUT = float(os.getenv("CHATBOT_TOOL_TIMEOUT", "30"))
class ChatbotToolClient:
"""Fetch tool schemas and execute domain tools via chatbotApi."""
def __init__(self, base_url: Optional[str] = None, timeout: float = TOOL_TIMEOUT):
self.base_url = (base_url or CHATBOT_API_BASE_URL).rstrip("/")
headers = {}
if INTERNAL_TOOL_API_KEY:
headers["X-Internal-Tool-Key"] = INTERNAL_TOOL_API_KEY
self.client = httpx.Client(timeout=timeout, headers=headers)
def list_tool_definitions(self) -> List[Dict[str, Any]]:
url = f"{self.base_url}/v1/tools/definitions"
response = self.client.get(url)
response.raise_for_status()
payload = response.json()
return payload.get("tools") or []
def execute_tool(
self,
tool_name: str,
arguments: Dict[str, Any],
*,
bot_id: Optional[str] = None,
user_input: Optional[str] = None,
) -> Dict[str, Any]:
url = f"{self.base_url}/v1/tools/execute"
body = {
"toolName": tool_name,
"arguments": arguments or {},
"botId": bot_id,
"userInput": user_input,
}
response = self.client.post(url, json=body)
response.raise_for_status()
return response.json()
def close(self) -> None:
self.client.close()
@@ -0,0 +1,117 @@
"""
Agent pending intent state — MongoDB with in-memory fallback.
"""
import os
from datetime import datetime, timezone
from typing import Any, Dict, Optional
try:
from pymongo import MongoClient
from pymongo.errors import PyMongoError
except ImportError: # pragma: no cover
MongoClient = None
PyMongoError = Exception
MONGO_HOST = os.getenv("MONGO_HOST", "localhost")
MONGO_PORT = int(os.getenv("MONGO_PORT", "27017"))
MONGO_USER = os.getenv("MONGO_USER", "")
MONGO_PASSWORD = os.getenv("MONGO_PASSWORD", "")
MONGO_DATABASE = os.getenv("MONGO_DATABASE", "chat_history")
PENDING_COLLECTION = os.getenv("AGENT_PENDING_COLLECTION", "agent_pending")
PENDING_TTL_SECONDS = int(os.getenv("AGENT_PENDING_TTL_SECONDS", str(30 * 60)))
class AgentPendingStore:
"""Stores clarify/pending context per botId for multi-turn slot filling."""
def __init__(self):
self._memory: Dict[str, Dict[str, Any]] = {}
self._collection = None
self._init_mongo()
def _init_mongo(self) -> None:
if MongoClient is None:
print("[AgentPending] pymongo unavailable — using in-memory store")
return
try:
if MONGO_USER and MONGO_PASSWORD:
uri = (
f"mongodb://{MONGO_USER}:{MONGO_PASSWORD}@{MONGO_HOST}:{MONGO_PORT}/"
f"{MONGO_DATABASE}?authSource={MONGO_DATABASE}"
)
else:
uri = f"mongodb://{MONGO_HOST}:{MONGO_PORT}/"
client = MongoClient(uri, serverSelectionTimeoutMS=3000)
client.admin.command("ping")
db = client[MONGO_DATABASE]
self._collection = db[PENDING_COLLECTION]
self._collection.create_index("bot_id", unique=True, background=True)
self._collection.create_index(
[("updated_at", 1)],
expireAfterSeconds=PENDING_TTL_SECONDS,
background=True,
name="idx_pending_ttl",
)
print(f"[AgentPending] Mongo connected: {MONGO_HOST}:{MONGO_PORT}/{PENDING_COLLECTION}")
except PyMongoError as exc:
print(f"[AgentPending] Mongo unavailable ({exc}) — using in-memory store")
self._collection = None
def get(self, bot_id: Optional[str]) -> Optional[Dict[str, Any]]:
key = self._normalize_bot_id(bot_id)
if self._collection is not None:
doc = self._collection.find_one({"bot_id": key}, {"_id": 0})
if doc:
return {
"pendingIntentType": doc.get("pending_intent_type"),
"pendingParams": doc.get("pending_params") or {},
"missingParams": doc.get("missing_params") or [],
}
return None
return self._memory.get(key)
def save(
self,
bot_id: Optional[str],
*,
pending_intent_type: str,
pending_params: Optional[Dict[str, Any]] = None,
missing_params: Optional[list] = None,
) -> None:
key = self._normalize_bot_id(bot_id)
payload = {
"pendingIntentType": pending_intent_type,
"pendingParams": pending_params or {},
"missingParams": missing_params or [],
}
if self._collection is not None:
now = datetime.now(timezone.utc)
self._collection.update_one(
{"bot_id": key},
{
"$set": {
"bot_id": key,
"pending_intent_type": pending_intent_type,
"pending_params": pending_params or {},
"missing_params": missing_params or [],
"updated_at": now,
}
},
upsert=True,
)
return
self._memory[key] = payload
def clear(self, bot_id: Optional[str]) -> None:
key = self._normalize_bot_id(bot_id)
if self._collection is not None:
self._collection.delete_one({"bot_id": key})
return
self._memory.pop(key, None)
@staticmethod
def _normalize_bot_id(bot_id: Optional[str]) -> str:
if bot_id and str(bot_id).strip():
return str(bot_id).strip()
return "anonymous"
@@ -0,0 +1,324 @@
"""
Local + remote tool execution for the agent loop.
"""
import json
import re
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional
from agent.chatbot_tool_client import ChatbotToolClient
RAG_SEARCH_TOOL = {
"type": "function",
"function": {
"name": "rag_search",
"description": (
"한국도로공사 FAQ/상담 지식베이스(Qdrant)에서 질문과 유사한 Q&A를 검색합니다. "
"통행료 안내, Hi-pass, 환불 절차, 민원, 일반 상담 FAQ 등 정적 지식 질문에 사용하세요."
),
"parameters": {
"type": "object",
"properties": {
"query": {"type": "string", "description": "검색할 질문 문장"},
},
"required": ["query"],
},
},
}
ASK_USER_TOOL = {
"type": "function",
"function": {
"name": "ask_user",
"description": (
"조회에 필요한 정보(차량번호, IC명, 휴게소명 등)가 부족할 때 사용자에게 "
"추가 질문을 합니다. 최종 답변 대신 clarification이 필요할 때만 사용하세요."
),
"parameters": {
"type": "object",
"properties": {
"question": {"type": "string", "description": "사용자에게 되물을 질문"},
"intentType": {
"type": "string",
"description": "되묻는 대상 intent (예: FARE_SEARCH, FARE_UNPAID). 첫 턴 clarify 시 필수.",
},
},
"required": ["question"],
},
},
}
class ToolExecutor:
"""Executes rag_search locally and domain tools via chatbotApi."""
_KEYWORD_RETRY_LIMIT = 3
_KEYWORD_RETRY_FIELDS = ("q", "a", "question", "answer", "category", "source")
_KEYWORD_RETRY_MAX_SCAN = 10000
_GENERIC_KEYWORD_QUERIES = {
"요금",
"통행료",
"할인",
"감면",
"환불",
"신청",
"방법",
"절차",
"문의",
"안내",
"고속도로",
"휴게소",
"하이패스",
}
def __init__(
self,
search_handler,
config,
chatbot_tool_client: Optional[ChatbotToolClient] = None,
):
self.search_handler = search_handler
self.config = config
self.chatbot_tool_client = chatbot_tool_client or ChatbotToolClient()
self.last_domain_result: Optional[Dict[str, Any]] = None
self.last_rag_result: Optional[Dict[str, Any]] = None
# 한 턴에 여러 domain tool이 성공한 경우(복합 질의) 모두 누적
self.domain_results: List[Dict[str, Any]] = []
self.tool_trace: List[Dict[str, Any]] = []
def reset_state(self) -> None:
"""Clear per-turn domain result and tool trace."""
self.last_domain_result = None
self.last_rag_result = None
self.domain_results = []
self.tool_trace = []
def load_remote_tools(self) -> List[Dict[str, Any]]:
try:
return self.chatbot_tool_client.list_tool_definitions()
except Exception as exc:
print(f"[ToolExecutor] remote tool definitions unavailable: {exc}")
return []
def all_tools(self) -> List[Dict[str, Any]]:
return [RAG_SEARCH_TOOL, ASK_USER_TOOL] + self.load_remote_tools()
def execute(
self,
tool_name: str,
arguments: Dict[str, Any],
*,
bot_id: Optional[str] = None,
user_input: Optional[str] = None,
) -> str:
started = datetime.now(timezone.utc).isoformat()
try:
if tool_name == "rag_search":
result = self._execute_rag_search(arguments)
elif tool_name == "ask_user":
result = {"status": True, "clarificationQuestion": arguments.get("question")}
else:
payload = self.chatbot_tool_client.execute_tool(
tool_name,
arguments,
bot_id=bot_id,
user_input=user_input,
)
if isinstance(payload, dict):
if payload.get("needsClarification") or payload.get("status") is True:
self.last_domain_result = payload
if payload.get("status") is True and payload.get("intentType"):
self.domain_results.append(payload)
result = payload
success = not (
isinstance(result, dict)
and result.get("status") is False
and not result.get("needsClarification")
)
self.tool_trace.append(
{
"tool": tool_name,
"arguments": arguments,
"startedAt": started,
"status": success,
}
)
return json.dumps(result, ensure_ascii=False)
except Exception as exc:
self.tool_trace.append(
{
"tool": tool_name,
"arguments": arguments,
"startedAt": started,
"status": False,
"error": str(exc),
}
)
return json.dumps({"status": False, "error": str(exc)}, ensure_ascii=False)
def _execute_rag_search(self, arguments: Dict[str, Any]) -> Dict[str, Any]:
query = (arguments or {}).get("query") or ""
ts = datetime.now().strftime("%H:%M:%S")
query_vec = self.search_handler.embed_query(query, ts)
if query_vec is None:
return {"status": False, "error": "embedding_failed", "references": []}
search_results = self.search_handler.search(query_vec, self.config.threshold, ts)
# no-match 시 완화된 threshold로 재검색 (Legacy /ask recall parity)
if not search_results:
retry_threshold = getattr(self.config, "threshold_rewrite", None)
if retry_threshold is not None and retry_threshold < self.config.threshold:
print(f"[ToolExecutor] rag_search no-match → threshold {retry_threshold} 재검색")
search_results = self.search_handler.search(query_vec, retry_threshold, ts)
# search 결과는 {"meta": {...}, "score": ...} 형태 → legacy /ask와 동일하게 meta 언랩
candidates = [r["meta"] for r in search_results if isinstance(r, dict) and r.get("meta")]
top_results, scores, _, _ = self.search_handler.rerank(query, candidates, ts)
references = self._build_references(top_results, scores)
search_info = getattr(self.search_handler, "last_search_info", {}) or {}
search_mode = search_info.get("searchMode") or "vector"
keyword_retry_used = False
keyword_retry_accepted = False
# 최종 guidance 직전 보조 검색: 벡터/완화 재검색이 모두 실패한 경우에만,
# 문자열 포함 결과 최대 3건을 reranker로 검증해 충분히 맞을 때만 근거로 채택한다.
if not references:
keyword_retry_used = self._keyword_retry_allowed(query)
if keyword_retry_used:
keyword_references = self._keyword_retry_with_rerank(query, ts)
if keyword_references:
references = keyword_references
search_mode = "keyword_retry"
keyword_retry_accepted = True
result = {
"status": True,
"query": query,
"references": references,
"referenceCount": len(references),
"searchMode": search_mode,
"candidateCount": search_info.get("candidateCount"),
"keywordRetryUsed": keyword_retry_used,
"keywordRetryAccepted": keyword_retry_accepted,
}
self.last_rag_result = result
return result
def _build_references(
self, top_results: List[Dict[str, Any]], scores: List[Any], limit: int = 5
) -> List[Dict[str, Any]]:
references = []
for item, score in zip((top_results or [])[:limit], (scores or [])[:limit]):
references.append(
{
"question": item.get("q") or item.get("question"),
"answer": item.get("a") or item.get("answer"),
"score": score,
"category": item.get("category"),
"url": item.get("url"),
"searchSource": item.get("_search_source"),
"denseScore": item.get("_dense_score"),
"sparseScore": item.get("_sparse_score"),
}
)
return references
def _keyword_retry_allowed(self, query: str) -> bool:
text = (query or "").strip()
if not text:
return False
normalized = re.sub(r"\s+", "", text).lower()
if len(normalized) < 3:
return False
if not re.search(r"[0-9a-zA-Z가-힣]", normalized):
return False
tokens = re.findall(r"[0-9a-zA-Z가-힣]+", text.lower())
if not tokens:
return False
compact_tokens = [re.sub(r"\s+", "", token) for token in tokens if token.strip()]
if len(compact_tokens) == 1 and compact_tokens[0] in self._GENERIC_KEYWORD_QUERIES:
return False
compact_query = "".join(compact_tokens)
if compact_query in self._GENERIC_KEYWORD_QUERIES:
return False
return True
def _keyword_retry_with_rerank(self, query: str, ts: str) -> List[Dict[str, Any]]:
vector_store = getattr(self.search_handler, "vector_store", None)
keyword_search = getattr(vector_store, "keyword_search", None)
if not callable(keyword_search):
return []
try:
keyword_items, total, exhausted = keyword_search(
query,
fields=self._KEYWORD_RETRY_FIELDS,
limit=self._KEYWORD_RETRY_LIMIT,
max_scan=self._KEYWORD_RETRY_MAX_SCAN,
count_total=False,
)
except TypeError:
# 이전 시그니처/테스트 더블 호환: count_total을 받지 못하면 기본 호출로 재시도
keyword_items, total, exhausted = keyword_search(
query,
fields=self._KEYWORD_RETRY_FIELDS,
limit=self._KEYWORD_RETRY_LIMIT,
)
except Exception as exc:
print(f"[ToolExecutor] keyword retry 실패: {exc}")
return []
if not isinstance(keyword_items, list) or not keyword_items:
print(f"[ToolExecutor] keyword retry no-match: query={query}")
return []
candidates = [
item.get("meta")
for item in keyword_items[: self._KEYWORD_RETRY_LIMIT]
if isinstance(item, dict) and isinstance(item.get("meta"), dict)
]
if not candidates:
return []
print(
f"[ToolExecutor] keyword retry → rerank 검증: "
f"query={query}, candidates={len(candidates)}, total_seen={total}, exhausted={exhausted}"
)
top_results, scores, rerank_used, _ = self.search_handler.rerank(query, candidates, ts)
if not top_results or not scores or scores[0] is None:
print("[ToolExecutor] keyword retry 거부: rerank 점수 없음")
return []
threshold = self._low_confidence_threshold()
try:
top_score = float(scores[0])
except (TypeError, ValueError):
print(f"[ToolExecutor] keyword retry 거부: 잘못된 rerank 점수={scores[0]}")
return []
if not rerank_used or top_score < threshold:
print(
f"[ToolExecutor] keyword retry 거부: score={top_score:.4f}, "
f"threshold={threshold:.4f}, rerank_used={rerank_used}"
)
return []
print(f"[ToolExecutor] keyword retry 채택: score={top_score:.4f}")
return self._build_references(
top_results[: self._KEYWORD_RETRY_LIMIT],
scores[: self._KEYWORD_RETRY_LIMIT],
limit=self._KEYWORD_RETRY_LIMIT,
)
def _low_confidence_threshold(self) -> float:
value = getattr(self.config, "low_confidence_threshold", 0.65)
try:
return float(value)
except (TypeError, ValueError):
return 0.65
@@ -0,0 +1,455 @@
"""
api_clients.py
────────────────────────────────────────────
외부 API 클라이언트 모듈: LLM, TEI Embedding, vLLM Reranker
"""
import os
import time
import json
from typing import List, Dict, Any, Optional, Tuple
import httpx
import numpy as np
from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception_type
# ────────────────────────────────────────────
# 환경 변수 설정
# ────────────────────────────────────────────
# 신규 게이트웨이 방식: HTTPS + 호스트명 라우팅 + Bearer 토큰 + OpenAI 호환 포맷
# 예) LLM_BASE_URL=https://llm-ai.ex.co.kr (클라이언트가 /v1/chat/completions 등을 붙임)
LLM_BASE_URL = os.getenv("LLM_BASE_URL", os.getenv("SGLANG_BASE_URL", "http://localhost:16000"))
LLM_MODEL_NAME = os.getenv("LLM_MODEL_NAME", "default") # 모델 이름 (예: Qwen/Qwen3.6-27B-FP8)
TEI_EMBED_URL = os.getenv("TEI_EMBED_URL", "http://localhost:16001")
EMBED_MODEL_NAME = os.getenv("EMBED_MODEL_NAME", "Qwen/Qwen3-Embedding-8B") # OpenAI 임베딩 model 필드
TEI_RERANK_URL = os.getenv("TEI_RERANK_URL", "http://localhost:16002")
TEI_RERANK_MODEL = os.getenv("TEI_RERANK_MODEL", "Qwen/Qwen3-Reranker-8B") # 리랭커 model 필드
API_TIMEOUT = float(os.getenv("API_TIMEOUT", "60"))
# 공통 인증/TLS: 모든 모델 API가 동일 게이트웨이를 사용하므로 단일 키 사용 (LLM_API_KEY는 하위호환)
MODEL_API_KEY = os.getenv("MODEL_API_KEY", os.getenv("LLM_API_KEY", ""))
LLM_API_KEY = MODEL_API_KEY
# 자체서명 인증서면 검증 비활성화 (curl -k 와 동일). 기본 false
MODEL_VERIFY_SSL = os.getenv("MODEL_VERIFY_SSL", "false").strip().lower() in ("1", "true", "yes")
# openai: /v1/embeddings (게이트웨이) | tei: /embed (로컬 TEI·exdev 호스트 AI)
EMBED_API_STYLE = os.getenv("EMBED_API_STYLE", "openai").strip().lower()
# gateway: /score (queries+documents) | legacy: /rerank (query+documents, exdev reranker_server)
RERANK_API_STYLE = os.getenv("RERANK_API_STYLE", "gateway").strip().lower()
def _normalize_base_url(url: str) -> str:
"""URL 끝의 / 또는 /v1 접미사만 제거 (rstrip('/v1')는 포트 16001 등을 깨뜨림)."""
url = url.rstrip("/")
if url.endswith("/v1"):
return url[:-3]
return url
def _auth_headers() -> Dict[str, str]:
"""Bearer 인증 헤더 (키가 있을 때만)"""
return {"Authorization": f"Bearer {MODEL_API_KEY}"} if MODEL_API_KEY else {}
# ────────────────────────────────────────────
# LLM Client (OpenAI 호환 Chat Completions)
# ────────────────────────────────────────────
class SGLangClient:
"""
OpenAI 호환 LLM API 클라이언트
지원 서비스:
- SGLang (self-hosted)
- vLLM (self-hosted)
- Ollama (self-hosted)
- 내부 LLM (OpenAI 호환 형식)
- OpenAI API (API 키 필요)
"""
def __init__(
self,
base_url: Optional[str] = None,
api_key: Optional[str] = None,
model_name: Optional[str] = None,
timeout: float = API_TIMEOUT
):
raw_url = base_url or LLM_BASE_URL
self.base_url = _normalize_base_url(raw_url)
self.model_name = model_name or LLM_MODEL_NAME
self.timeout = timeout
# API 키 설정 (있으면 헤더에 추가)
api_key = api_key or MODEL_API_KEY
headers = {"Authorization": f"Bearer {api_key}"} if api_key else {}
if api_key:
print(f"[LLM Client] API 키 인증 활성화")
self.client = httpx.Client(timeout=timeout, headers=headers, verify=MODEL_VERIFY_SSL)
print(f"[LLM Client] 초기화: {self.base_url}, model={self.model_name}, "
f"timeout={timeout}s, verify_ssl={MODEL_VERIFY_SSL}")
@retry(
stop=stop_after_attempt(3),
wait=wait_exponential(multiplier=1, min=2, max=10),
retry=retry_if_exception_type((httpx.TimeoutException, httpx.ConnectError)),
)
def chat_completion(
self,
messages: List[Dict[str, str]],
max_tokens: int = 64,
temperature: float = 0.1,
model: Optional[str] = None,
**kwargs
) -> str:
"""
Chat Completions API 호출 (OpenAI 호환 형식)
Args:
messages: [{"role": "system"|"user"|"assistant", "content": "..."}]
max_tokens: 최대 생성 토큰 수
temperature: 샘플링 온도 (0=deterministic)
model: 모델 이름 (지정하지 않으면 초기화 시 설정한 모델 사용)
Returns:
생성된 텍스트
"""
endpoint = f"{self.base_url}/v1/chat/completions"
payload = {
"model": model or self.model_name,
"messages": messages,
"max_tokens": max_tokens,
"temperature": temperature,
"chat_template_kwargs": {"enable_thinking": False},
**kwargs
}
try:
response = self.client.post(endpoint, json=payload)
response.raise_for_status()
data = response.json()
return data["choices"][0]["message"]["content"]
except httpx.HTTPStatusError as e:
print(f"[LLM Client] HTTP 오류: {e.response.status_code} - {e.response.text}")
raise
except Exception as e:
print(f"[LLM Client] 오류: {e}")
raise
@retry(
stop=stop_after_attempt(3),
wait=wait_exponential(multiplier=1, min=2, max=10),
retry=retry_if_exception_type((httpx.TimeoutException, httpx.ConnectError)),
)
def chat_completion_message(
self,
messages: List[Dict[str, Any]],
max_tokens: int = 512,
temperature: float = 0.1,
model: Optional[str] = None,
tools: Optional[List[Dict[str, Any]]] = None,
tool_choice: Optional[Any] = "auto",
**kwargs
) -> Dict[str, Any]:
"""Chat Completions → assistant message dict (content + tool_calls)."""
endpoint = f"{self.base_url}/v1/chat/completions"
payload: Dict[str, Any] = {
"model": model or self.model_name,
"messages": messages,
"max_tokens": max_tokens,
"temperature": temperature,
"chat_template_kwargs": {"enable_thinking": False},
**kwargs,
}
if tools:
payload["tools"] = tools
payload["tool_choice"] = tool_choice
response = self.client.post(endpoint, json=payload)
try:
response.raise_for_status()
except httpx.HTTPStatusError as e:
print(f"[LLM Client] tool-call HTTP 오류: {e.response.status_code} - {e.response.text}")
raise
data = response.json()
return data["choices"][0]["message"]
def __del__(self):
if hasattr(self, 'client'):
self.client.close()
# ────────────────────────────────────────────
# TEI Embedding Client
# ────────────────────────────────────────────
class TEIEmbeddingClient:
"""Text Embeddings Inference (TEI) Embedding API 클라이언트"""
def __init__(self, base_url: str = TEI_EMBED_URL, model: str = EMBED_MODEL_NAME, timeout: float = API_TIMEOUT):
self.base_url = _normalize_base_url(base_url)
self.model = model
self.timeout = timeout
self.client = httpx.Client(timeout=timeout, headers=_auth_headers(), verify=MODEL_VERIFY_SSL)
self.api_style = EMBED_API_STYLE
print(f"[EmbeddingClient] 초기화: {self.base_url}, model={self.model}, "
f"style={self.api_style}, timeout={timeout}s, verify_ssl={MODEL_VERIFY_SSL}")
@retry(
stop=stop_after_attempt(3),
wait=wait_exponential(multiplier=1, min=2, max=10),
retry=retry_if_exception_type((httpx.TimeoutException, httpx.ConnectError)),
)
def embed(self, texts: List[str], normalize: bool = True, is_query: bool = False) -> List[List[float]]:
"""
텍스트 임베딩 생성 (OpenAI 호환 /v1/embeddings, Qwen3-Embedding-8B)
Args:
texts: 임베딩할 텍스트 리스트
normalize: L2 정규화 여부 (코사인/Qdrant 호환). 서버 정규화에 의존하지 않고 클라이언트에서 수행
is_query: True면 검색 질의(Query), False면 문서(Document)
Returns:
임베딩 벡터 리스트 [[dim], [dim], ...]
Note:
Qwen3-Embedding은 Query/Document를 구분합니다:
- Query: "Instruct: ...\\nQuery: [질문]" 프리픽스
- Document: 원문 그대로
"""
# Qwen3-Embedding: Query에만 Instruct 문구 추가
if is_query:
processed_texts = [
f"Instruct: Given a web search query, retrieve relevant passages that answer the query\nQuery: {text}"
for text in texts
]
else:
processed_texts = list(texts)
if self.api_style == "tei":
endpoint = f"{self.base_url}/embed"
payload = {"inputs": processed_texts, "normalize": normalize}
else:
endpoint = f"{self.base_url}/v1/embeddings"
payload = {"model": self.model, "input": processed_texts}
try:
response = self.client.post(endpoint, json=payload)
response.raise_for_status()
data = response.json()
if self.api_style == "tei":
vectors = data if isinstance(data, list) else data.get("embeddings", data)
elif isinstance(data, dict) and "data" in data:
items = sorted(data["data"], key=lambda x: x.get("index", 0))
vectors = [item["embedding"] for item in items]
elif isinstance(data, list):
vectors = data
elif isinstance(data, dict) and "embeddings" in data:
vectors = data["embeddings"]
else:
raise ValueError(f"예상치 못한 임베딩 응답 형식: {str(data)[:200]}")
if normalize:
vectors = self._l2_normalize(vectors)
return vectors
except httpx.HTTPStatusError as e:
print(f"[EmbeddingClient] HTTP 오류: {e.response.status_code} - {e.response.text}")
raise
except Exception as e:
print(f"[EmbeddingClient] 오류: {e}")
raise
@staticmethod
def _l2_normalize(vectors: List[List[float]]) -> List[List[float]]:
"""L2 정규화 (코사인 유사도/Qdrant 호환). 0벡터는 그대로 둠."""
arr = np.asarray(vectors, dtype="float32")
norms = np.linalg.norm(arr, axis=1, keepdims=True)
norms[norms == 0] = 1.0
return (arr / norms).tolist()
def __del__(self):
if hasattr(self, 'client'):
self.client.close()
# ────────────────────────────────────────────
# vLLM Reranker Client
# ────────────────────────────────────────────
class TEIRerankerClient:
"""
vLLM 기반 Reranker API 클라이언트 (Qwen3-Reranker-8B)
Note: 클래스명은 호환성을 위해 'TEIRerankerClient'로 유지하지만,
실제로는 vLLM 기반 커스텀 reranker_server.py를 사용합니다.
"""
def __init__(self, base_url: str = TEI_RERANK_URL, model: str = TEI_RERANK_MODEL, timeout: float = API_TIMEOUT):
self.base_url = base_url.rstrip("/")
self.model = model
self.timeout = timeout
self.client = httpx.Client(timeout=timeout, headers=_auth_headers(), verify=MODEL_VERIFY_SSL)
self.api_style = RERANK_API_STYLE
print(f"[Reranker] 초기화: {self.base_url}, model={self.model}, "
f"style={self.api_style}, timeout={timeout}s, verify_ssl={MODEL_VERIFY_SSL}")
@retry(
stop=stop_after_attempt(3),
wait=wait_exponential(multiplier=1, min=2, max=10),
retry=retry_if_exception_type((httpx.TimeoutException, httpx.ConnectError)),
)
def rerank(
self,
query: str,
documents: List[str],
top_k: Optional[int] = None,
return_documents: bool = False
) -> List[Dict[str, Any]]:
"""
문서 재랭킹 (vLLM 기반 Qwen3-Reranker-8B)
Args:
query: 검색 질의
documents: 재랭킹할 문서 리스트
top_k: 상위 k개만 반환 (None이면 전체)
return_documents: 문서 텍스트도 함께 반환할지 여부
Returns:
[{"index": int, "score": float, "text": str (옵션)}, ...]
score가 높은 순서대로 정렬됨
Note:
vLLM 기반 커스텀 reranker_server.py 사용
- 내부적으로 "Yes/No" 확률을 계산
- 요청 형식: {"query": "질문", "documents": ["문서1", "문서2", ...]}
- 응답 형식: [{"index": int, "score": float}, ...] (score 내림차순 정렬)
"""
if self.api_style == "legacy":
endpoint = f"{self.base_url}/rerank"
payload = {"query": query, "documents": documents}
else:
endpoint = f"{self.base_url}/score"
payload = {
"model": self.model,
"queries": f"<Query>: {query}",
"documents": documents,
}
try:
print(f"[Reranker] 요청: endpoint={endpoint}, model={self.model}, query={query[:50]}..., documents={len(documents)}개")
response = self.client.post(endpoint, json=payload)
print(f"[Reranker] 응답 상태: {response.status_code}")
response.raise_for_status()
data = response.json()
results = self._parse_scores(data, len(documents))
print(f"[Reranker] 파싱 결과: {len(results)}개")
if results:
print(f"[Reranker] 첫 번째 결과: {results[0]}")
return results
except httpx.HTTPStatusError as e:
print(f"[Reranker] HTTP 오류: {e.response.status_code} - {e.response.text}")
raise
except Exception as e:
print(f"[Reranker] 오류: {e}")
raise
@staticmethod
def _parse_scores(data: Any, num_docs: int) -> List[Dict[str, Any]]:
"""
다양한 reranker 응답 형식을 [{"index": int, "score": float}, ...]로 정규화.
지원: {"results": [...]}, {"data": [...]}, {"scores": [...]}, bare list 등
항목은 {"index","score"} dict 또는 점수(float) 둘 다 허용.
"""
if isinstance(data, dict):
for key in ("results", "data", "scores", "output"):
if key in data:
data = data[key]
break
results: List[Dict[str, Any]] = []
if isinstance(data, list):
for i, item in enumerate(data):
if isinstance(item, dict):
idx = item.get("index", item.get("idx", i))
score = item.get("score", item.get("relevance_score", item.get("logit")))
if score is None:
continue
results.append({"index": int(idx), "score": float(score)})
elif isinstance(item, (int, float)):
results.append({"index": i, "score": float(item)})
if not results:
print(f"[Reranker] ⚠️ 점수 파싱 실패 (응답 형식 확인 필요): {str(data)[:200]}")
return results
def predict(self, pairs: List[Tuple[str, str]], batch_size: int = 16, show_progress_bar: bool = False) -> List[float]:
"""
CrossEncoder 호환 인터페이스
Args:
pairs: [(query, doc), (query, doc), ...] 쌍 리스트
batch_size: 배치 크기 (내부적으로 처리, 호환성 유지용)
show_progress_bar: 진행률 표시 (미사용, 호환성 유지용)
Returns:
점수 리스트 [score1, score2, ...]
"""
if not pairs:
return []
# 모든 쌍이 같은 query를 가진다고 가정 (일반적인 재랭킹 시나리오)
query = pairs[0][0]
documents = [doc for _, doc in pairs]
results = self.rerank(query, documents, return_documents=False)
# index 순서대로 정렬하여 점수 반환
sorted_results = sorted(results, key=lambda x: x["index"])
return [r["score"] for r in sorted_results]
def __del__(self):
if hasattr(self, 'client'):
self.client.close()
# ────────────────────────────────────────────
# 헬스체크 유틸리티
# ────────────────────────────────────────────
def check_api_health() -> Dict[str, bool]:
"""
모든 외부 API의 헬스체크 (게이트웨이 기준).
게이트웨이에는 /health가 없을 수 있으므로 '응답이 오면(상태코드 무관) 도달 가능'으로 판단한다.
"""
results = {}
headers = _auth_headers()
def _reachable(url: str) -> bool:
try:
# 어떤 HTTP 응답이든 오면 도달 가능으로 간주 (4xx 포함)
httpx.get(url, headers=headers, timeout=5, verify=MODEL_VERIFY_SSL)
return True
except Exception:
return False
llm_base = _normalize_base_url(LLM_BASE_URL)
results["llm"] = _reachable(f"{llm_base}/v1/models")
embed_base = TEI_EMBED_URL.rstrip("/")
results["tei_embed"] = _reachable(
f"{embed_base}/health" if EMBED_API_STYLE == "tei" else embed_base
)
results["tei_rerank"] = _reachable(TEI_RERANK_URL.rstrip("/"))
return results
if __name__ == "__main__":
# 간단한 테스트
print("=== API 클라이언트 테스트 ===")
print(f"LLM API: {LLM_BASE_URL}")
print(f"LLM Model: {LLM_MODEL_NAME}")
print(f"TEI Embed: {TEI_EMBED_URL}")
print(f"TEI Rerank: {TEI_RERANK_URL}")
print()
health = check_api_health()
print("헬스체크 결과:")
for service, status in health.items():
print(f" {service}: {'✅ OK' if status else '❌ FAIL'}")
@@ -0,0 +1,80 @@
# rag-demo/scripts/build_index_qa.py
"""
벡터 DB 인덱스 구축 (FAISS 전용)
Qdrant 모드에서는 ingest_qa.py에서 실시간 인덱싱되므로 이 스크립트는 skip됩니다.
"""
import os
import sys
from pathlib import Path
VECTOR_STORE = os.getenv("VECTOR_STORE", "faiss").lower()
if VECTOR_STORE == "qdrant":
print("✅ Qdrant 모드: 인덱스 빌드 불필요 (실시간 인덱싱)")
sys.exit(0)
# FAISS 모드: 기존 벡터 파일 → 인덱스 생성
import json, pickle
import numpy as np
from vector_store import FAISSStore
# ───────────────────────────────────────────
SRC = Path("/app/data/qa_vecs.jsonl")
DATA_DIR = Path("/app/data")
INDEX_FILE = DATA_DIR / "qa.index"
META_FILE = DATA_DIR / "qa_meta.pkl"
# ──────────────────────────────────────────────
# ✅ 스킵 로직: 이미 인덱스가 생성되었는지 확인
# ──────────────────────────────────────────────
def should_skip_indexing():
"""인덱싱 작업을 스킵해야 하는지 판단"""
if not INDEX_FILE.exists() or not META_FILE.exists():
return False
# qa_vecs.jsonl과 qa_meta.pkl의 라인 수 비교
try:
with open(SRC, 'r', encoding='utf-8') as f:
vec_lines = sum(1 for line in f if line.strip())
with open(META_FILE, 'rb') as f:
import pickle
metas = pickle.load(f)
meta_count = len(metas)
if vec_lines == meta_count:
print(f"[Index] ✅ 인덱스 이미 생성됨 (vectors: {vec_lines}개, metadata: {meta_count}개)")
print(f"[Index] ⏩ 스킵합니다. 재인덱싱이 필요하면 'rm {INDEX_FILE} {META_FILE}'을 실행하세요.")
return True
else:
print(f"[Index] ⚠️ 개수 불일치 (vectors: {vec_lines}개, metadata: {meta_count}개) → 재인덱싱")
return False
except Exception as e:
print(f"[Index] ⚠️ 스킵 체크 실패: {e} → 인덱싱 진행")
return False
# 벡터 파일이 없으면 ingest_qa.py가 이미 인덱싱 완료
if not SRC.exists():
print("[Index] ✅ qa_vecs.jsonl 없음: ingest_qa.py에서 이미 인덱싱 완료")
sys.exit(0)
if should_skip_indexing():
print("[Index] 🎉 인덱싱 작업 완료 (스킵)")
sys.exit(0)
# 기존 JSONL 형식 벡터 파일 읽기 (하위 호환)
vecs, metas = [], []
with SRC.open(encoding="utf-8") as fin:
for line in fin:
obj = json.loads(line)
vecs.append(obj["vec"])
metas.append(obj["meta"])
arr = np.asarray(vecs, dtype="float32")
# FAISS 스토어에 추가 및 저장
store = FAISSStore()
store.add_vectors(arr, metas)
store.save(str(DATA_DIR))
print(f"✅ FAISS 인덱스 저장: {DATA_DIR}/qa.index (vectors={arr.shape[0]}, dim={arr.shape[1]})")
@@ -0,0 +1,283 @@
"""
chat_history.py
────────────────────────────────────────────
MongoDB 대화 이력 관리 모듈
"""
import os
from datetime import datetime, timedelta, timezone
from typing import List, Dict, Optional
from pymongo import MongoClient, DESCENDING
from pymongo.errors import PyMongoError
from handlers.suggestion_handler import strip_suggestion_block
# 환경 변수
MONGO_HOST = os.getenv("MONGO_HOST", "localhost")
MONGO_PORT = int(os.getenv("MONGO_PORT", "27017"))
MONGO_USER = os.getenv("MONGO_USER", "exlink")
MONGO_PASSWORD = os.getenv("MONGO_PASSWORD", "!wkcproqkf1")
MONGO_DATABASE = os.getenv("MONGO_DATABASE", "chat_history")
MONGO_COLLECTION = os.getenv("MONGO_COLLECTION", "rag_conversations")
MONGO_TTL_DAYS = int(os.getenv("MONGO_TTL_DAYS", "30")) # 데이터 보관 기간 (일 단위, 기본값: 30일)
class ChatHistoryManager:
"""MongoDB 기반 대화 이력 관리"""
def __init__(self):
"""MongoDB 연결 초기화"""
try:
# MongoDB 연결 문자열 (데이터베이스별 인증)
# 연결: mongodb://localhost:27017/
# 인증: chat_history 데이터베이스에서 exlink/!wkcproqkf1
if MONGO_USER and MONGO_PASSWORD:
connection_string = f"mongodb://{MONGO_USER}:{MONGO_PASSWORD}@{MONGO_HOST}:{MONGO_PORT}/{MONGO_DATABASE}?authSource={MONGO_DATABASE}"
else:
connection_string = f"mongodb://{MONGO_HOST}:{MONGO_PORT}/"
# 연결 풀링 설정 (고트래픽 대응)
self.client = MongoClient(
connection_string,
serverSelectionTimeoutMS=5000,
maxPoolSize=50, # 최대 연결 수 (기본값: 100)
minPoolSize=10, # 최소 연결 수 (기본값: 0)
maxIdleTimeMS=45000, # 유휴 연결 유지 시간
)
self.db = self.client[MONGO_DATABASE]
self.collection = self.db[MONGO_COLLECTION]
# 인덱스 생성 (성능 최적화)
# background=True: 인덱스 생성 시 DB 블로킹 방지
self.collection.create_index(
[("bot_id", 1), ("timestamp", -1)], # -1 = DESCENDING
background=True,
name="idx_bot_timestamp"
)
# TTL 인덱스 생성 (자동 삭제)
ttl_seconds = MONGO_TTL_DAYS * 24 * 60 * 60 # 일 단위 → 초 단위 변환
self.collection.create_index(
[("timestamp", 1)],
expireAfterSeconds=ttl_seconds,
background=True,
name="idx_ttl"
)
print(f"[ChatHistory] MongoDB 연결 성공: {MONGO_HOST}:{MONGO_PORT}/{MONGO_DATABASE}")
print(f"[ChatHistory] TTL 설정: {MONGO_TTL_DAYS}일 ({ttl_seconds}초) 후 자동 삭제")
except PyMongoError as e:
print(f"[ChatHistory] MongoDB 연결 실패: {e}")
raise
def save_conversation(
self,
bot_id: Optional[str],
user_query: str,
ai_response: str,
matched_questions: List[str],
scores: List[float],
metadata: Optional[Dict] = None
) -> str:
"""
대화 기록 저장
Args:
bot_id: 봇 ID (없으면 None)
user_query: 사용자 질문
ai_response: AI 답변
matched_questions: 매칭된 질문 목록
scores: 매칭 점수
metadata: 추가 메타데이터
Returns:
저장된 문서의 ObjectId (문자열)
"""
try:
doc = {
"bot_id": bot_id, # None 허용
"user_query": user_query,
"ai_response": ai_response,
"matched_questions": matched_questions,
"scores": scores,
"metadata": metadata or {},
"timestamp": datetime.now(timezone.utc)
}
result = self.collection.insert_one(doc)
print(f"[ChatHistory] 대화 저장 완료: bot_id={bot_id}, id={result.inserted_id}")
return str(result.inserted_id)
except PyMongoError as e:
print(f"[ChatHistory] 저장 실패: {e}")
raise
def get_recent_history(
self,
bot_id: Optional[str],
hours: int = 24,
limit: int = 10
) -> List[Dict]:
"""
최근 대화 이력 조회 (24시간 이내)
Args:
bot_id: 봇 ID (None이면 전체 조회)
hours: 조회 시간 범위 (기본 24시간)
limit: 최대 조회 개수
Returns:
대화 이력 리스트 (최신순)
"""
try:
# 시간 필터 (UTC 기준)
cutoff_time = datetime.now(timezone.utc) - timedelta(hours=hours)
# 쿼리 구성 (인덱스 순서에 맞춤: bot_id → timestamp)
query = {}
if bot_id is not None:
query["bot_id"] = bot_id
query["timestamp"] = {"$gte": cutoff_time}
# 조회 (복합 인덱스 활용: bot_id + timestamp)
cursor = self.collection.find(query).sort("timestamp", DESCENDING).limit(limit)
# 결과 변환
history = []
for doc in cursor:
history.append({
"user_query": doc.get("user_query"),
"ai_response": doc.get("ai_response"),
"timestamp": doc.get("timestamp").isoformat() if doc.get("timestamp") else None
})
print(f"[ChatHistory] 이력 조회: bot_id={bot_id}, {len(history)}개")
return list(reversed(history)) # 시간순 정렬 (오래된 것 → 최신)
except PyMongoError as e:
print(f"[ChatHistory] 조회 실패: {e}")
return []
def get_context_for_llm(
self,
bot_id: Optional[str],
hours: int = 24,
max_conversations: int = 10
) -> str:
"""
LLM에 전달할 대화 컨텍스트 생성
Args:
bot_id: 봇 ID
hours: 조회 시간 범위
max_conversations: 최대 대화 개수
Returns:
포맷된 대화 이력 문자열
"""
history = self.get_recent_history(bot_id, hours, max_conversations)
if not history:
return ""
# 포맷팅 (추천 블록 제외 — LLM용 본문 전문, get_messages_for_llm과 동일)
context_parts = ["【이전 대화 이력】"]
for i, conv in enumerate(history, 1):
body = strip_suggestion_block(conv.get("ai_response"))
context_parts.append(
f"[대화 {i}]\n"
f"고객: {conv['user_query']}\n"
f"상담원: {body}"
)
return "\n\n".join(context_parts)
def get_messages_for_llm(
self,
bot_id: Optional[str],
hours: int = 24,
max_conversations: int = 10
) -> List[Dict[str, str]]:
"""
LLM messages format용 대화 이력 (표준 chat completion)
Args:
bot_id: 봇 ID
hours: 조회 시간 범위
max_conversations: 최대 대화 개수
Returns:
[{"role": "user", "content": "..."}, {"role": "assistant", "content": "..."}]
"""
history = self.get_recent_history(bot_id, hours, max_conversations)
if not history:
return []
# messages 포맷으로 변환 (추천 블록 제외 — LLM용 본문만)
messages = []
for conv in history:
user_content = str(conv.get("user_query") or "").strip()
assistant_content = strip_suggestion_block(conv.get("ai_response")).strip()
if not user_content or not assistant_content:
continue
messages.append({"role": "user", "content": user_content})
messages.append({
"role": "assistant",
"content": assistant_content,
})
return messages
def cleanup_old_records(self, days: int = 7):
"""
오래된 기록 정리 (TTL 인덱스와 별개로 수동 정리)
Args:
days: 보관 일수
"""
try:
cutoff_time = datetime.now(timezone.utc) - timedelta(days=days)
result = self.collection.delete_many({"timestamp": {"$lt": cutoff_time}})
print(f"[ChatHistory] 정리 완료: {result.deleted_count}개 삭제")
except PyMongoError as e:
print(f"[ChatHistory] 정리 실패: {e}")
def __del__(self):
"""연결 종료"""
if hasattr(self, 'client'):
self.client.close()
# 싱글톤 인스턴스
_chat_history_manager = None
def get_chat_history_manager() -> ChatHistoryManager:
"""ChatHistoryManager 싱글톤 인스턴스 반환"""
global _chat_history_manager
if _chat_history_manager is None:
_chat_history_manager = ChatHistoryManager()
return _chat_history_manager
if __name__ == "__main__":
# 테스트
manager = get_chat_history_manager()
# 저장 테스트
doc_id = manager.save_conversation(
user_id="test_user_123",
user_query="테스트 질문입니다",
ai_response="테스트 답변입니다",
matched_questions=["관련 질문 1", "관련 질문 2"],
scores=[0.95, 0.88]
)
print(f"저장된 ID: {doc_id}")
# 조회 테스트
history = manager.get_recent_history("test_user_123")
print(f"조회 결과: {len(history)}개")
# 컨텍스트 생성 테스트
context = manager.get_context_for_llm("test_user_123")
print(f"컨텍스트:\n{context}")
@@ -0,0 +1,168 @@
"""
excel_to_jsonl.py
────────────────────────────────────────────
엑셀 파일(질문-답변 쌍) → JSONL 형식 변환
사용법:
python excel_to_jsonl.py input.xlsx
출력:
data/qa_raw.jsonl
"""
import json
import pathlib
import sys
import pandas as pd
def excel_to_jsonl(excel_path: str, output_path: str = None, q_col_index: int = None, a_col_index: int = None):
"""
엑셀 파일을 JSONL 형식으로 변환
Args:
excel_path: 입력 엑셀 파일 경로
output_path: 출력 JSONL 파일 경로 (기본: data/qa_raw.jsonl)
q_col_index: 질문 열 인덱스 (0부터 시작, 기본: 자동 감지)
a_col_index: 답변 열 인덱스 (0부터 시작, 기본: 자동 감지)
엑셀 형식:
- 열 이름: "질문", "답변" 또는 "question", "answer" (자동 감지)
- 또는 컬럼 인덱스 지정 (예: 3열=2, 4열=3)
"""
# 경로 설정
excel_path = pathlib.Path(excel_path)
if not excel_path.exists():
print(f"❌ 파일을 찾을 수 없습니다: {excel_path}")
sys.exit(1)
if output_path is None:
BASE_DIR = pathlib.Path(__file__).resolve().parent.parent
output_path = BASE_DIR / "data" / "qa_raw.jsonl"
else:
output_path = pathlib.Path(output_path)
output_path.parent.mkdir(parents=True, exist_ok=True)
print(f"📖 엑셀 파일 읽기: {excel_path}")
# 엑셀 파일 읽기 (.xlsx, .xls 지원)
try:
df = pd.read_excel(excel_path)
except Exception as e:
print(f"❌ 엑셀 파일 읽기 실패: {e}")
print("pandas와 openpyxl이 설치되어 있는지 확인하세요:")
print(" pip install pandas openpyxl")
sys.exit(1)
print(f"📊 총 {len(df)}개 행 발견")
print(f"📋 컬럼: {list(df.columns)}")
print(f"📋 컬럼 수: {len(df.columns)}개")
# 컬럼 이름 찾기
q_col = None
a_col = None
# 1. 명시적 인덱스가 제공된 경우
if q_col_index is not None and a_col_index is not None:
if q_col_index < len(df.columns) and a_col_index < len(df.columns):
q_col = df.columns[q_col_index]
a_col = df.columns[a_col_index]
print(f"✅ 인덱스로 컬럼 선택:")
print(f" 질문(q): 열 {q_col_index+1} ({q_col})")
print(f" 답변(a): 열 {a_col_index+1} ({a_col})")
else:
print(f"❌ 잘못된 컬럼 인덱스입니다. 컬럼 수: {len(df.columns)}")
sys.exit(1)
# 2. 한글 컬럼명 찾기
elif q_col is None or a_col is None:
for col in df.columns:
col_lower = str(col).lower().strip()
if '질문' in col_lower or 'q' == col_lower or 'question' in col_lower:
q_col = col
if '답변' in col_lower or '답' in col_lower or 'a' == col_lower or 'answer' in col_lower:
a_col = col
# 3. 컬럼명을 못 찾은 경우, 첫 2개 컬럼 사용
if q_col is None or a_col is None:
if len(df.columns) >= 2:
q_col = df.columns[0]
a_col = df.columns[1]
print(f"⚠️ 컬럼명을 자동 인식하지 못했습니다. 첫 2개 컬럼을 사용합니다:")
print(f" 질문(q): {q_col}")
print(f" 답변(a): {a_col}")
else:
print(f"❌ 엑셀 파일에 최소 2개의 컬럼이 필요합니다.")
sys.exit(1)
else:
print(f"✅ 컬럼 매핑:")
print(f" 질문(q): {q_col}")
print(f" 답변(a): {a_col}")
# JSONL 변환
print(f"🔄 JSONL 변환 중...")
count = 0
skipped = 0
with output_path.open("w", encoding="utf-8") as fout:
for idx, row in df.iterrows():
q = str(row[q_col]).strip()
a = str(row[a_col]).strip()
# 빈 값 건너뛰기
if not q or not a or q == 'nan' or a == 'nan':
skipped += 1
continue
# JSONL 형식으로 저장
obj = {"q": q, "a": a}
fout.write(json.dumps(obj, ensure_ascii=False) + "\n")
count += 1
print(f"✅ 변환 완료!")
print(f" 출력: {output_path}")
print(f" 변환된 QA 쌍: {count}개")
if skipped > 0:
print(f" 건너뛴 행: {skipped}개 (빈 값)")
# 샘플 출력
if count > 0:
print(f"\n📝 샘플 (첫 3개):")
with output_path.open("r", encoding="utf-8") as fin:
for i, line in enumerate(fin):
if i >= 3:
break
obj = json.loads(line)
print(f"\n[{i+1}]")
print(f"Q: {obj['q'][:80]}{'...' if len(obj['q']) > 80 else ''}")
print(f"A: {obj['a'][:80]}{'...' if len(obj['a']) > 80 else ''}")
if __name__ == "__main__":
if len(sys.argv) < 2:
print("사용법: python excel_to_jsonl.py <엑셀파일경로> [질문열] [답변열]")
print("\n예제:")
print(" python excel_to_jsonl.py qa_data.xlsx")
print(" python excel_to_jsonl.py qa_data.xlsx 2 3 # 3열, 4열 사용 (0부터 시작)")
print(" python excel_to_jsonl.py /path/to/questions.xlsx")
print("\n출력: data/qa_raw.jsonl")
print("\n컬럼 인덱스는 0부터 시작합니다 (1열=0, 2열=1, 3열=2, 4열=3)")
sys.exit(1)
excel_path = sys.argv[1]
q_col_index = None
a_col_index = None
# 컬럼 인덱스가 제공된 경우
if len(sys.argv) >= 4:
try:
q_col_index = int(sys.argv[2])
a_col_index = int(sys.argv[3])
print(f"📌 지정된 컬럼: 질문={q_col_index+1}열, 답변={a_col_index+1}열")
except ValueError:
print("❌ 컬럼 인덱스는 숫자여야 합니다.")
sys.exit(1)
excel_to_jsonl(excel_path, q_col_index=q_col_index, a_col_index=a_col_index)
@@ -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)로 문의해 주세요."
)
@@ -0,0 +1,194 @@
# rag-demo/scripts/ingest_qa.py
import json, pathlib, os, sys
import numpy as np
from api_clients import TEIEmbeddingClient
from vector_store import get_vector_store, source_created_at_cmp
def print_progress(current, total, prefix='', suffix='', step=10):
"""진행률 출력 (Docker 환경 대응 - 일정 간격으로 새 줄 출력)"""
percent = int(100 * current / total)
# step% 간격으로만 출력 (10%, 20%, ... 또는 완료 시)
if percent % step == 0 or current == total:
# 이전에 이 퍼센트를 출력했는지 체크 (중복 방지)
if not hasattr(print_progress, '_last_percent'):
print_progress._last_percent = {}
key = f"{prefix}_{total}"
if key not in print_progress._last_percent or print_progress._last_percent[key] != percent:
print_progress._last_percent[key] = percent
# 프로그레스 바 생성
length = 40
filled = int(length * current / total)
bar = '█' * filled + '░' * (length - filled)
print(f'{prefix} [{bar}] {percent}% ({current}/{total}) {suffix}', flush=True)
# 완료 시 초기화
if current == total:
key = f"{prefix}_{total}"
if hasattr(print_progress, '_last_percent') and key in print_progress._last_percent:
del print_progress._last_percent[key]
# ⚠️ 변경: qa.jsonl 대신 qa_raw.jsonl 직접 사용 (preprocess 단계 생략)
QA_FILE = pathlib.Path("/app/data/qa_raw.jsonl") # 원본 파일 직접 사용
DATA_DIR = pathlib.Path("/app/data")
VECS_FILE = DATA_DIR / "qa_vecs.jsonl"
# 배치 크기 설정 (임베딩 API 호출 단위)
# - TEI API 제한: 최대 32개까지 한 번에 처리 가능
# - 권장값: 16~32 (안정성을 위해 32 이하 권장)
EMBED_BATCH_SIZE = int(os.getenv("EMBED_BATCH_SIZE", "32")) # API 호출 시 한 번에 임베딩할 개수 (최대 32)
VECTOR_BATCH_SIZE = int(os.getenv("VECTOR_BATCH_SIZE", "500")) # 벡터 스토어 저장 단위
# ──────────────────────────────────────────────
# ✅ 스킵 로직: 이미 임베딩이 완료되었는지 확인
# ──────────────────────────────────────────────
def should_skip_embedding():
"""임베딩 작업을 스킵해야 하는지 판단"""
if not VECS_FILE.exists():
return False
# qa_vecs.jsonl의 라인 수와 qa_raw.jsonl의 라인 수 비교
try:
with open(QA_FILE, 'r', encoding='utf-8') as f:
raw_lines = sum(1 for line in f if line.strip())
with open(VECS_FILE, 'r', encoding='utf-8') as f:
vec_lines = sum(1 for line in f if line.strip())
if raw_lines == vec_lines:
print(f"[Ingest] ✅ 임베딩 이미 완료됨 (qa_raw: {raw_lines}개, qa_vecs: {vec_lines}개)")
print(f"[Ingest] ⏩ 스킵합니다. 재임베딩이 필요하면 'rm {VECS_FILE}'을 실행하세요.")
return True
else:
print(f"[Ingest] ⚠️ 라인 수 불일치 (qa_raw: {raw_lines}개, qa_vecs: {vec_lines}개) → 재임베딩")
return False
except Exception as e:
print(f"[Ingest] ⚠️ 스킵 체크 실패: {e} → 임베딩 진행")
return False
if should_skip_embedding():
print("[Ingest] 🎉 임베딩 작업 완료 (스킵)")
exit(0)
# API 클라이언트 및 벡터 스토어 초기화
embed_client = TEIEmbeddingClient()
vector_store = get_vector_store()
print(f"[Ingest] 임베딩 배치 크기: {EMBED_BATCH_SIZE}개 (API 호출 단위)")
print(f"[Ingest] 벡터 저장 배치 크기: {VECTOR_BATCH_SIZE}개 (벡터 스토어 저장 단위)")
print("") # 빈 줄
# ── Step 1: QA 데이터 로드 ─────────────────────
print("[Ingest] QA 데이터 로딩 중...")
qa_data = []
skipped_no_date = 0
skipped_no_qa = 0
for ln, line in enumerate(QA_FILE.open(encoding="utf-8"), 1):
line = line.strip()
if not line:
continue
try:
obj = json.loads(line)
except json.JSONDecodeError as e:
raise RuntimeError(f"❌ JSON 오류 (line {ln}): {e.msg}\n> {line}") from None
question = obj.get("question") or obj.get("q")
answer = obj.get("answer") or obj.get("a")
if not question or not answer:
skipped_no_qa += 1
print(f"[Ingest] ⚠️ 필수 필드 누락으로 스킵 (line {ln}): q/a 필요")
continue
source_created_at = obj.get("source_created_at")
if source_created_at is None or str(source_created_at).strip() == "":
skipped_no_date += 1
print(f"[Ingest] ⚠️ source_created_at 없음으로 스킵 (line {ln})")
continue
meta = dict(obj)
meta["q"] = question
meta["a"] = answer
meta["source_created_at"] = str(source_created_at).strip()
meta.pop("question", None)
meta.pop("answer", None)
qa_data.append({"question": str(question).strip(), "answer": answer, "meta": meta})
# 동일 질문 중 source_created_at 최신만 유지
deduped = {}
dedup_skipped = 0
for item in qa_data:
q = item["question"]
prev = deduped.get(q)
if prev is None:
deduped[q] = item
continue
if source_created_at_cmp(item["meta"]["source_created_at"], prev["meta"]["source_created_at"]) > 0:
deduped[q] = item
dedup_skipped += 1
else:
dedup_skipped += 1
qa_data = list(deduped.values())
print(
f"[Ingest] 총 {len(qa_data)}개 QA 쌍 로드 완료 "
f"(q/a 스킵 {skipped_no_qa}, 날짜 스킵 {skipped_no_date}, 중복 제거 {dedup_skipped})"
)
# ── Step 2: 배치 임베딩 ─────────────────────────
print(f"[Ingest] 임베딩 시작 (배치 크기: {EMBED_BATCH_SIZE})")
all_vectors = []
all_metadatas = []
for i in range(0, len(qa_data), EMBED_BATCH_SIZE):
batch_data = qa_data[i:i + EMBED_BATCH_SIZE]
batch_texts = [item["question"] for item in batch_data]
# 배치 임베딩 (한 번에 여러 개)
embeddings = embed_client.embed(batch_texts, normalize=True, is_query=False)
# 벡터 및 메타데이터 수집
for j, emb in enumerate(embeddings):
all_vectors.append(emb)
all_metadatas.append(batch_data[j]["meta"])
# 진행률 바 표시 (10% 간격)
processed = min(i + EMBED_BATCH_SIZE, len(qa_data))
print_progress(processed, len(qa_data), prefix='[Ingest] 임베딩 진행', suffix='✨', step=10)
print(f"[Ingest] ✅ 임베딩 완료: {len(all_vectors)}개 벡터")
print("") # 빈 줄
# ── Step 3: 벡터 스토어에 배치 저장 ──────────────
print(f"[Ingest] 벡터 저장 시작 (배치 크기: {VECTOR_BATCH_SIZE})")
total_inserted = total_updated = total_skipped = 0
for i in range(0, len(all_vectors), VECTOR_BATCH_SIZE):
batch_vectors = all_vectors[i:i + VECTOR_BATCH_SIZE]
batch_metas = all_metadatas[i:i + VECTOR_BATCH_SIZE]
vectors_array = np.array(batch_vectors, dtype="float32")
if hasattr(vector_store, "upsert_vectors"):
stats = vector_store.upsert_vectors(vectors_array, batch_metas, skip_if_older=True)
total_inserted += stats.get("inserted", 0)
total_updated += stats.get("updated", 0)
total_skipped += stats.get("skipped", 0)
else:
vector_store.add_vectors(vectors_array, batch_metas, skip_if_older=True)
# 진행률 바 표시 (10% 간격)
processed = min(i + VECTOR_BATCH_SIZE, len(all_vectors))
print_progress(processed, len(all_vectors), prefix='[Ingest] 저장 진행', suffix='💾', step=10)
print("") # 빈 줄
if total_inserted or total_updated or total_skipped:
print(
f"[Ingest] 저장 결과: 신규 {total_inserted}, 갱신 {total_updated}, "
f"날짜 구버전 스킵 {total_skipped}"
)
# ── Step 4: 최종 저장 ─────────────────────────────
vector_store.save(str(DATA_DIR))
print(f"✅ 임베딩 및 인덱싱 완료: {len(all_vectors)}개 벡터", flush=True)
@@ -0,0 +1,77 @@
"""
preprocess_qa.py
────────────────────────────────────────────
원본 QA(긴 질문) → LLM 한 문장 요약(q_short) 추가
실행:
python preprocess_qa.py
산출물:
data/qa.jsonl # q, a, q_short
"""
import json, re, pathlib, os
from api_clients import SGLangClient
# ────────────────────────────────────────────
# 0. 경로 설정
BASE_DIR = pathlib.Path(__file__).resolve().parent.parent # rag-demo/
SRC = BASE_DIR / "data" / "qa_raw.jsonl" # 긴 질문·답
DST = BASE_DIR / "data" / "qa.jsonl" # 요약 포함
# ────────────────────────────────────────────
# 1. 후처리: <think>·마크다운·개행 제거 → 첫 문장만
def postprocess(text: str) -> str:
text = re.sub(r"<think>.*?</think>", " ", text, flags=re.S)
text = re.sub(r"[\n\r]+", " ", text)
text = re.sub(r"\s+", " ", text).strip()
# 질문형(물음표 포함) 추출
q_match = re.search(r"([^?]+[?])", text)
if q_match:
return q_match.group(1).strip()
# 물음표가 없으면 설명문 잘라내고 마지막에 물음표 추가
text = re.split(r"[.]", text, maxsplit=1)[0].strip()
if not text.endswith("?"):
text += "?"
return text
# ────────────────────────────────────────────
# 2. 요약용 LLM 클라이언트 (외부 SGLang API)
llm_client = SGLangClient()
SYSTEM_PROMPT = (
"당신은 사용자의 긴 질문을 **같은 의미의 질문 한 문장**으로 바꿔주는 도우미다.\n"
"규칙:\n"
"1) 반드시 질문형 어미로 끝나는 문장(물음표 포함)을 출력하라.\n"
"2) 절대 정의·답변·설명을 포함하지 마라.\n"
"3) 문장 하나만 출력하고 다른 문구를 덧붙이지 마라."
)
def summarize(text: str) -> str:
"""SGLang API를 사용한 질문 요약"""
messages = [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": text},
]
raw = llm_client.chat_completion(
messages=messages,
max_tokens=64,
temperature=0.1 # deterministic에 가깝게
)
return postprocess(raw)
# ────────────────────────────────────────────
# 3. 원본 QA 읽어 요약 추가
assert SRC.exists(), f"{SRC} not found"
DST.parent.mkdir(parents=True, exist_ok=True)
with SRC.open(encoding="utf-8") as fin, DST.open("w", encoding="utf-8") as fout:
for line in fin:
if not line.strip():
continue
obj = json.loads(line)
obj["q_short"] = summarize(obj["q"])
fout.write(json.dumps(obj, ensure_ascii=False) + "\n")
print("✅ 요약 추가 완료 →", DST)
@@ -0,0 +1,832 @@
"""
rag-demo/scripts/run_service_qa.py (리팩토링 버전)
────────────────────────────────────────────────
FastAPI 서버: 질문 → 임베딩 → 벡터 검색 → 재랭킹 → 답변
(모든 AI 모델은 외부 API 사용 + MongoDB 대화 이력)
"""
import json
import os
import re
from pathlib import Path
from datetime import datetime, timezone
from typing import Optional, Dict, Any
from fastapi import FastAPI
from pydantic import BaseModel, Field, model_validator
# 외부 API 클라이언트 및 벡터 스토어
from api_clients import TEIEmbeddingClient, TEIRerankerClient, SGLangClient
from vector_store import get_vector_store
from chat_history import get_chat_history_manager
# 리팩토링된 핸들러
from handlers import (
Config,
QueryRewriter,
SearchHandler,
PromptBuilder,
LLMHandler,
ResponseHandler,
IntentDetector,
GreetingHandler,
EmotionDetector,
EmotionHandler,
SuggestionHandler
)
app = FastAPI()
# ───────────────────────────────────────────
# 1) 초기화
# ───────────────────────────────────────────
print("[Service] 초기화 시작...")
# API 클라이언트
embed_client = TEIEmbeddingClient()
rerank_client = TEIRerankerClient()
llm_client = SGLangClient()
# 벡터 스토어
DATA_DIR = Path("/app/data")
vector_store = get_vector_store()
vector_store.load(str(DATA_DIR))
print(f"[Service] 벡터 스토어 로드 완료: {vector_store.count()}개")
# MongoDB 대화 이력
try:
chat_manager = get_chat_history_manager()
CHAT_HISTORY_ENABLED = True
print("[Service] ✅ 대화 이력 기능 활성화")
except Exception as e:
print(f"[Service] ❌ 대화 이력 기능 비활성화: {e}")
chat_manager = None
CHAT_HISTORY_ENABLED = False
# 설정 로드
config = Config.from_env(chat_history_enabled=CHAT_HISTORY_ENABLED)
config.print_summary()
# 핸들러 초기화
query_rewriter = QueryRewriter(llm_client, embed_client)
search_handler = SearchHandler(vector_store, embed_client, rerank_client, config)
prompt_builder = PromptBuilder()
llm_handler = LLMHandler(llm_client, config)
response_handler = ResponseHandler(DATA_DIR, chat_manager, config)
intent_detector = IntentDetector()
greeting_handler = GreetingHandler()
emotion_detector = EmotionDetector()
emotion_handler = EmotionHandler()
suggestion_handler = SuggestionHandler(
low_confidence_threshold=config.low_confidence_threshold,
high_confidence_threshold=config.high_confidence_threshold,
)
print("[Service] 초기화 완료!\n")
LLM_PROMPT_LOG_ENABLED = os.getenv("LLM_PROMPT_LOG_ENABLED", "true").lower() == "true"
LLM_PROMPT_LOG_MAX_CHARS = int(os.getenv("LLM_PROMPT_LOG_MAX_CHARS", "12000"))
# ───────────────────────────────────────────
# 2) 요청/응답 스키마
# ───────────────────────────────────────────
class QueryRequest(BaseModel):
query: str = Field(..., min_length=1, max_length=500, description="사용자 질문")
bot_id: Optional[str] = Field(None, max_length=100, description="봇 ID", alias="botId")
domain_data: Optional[dict] = Field(None, description="chatbotApi 도메인 서비스 DB 조회 결과", alias="domainData")
intent_type: Optional[str] = Field(None, description="의도 타입 (FARE_SEARCH 등)", alias="intentType")
class Config:
populate_by_name = True
class IntentAnalysisRequest(BaseModel):
query: str = Field(..., min_length=1, max_length=500, description="사용자 질문")
bot_id: Optional[str] = Field(None, max_length=100, description="봇 ID", alias="botId")
pending_intent_type: Optional[str] = Field(None, description="이전 턴에서 대기 중인 의도", alias="pendingIntentType")
pending_params: Optional[Dict[str, Any]] = Field(None, description="이전 턴에서 수집된 파라미터", alias="pendingParams")
intent_definitions: Optional[list[Dict[str, Any]]] = Field(None, description="chatbotApi가 전달한 intent 정의", alias="intentDefinitions")
class Config:
populate_by_name = True
class AgentChatRequest(BaseModel):
query: Optional[str] = Field(None, min_length=1, max_length=500)
question: Optional[str] = Field(None, min_length=1, max_length=500)
bot_id: Optional[str] = Field(None, max_length=100, alias="botId")
pending_intent_type: Optional[str] = Field(None, alias="pendingIntentType")
pending_params: Optional[Dict[str, Any]] = Field(None, alias="pendingParams")
class Config:
populate_by_name = True
@model_validator(mode="after")
def resolve_query(self):
resolved = (self.query or self.question or "").strip()
if not resolved:
raise ValueError("query or question is required")
self.query = resolved
return self
# Agent tool loop (Phase 2+)
from agent.pending_store import AgentPendingStore
from agent.tool_executor import ToolExecutor
from agent.agent_service import AgentService
pending_store = AgentPendingStore()
tool_executor = ToolExecutor(
search_handler=search_handler,
config=config,
)
agent_service = AgentService(
llm_client=llm_client,
tool_executor=tool_executor,
config=config,
pending_store=pending_store,
prompt_builder=prompt_builder,
llm_handler=llm_handler,
intent_detector=intent_detector,
greeting_handler=greeting_handler,
emotion_detector=emotion_detector,
emotion_handler=emotion_handler,
suggestion_handler=suggestion_handler,
chat_manager=chat_manager if CHAT_HISTORY_ENABLED else None,
response_handler=response_handler,
query_rewriter=query_rewriter,
)
print("[Service] ✅ Agent tool-calling 모듈 초기화")
# ───────────────────────────────────────────
# 3) 헬퍼 함수
# ───────────────────────────────────────────
def handle_no_match_with_rewriting(
original_query: str,
bot_id: Optional[str],
ts: str
) -> Optional[dict]:
"""검색 실패 시 Query Rewriting 시도
Returns:
재검색 성공 시 검색 결과, 실패 시 None
"""
if not (config.query_rewrite_enabled and CHAT_HISTORY_ENABLED and bot_id):
return None
# 대화 이력 조회
try:
history = chat_manager.get_recent_history(
bot_id=bot_id,
hours=config.chat_history_hours,
limit=config.chat_history_limit
)
if not history:
return None
# 질문 재작성
rewritten_query = query_rewriter.rewrite_query(original_query, history, ts)
if not rewritten_query:
return None
# 재임베딩 & 재검색
print(f"[ask] {ts} 🔄 재임베딩 중... (threshold={config.threshold_rewrite})")
query_vec = search_handler.embed_query(rewritten_query, ts)
if query_vec is None:
return None
print(f"[ask] {ts} 🔍 재검색 중...")
search_results = search_handler.search(query_vec, config.threshold_rewrite, ts)
if search_results:
top_scores = [r["score"] for r in search_results[:5]]
print(f"[ask] {ts} ✅ 재검색 성공! {len(search_results)}개 발견 (top 5: {top_scores})")
print(f"[ask] {ts} 📝 적용: '{original_query}' → '{rewritten_query}'")
return {
"results": search_results,
"rewritten_query": rewritten_query
}
print(f"[ask] {ts} ⚠️ 재검색도 실패 (threshold={config.threshold_rewrite} 미달)")
return None
except Exception as e:
print(f"[ask] {ts} ❌ Query Rewriting 프로세스 실패: {e}")
return None
def handle_no_match_guidance(
original_query: str,
bot_id: Optional[str],
ts: str
) -> dict:
"""검색 실패 시 LLM으로 짧은 답변 불가/역할 안내를 생성한다."""
print(f"[ask] {ts} Query Rewriting도 실패 → guidance LLM 답변 생성")
conversation_context = ""
if CHAT_HISTORY_ENABLED and bot_id:
try:
conversation_context = chat_manager.get_context_for_llm(
bot_id=bot_id,
hours=config.chat_history_hours,
max_conversations=config.chat_history_limit
)
if conversation_context:
print(f"[ask] {ts} 🔄 대화 이력 포함 (guidance)")
except Exception as e:
print(f"[ask] {ts} 대화 이력 조회 실패: {e}")
try:
system_prompt, user_prompt = prompt_builder.build_guidance_prompt(
original_query, conversation_context
)
guidance_response = llm_handler.generate_answer(
system_prompt, user_prompt, ts
)
except Exception as e:
print(f"[ask] {ts} ❌ guidance LLM 실패, 기본 안내 사용: {e}")
guidance_response = (
"문의하신 내용은 현재 정확한 정보를 확인하기 어렵습니다.\n"
"정확한 확인이 필요한 경우 한국도로공사 콜센터(1588-2504)로 문의해 주세요."
)
# MongoDB 저장
response_handler.save_to_mongodb(
bot_id=bot_id,
user_query=original_query,
ai_response=guidance_response,
matched_questions=[],
scores=[],
metadata={
"type": "no_match",
"reason": "threshold_not_met",
"threshold": config.threshold,
"answer_confidence": "low",
"num_references": 0,
"statusMsg": "no_match",
},
ts=ts
)
return response_handler.build_no_match_response(
answer=guidance_response,
bot_id=bot_id,
top_k=config.top_k
)
def has_usable_domain_data(domain_data: Optional[dict]) -> bool:
"""LLM 답변 근거로 사용할 수 있는 도메인 데이터인지 확인"""
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_domain_data_fallback_answer(domain_data: Optional[dict]) -> str:
"""LLM 실패 시 domainData만으로 최소 응답 생성"""
if not domain_data:
return llm_handler.generate_default_guidance()
summary = domain_data.get("llmSummary")
if summary is not None and str(summary).strip():
return str(summary).strip()
status_msg = domain_data.get("statusMsg")
if status_msg is not None and str(status_msg).strip():
return str(status_msg).strip()
lines = []
for key, value in domain_data.items():
if key in ("status", "errorMsg") or value is None:
continue
lines.append(f"- {key}: {value}")
if lines:
return "조회된 정보는 다음과 같습니다.\n" + "\n".join(lines)
return llm_handler.generate_default_guidance()
def log_llm_messages(messages: list[Dict[str, Any]], ts: str, intent_type: Optional[str], domain_data_present: bool):
"""실제 LLM 호출에 전달되는 messages를 로그로 출력한다."""
if not LLM_PROMPT_LOG_ENABLED:
return
print(
f"[ask] {ts} ===== LLM messages 시작 "
f"(intentType={intent_type}, domainData={domain_data_present}, count={len(messages)}) ====="
)
for idx, message in enumerate(messages):
role = message.get("role")
raw_content = message.get("content", "")
content = str(raw_content)
original_length = len(content)
if original_length > LLM_PROMPT_LOG_MAX_CHARS:
content = (
content[:LLM_PROMPT_LOG_MAX_CHARS]
+ f"\n...(이하 로그 생략, totalChars={original_length})"
)
print(f"[ask] {ts} [LLM message {idx}] role={role}, chars={original_length}")
print(content)
print(f"[ask] {ts} ===== LLM messages 끝 =====")
def _extract_json_object(text: str) -> Dict[str, Any]:
"""LLM 응답에서 JSON object만 추출한다."""
cleaned = llm_handler._remove_think_tags(text).strip()
cleaned = re.sub(r"^```(?:json)?\s*", "", cleaned)
cleaned = re.sub(r"\s*```$", "", cleaned)
try:
return json.loads(cleaned)
except json.JSONDecodeError:
match = re.search(r"\{.*\}", cleaned, flags=re.DOTALL)
if not match:
raise
return json.loads(match.group(0))
def _fallback_intent_analysis(query: str) -> Dict[str, Any]:
"""LLM 분석 실패 시 최소한의 안전한 라우팅만 수행한다."""
text = query.strip()
params: Dict[str, Any] = {}
if any(keyword in text for keyword in ["통행요금", "통행료", "요금"]):
if "에서" in text and "까지" in text:
before, after = text.split("에서", 1)
destination = after.split("까지", 1)[0].strip()
params["fromIc"] = before.strip() or None
params["toIc"] = destination or None
return {
"intentType": "FARE_SEARCH",
"confidence": 0.65,
"params": params,
"routeType": "domain",
"needsClarification": False,
"clarificationQuestion": None,
"reason": "keyword_fallback"
}
return {
"intentType": None,
"confidence": 0.0,
"params": {},
"routeType": "rag",
"needsClarification": False,
"clarificationQuestion": None,
"reason": "llm_failed_fallback_to_rag"
}
def _build_intent_router_prompt(intent_definitions: Optional[list[Dict[str, Any]]]) -> str:
intents = intent_definitions or []
lines = [
"당신은 한국도로공사 카카오 챗봇의 intent router입니다.",
"사용자 발화를 아래 JSON object 하나로만 분류하세요. 설명, markdown, 코드블록은 금지합니다.",
"",
"허용 intentType:"
]
for definition in intents:
intent_type = definition.get("intentType")
params = definition.get("params") or []
param_descriptions = []
for param in params:
if isinstance(param, dict):
name = param.get("name")
required_label = "required" if param.get("required") else "optional"
description = param.get("description") or ""
examples = param.get("examples") or []
example_text = f" 예: {', '.join(map(str, examples))}" if examples else ""
param_descriptions.append(f"{name}({required_label}): {description}{example_text}")
else:
param_descriptions.append(str(param))
lines.append(f"- {intent_type}: {definition.get('description', '')}. params: {'; '.join(param_descriptions)}")
lines.extend([
"",
"규칙:",
"- 특정 도메인 DB/API 조회 intent가 아니면 intentType은 null로 둡니다.",
"- 미납 조회 방법, 미납 납부 방법, 미납 확인 경로처럼 방법/절차/위치를 묻는 안내성 질문은 차량번호가 없으면 FARE_UNPAID로 분류하지 말고 intentType을 null로 둡니다.",
"- \"여기\", \"거기\", \"저기\", \"현재 위치\", \"내 위치\"는 실제 IC명으로 확정하지 말고 해당 param을 null로 둡니다.",
"- 알 수 없는 필수 파라미터는 추측하지 말고 null로 둡니다.",
"- 이전 pendingIntentType이 있고 사용자가 누락 파라미터만 짧게 답한 경우, pending intent의 param으로 해석하세요.",
"- 차량번호 파라미터(carNo)는 공백과 하이픈을 제거한 전체 차량번호로 반환하세요. 예: 12가 3456→12가3456, 123가-4567→123가4567. 끝자리만 있는 부분 차량번호는 carNo로 확정하지 말고 null로 둡니다.",
"- IC/영업소명 파라미터(fromIc, toIc, icName)는 IC, 영업소, 톨게이트, 요금소 같은 접미사와 공백을 제거한 짧은 한글명으로 반환하세요. 예: 판교IC→판교, 서울 영업소→서울, 신갈 톨게이트→신갈.",
"- 휴게소명 파라미터(restAreaName)는 휴게소 접미사와 공백을 제거한 짧은 한글명으로 반환하세요. 예: 죽전휴게소→죽전, 죽전 휴게소→죽전, 망향휴게소→망향.",
"",
"반드시 다음 schema를 지키세요:",
"{",
" \"intentType\": string|null,",
" \"confidence\": number,",
" \"params\": object,",
" \"reason\": string",
"}"
])
return "\n".join(lines)
@app.post("/intent/analyze")
def analyze_intent(req: IntentAnalysisRequest):
"""LLM 기반 intent/slot 분석 전용 엔드포인트."""
ts = datetime.now(timezone.utc).isoformat()
print(f"\n[intent] {ts} 분석 요청: {req.query}")
system_prompt = _build_intent_router_prompt(req.intent_definitions)
user_prompt = {
"query": req.query,
"pendingIntentType": req.pending_intent_type,
"pendingParams": req.pending_params or {}
}
try:
raw = llm_handler.generate_answer_from_messages(
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": json.dumps(user_prompt, ensure_ascii=False)}
],
ts=ts,
max_tokens=512,
temperature=0.0
)
result = _extract_json_object(raw)
result.setdefault("params", {})
result.setdefault("confidence", 0.0)
result.setdefault("routeType", "rag" if not result.get("intentType") else "domain")
result.setdefault("needsClarification", False)
result.setdefault("clarificationQuestion", None)
result.setdefault("reason", "llm")
print(f"[intent] {ts} 분석 결과: {result}")
return result
except Exception as e:
print(f"[intent] {ts} ❌ LLM intent 분석 실패: {e}")
result = _fallback_intent_analysis(req.query)
print(f"[intent] {ts} 폴백 분석 결과: {result}")
return result
# ───────────────────────────────────────────
# 4) /ask 엔드포인트
# ───────────────────────────────────────────
@app.post("/ask")
def ask(q: QueryRequest):
"""질문 처리 메인 엔드포인트"""
ts = datetime.now(timezone.utc).isoformat()
print(f"\n[ask] {ts} 질문: {q.query}")
# ─── ⓪ 의도 감지 ─────────────────────────────
intent = intent_detector.detect(q.query, ts)
# 특별 의도 처리 (인사, 종료, 불만)
if intent.is_special():
print(f"[ask] {ts} 특별 의도 감지: {intent.name}")
# 특별 응답 생성
response = greeting_handler.generate_response(
intent_name=intent.name,
query=q.query,
matched_keywords=intent.matched_keywords,
bot_id=q.bot_id
)
# MongoDB 저장 (대화 이력 기록)
if CHAT_HISTORY_ENABLED:
response_handler.save_to_mongodb(
bot_id=q.bot_id,
user_query=q.query,
ai_response=response["answer"],
matched_questions=[],
scores=[],
metadata={
"type": "special_intent",
"intent": intent.name,
"confidence": intent.confidence,
"matched_keywords": intent.matched_keywords
},
ts=ts
)
print(f"[ask] {ts} 특별 응답 반환: {intent.name}")
return response
original_query = q.query
rewritten_query = None
domain_data_available = has_usable_domain_data(q.domain_data)
# ─── ① 임베딩 ─────────────────────────────
query_vec = search_handler.embed_query(q.query, ts)
if query_vec is None:
if domain_data_available:
print(f"[ask] {ts} 임베딩 실패, domainData 기반 LLM 답변으로 진행")
search_results = []
else:
return {
"answer": "죄송합니다. 일시적인 오류가 발생했습니다. (임베딩 실패)",
"matched_question": None,
"score": None,
}
else:
# ─── ② 검색 ───────────────────────────────
search_results = search_handler.search(query_vec, config.threshold, ts)
# 검색 실패 → domainData가 있으면 즉시 LLM, 없으면 Query Rewriting 시도
if not search_results:
response_handler.log_failed_query(original_query, ts)
print(f"[ask] {ts} threshold 미달: q={original_query}")
if domain_data_available:
print(f"[ask] {ts} threshold 미달이지만 domainData 존재 → 재작성/재검색 생략, domainData 기반 LLM 답변으로 진행")
search_results = []
else:
# Query Rewriting 시도. 임베딩 실패로 검색을 못 한 경우에는 재작성도 건너뛴다.
rewrite_result = None
if query_vec is not None:
rewrite_result = handle_no_match_with_rewriting(original_query, q.bot_id, ts)
else:
print(f"[ask] {ts} 임베딩 실패로 Query Rewriting 건너뜀")
if rewrite_result:
search_results = rewrite_result["results"]
rewritten_query = rewrite_result["rewritten_query"]
else:
# 재검색도 실패 → 질문 유도
return handle_no_match_guidance(original_query, q.bot_id, ts)
# ─── ③ 재랭킹 ─────────────────────────────
candidates = [r["meta"] for r in search_results]
faiss_scores = [r["score"] for r in search_results[:config.top_n_for_llm]]
# Reranking 시 재작성 질문 사용 (있으면)
query_for_rerank = rewritten_query if rewritten_query else q.query
top_n_results, top_scores, rerank_used, rerank_info = search_handler.rerank(
query_for_rerank, candidates, ts
)
# ─── ③-1 낮은 신뢰도 시 Full Context Query Rewriting ──────
if (
config.query_rewrite_enabled
and top_scores
and top_scores[0] < config.low_confidence_threshold
and not rewritten_query
):
print(
f"[ask] {ts} 낮은 신뢰도 감지 ({top_scores[0]:.2f} < {config.low_confidence_threshold}) "
f"→ Full Context Rewriting 시도"
)
if CHAT_HISTORY_ENABLED and q.bot_id:
try:
# 대화 이력 조회 (질문 + 답변)
history_data = chat_manager.get_recent_history(
bot_id=q.bot_id,
hours=config.chat_history_hours,
limit=config.chat_history_limit
)
if history_data:
# Full Context Rewriting (질문 + 답변 활용)
full_context_query = query_rewriter.rewrite_query_with_full_context(
original_query=original_query,
history=history_data,
ts=ts
)
if full_context_query:
# 재임베딩 & 재검색
print(f"[ask] {ts} 🔄 Full Context 재임베딩 중...")
query_vec_full = search_handler.embed_query(full_context_query, ts)
if query_vec_full is not None:
print(f"[ask] {ts} 🔍 Full Context 재검색 중...")
search_results_full = search_handler.search(
query_vec_full,
config.threshold_rewrite, # 더 관대한 threshold
ts
)
if search_results_full and len(search_results_full) > 0:
prior_rerank_score = top_scores[0] if top_scores else None
candidates_full = [r["meta"] for r in search_results_full]
# 재리랭킹 후 리랭커 점수끼리만 비교 (Qdrant 코사인과 혼용 금지)
(
top_n_full,
top_scores_full,
rerank_used_full,
rerank_info_full,
) = search_handler.rerank(
full_context_query, candidates_full, ts
)
full_rerank_score = (
top_scores_full[0] if top_scores_full else None
)
if full_rerank_score is not None and (
prior_rerank_score is None
or full_rerank_score > prior_rerank_score
):
if prior_rerank_score is not None:
print(
f"[ask] {ts} ✅ Full Context 재검색 성공! "
f"리랭커 점수 향상: "
f"{prior_rerank_score:.2f} → {full_rerank_score:.2f}"
)
else:
print(
f"[ask] {ts} ✅ Full Context 재검색 성공! "
f"리랭커 점수: {full_rerank_score:.2f}"
)
top_n_results = top_n_full
top_scores = top_scores_full
rerank_used = rerank_used_full
rerank_info = rerank_info_full
rewritten_query = full_context_query
else:
prior_label = (
f"{prior_rerank_score:.2f}"
if prior_rerank_score is not None
else "None"
)
full_label = (
f"{full_rerank_score:.2f}"
if full_rerank_score is not None
else "None"
)
print(
f"[ask] {ts} ⚠️ Full Context 재검색 리랭커 점수 미개선 "
f"({full_label} ≤ {prior_label})"
)
else:
print(f"[ask] {ts} ⚠️ Full Context 재검색 결과 없음")
except Exception as e:
print(f"[ask] {ts} ❌ Full Context Rewriting 실패: {e}")
# ─── ④ LLM 답변 생성 (Messages Format) ─────
# 감정 분석
emotion = emotion_detector.detect(original_query, ts)
# 대화 이력 조회 (messages format)
conversation_messages = []
if config.chat_history_always_include and CHAT_HISTORY_ENABLED and q.bot_id:
try:
conversation_messages = chat_manager.get_messages_for_llm(
bot_id=q.bot_id,
hours=config.chat_history_hours,
max_conversations=config.chat_history_limit
)
if conversation_messages:
print(f"[ask] {ts} 🔄 대화 이력 포함 ({len(conversation_messages)//2}개 대화, bot_id={q.bot_id})")
except Exception as e:
print(f"[ask] {ts} 대화 이력 조회 실패: {e}")
# 감정별 추가 지시사항
emotion_instruction = emotion_handler.get_emotion_instruction(emotion.primary)
# Messages 프롬프트 생성 (표준 chat completion format + 감정 정보)
messages = prompt_builder.build_answer_prompt_messages(
original_query=original_query,
rewritten_query=rewritten_query,
references=top_n_results,
scores=top_scores,
conversation_history=conversation_messages,
emotion_instruction=emotion_instruction,
emotion_name=emotion.primary,
domain_data=q.domain_data
)
log_llm_messages(messages, ts, q.intent_type, domain_data_available)
# LLM 답변 생성 (Messages Format)
# LLM이 감정 정보를 받아 맥락에 맞게 공감하며 답변 생성
try:
answer = llm_handler.generate_answer_from_messages(messages, ts)
# ❌ 정해진 공감 메시지 제거 (LLM이 직접 맥락 파악하여 공감)
# answer = emotion_handler.enhance_answer_with_empathy(...)
# 낮은 신뢰도 시 대안 질문 제안 추가
answer = suggestion_handler.enhance_answer_with_suggestions(
answer=answer,
top_results=top_n_results,
top_scores=top_scores,
max_suggestions=3
)
except Exception as e:
print(f"[ask] {ts} ❌ LLM 실패, 폴백 답변 사용: {e}")
if top_n_results:
answer = llm_handler.generate_fallback_answer(top_n_results[0])
else:
answer = build_domain_data_fallback_answer(q.domain_data)
# ─── ⑤ 저장 & 로깅 ─────────────────────────
# 신뢰도 레벨 계산
confidence_level = suggestion_handler.get_confidence_level(top_scores[0] if top_scores else None)
# MongoDB 저장
references = response_handler.build_references(top_n_results, top_scores)
faq_urls = response_handler.extract_faq_urls(top_n_results)
metadata = {
"rerank_used": rerank_used,
"faiss_top_k": config.top_k,
"num_references": len(top_n_results),
"references": references,
"faq_urls": faq_urls,
"emotion": emotion.primary,
"emotion_intensity": emotion.intensity,
"emotion_confidence": emotion.confidence,
"answer_confidence": confidence_level,
"top_score": top_scores[0] if top_scores else None,
"domain_data_present": domain_data_available,
"domain_data_only": domain_data_available and not top_n_results
}
if rewritten_query:
metadata["query_rewritten"] = True
metadata["original_query"] = original_query
metadata["rewritten_query"] = rewritten_query
response_handler.save_to_mongodb(
bot_id=q.bot_id,
user_query=original_query,
ai_response=answer,
matched_questions=[r["q"] for r in top_n_results],
scores=top_scores,
metadata=metadata,
ts=ts
)
# 로깅
response_handler.log_success(
original_query, rewritten_query, top_n_results, top_scores, answer, ts
)
response_handler.print_console_log(
original_query, rewritten_query, top_n_results, top_scores, answer, ts
)
# ─── ⑥ 응답 반환 ───────────────────────────
return response_handler.build_response(
answer=answer,
matched_questions=[r["q"] for r in top_n_results],
scores=top_scores,
bot_id=q.bot_id,
references=references,
faq_urls=faq_urls,
rerank_info={
"used": rerank_used,
"faiss_top_k": config.top_k,
"rerank_top_n": len(top_n_results),
"faiss_scores": [round(s, 4) for s in faiss_scores],
"rerank_scores": top_scores,
"detail": rerank_info if rerank_info else "Reranking not used"
},
faiss_scores=faiss_scores
)
@app.post("/agent/chat")
def agent_chat(request: AgentChatRequest):
"""Tool-calling agent — RAG + domain tools(chatbotApi) 통합 응답."""
ts = datetime.now(timezone.utc).isoformat()
print(f"\n[agent] {ts} 질문: {request.query} botId={request.bot_id}")
result = agent_service.chat(
request.query,
bot_id=request.bot_id,
pending_intent_type=request.pending_intent_type,
pending_params=request.pending_params,
)
print(f"[agent] {ts} routeType={result.get('routeType')} intent={result.get('intentType')}")
return result
@app.get("/health")
def healthz():
"""헬스체크 엔드포인트"""
from api_clients import check_api_health
api_health = check_api_health()
vector_count = vector_store.count()
all_ok = all(api_health.values()) and vector_count > 0
return {
"status": "ok" if all_ok else "degraded",
"vector_count": vector_count,
"api_services": api_health
}
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=28012)
@@ -0,0 +1,115 @@
"""
Model-free sparse vector encoder for Qdrant hybrid search.
This encoder intentionally avoids external model dependencies. It converts
Korean/English/numeric tokens into stable integer dimensions and assigns
simple field-aware weights. The goal is exact keyword recall for terms such as
"희망드림" while dense embeddings keep handling semantic similarity.
"""
from __future__ import annotations
import math
import re
import zlib
from collections import Counter
from typing import Any, Dict, Iterable, List, Tuple
_TOKEN_RE = re.compile(r"[0-9a-zA-Z가-힣][0-9a-zA-Z가-힣+\-_.]*")
_MAX_DIM = 2_000_000_000
STOPWORDS = {
"은",
"는",
"이",
"가",
"을",
"를",
"의",
"에",
"에서",
"으로",
"로",
"와",
"과",
"도",
"만",
"및",
"또는",
"그리고",
"안내",
"문의",
"방법",
}
def _token_id(token: str) -> int:
# Qdrant sparse indices are unsigned integer dimensions. crc32 is stable
# across processes, unlike Python's built-in hash().
return zlib.crc32(token.encode("utf-8")) % _MAX_DIM
def tokenize(text: Any) -> List[str]:
raw = str(text or "").lower()
tokens: List[str] = []
for match in _TOKEN_RE.finditer(raw):
token = match.group(0).strip("._-+")
if len(token) < 2:
continue
if token in STOPWORDS:
continue
tokens.append(token)
return tokens
def _weighted_counts(parts: Iterable[Tuple[Any, float]]) -> Counter:
counts: Counter = Counter()
for text, weight in parts:
for token in tokenize(text):
counts[token] += weight
return counts
def _to_sparse_vector(counts: Counter, *, max_terms: int) -> Dict[str, List[float]]:
if not counts:
return {"indices": [], "values": []}
# Field-aware TF weight. Without corpus-wide IDF, rare proper nouns still
# get strong exact-match behavior because they occupy unique dimensions.
scored = [
(token, 1.0 + math.log(float(count)))
for token, count in counts.items()
if count > 0
]
scored.sort(key=lambda item: item[1], reverse=True)
by_index: Dict[int, float] = {}
for token, value in scored[:max_terms]:
idx = _token_id(token)
by_index[idx] = by_index.get(idx, 0.0) + float(value)
ordered = sorted(by_index.items())
return {
"indices": [idx for idx, _ in ordered],
"values": [round(value, 6) for _, value in ordered],
}
def encode_document(meta: Dict[str, Any], *, max_terms: int = 256) -> Dict[str, List[float]]:
"""Encode FAQ payload into a model-free sparse vector."""
q = meta.get("q") or meta.get("question") or ""
a = meta.get("a") or meta.get("answer") or ""
parts = [
(q, 3.0),
(a, 1.0),
(meta.get("category"), 1.4),
(meta.get("source"), 1.2),
]
return _to_sparse_vector(_weighted_counts(parts), max_terms=max_terms)
def encode_query(query: str, *, max_terms: int = 64) -> Dict[str, List[float]]:
"""Encode user query into a sparse vector using the same token space."""
return _to_sparse_vector(_weighted_counts([(query, 1.0)]), max_terms=max_terms)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,40 @@
"""Functional tests for AgentPendingStore (in-memory path)."""
from agent.pending_store import AgentPendingStore
def test_pending_save_get_clear_memory_fallback(monkeypatch):
monkeypatch.setattr(AgentPendingStore, "_init_mongo", lambda self: None)
store = AgentPendingStore()
store.save(
"user-a",
pending_intent_type="FARE_SEARCH",
pending_params={"toIc": "신갈"},
missing_params=["fromIc"],
)
loaded = store.get("user-a")
assert loaded is not None
assert loaded["pendingIntentType"] == "FARE_SEARCH"
assert loaded["pendingParams"]["toIc"] == "신갈"
assert loaded["missingParams"] == ["fromIc"]
store.clear("user-a")
assert store.get("user-a") is None
def test_pending_normalizes_anonymous_bot_id(monkeypatch):
monkeypatch.setattr(AgentPendingStore, "_init_mongo", lambda self: None)
store = AgentPendingStore()
store.save(
None,
pending_intent_type="FARE_UNPAID",
pending_params={},
missing_params=["carNo"],
)
loaded = store.get("")
assert loaded is not None
assert loaded["pendingIntentType"] == "FARE_UNPAID"
File diff suppressed because it is too large Load Diff