dp-zp-agent/tools/knowledge/planned_answers.py

121 lines
8.2 KiB
Python

"""Execute facet requests against scoped assertions, never infer missing facts."""
from collections import defaultdict
import re
from tools.answers.answer_errors import clarification_result
from tools.answers.context_policy import normalized
from tools.answers.question_intent import FACET_LABELS
from tools.answers.content_selection import facets_for, unit_score
from tools.knowledge.evidence import claim_eligible
def answer_facets(bound, index, units, *, engine, limit):
plan=bound.spec
people=[p for p in plan.people if p.role=='student']
base={'engine':engine,'index_version':index['index_version'],'knowledge_version':index['version'],
'accepted_for_delivery':True,'claims':[],'complete':False,'truncated':False,'truncation_reasons':[],
'name_resolution':bound.resolution}
def clarify(reason,text):
return {**base,**clarification_result(text,reason,engine=engine,index_version=index['index_version'])}
if not people:return clarify('missing_subject','Uveď celé meno študenta, ktorého práce mám prehľadať.')
paths={p.source_document for p in people if p.source_document}
groups=defaultdict(list)
for row in units:
if not claim_eligible(row) or row['person'] not in {p.name for p in people}:continue
if paths and row['source_path'] not in paths:continue
if not row.get('work_label') or not plan.matches_work(row['person'],row['work_label'],row.get('work_type'),row.get('work_year')):continue
period=plan.period
if period and period.field!='work_year':
actual=row.get(period.field)
if not actual or not period.start<=actual<=period.end:continue
groups[(row['person'],row['work_label'])].append(row)
from tools.answers.question_plan import ambiguous_work_choice
message=ambiguous_work_choice(plan,[{'person':p,'work_label':label} for p,label in groups])
if message:return clarify('ambiguous_work',message)
for person in people:
labels=[label for name,label in groups if name==person.name]
if plan.scope=='selected_works':
from tools.retrieval.work_selector import label_key
for work in plan.works:
if work.person in {None,person.name} and any(v not in {label_key(label) for label in labels} for v in work.labels):
return clarify('unresolved_work_selection','Niektoré požadované práce osoby '+person.name+' sa v indexe nenašli. Uveď presné označenia prác.')
order={p.name:i for i,p in enumerate(people)}
keys=sorted(groups,key=lambda k:(order[k[0]],-(max(r.get('work_year') or 0 for r in groups[k])),k[1]))
if len(people)>1:
by_person={p.name:[key for key in keys if key[0]==p.name] for p in people}
keys=[part[i] for i in range(max((len(v) for v in by_person.values()),default=0))
for part in by_person.values() if i<len(part)]
requested=plan.facets or ['assignment','results']
lines=[];evidence=[];missing=False;truncated=len(keys)>limit;size=0;notes=[]
cells=defaultdict(list)
for key in keys[:limit]:
person,label=key
lines.append('**'+person+' — '+label+'**')
used=set();work_size=0
work_budget=18000//max(1,min(len(keys),limit))
for facet in requested:
candidates=[]
for row in groups[key]:
if row['kind'] in {'reference','work_record','meeting','supervisor','consultant','lifecycle','topic'}:continue
role={'assignment':'assignment','planned':'task','result':'progress','progress':'progress'}.get(row['phase'],'other')
if facet in facets_for(row['text'],role,plan.question,row['phase']):candidates.append(row)
candidates.sort(key=lambda r:(-unit_score(r['text'], {'result':'progress','progress':'progress','planned':'task'}.get(r['phase'],r['phase']),facet),r['line']))
if facet=="assignment":candidates.sort(key=lambda r:r["line"])
distinct={}
for row in candidates:distinct.setdefault(normalized(row['text']),row)
candidates=list(distinct.values())
lines.append(FACET_LABELS[facet]+':')
if not candidates:
notes.append(person+' — '+label+': '+FACET_LABELS[facet].lower()+': v podkladoch chýbajú doložené údaje.')
lines.append('V podkladoch k tejto práci chýbajú doložené údaje.');cells[(key,facet)].append('V podkladoch chýbajú doložené údaje.');missing=True;continue
take=16 if facet=='assignment' else 2 if plan.presentation=='brief' else 5
if len(candidates)>take:truncated=True
for row in candidates[:take]:
if size+len(row['text'])>18000 or work_size+len(row['text'])>work_budget:
truncated=True;continue
size+=len(row['text']);work_size+=len(row['text'])
quote=re.sub(r'^\s*(?:[-*+]\s+|\d+[.)]\s+)','',row['text'])
phase={'assignment':'zadanie','planned':'plán','progress':'priebežný stav','result':'zaznamenaný výsledok'}.get(row['phase'],'podklad')
lines.append('- '+quote+' ('+phase+')')
cells[(key,facet)].append(quote+' ('+phase+')')
if row['id'] not in used:evidence.append(row);used.add(row['id'])
if not cells[(key,facet)]:lines.append('Doložené výňatky sa nezmestili do tohto výpisu.')
if plan.presentation in {'comparison','table'} and keys:
from tools.answers.presentation import comparison_table
table=comparison_table(['Oblasť',*(name+' — '+label for name,label in keys[:limit])],
[(FACET_LABELS[facet],['\n'.join(cells[(key,facet)]) for key in keys[:limit]]) for facet in requested])
if table:lines=['\n'.join(table)]
elif plan.presentation=='table':lines.append('Podklady sú príliš dlhé alebo obsahujú zložité citácie; prehľad uvádzam v samostatných sekciách.')
if groups and not evidence and truncated:
return clarify('content_budget_exceeded','Doložené podklady sa nezmestili do výpisu. Zúž otázku na jednu prácu alebo konkrétnu oblasť; nejde o potvrdenie, že údaje chýbajú.')
if not groups:
lines=['Pre požadované osoby a práce sa v dostupných podkladoch nenašli zodpovedajúce údaje.']
for p in people:
if not any(key[0]==p.name for key in groups):
notes.append(p.name+': požadované podklady k práci sa nenašli.')
lines.append(p.name+': požadované podklady k práci sa nenašli.');missing=True
elif not any(key[0]==p.name for key in keys[:limit]):
notes.append(p.name+': podklady sa nezmestili do limitu výpisu. Zvýš limit alebo zúž otázku.')
lines.append(p.name+': podklady sa nezmestili do limitu výpisu. Zvýš limit alebo zúž otázku.');missing=True
if any(c.kind=='current_status' for c in plan.conditions):
note='Ide o zaznamenané podklady; samy nepotvrdzujú aktuálny stav k dnešnému dňu.'
lines.append(note);notes.append(note)
if truncated:
note='Zobrazený je obmedzený výber. Zúž otázku na osobu, prácu, obdobie alebo konkrétnu oblasť.'
lines.append(note);notes.append(note)
urls=list(dict.fromkeys(r['source_url'] for r in evidence))
if urls:lines.append('Zdroje: '+', '.join(urls))
text='\n\n'.join([*bound.resolution.get('notices',[]),*lines])
from tools.answers.context_policy import citation_check,text_urls
allowed=set(urls)
for row in evidence:allowed|=text_urls(row['text'])
if not citation_check({'answer':text,'source_urls':urls},allowed)['citations_ok']:
from tools.knowledge.query import ContentAnswerRejected
raise ContentAnswerRejected('Planned answer citations rejected')
return {**base,'status':'ok' if evidence else 'no_answer','reason':None if evidence else 'missing_evidence',
'answer':text,'source_urls':urls,'evidence':evidence,'truncated':truncated,'presentation_notes':notes,
'truncation_reasons':['content_result_limit'] if truncated else [],
'answer_coverage':{'scope':'assignment' if requested==['assignment'] else 'overview',
'status':'partial' if missing or truncated else 'supported' if evidence else 'not_found','warnings':[],
'limitations':['content_result_limit'] if truncated else []}}