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

129 lines
8.3 KiB
Python

"""Render witnessed graph paths; an ABOUT edge never means 'used' or 'finished'."""
from collections import defaultdict
from tools.answers.answer_errors import clarification_result
from tools.answers.context_policy import citation_check, text_urls
from tools.knowledge.concepts import concept_key
PHASES={'result':'zaznamenaný výsledok','progress':'priebežný stav',
'assignment':'zadanie','planned':'plán','unknown':'podklad'}
PREDICATES={'uses':'používa','evaluates':'vyhodnocuje','produces':'vytvára','about':'týka sa'}
def graph_details(answer, local):
"""Keep witnesses for delivered evidence only; never append unseen claims."""
ids={r['id'] for r in answer.get('evidence',[])}
paths=[p for p in local.paths if p['anchor_id'] in ids and p['peer_id'] in ids]
relations=[r for r in local.relations if r['evidence_id'] in ids]
reasons=list(dict.fromkeys([*answer.get('truncation_reasons',[]),*local.truncation_reasons]))
if local.truncation_reasons and answer.get('accepted_for_delivery'):
notice='Prechod grafom dosiahol limit výberu. Zúž otázku na prácu, tému alebo obdobie.'
if notice not in answer['answer']:answer={**answer,'answer':answer['answer']+'\n\n'+notice}
# Predicate claims are shown only with their own exact, phase-bound witness.
if relations and answer.get('status')=='ok':
by_id={r['id']:r for r in answer['evidence']}
lines=[]
for relation in relations[:3]:
row=by_id[relation['evidence_id']]
lines.append(f"- {row['person']} — {row['work_label']} ({PHASES.get(row['phase'],'podklad')}): "
f"{relation['subject']} → {PREDICATES[relation['predicate']]} → {relation['object']}. "
f"Dôkaz: „{relation['quote']}“")
text=answer['answer']; marker='\n\nZdroje:'
before,sep,after=text.rpartition(marker)
block='\n\nOverené významové vzťahy:\n'+'\n'.join(lines)
text=before+block+sep+after if sep else text+block
answer={**answer,'answer':text}
return {**answer,'complete':False,'truncated':bool(reasons), 'truncation_reasons':reasons,
'graph_paths':paths,'graph_relations':relations,
'graph_retrieval':{'strategy':'bounded_local_graph','seeds':len(local.seeds),
'evidence':len(local.units),'paths':len(local.paths)}}
def render_similarity(question,index,bound,local,*,limit=5,complete=None):
spec=bound.spec
if spec.period or any(w.work_type or w.years or w.labels or w.numbers or w.excluded_labels or
w.excluded_numbers or w.unnumbered for w in spec.works):
return clarification_result('Má rok alebo typ práce obmedziť porovnávanú prácu, alebo aj hľadaných študentov? Uveď rozsah tejto podmienky.',
'ambiguous_similarity_scope',engine='graphrag',index_version=index['index_version'])
by_id={u['id']:u for u in local.units}
valid=[p for p in local.paths if p['anchor_id'] in by_id and p['peer_id'] in by_id
and by_id[p['anchor_id']]['source_path']!=by_id[p['peer_id']]['source_path']]
candidates=list({p['peer_id']:by_id[p['peer_id']] for p in valid}.values())
references=list({p['anchor_id']:by_id[p['anchor_id']] for p in valid}.values())
if complete and candidates:
from tools.knowledge.query import select_topic_evidence
if len(candidates)>40:local.truncation_reasons.append('graph_selection_limit')
candidates=select_topic_evidence(question,candidates[:40],complete,references=references[:8])
chosen={r['id'] for r in candidates}
groups=defaultdict(list)
for path in valid:
if path['peer_id'] in chosen:groups[by_id[path['peer_id']]['source_path']].append(path)
def priority(key):
rows=[by_id[p['peer_id']] for p in groups[key]]
return (-sum(r['phase'] in {'result','progress'} for r in rows),key)
paths=[];evidence={};lines=[];size=0
if len(groups)>limit:local.truncation_reasons.append('graph_result_limit')
for source in sorted(groups,key=priority)[:limit]:
matches=groups[source]
peer=by_id[matches[0]['peer_id']]
selected=[];topics_seen=set()
for path in sorted(matches,key=lambda p:(by_id[p['peer_id']]['phase'] not in {'result','progress'},p['concept'])):
if path['concept'] in topics_seen:continue
selected.append(path);topics_seen.add(path['concept'])
if len(selected)==2:break
fits=[];used=set()
for path in selected:
ids={path['anchor_id'],path['peer_id']} - used
cost=sum(len(by_id[key]['text'])+len(by_id[key]['work_label'])+150 for key in ids)
if size+cost>18000:
local.truncation_reasons.append('graph_answer_budget');continue
fits.append(path);used|=ids;size+=cost
selected=fits
if not selected:continue
lines.append('**'+peer['person']+'**')
topics=sorted({p['concept'] for p in selected})
lines.append('Prepája ich doložená téma: '+', '.join(topics)+'.')
shown=set()
for path in selected:
for endpoint in ('anchor_id','peer_id'):
row=by_id[path[endpoint]]
if row['id'] in shown:continue
shown.add(row['id']);evidence[row['id']]=row
stamp='; záznam '+row['record_date'] if row.get('record_date') else ''
lines.append(f"- {row['person']} — {row['work_label']} ({PHASES.get(row['phase'],'podklad')}{stamp}): {row['text']}")
paths.append(path)
# An explicit common predicate requires two verified relations, never
# just both students mentioning its endpoints.
left=[r for r in local.relations if r['evidence_id'] in shown and by_id[r['evidence_id']]['source_path']!=source]
right=[r for r in local.relations if r['evidence_id'] in shown and by_id[r['evidence_id']]['source_path']==source]
for a in left:
for b in right:
actual=lambda r:by_id[r['evidence_id']]['phase'] in {'result','progress'}
planned=lambda r:by_id[r['evidence_id']]['phase'] in {'assignment','planned'}
if a['predicate']==b['predicate'] and concept_key(a['object'])==concept_key(b['object']) and (
actual(a) and actual(b) or planned(a) and planned(b)):
lines.append('Spoločný overený vzťah'+('' if actual(a) else ' v zadaní alebo pláne')+
': '+PREDICATES[a['predicate']]+' → '+a['object']+'.')
status='ok' if evidence else 'no_answer'
if not evidence and 'graph_answer_budget' in local.truncation_reasons:
return graph_details(clarification_result('Doložené prepojenia sa nezmestili do odpovede. Zúž otázku na konkrétnu prácu alebo tému.',
'graph_answer_budget',engine='graphrag',index_version=index['index_version']),local)
if evidence:
lines.append('Prepojenie dokladá uvedenú tému alebo činnosť; nepreukazuje spoluprácu ani rovnaký stupeň dokončenia.')
else:
lines=['V ohraničenom lokálnom grafe sa nenašlo dostatočne doložené prepojenie k porovnávanej osobe. Nejde o potvrdenie, že podobná práca neexistuje.']
urls=list(dict.fromkeys(r['source_url'] for r in evidence.values()))
if urls:lines.append('Zdroje: '+', '.join(urls))
text='\n\n'.join([*bound.resolution.get('notices',[]),*lines])
allowed=set(urls)
for row in evidence.values():allowed|=text_urls(row['text'])|text_urls(row['work_label'])
if not citation_check({'answer':text,'source_urls':urls},allowed)['citations_ok']:
from tools.knowledge.query import ContentAnswerRejected
raise ContentAnswerRejected('Graph path citations rejected')
return graph_details({'status':status,'reason':None if evidence else 'no_local_graph_connection',
'engine':'graphrag','accepted_for_delivery':True,'claims':[],'answer':text,'source_urls':urls,
'index_version':index['index_version'],'knowledge_version':index['version'],
'evidence':list(evidence.values()),'complete':False,'truncated':False,'truncation_reasons':[],
'answer_coverage':{'scope':'overview','status':'partial' if local.truncation_reasons else 'supported' if evidence else 'not_found',
'warnings':[],'limitations':list(dict.fromkeys(local.truncation_reasons))}},local)