"""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()