dp-zp-agent/tools/answers/answer_delivery.py
2026-11-07 12:11:41 +01:00

163 lines
10 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.answer_evaluation.graph_context import graph_context
from tools.answer_evaluation.checked_generation import generate_checked_answer
from tools.answer_evaluation.verified_rag import generate_verified_rag
from tools.answer_evaluation.rag_evidence import expand_comparison_sources
from tools.answer_evaluation.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
_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.answer_evaluation.question_intent import unresolved_followup
if unresolved_followup(question):
return {'status':'ambiguous','reason':'missing_followup_context','accepted_for_delivery':True,
'answer':'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ť.',
'claims':[],'source_urls':[],'engine':engine,'index_version':None,'complete':False,'truncated':False,'truncation_reasons':[]}
from tools.knowledge.query import (invalid_question, deliver_content,
ContentModelUnavailable, ContentAnswerRejected)
if invalid_question(question):
return {'status':'ambiguous','reason':'unintelligible_question','accepted_for_delivery':True,
'answer':'Otázke nerozumiem. Napíš, na ktorého študenta, tému alebo výsledok sa pýtaš.',
'claims':[],'source_urls':[],'engine':engine,'index_version':None,
'complete':False,'truncated':False,'truncation_reasons':[]}
import re
from tools.answer_evaluation.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 {'status':'ambiguous','reason':'enrollment_not_established','accepted_for_delivery':True,
'answer':'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.',
'claims':[],'source_urls':[],'engine':engine,'index_version':None,
'complete':False,'truncated':False,'truncation_reasons':[]}
unsupported=unsupported_constraint(question)
if unsupported and unsupported!='year_range':
return {'status':'ambiguous','reason':'unsupported_constraint','accepted_for_delivery':True,
'answer':constraint_message(unsupported),'claims':[],'source_urls':[],
'index_version':None,'complete':False,'truncated':False,'truncation_reasons':[],'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 {'status':'ambiguous','reason':'unsupported_constraint','accepted_for_delivery':True,
'answer':constraint_message(unsupported),'claims':[],'source_urls':[],
'index_version':None,'complete':False,'truncated':False,'truncation_reasons':[], '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.answer_evaluation.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 {'status':'ambiguous','reason':'text_evidence_required','accepted_for_delivery':True,
'answer':'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.',
'claims':[],'source_urls':[],'engine':engine,'index_version':None,
'complete':False,'truncated':False,'truncation_reasons':[]}
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({'status':'ambiguous','reason':context.get('reason'),'accepted_for_delivery':True,
'answer':context['clarification'],'claims':[],'source_urls':[], 'engine':engine,
'complete':False,'truncated':False,'truncation_reasons':[],
'index_version':context.get('index_version')}, retrieved)
prompt = (Path(__file__).resolve().parents[1] / 'answer_evaluation/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()