dp-zp-agent/tools/knowledge/planned_answers.py
2026-11-13 19:13:35 +01:00

159 lines
11 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']
from tools.answers.question_intent import requested_facets
personal_facets={p.name:(requested_facets(p.focus) or ['assignment','results']) if p.focus else requested for p in people}
requested=list(dict.fromkeys(f for facets in personal_facets.values() for f in facets))
# The checkpoint preserves uncertain/proposed goals as planned, not as an
# established assignment. Use the literal preceding goal heading to make
# those proposals available without changing their recorded phase.
goal_headers={}
tentative_goals=set()
for row in units:
if row.get('section_scope')=='work' and re.fullmatch(r'(?:mozne |predbezne )?ciele(?: semestralneho projektu)?',normalized(row['text'])):
goal_headers.setdefault((row['person'],row.get('work_label')),[]).append(row['line'])
if normalized(row['text']).startswith(('mozne ','predbezne ')):
tentative_goals.add((row['person'],row.get('work_label')))
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 personal_facets[person]:
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')
proposal=(facet=='assignment' and row['phase']=='planned' and row.get('section_scope')=='work'
and any(n<row['line'] for n in goal_headers.get(key,[])))
if re.fullmatch(r'(?:predbezne )?(?:zadanie|ciele)',normalized(row['text'])):continue
if facet in facets_for(row['text'],role,plan.question,row['phase']) or proposal:candidates.append(row)
if facet=='assignment' and not candidates:
# A work without an "Zadanie" section still states its topic
# ("Téma: Agent pre manažment záverečných prác").
candidates=[r for r in groups[key] if r['kind']=='topic' and not re.fullmatch(r'\s*#+\s.*',r['text'])
and not r['text'].strip().endswith(':')]
if facet=='methods' and re.search(r'\b(?:frontend\w*|backend\w*)\b',normalized(plan.question)):
candidates=[r for r in candidates if re.search(r'\b(?:frontend\w*|backend\w*)\b',normalized(r['text']))
or 'databaz' in normalized(r['text']) and not re.search(r'\[[xX]\]',r['text'])]
if facet=='results' and 'funkcnost' in normalized(plan.question):
candidates=[r for r in candidates if re.search(r'\b(?:funguje|funguju|funkcn\w*)\b',normalized(r['text']))]
from tools.knowledge.query import _terms
question_terms=_terms(plan.question)
def score(row):
text_terms=_terms(row['text'])
overlap=sum(any(a.startswith(b) or b.startswith(a) for a in text_terms) for b in question_terms)
checklist=bool(re.match(r'\s*[-*]?\s*\[[xX]\]',row['text']))
return unit_score(row['text'],{'result':'progress','progress':'progress','planned':'task'}.get(row['phase'],row['phase']),facet)+12*overlap-35*checklist
candidates.sort(key=lambda r:(-score(r),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:
notice=person+' — '+label+': '+FACET_LABELS[facet].lower()+': v podkladoch chýbajú doložené údaje.'
notes.append(notice);lines.append(notice);cells[(key,facet)].append(notice);missing=True;continue
take=2 if plan.presentation=='brief' and facet!='assignment' else len(candidates)
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')
if facet=='assignment' and row['phase']=='planned':phase='navrhovaný cieľ'
if row['kind']=='topic':phase='téma alebo názov'
lines.append('- '+quote+' ('+phase+')')
cells[(key,facet)].append(quote+' ('+phase+')')
if row['id'] not in used:evidence.append(row);used.add(row['id'])
if facet=='assignment' and candidates and key in tentative_goals:
note=person+' — '+label+': zdroj tieto body označuje ako možné/navrhované ciele, nie ako potvrdené splnené výsledky.'
notes.append(note);lines.append(note)
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 []}}