159 lines
11 KiB
Python
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 []}}
|