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:
@@ -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=>({'&':'&','<':'<','>':'>'}[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
Reference in New Issue
Block a user