390 lines
18 KiB
Python
390 lines
18 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, delivery_facts
|
|
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.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
|
|
|
|
import os
|
|
import time
|
|
from collections import OrderedDict
|
|
|
|
_capacity = threading.BoundedSemaphore(int(os.getenv("ANSWER_CONCURRENCY", "3")))
|
|
|
|
# A host that timed out often repeats the same question. The identical,
|
|
# already-checked answer for the same data version is returned at once, and
|
|
# an identical question that is still running is awaited instead of duplicated.
|
|
_CACHE_SECONDS = float(os.getenv("ANSWER_CACHE_SECONDS", "900"))
|
|
_cache: OrderedDict = OrderedDict()
|
|
_inflight: dict = {}
|
|
_cache_lock = threading.Lock()
|
|
|
|
|
|
def _cache_key(question, engine, limit, context_token, db_file):
|
|
from tools.sync.index_state import read_version
|
|
try:
|
|
version = (read_version(db_file.parent) or {}).get("version")
|
|
except Exception:
|
|
return None
|
|
# Tests patch the model per test case; never share cached answers across them.
|
|
return (question.strip(), engine, limit, context_token or "", str(db_file), version,
|
|
os.getenv("PYTEST_CURRENT_TEST", ""), id(complete_with_configured_model))
|
|
|
|
|
|
def _cacheable(result):
|
|
return result.get("accepted_for_delivery") is True and result.get("status") in {"ok", "no_answer", "ambiguous"}
|
|
|
|
|
|
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,
|
|
context_token: str | None = None,
|
|
) -> dict:
|
|
"""Checked answer within one time budget; identical repeats reuse it."""
|
|
from tools.answers.model_client import start_deadline, reset_deadline, answer_budget_seconds
|
|
import copy
|
|
|
|
key = None if complete is not None or include_diagnostics else _cache_key(
|
|
question, engine, limit, context_token, db_file)
|
|
owner = False
|
|
if key is not None:
|
|
with _cache_lock:
|
|
hit = _cache.get(key)
|
|
if hit and time.monotonic() - hit[0] < _CACHE_SECONDS:
|
|
_cache.move_to_end(key)
|
|
return copy.deepcopy(hit[1])
|
|
event = _inflight.get(key)
|
|
if event is None:
|
|
event = _inflight[key] = threading.Event()
|
|
owner = True
|
|
if not owner:
|
|
event.wait(answer_budget_seconds() + 10)
|
|
with _cache_lock:
|
|
hit = _cache.get(key)
|
|
if hit:
|
|
return copy.deepcopy(hit[1])
|
|
token = start_deadline()
|
|
try:
|
|
result = _deliver_checked(question, engine=engine, limit=limit, complete=complete, db_file=db_file,
|
|
include_diagnostics=include_diagnostics, context_token=context_token)
|
|
if key is not None and _cacheable(result):
|
|
with _cache_lock:
|
|
_cache[key] = (time.monotonic(), copy.deepcopy(result))
|
|
while len(_cache) > 256:
|
|
_cache.popitem(last=False)
|
|
return result
|
|
finally:
|
|
reset_deadline(token)
|
|
if owner:
|
|
with _cache_lock:
|
|
_inflight.pop(key, None).set()
|
|
|
|
|
|
def _deliver_checked(
|
|
question: str,
|
|
*,
|
|
engine: str = "rag",
|
|
limit: int = 5,
|
|
complete=None,
|
|
db_file: Path = DB_FILE,
|
|
include_diagnostics: bool = False,
|
|
context_token: str | None = None,
|
|
) -> dict:
|
|
from tools.answers.reference_links import checked_repository_links
|
|
from tools.answers.conversation import resolve_question, issue_context, ContextError
|
|
from tools.answers.question_plan import planning_catalog,QuestionPlan
|
|
from tools.knowledge.store import load_index
|
|
from tools.sync.index_state import read_version
|
|
from tools.answers.delivery_contract import diagnose
|
|
|
|
catalog=[];index=None;version=None
|
|
try:
|
|
with index_snapshot(db_file.parent,db_file=db_file):
|
|
version=read_version(db_file.parent)
|
|
catalog=planning_catalog(db_file)
|
|
index=load_index(db_file.parent,version)
|
|
except (OSError,ValueError,RuntimeError):
|
|
if context_token:
|
|
return diagnose(public_failure({'reason':'stale_context','engine':engine}),question,engine)
|
|
try:
|
|
resolved=resolve_question(question,token=context_token,catalog=catalog,index=index,version=version)
|
|
except ContextError as error:
|
|
return diagnose(public_failure({'reason':str(error),'engine':engine}),question,engine)
|
|
if resolved.clarification:
|
|
result=clarification_result(resolved.clarification,resolved.reason,engine=engine,index_version=version)
|
|
else:
|
|
result = _deliver_answer(
|
|
resolved.query,
|
|
engine=engine,
|
|
limit=limit,
|
|
complete=complete,
|
|
db_file=db_file,
|
|
include_diagnostics=include_diagnostics,
|
|
meeting=resolved.meeting,
|
|
expected_version=version if context_token else None,
|
|
)
|
|
plan_data=result.pop('_delivery_plan',None)
|
|
facts=result.pop('_delivery_facts',[])
|
|
prose_swaps=result.pop('_prose_swaps',[])
|
|
result=checked_repository_links(result)
|
|
from tools.answers.notices import clean_answer_notices
|
|
result=clean_answer_notices(result)
|
|
if plan_data:
|
|
from tools.answers.delivery_contract import enforce
|
|
result=enforce(question,engine,QuestionPlan.model_validate(plan_data),result,index=index,facts=facts,
|
|
complete=complete,prose_swaps=prose_swaps)
|
|
else:
|
|
from tools.answers.delivery_contract import verbatim
|
|
result=diagnose(verbatim(result,prose_swaps) if prose_swaps else result,question,engine)
|
|
if resolved.query!=question.strip():result['resolved_query']=resolved.query
|
|
conversation=issue_context(resolved.query,result,catalog,index,version)
|
|
if conversation:result['conversation_context']=conversation
|
|
return result
|
|
|
|
|
|
def _deliver_answer(
|
|
question: str,
|
|
*,
|
|
engine: str = "rag",
|
|
limit: int = 5,
|
|
complete=None,
|
|
db_file: Path = DB_FILE,
|
|
include_diagnostics: bool = False,
|
|
meeting=None,
|
|
expected_version=None,
|
|
) -> dict:
|
|
# Wait briefly for a free slot instead of failing a question at once.
|
|
if not _capacity.acquire(timeout=float(os.getenv("ANSWER_CAPACITY_WAIT_SECONDS", "15"))):
|
|
return public_failure({"reason": "answer_capacity_exceeded", "engine": engine})
|
|
try:
|
|
model = complete or complete_with_configured_model
|
|
from tools.answers.question_plan import (build_question_plan, planning_catalog, QuestionPlan, PlanMeaning,
|
|
PlanRejected, PlanModelUnavailable)
|
|
from tools.knowledge.query import deliver_content, ContentModelUnavailable, ContentAnswerRejected
|
|
try:
|
|
with index_snapshot(db_file.parent, db_file=db_file) as planned_version:
|
|
if expected_version is not None and expected_version!=planned_version:
|
|
return public_failure({'reason':'stale_context','engine':engine})
|
|
catalog = planning_catalog(db_file)
|
|
plan_model = complete or (lambda messages: complete_with_configured_model(
|
|
messages, output_schema=QuestionPlan.model_json_schema()))
|
|
verify_model = complete or (lambda messages: complete_with_configured_model(
|
|
messages, output_schema=PlanMeaning.model_json_schema()))
|
|
classify = None
|
|
if complete is None and os.getenv("ANSWER_QUESTION_CATALOG", "1") != "0":
|
|
from tools.answers.question_catalog import classify as classify_question
|
|
classify = classify_question
|
|
plan = build_question_plan(question, catalog, complete=plan_model,verify_complete=verify_model,index_version=planned_version,meeting=meeting,
|
|
classify=classify)
|
|
except PlanModelUnavailable:
|
|
return public_failure({'reason':'model_unavailable','engine':engine,'_failure_stage':'planning'})
|
|
except PlanRejected:
|
|
return public_failure({'reason':'answer_rejected','engine':engine,'_failure_stage':'planning'})
|
|
except Exception:
|
|
return retrieval_failure(engine)
|
|
if plan.clarification:
|
|
value=clarification_result(plan.clarification,plan.reason,engine=engine,index_version=planned_version)
|
|
if plan.resolution.get('status')=='no_answer' and plan.reason==plan.resolution.get('reason'):value['status']='no_answer'
|
|
value['name_resolution']=plan.resolution
|
|
if include_diagnostics:value['question_plan']=plan.model_dump()
|
|
return value
|
|
def planned(value, facts=()):
|
|
return {**value,'_delivery_plan':plan.spec.model_dump(),'_delivery_facts':list(facts)}
|
|
question = plan.spec.question
|
|
from tools.knowledge.roster import (roster_question, answer_roster, both_types_question,
|
|
answer_work_types, related_topic, answer_related)
|
|
if both_types_question(plan.spec) or related_topic(plan.spec):
|
|
from tools.knowledge.store import load_index
|
|
try:
|
|
with index_snapshot(db_file.parent, db_file=db_file) as current:
|
|
code_index = load_index(db_file.parent, current)
|
|
except Exception:
|
|
code_index = None
|
|
if code_index is not None:
|
|
if both_types_question(plan.spec):
|
|
return planned(answer_work_types(plan.spec, code_index, engine=engine))
|
|
return planned(answer_related(plan.spec, code_index, engine=engine, db_file=db_file))
|
|
if roster_question(plan.spec):
|
|
# A list of works/topics of given years is assembled by code.
|
|
from tools.knowledge.store import load_index
|
|
try:
|
|
with index_snapshot(db_file.parent, db_file=db_file) as current:
|
|
roster_index = load_index(db_file.parent, current)
|
|
except Exception:
|
|
roster_index = None
|
|
if roster_index is not None:
|
|
return planned(answer_roster(plan.spec, roster_index, engine=engine))
|
|
try:
|
|
content_result = deliver_content(
|
|
question, engine=engine, limit=limit, db_file=db_file, complete=model, plan=plan,
|
|
include_diagnostics=include_diagnostics,
|
|
)
|
|
if content_result is not None:
|
|
from tools.answers.synthesis import synthesize
|
|
from tools.answers.recommendations import generate
|
|
from tools.knowledge.academic import with_completion_status
|
|
completion = content_result.pop('_completion_rows', [])
|
|
render=generate if plan.spec.intent=='proposal' else synthesize
|
|
content_result = render(
|
|
content_result, plan.spec, complete=complete,
|
|
known_people=[person['name'] for person in catalog if person.get('kind') != 'Document'],
|
|
diagnostics=include_diagnostics,
|
|
)
|
|
content_result = with_completion_status(content_result, completion)
|
|
if include_diagnostics:content_result["question_plan"]=plan.model_dump()
|
|
return planned(content_result)
|
|
except ContentModelUnavailable:
|
|
return public_failure({"reason": "model_unavailable", "engine": engine,'_failure_stage':'evidence_selection'})
|
|
except ContentAnswerRejected:
|
|
return public_failure({"reason": "answer_rejected", "engine": engine,'_failure_stage':'evidence_selection'})
|
|
except Exception:
|
|
return public_failure({"reason": "content_index_unavailable", "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, question_plan=plan.spec)
|
|
if retrieved.get("status") == "error":
|
|
return retrieval_failure(engine)
|
|
if failure := name_failure(retrieved, engine):
|
|
return failure
|
|
question = retrieved.get("resolved_query") or question
|
|
if planned_version is not None and retrieved.get("index_version") != planned_version:
|
|
return retrieval_failure(engine)
|
|
retrieved["question_plan"] = plan.spec.model_dump()
|
|
retrieved["name_resolution"] = plan.resolution
|
|
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, include_bindings=True)
|
|
facts = result.pop('_delivery_facts', [])
|
|
context = retrieved
|
|
elif engine == "graphrag":
|
|
if plan.spec.intent != "metadata":
|
|
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, question_plan=plan.spec)
|
|
if retrieved.get("status") == "error":
|
|
return retrieval_failure(engine)
|
|
if failure := name_failure(retrieved, engine):
|
|
return failure
|
|
question = retrieved.get("resolved_query") or question
|
|
if planned_version is not None and retrieved.get("index_version") != planned_version:
|
|
return retrieval_failure(engine)
|
|
retrieved["name_resolution"] = plan.resolution
|
|
context = graph_context(retrieved)
|
|
facts = delivery_facts(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 planned({
|
|
**result,
|
|
"engine": engine,
|
|
**({"retrieval_context": context, "question_plan": plan.model_dump()} if include_diagnostics else {}),
|
|
},facts)
|
|
finally:
|
|
_capacity.release()
|