dp-zp-agent/tools/answers/answer_delivery.py

245 lines
9.7 KiB
Python

"""Production answer path: retrieval -> model selection -> guard -> rendered text."""
from __future__ import annotations
import threading
from pathlib import Path
from tools.rag.common import DB_FILE
from tools.rag.rag_utils import build_rag_context
from tools.rag.llm_context import compact_rag_payload
from tools.graphrag.graph_search import search_graph
from tools.answers.graph_context import graph_context
from tools.answers.checked_generation import generate_checked_answer
from tools.answers.verified_rag import generate_verified_rag
from tools.answers.rag_evidence import expand_comparison_sources
from tools.answers.rag_preparation import prepare_named_content
from tools.retrieval.question_constraints import unsupported_constraint, constraint_message
from tools.sync.index_state import index_snapshot
from tools.answers.model_client import complete_with_configured_model
from tools.answers.answer_errors import public_failure, clarification_result
_capacity = threading.BoundedSemaphore(2)
def retrieval_failure(engine: str) -> dict:
return public_failure(
{
"reason": "retrieval_unavailable",
"engine": engine,
"index_version": None,
"complete": False,
"truncated": False,
"truncation_reasons": [],
}
)
def name_failure(retrieved: dict, engine: str) -> dict | None:
resolution = retrieved.get("name_resolution") or {}
if not resolution or resolution.get("status") == "ok":
return None
return {
"status": resolution["status"],
"reason": resolution["reason"],
"engine": engine,
"accepted_for_delivery": True,
"answer": resolution["message"],
"claims": [],
"source_urls": [],
"name_resolution": resolution,
"index_version": retrieved.get("index_version"),
"complete": False,
"truncated": False,
"truncation_reasons": [],
}
def with_name_resolution(result: dict, retrieved: dict) -> dict:
resolution = retrieved.get("name_resolution") or {}
if result.get("accepted_for_delivery") and resolution.get("notices"):
result["answer"] = "\n".join(resolution["notices"]) + "\n\n" + result["answer"]
if resolution:
result["name_resolution"] = resolution
return result
def deliver_answer(
question: str,
*,
engine: str = "rag",
limit: int = 5,
complete=None,
db_file: Path = DB_FILE,
include_diagnostics: bool = False,
) -> dict:
from tools.answers.reference_links import checked_repository_links
result = _deliver_answer(
question,
engine=engine,
limit=limit,
complete=complete,
db_file=db_file,
include_diagnostics=include_diagnostics,
)
return checked_repository_links(result)
def _deliver_answer(
question: str,
*,
engine: str = "rag",
limit: int = 5,
complete=None,
db_file: Path = DB_FILE,
include_diagnostics: bool = False,
) -> dict:
if not _capacity.acquire(blocking=False):
return public_failure({"reason": "answer_capacity_exceeded", "engine": engine})
try:
model = complete or complete_with_configured_model
from tools.answers.question_intent import unresolved_followup
if unresolved_followup(question):
return clarification_result(
"Uveď osobu a presné označenia prác alebo úplnú otázku. Samotné „obe“ bez predchádzajúcich možností neviem spoľahlivo vyhľadať.",
"missing_followup_context",
engine=engine,
)
from tools.knowledge.query import (
invalid_question,
deliver_content,
ContentModelUnavailable,
ContentAnswerRejected,
)
if invalid_question(question):
return clarification_result(
"Otázke nerozumiem. Napíš, na ktorého študenta, tému alebo výsledok sa pýtaš.",
"unintelligible_question",
engine=engine,
)
import re
from tools.answers.context_policy import normalized
wording = normalized(question)
if re.search(r"\bsemest\w*\b", wording) and re.search(
r"\b(?:aktual\w*|teraz|v akom|v ktorom)\b", wording
):
return clarification_result(
"Aktuálny semester neviem určiť len z roku začiatku štúdia alebo z názvu práce. Potrebný je doložený údaj o aktuálnom štúdiu; môžem vyhľadať evidované práce a ich roky.",
"enrollment_not_established",
engine=engine,
)
unsupported = unsupported_constraint(question)
from tools.knowledge.academic import academic_intent
if (
unsupported
and unsupported != "year_range"
and not (unsupported == "work_event_date" and academic_intent(question) == "completion")
):
return clarification_result(
constraint_message(unsupported), "unsupported_constraint", engine=engine
)
try:
content_result = deliver_content(
question, engine=engine, limit=limit, db_file=db_file, complete=model
)
if content_result is not None:
return content_result
except ContentModelUnavailable:
return public_failure({"reason": "model_unavailable", "engine": engine})
except ContentAnswerRejected:
return public_failure({"reason": "answer_rejected", "engine": engine})
except Exception:
return public_failure({"reason": "content_index_unavailable", "engine": engine})
unsupported = unsupported_constraint(question)
if unsupported:
return clarification_result(
constraint_message(unsupported), "unsupported_constraint", engine=engine
)
if engine == "rag":
try:
with index_snapshot(db_file.parent, db_file=db_file):
retrieved = build_rag_context(db_file, question, limit=limit)
if retrieved.get("status") == "error":
return retrieval_failure(engine)
if failure := name_failure(retrieved, engine):
return failure
question = retrieved.get("resolved_query") or question
retrieved = expand_comparison_sources(db_file, question, retrieved)
retrieved = prepare_named_content(db_file, question, retrieved)
retrieved = compact_rag_payload(retrieved)
except Exception:
return retrieval_failure(engine)
result = generate_verified_rag(question, retrieved, model)
context = retrieved
elif engine == "graphrag":
from tools.answers.question_intent import graph_needs_text
from tools.retrieval.work_selector import parse_work_selector
if graph_needs_text(question) or parse_work_selector(question).specific:
return clarification_result(
"Táto otázka vyžaduje textové podklady o zadaní, priebehu alebo výsledkoch práce. Použi /rag s rovnakou otázkou; graf samotný tieto výňatky neposkytuje.",
"text_evidence_required",
engine=engine,
)
try:
retrieved = search_graph(question, limit=limit)
if retrieved.get("status") == "error":
return retrieval_failure(engine)
if failure := name_failure(retrieved, engine):
return failure
question = retrieved.get("resolved_query") or question
context = graph_context(retrieved)
except Exception:
return retrieval_failure(engine)
if context.get("clarification"):
return with_name_resolution(
clarification_result(
context["clarification"],
context.get("reason"),
engine=engine,
index_version=context.get("index_version"),
),
retrieved,
)
prompt = (
Path(__file__).resolve().parents[1] / "answers/graph_answer_prompt.txt"
).read_text()
generated = generate_checked_answer(question, context, prompt, model)
if generated.get("accepted_for_delivery"):
result = {
**generated["answer"],
"accepted_for_delivery": True,
"reason": None,
"index_version": context.get("index_version"),
"complete": context.get("complete"),
"truncated": context.get("truncated"),
"truncation_reasons": context.get("truncation_reasons"),
"attempts": generated["attempts"],
}
else:
reason = generated.get("reason", "answer_rejected")
result = public_failure(
{
"reason": reason,
"index_version": context.get("index_version"),
"attempts": generated["attempts"],
}
)
else:
raise ValueError("Unsupported answer engine")
if result.get("status") == "error" or result.get("accepted_for_delivery") is not True:
result = public_failure(result)
result = with_name_resolution(result, retrieved)
return {
**result,
"engine": engine,
**({"retrieval_context": context} if include_diagnostics else {}),
}
finally:
_capacity.release()