dp-zp-agent/tools/answer_evaluation/answer_coverage.py
2026-11-04 00:55:54 +01:00

95 lines
5.4 KiB
Python

"""Separate supported answer scope from the search's technical selection limits.
Supported means the delivered facts/excerpts have evidence; it never promises
exhaustive retrieval, a complete source document or absence of other records.
"""
import re
from .context_policy import normalized
def response_scope(question: str, *, content: bool = False) -> str:
query = normalized(re.sub(r'[„“"«][^„“”"»]+[“”"»]', ' ', question))
# Record lists differ from the numbered assignment of one selected work.
records = r'(?:prac\w*|projekt\w*|student\w*|osob\w*|autor\w*|tag\w*|kategori\w*|zdroj\w*)'
modifiers = r'(?:(?:vsetk\w*|diplomov\w*|bakalarsk\w*|zaverecn\w*|timov\w*|vyskumn\w*|a|aj)\s+)*'
if re.search(r'\b(?:kolko|pocet|spocitaj)\b', query):
return 'count'
if re.search(rf'\b(?:vsetk\w*|upln\w*|kompletn\w*)\s+{modifiers}{records}\b'
rf'|\b(?:ake|ktore|vypis|zoznam|prehlad)\s+{modifiers}{records}\b', query):
return 'list'
if content:
if re.search(r'\b(?:zadani\w*|ciel\w*)\b', query) and not re.search(r'\b(?:strucn\w*|zhrn\w*)\b', query):
return 'assignment'
if re.search(r'\b(?:comu|o com|opis\w*|popis\w*|obsah\w*|zamer\w*|zhrn\w*|strucn\w*)\b', query):
return 'overview'
return 'fact'
def answer_coverage(answer: dict, context: dict) -> dict:
"""Describe only the scope of an already validated RAG selection."""
requirements = context.get('requirements', {})
scope = response_scope(context.get('question', ''), content=requirements.get('content', False))
reasons = list(context.get('truncation_reasons') or [])
selected = {row.get('source_id') for row in answer.get('evidence_quotes', [])}
cited = set(answer.get('source_urls', []))
clipped = any('[OBSAH ZDROJA OREZANÝ]' in row.get('body', '')
for row in context.get('source_blocks', [])
if row.get('source_id') in selected or (not selected and
(row.get('source_url') in cited or answer.get('status') == 'no_answer')))
# A context budget may drop only unrelated sources. A clipping marker in
# the actual cited block is stronger evidence than this global reason.
context_limited = any(reason in reasons for reason in ['llm_context_budget','answer_candidate_budget','evidence_unit_limit'])
limited = context.get('complete') is not True or context.get('truncated') is True
limitations = [*reasons]
if clipped:
limitations.append('cited_source_text_clipped')
warnings = []
status = answer.get('status')
if status == 'ok':
coverage = 'supported'
if any(requirements.get(key) and key not in fields for fields in requirements.get('available_fields',{}).values()
for key in ['title','year','start_year']):
coverage='partial'; warnings.append('partial_requested_fields')
if requirements.get('facets'):
from .content_selection import missing_facets
if missing_facets(answer,context):
coverage='partial'; warnings.append('partial_requested_aspects')
elif requirements.get('content'):
from .rag_evidence import missing_content_people
if missing_content_people(answer,context):
coverage='partial'; warnings.append('partial_requested_aspects')
if scope=='overview' and (clipped or context_limited):
coverage='partial';warnings.append('overview_aspects_clipped')
if scope == 'list' and (limited or clipped):
coverage = 'partial'; warnings.append('list_may_be_incomplete')
elif scope == 'count' and (context.get('aggregation') or {}).get('complete') is not True:
coverage = 'unknown'; warnings.append('count_not_established')
elif scope == 'assignment' and clipped:
coverage = 'partial'; warnings.append('assignment_text_clipped')
elif scope == 'assignment' and limited and re.search(r'\b(?:vsetk\w*|upln\w*|kompletn\w*|cele)\b', normalized(context.get('question', ''))):
coverage = 'partial'; warnings.append('assignment_completeness_unknown')
elif status == 'no_answer':
coverage = 'not_found'
if clipped or context_limited:
warnings.append('missing_information_with_clipped_context')
else:
coverage = 'unavailable'
return {'scope': scope, 'status': coverage, 'warnings': warnings,
'limitations': list(dict.fromkeys(limitations))}
def coverage_notices(coverage: dict) -> list[str]:
messages = {
'overview_aspects_clipped':'Časť podkladov bola skrátená; prehľad preto nemusí pokrývať všetky oblasti práce.',
'list_may_be_incomplete': 'Uvedený zoznam nemusí obsahovať všetky záznamy.',
'assignment_text_clipped': 'Časť podkladov k zadaniu bola skrátená; uvedené body nemusia tvoriť celé zadanie.',
'assignment_completeness_unknown': 'Z dostupných podkladov nemožno zaručiť všetky body zadania.',
'missing_information_with_clipped_context': 'Časť podkladov bola pri príprave kontextu skrátená.',
'count_not_established': 'Celkový počet sa z dostupných podkladov nedá spoľahlivo určiť.',
}
notices=[messages[reason] for reason in coverage['warnings'] if reason in messages]
if 'list_may_be_incomplete' in coverage['warnings']:
notices.append('Výpis môžeš zúžiť uvedením osoby, typu práce alebo konkrétneho roku.')
return notices