feat(nanoclaude): document.ask + search_full 통합
- document_tool.py: ask() (/api/search/ask 35초 timeout, citation 포맷, refused 시 검색 결과 fallback) + search_full() (rerank+analyze 포함) - registry.py: ALLOWED_OPS에 ask, search_full 추가 - worker.py: 질문/탐색 점수 기반 분기 (ask 강신호 2개 이상), document.ask 전용 35초 timeout, render_mode="final" 시 EXAONE 스킵 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -25,6 +25,7 @@ MAX_PROMPT_LENGTH = 1000
|
||||
SYNOLOGY_MAX_LEN = 4000
|
||||
MAX_TOOL_PAYLOAD = 2000
|
||||
TOOL_TIMEOUT = 10.0
|
||||
DOCUMENT_ASK_TIMEOUT = 35.0
|
||||
|
||||
|
||||
async def _complete_with_heartbeat(adapter, message: str, job_id: str, *, messages=None, beat_msg="처리 중...") -> str:
|
||||
@@ -179,15 +180,27 @@ def _pre_route(message: str) -> dict | None:
|
||||
folder = "INBOX"
|
||||
return {"action": "tools", "tool": "email", "operation": "search", "params": {"query": query, "days": days, "folder": folder}}
|
||||
|
||||
# 문서 키워드
|
||||
if any(k in msg for k in ["문서", "도큐먼트", "자료", "파일"]) and any(k in msg for k in ["찾아", "검색", "확인"]):
|
||||
# 검색어 추출: 키워드 제거 후 남은 텍스트
|
||||
# 문서 키워드 — 질문형/탐색형 점수 기반 분기
|
||||
doc_entry = any(k in msg for k in ["문서", "도큐먼트", "자료", "파일"])
|
||||
doc_action = any(k in msg for k in ["찾아", "검색", "확인", "알려", "설명", "뭐야"])
|
||||
if doc_entry and doc_action:
|
||||
query = msg
|
||||
for rm in ["문서", "도큐먼트", "자료", "파일", "찾아줘", "찾아", "검색", "확인", "해줘", "줘", "좀"]:
|
||||
query = query.replace(rm, "")
|
||||
query = query.strip()
|
||||
if query:
|
||||
return {"action": "tools", "tool": "document", "operation": "search", "params": {"query": query}}
|
||||
# 질문형 강신호
|
||||
ask_signals = ["알려줘", "설명해", "차이", "비교", "절차", "요건", "무엇", "왜", "어떻게", "뭐야", "뭔가"]
|
||||
# 탐색형 강신호
|
||||
search_signals = ["찾아", "검색", "목록", "최근", "업로드", "리스트"]
|
||||
ask_score = sum(1 for s in ask_signals if s in msg)
|
||||
search_score = sum(1 for s in search_signals if s in msg)
|
||||
# 초기 운영 가드: ask는 강신호 2개 이상일 때만
|
||||
if ask_score >= 2 and ask_score > search_score:
|
||||
operation = "ask"
|
||||
else:
|
||||
operation = "search_full"
|
||||
return {"action": "tools", "tool": "document", "operation": operation, "params": {"query": query}}
|
||||
|
||||
# 인프라 도구 키워드
|
||||
infra_keywords = ["docker", "컨테이너", "디스크", "용량", "헬스체크", "tailscale", "ollama 모델", "mlx 모델", "스케줄러", "scheduler", "큐 상태", "queue", "처리 큐", "verify", "검증"]
|
||||
@@ -344,9 +357,10 @@ async def run(job: Job) -> None:
|
||||
await state_stream.push(job.id, "result", {"content": response})
|
||||
await conversation_store.add(user_id, "assistant", response)
|
||||
else:
|
||||
# 일반 도구 실행
|
||||
# 일반 도구 실행 (document.ask는 긴 timeout)
|
||||
timeout = DOCUMENT_ASK_TIMEOUT if (tool_name == "document" and operation == "ask") else TOOL_TIMEOUT
|
||||
try:
|
||||
result = await asyncio.wait_for(execute_tool(tool_name, operation, params), timeout=TOOL_TIMEOUT)
|
||||
result = await asyncio.wait_for(execute_tool(tool_name, operation, params), timeout=timeout)
|
||||
except asyncio.TimeoutError:
|
||||
result = {"ok": False, "tool": tool_name, "operation": operation, "data": [], "summary": "", "error": "⚠️ 서비스 응답 시간이 초과되었습니다."}
|
||||
|
||||
@@ -361,6 +375,11 @@ async def run(job: Job) -> None:
|
||||
response = result["summary"] + "\n\n'확인' 또는 '취소'로 답해주세요."
|
||||
collected.append(response)
|
||||
await state_stream.push(job.id, "result", {"content": response})
|
||||
elif result.get("render_mode") == "final":
|
||||
# 이미 포맷된 응답 (document.ask 등) — EXAONE 재포맷 스킵
|
||||
response = result.get("rendered_text", result.get("summary", "결과를 조회했습니다."))
|
||||
collected.append(response)
|
||||
await state_stream.push(job.id, "result", {"content": response})
|
||||
else:
|
||||
# 결과를 EXAONE에 전달하여 자연어로 정리 (평문 프롬프트 사용)
|
||||
tool_json = json.dumps(result["data"], ensure_ascii=False)
|
||||
|
||||
@@ -12,10 +12,16 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
TOOL_NAME = "document"
|
||||
MAX_RESULTS = 5
|
||||
SEARCH_TIMEOUT = 15.0
|
||||
ASK_TIMEOUT = 35.0
|
||||
|
||||
CONFIDENCE_LABELS = {"high": "높음", "medium": "보통", "low": "낮음"}
|
||||
|
||||
|
||||
def _make_result(ok: bool, operation: str, data=None, summary: str = "", error: str | None = None) -> dict:
|
||||
return {"ok": ok, "tool": TOOL_NAME, "operation": operation, "data": data or [], "summary": summary, "error": error}
|
||||
def _make_result(ok: bool, operation: str, data=None, summary: str = "", error: str | None = None, **extra) -> dict:
|
||||
result = {"ok": ok, "tool": TOOL_NAME, "operation": operation, "data": data or [], "summary": summary, "error": error}
|
||||
result.update(extra)
|
||||
return result
|
||||
|
||||
|
||||
def _headers() -> dict:
|
||||
@@ -23,12 +29,12 @@ def _headers() -> dict:
|
||||
|
||||
|
||||
async def search(query: str) -> dict:
|
||||
"""문서 하이브리드 검색."""
|
||||
"""문서 하이브리드 검색 (basic)."""
|
||||
if not settings.document_api_url:
|
||||
return _make_result(False, "search", error="Document Server 설정이 없습니다.")
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
async with httpx.AsyncClient(timeout=SEARCH_TIMEOUT) as client:
|
||||
resp = await client.get(
|
||||
f"{settings.document_api_url}/search/",
|
||||
params={"q": query, "mode": "hybrid"},
|
||||
@@ -41,7 +47,6 @@ async def search(query: str) -> dict:
|
||||
if isinstance(results, dict):
|
||||
results = results.get("results", results.get("data", []))
|
||||
|
||||
# top-N 제한
|
||||
results = results[:MAX_RESULTS]
|
||||
|
||||
items = []
|
||||
@@ -61,13 +66,127 @@ async def search(query: str) -> dict:
|
||||
return _make_result(False, "search", error=str(e))
|
||||
|
||||
|
||||
async def search_full(query: str) -> dict:
|
||||
"""문서 하이브리드 검색 (rerank + analyze 포함)."""
|
||||
if not settings.document_api_url:
|
||||
return _make_result(False, "search_full", error="Document Server 설정이 없습니다.")
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=SEARCH_TIMEOUT) as client:
|
||||
resp = await client.get(
|
||||
f"{settings.document_api_url}/search/",
|
||||
params={"q": query, "mode": "hybrid", "rerank": "true", "analyze": "true", "limit": "10"},
|
||||
headers=_headers(),
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
return _make_result(False, "search_full", error=f"API 응답 오류 ({resp.status_code})")
|
||||
|
||||
body = resp.json()
|
||||
results = body.get("results", [])[:MAX_RESULTS]
|
||||
confidence = body.get("confidence_signal", 0)
|
||||
|
||||
items = []
|
||||
for doc in results:
|
||||
items.append({
|
||||
"id": doc.get("id", ""),
|
||||
"title": doc.get("title", "(제목 없음)"),
|
||||
"domain": doc.get("domain", ""),
|
||||
"score": doc.get("score", 0),
|
||||
"summary": str(doc.get("ai_summary", ""))[:150],
|
||||
})
|
||||
|
||||
summary = f"'{query}' 검색 결과 {len(items)}건 (신뢰도: {confidence:.0%})"
|
||||
return _make_result(True, "search_full", data=items, summary=summary)
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("Document search_full failed")
|
||||
return _make_result(False, "search_full", error=str(e))
|
||||
|
||||
|
||||
async def ask(query: str) -> dict:
|
||||
"""문서 기반 AI 답변 (evidence-grounded synthesis)."""
|
||||
if not settings.document_api_url:
|
||||
return _make_result(False, "ask", error="Document Server 설정이 없습니다.")
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=ASK_TIMEOUT) as client:
|
||||
resp = await client.get(
|
||||
f"{settings.document_api_url}/search/ask",
|
||||
params={"q": query, "limit": "10"},
|
||||
headers=_headers(),
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
return _make_result(False, "ask", error=f"API 응답 오류 ({resp.status_code})")
|
||||
|
||||
body = resp.json()
|
||||
|
||||
ai_answer = body.get("ai_answer")
|
||||
refused = body.get("refused", False)
|
||||
completeness = body.get("completeness", "insufficient")
|
||||
confidence = body.get("confidence")
|
||||
citations = body.get("citations", [])
|
||||
results = body.get("results", [])
|
||||
|
||||
# refused 또는 insufficient → 검색 결과 fallback
|
||||
if refused or not ai_answer or completeness == "insufficient":
|
||||
reason = body.get("no_results_reason", "관련 근거를 찾지 못했습니다.")
|
||||
lines = [reason, ""]
|
||||
if results:
|
||||
lines.append("[관련 문서]")
|
||||
for doc in results[:5]:
|
||||
title = doc.get("title", "(제목 없음)")
|
||||
score = doc.get("score", 0)
|
||||
lines.append(f"- {title} (유사도: {score:.0%})")
|
||||
|
||||
rendered = "\n".join(lines)
|
||||
return _make_result(
|
||||
True, "ask", data=results[:5], summary=rendered,
|
||||
rendered_text=rendered, render_mode="final",
|
||||
citations=[], confidence=None,
|
||||
)
|
||||
|
||||
# 정상 답변 → 포맷팅
|
||||
conf_label = CONFIDENCE_LABELS.get(confidence, "")
|
||||
lines = [f"[AI 답변] (신뢰도: {conf_label})" if conf_label else "[AI 답변]"]
|
||||
lines.append(ai_answer)
|
||||
|
||||
if citations:
|
||||
lines.append("")
|
||||
lines.append("[출처]")
|
||||
for c in citations:
|
||||
n = c.get("n", "")
|
||||
title = c.get("title", "")
|
||||
rel = c.get("relevance", 0)
|
||||
lines.append(f"[{n}] {title} (관련도: {rel:.0%})")
|
||||
|
||||
if completeness == "partial":
|
||||
lines.append("")
|
||||
lines.append("(일부 내용만 확인 가능합니다)")
|
||||
|
||||
rendered = "\n".join(lines)
|
||||
citation_meta = [{"n": c.get("n"), "title": c.get("title"), "relevance": c.get("relevance"), "doc_id": c.get("doc_id")} for c in citations]
|
||||
|
||||
return _make_result(
|
||||
True, "ask", data=results[:5], summary=rendered,
|
||||
rendered_text=rendered, render_mode="final",
|
||||
citations=citation_meta, confidence=confidence,
|
||||
)
|
||||
|
||||
except httpx.TimeoutException:
|
||||
logger.warning("Document ask timeout")
|
||||
return _make_result(False, "ask", error="답변 생성 시간이 초과되었습니다. 잠시 후 다시 시도해주세요.")
|
||||
except Exception as e:
|
||||
logger.exception("Document ask failed")
|
||||
return _make_result(False, "ask", error=str(e))
|
||||
|
||||
|
||||
async def read(doc_id: str) -> dict:
|
||||
"""문서 내용 조회."""
|
||||
if not settings.document_api_url:
|
||||
return _make_result(False, "read", error="Document Server 설정이 없습니다.")
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
async with httpx.AsyncClient(timeout=SEARCH_TIMEOUT) as client:
|
||||
resp = await client.get(
|
||||
f"{settings.document_api_url}/documents/{doc_id}",
|
||||
headers=_headers(),
|
||||
|
||||
@@ -20,7 +20,7 @@ ERROR_MESSAGES = {
|
||||
ALLOWED_OPS = {
|
||||
"calendar": {"today", "search", "create_draft", "create_confirmed"},
|
||||
"email": {"search", "read"},
|
||||
"document": {"search", "read"},
|
||||
"document": {"search", "search_full", "ask", "read"},
|
||||
"infra": {"status", "health", "disk", "network", "models", "scheduler", "queue", "verify", "restart"},
|
||||
}
|
||||
|
||||
@@ -97,6 +97,10 @@ async def _exec_email(operation: str, params: dict) -> dict:
|
||||
async def _exec_document(operation: str, params: dict) -> dict:
|
||||
if operation == "search":
|
||||
return await document_tool.search(params.get("query", ""))
|
||||
elif operation == "search_full":
|
||||
return await document_tool.search_full(params.get("query", ""))
|
||||
elif operation == "ask":
|
||||
return await document_tool.ask(params.get("query", ""))
|
||||
elif operation == "read":
|
||||
return await document_tool.read(params.get("doc_id", ""))
|
||||
return _error("document", operation, "미구현")
|
||||
|
||||
Reference in New Issue
Block a user