dp-zp-agent/tools/answers/conversation.py
2026-11-15 16:27:08 +01:00

320 lines
19 KiB
Python

"""Stateless, signed conversation references; source text is never chat memory.
The caller explicitly returns a token from its own previous tool response. No
global last-person/last-meeting state is kept, and nothing is written to wiki.
"""
from __future__ import annotations
import base64
from dataclasses import dataclass
import hashlib
import hmac
import json
import os
import re
import time
from tools.answers.context_policy import normalized
from tools.answers.question_intent import normalize_question
from tools.answers.question_plan import MeetingRequest, direct_spec
from tools.retrieval.person_names import resolve_person_names
MAX_TOKEN = 16000
LIFETIME = 24 * 60 * 60
MEETING_REF = re.compile(r'\b(?:vtedy|(?:v|na|o|z|pri) (?:danom|tomto|tom|tomto istom) stretnuti)\b')
WORK_REF = re.compile(r'\b(?:(?:tej|tu|t[uú]) (?:druhej|druhu|prvej|prvu)|(?:pri|o) druhej) (?:praci|pracu|projekte)\b')
# A pronoun points at the previous person ("Kto bol jeho vedúci?", "Aké výsledky dosiahla?" is no pronoun).
PRONOUN_REF = re.compile(r'\b(?:jeho|jej|ho|mu|nemu|nej|nom|onej|on|ona|tento student|tato studentka|ten student|'
r'ta studentka|toho studenta|tej studentky|ten isty|ta ista|daneho studenta|danej studentky|'
r'tohto studenta|tejto studentky)\b')
# A short question made only of these words asks about an aspect of the previous subject
# ("Kto bol vedúci?", "Aké technológie použil?"); any other content word makes it a new question.
ASPECT_WORDS = set("""kto bol bola boli je su bude co aky aka ake akej akom akych ktory ktora ktore kedy kde ako kolko
mal mala mali ma maju robil robila pracoval pracovala pouzil pouzila pouzival pouzivala dosiahol dosiahla urobil
urobila napisal napisala veduci veduceho veducim skolitel skolitela konzultant konzultanta oponent oponenta vysledky
vysledok vysledkoch technologie technologiu nastroje nastroj metody metodu ciel ciele tema temu temy nazov nazvy
praca prace pracu praci rok roku roky stretnutie stretnutia stretnuti posledne prve ulohy ulohu stav pokrok zadanie
plan klucove slova tagy jazyk jazyky data dataset model modely sa si mu jej jeho ho tam v vo na o z zo s so k a aj ale
uz este teraz potom tiez prosim daj ukaz vypis povedz""".split())
BOTH = re.compile(r'^(?:(?:daj|uved|ukaz|vypis|porovnaj|chcem|prosim)\s+)?(?:obe|oba|obidv\w*)(?:\s+(?:prace|projekty))?[.!?]?$')
class ContextError(ValueError):
pass
@dataclass
class ResolvedQuestion:
query: str
meeting: MeetingRequest | None = None
clarification: str | None = None
reason: str | None = None
def _key():
# Domain-separated from API authentication; no additional .env secret.
key = os.getenv('SEARCH_API_KEY', '')
return hashlib.sha256(('zp-conversation-v1\0'+key).encode()).digest() if key else None
def _encode(value):
return base64.urlsafe_b64encode(value).decode().rstrip('=')
def _decode(value):
return base64.b64decode(value+'='*(-len(value)%4),altchars=b'-_',validate=True)
def seal(payload):
key = _key()
if key is None:
return None # Local library tests need no private API configuration.
body = _encode(json.dumps(payload,ensure_ascii=False,separators=(',',':')).encode())
token = body+'.'+_encode(hmac.digest(key,body.encode(),'sha256'))
return token if len(token)<=MAX_TOKEN else None
def unseal(token, version):
try:
if not isinstance(token,str) or not 1<=len(token)<=MAX_TOKEN or _key() is None:
raise ValueError()
body,signature=token.split('.')
if not hmac.compare_digest(_decode(signature),hmac.digest(_key(),body.encode(),'sha256')):
raise ValueError()
value=json.loads(_decode(body))
if value.get('schema')!=1 or not isinstance(value.get('question'),str):raise ValueError()
if value['expires']<time.time():raise ContextError('expired_context')
if value['version']!=(version or {}).get('version'):raise ContextError('stale_context')
return value
except ContextError:
raise
except (ValueError,KeyError,TypeError,UnicodeError):
raise ContextError('invalid_context') from None
def _options(index, spec):
people={p.name for p in spec.people if p.role=='student'}
rows=[r for r in (index or {}).get('units',[]) if r['person'] in people and
spec.matches_work(r['person'],r.get('work_label'),r.get('work_type'),r.get('work_year'))]
works=[];seen=set()
for r in rows:
if r.get('work_id') and r['work_id'] not in seen:
seen.add(r['work_id'])
works.append({'person':r['person'],'id':r['work_id'],'label':r['work_label']})
return works, [r for r in rows if r['kind']=='meeting']
def issue_context(question, result, catalog, index, version):
if result.get('accepted_for_delivery') is not True or result.get('status') not in {'ok','ambiguous'}:
return None
if result.get('index_version')!=version or index is None:return None
resolution=resolve_person_names(question,catalog)
if resolution['status']=='ambiguous' and result.get('status')=='ambiguous' and resolution.get('candidates'):
# "Ktorého Jána myslíš?" -> the next reply "Ján Holp" answers THIS
# question. Only catalog choices actually offered can be selected.
options=list(dict.fromkeys(c['name'] for c in resolution['candidates']))
if not 2<=len(options)<=20:return None
payload={'schema':1,'version':version['version'],'expires':int(time.time())+LIFETIME,
'question':question,'people':[],'works':[],'meetings':[],
'pending_person':{'mention':resolution['mention'],'options':options}}
token=seal(payload)
return {'token':token,'work_options':[],'meeting_options':[],'person_options':options} if token else None
if resolution['status']!='ok':return None
spec=direct_spec(question,resolution)
works,markers=_options(index,spec)
if not spec.people:return None
# Options must follow the displayed order, particularly numbered choices.
answer=normalized(result.get('answer',''))
if result.get('status')=='ok':
shown=[w for w in works if normalized(w['label']) in answer]
if shown:works=shown
works.sort(key=lambda w:answer.find(normalized(w['label'])) if normalized(w['label']) in answer else len(answer))
evidence=result.get('evidence',[])
focused={r.get('meeting_id') or (r.get('id') if r.get('kind')=='meeting' else None) for r in evidence}
meetings=[{'id':r['id'],'person':r['person'],'work_id':r.get('work_id'),
'label':r.get('work_label'),'date':r.get('date'),'date_label':r.get('date_label')}
for r in markers if (r.get('meeting_id') or r['id']) in focused]
if spec.meeting and spec.meeting.selection=='latest' and any(m['date'] for m in meetings):
dates=sorted({m['date'] for m in meetings if m['date']},reverse=True)[:spec.meeting.count]
meetings=[m for m in meetings if m['date'] in dates]
# Include exact source-backed options in a clarification, never every
# meeting as if it had already been selected by a successful answer.
if not meetings and result.get('reason')=='missing_meeting_context':
meetings=[{'id':r['id'],'person':r['person'],'work_id':r.get('work_id'),
'label':r.get('work_label'),'date':r.get('date'),'date_label':r.get('date_label')}
for r in markers[:12]]
if len(works)>20 or len(meetings)>12:return None
payload={'schema':1,'version':version['version'],'expires':int(time.time())+LIFETIME,
'question':question,'people':[p.model_dump() for p in spec.people],
'works':works,'meetings':meetings}
token=seal(payload)
return {'token':token,'work_options':works,'meeting_options':meetings} if token else None
def resolve_question(question, *, token=None, catalog, index, version):
query=normalize_question(question)
n=normalized(query)
context=unseal(token,version) if token else None
if context and context.get('pending_person'):
chosen=_chosen_person(query,context['pending_person'],catalog)
if chosen:
original=context['question']
mention=context['pending_person']['mention']
resolved=re.sub(r'(?<!\w)'+re.escape(mention)+r'(?!\w)',chosen,original,count=1)
return ResolvedQuestion(resolved if resolved!=original else original.rstrip(' ?.')+' Osoba: '+chosen+'.')
# Anything else is a new, independent question.
return ResolvedQuestion(query)
year_followup=re.fullmatch(r'(?:(?:a|ale)(?: co)?\s+)?v roku ([12]\d{3})[.?]?',n)
if context and year_followup:
old=direct_spec(context['question'],resolve_person_names(context['question'],catalog))
if old.intent=='meeting' and old.meeting and old.meeting.selection=='latest':
query=re.sub(r'\bv roku [12]\d{3}\b','',context['question'],flags=re.I).rstrip(' .?')+' v roku '+year_followup[1]+'?'
n=normalized(query)
meeting_ref=bool(MEETING_REF.search(n))
work_ref=WORK_REF.search(n)
both=bool(BOTH.fullmatch(n))
explicit_date=bool(re.search(r'\b\d{1,2}\.\s*\d{1,2}\.\s*[12]\d{3}\b',query))
# An explicit date is stronger than a redundant word such as "vtedy".
needs_context=meeting_ref and not explicit_date or bool(work_ref) or both
aspect_only=bool(n) and len(n.split())<=7 and all(w in ASPECT_WORDS or w.isdigit() for w in n.split())
short_followup=bool(context and (explicit_date or re.match(r'^(?:a |ale |čo |co |aké |ake |kedy |čo sa |co sa )',n)
or PRONOUN_REF.search(n) or aspect_only))
if needs_context and context is None:
return ResolvedQuestion(query,clarification=(
'Ktoré stretnutie máš na mysli? Uveď študenta a dátum alebo presné označenie stretnutia.' if meeting_ref
else 'Ktoré práce máš na mysli? Uveď osobu a presné označenia prác.'),
reason='missing_meeting_context' if meeting_ref else 'missing_followup_context')
if not context or not (needs_context or short_followup):return ResolvedQuestion(query)
# Revalidate references against the captured published generation, not just
# a plausible name, an unsigned caller dictionary or a model's assertions.
by_id={r['id']:r for r in (index or {}).get('units',[])}
for marker in context['meetings']:
row=by_id.get(marker['id'])
if not row or row['kind']!='meeting' or row['person']!=marker['person'] or row.get('work_id')!=marker['work_id']:
raise ContextError('invalid_context')
for work in context['works']:
if not any(r.get('work_id')==work['id'] and r['person']==work['person'] and r.get('work_label')==work['label'] for r in by_id.values()):
raise ContextError('invalid_context')
# A verified relative reference is a grammatical slot, not a person mention.
# Resolve identities outside that slot (e.g. "danom" can also resemble an
# inflection of a real given name). The original query is kept unchanged.
identity_query=MEETING_REF.sub(' ',n) if meeting_ref else query
actual=resolve_person_names(identity_query,catalog)
if actual['status']!='ok':return ResolvedQuestion(query)
named=[m['name'] for m in actual.get('matches',[])]
previous={p['name'] for p in context['people']}
if needs_context and named and not set(named)<=previous:
return ResolvedQuestion(query,clarification='Odkaz patrí k inej osobe. Uveď pre aktuálneho študenta konkrétnu prácu alebo dátum stretnutia.',reason='context_subject_mismatch')
if both or work_ref:
works=context['works']
if both and len(works)!=2 or work_ref and len(works)<2:
return ResolvedQuestion(query,clarification='Predchádzajúce možnosti neurčujú jednoznačný výber. Uveď presné názvy požadovaných prác.',reason='ambiguous_work_reference')
chosen=works if both else [works[0 if 'prv' in work_ref[0] else 1]]
if len({w['person'] for w in chosen})!=1:
return ResolvedQuestion(query,clarification='Uveď osobu a presné označenie každej vyberanej práce.',reason='ambiguous_work_reference')
selection_only=bool(work_ref and WORK_REF.sub('',n).strip(' .?!') in {'','pri','o','daj','ukaz'})
if both or selection_only:
from tools.retrieval.work_selector import QUOTED,label_key
old_labels={label_key(w['label']) for w in works}
query=QUOTED.sub(lambda m:'' if label_key(m[1]) in old_labels else m[0],context['question'])
else:
query=re.sub(r'\b(?:pri\s+)?(?:tej\s+)?(?:druhej|prvej)\s+(?:práci|praci|projekte)\b',
'pri vybranej práci',query,flags=re.I)
query+=' Pýtam sa na '+('obe práce: ' if both else 'prácu: ')+ ' aj '.join('"'+w['label']+'"' for w in chosen)+'.'
if not named and not (both or selection_only):query+=' Osoba: '+chosen[0]['person']+'.'
elif not named and len(previous)==1:
if re.fullmatch(r'\d{1,2}\.\s*\d{1,2}\.\s*[12]\d{3}[.?]?',query):
query='Na čom pracoval na stretnutí '+query.rstrip('?')+'?'
person=context['people'][0]
prefix={'supervisor':'Pod vedením ','consultant':'Konzultant ',
'document_author':'Autor dokumentu '}.get(person['role'],'Osoba: ')
query+=' '+prefix+person['name']+'.'
elif not named and previous:
return ResolvedQuestion(query,clarification='Na ktorú z predchádzajúcich osôb otázka nadväzuje? Uveď celé meno.',reason='ambiguous_person_reference')
meeting=None
from tools.retrieval.question_constraints import requested_years
inherited_meeting=bool(not named and not explicit_date and not requested_years(question) and not work_ref and not both and short_followup
and context['meetings'] and direct_spec(context['question'],resolve_person_names(context['question'],catalog)).intent=='meeting')
if (meeting_ref or inherited_meeting) and not explicit_date:
markers=context['meetings']
if len(markers)!=1:
options='; '.join(m['person']+' — '+str(m['label'])+' — '+str(m['date'] or m['date_label']) for m in markers)
return ResolvedQuestion(query,clarification='Ktoré stretnutie máš na mysli? '+(options+'. ' if options else '')+'Uveď konkrétny dátum alebo označenie.',reason='ambiguous_meeting_reference')
marker=markers[0]
meeting=MeetingRequest(selection='exact',ids=[by_id[marker['id']].get('meeting_id') or marker['id']])
label=marker['date'] or marker['date_label']
if marker['date']:
y,m,d=marker['date'].split('-');label=f'{int(d)}.{int(m)}.{y}'
# Match on original Unicode spelling without replacing unrelated words.
query=re.sub(r'\bvtedy\b|\b(?:v|na|o|z|pri)\s+(?:danom|tomto|tom|tomto istom)\s+stretnut[ií]\b',
'na stretnutí '+label,query,flags=re.I)
if inherited_meeting:query+=' Na stretnutí '+label+'.'
query+=' Pri práci "'+marker['label']+'".' if marker['label'] else ''
if not both and meeting is None and len(previous)==1 and not named:
query=_inherit_constraints(query,context,catalog)
if len(query)>500:
return ResolvedQuestion(query,clarification='Nadväzujúca otázka je príliš dlhá. Uveď stručne osobu, prácu, dátum a požadovanú informáciu.',reason='resolved_query_too_long')
return ResolvedQuestion(query,meeting=meeting)
def _chosen_person(query, pending, catalog):
"""A short reply that names exactly one of the offered people."""
options=pending.get('options') or []
if not isinstance(options,list) or not all(isinstance(o,str) for o in options):return None
resolution=resolve_person_names(query,[row for row in catalog if row.get('name') in options])
if resolution['status']!='ok':return None
chosen={m['name'] for m in resolution.get('matches',[])}&set(options)
if len(chosen)!=1:return None
name=chosen.pop()
rest=normalized(query)
for match in resolution['matches']:
rest=rest.replace(normalized(match['input']),' ')
# "Ján Holp", "toho Holpa", "myslím Jána Holpa, prosím" select; a long
# new sentence that happens to contain the name is a new question.
filler={'myslim','mam','na','mysli','toho','tu','teho','prosim','ano','ten','ta','to','je','sa','pytam','na','osobu','studenta'}
return name if len([w for w in rest.split() if w not in filler])<=1 else None
def _inherit_constraints(query, context, catalog):
"""Carry previous explicit constraints, allowing explicit new replacements."""
from tools.answers.rag_evidence import WORK_LABELS
old=direct_spec(context['question'],resolve_person_names(context['question'],catalog))
new=direct_spec(query,resolve_person_names(query,catalog))
if len(old.people)!=1:return query
prior=old.works[0] if old.works else None
current=new.works[0] if new.works else None
if prior and current:
if not (current.work_type or current.labels or current.numbers):
if prior.labels:query+=' Pri práci '+ ' alebo '.join('"'+v+'"' for v in prior.labels)+'.'
elif prior.work_type:query+=' Typ práce: '+WORK_LABELS[prior.work_type]+'.'
if prior.years and not new.years and new.period is None:
query+=' Rok práce: '+', '.join(str(y) for y in prior.years)+'.'
if prior.excluded_labels and not current.excluded_labels:
query+=' Nie '+ ' ani '.join('"'+v+'"' for v in prior.excluded_labels)+'.'
for condition in old.conditions:
if condition.kind in {'tag','category','document_author','exclusion','current_status'} and not any(c.kind==condition.kind for c in new.conditions):
query+=' '+condition.quote+'.'
if old.period and not new.period:
def label(value):
y,m,d=value.split('-');return f'{int(d)}.{int(m)}.{y}'
query+=' Obdobie od '+label(old.period.start)+' do '+label(old.period.end)+'.'
return query
def answer_matches_scope(question, result, catalog, meeting=None):
"""Necessary final identity/meeting check, independent of prose quality."""
if result.get('status')!='ok' or not result.get('accepted_for_delivery'):return True
resolution=resolve_person_names(question,catalog)
if resolution['status']!='ok':return False
spec=direct_spec(question,resolution,meeting=meeting)
names={p.name for p in spec.people if p.role=='student'}
rows=result.get('evidence',[])
if names and spec.intent not in {'similar','discovery'}:
if any(r.get('person') and r['person'] not in names for r in [*rows,*result.get('claims',[])]):return False
if spec.meeting:
if not rows:return False
if any(not spec.matches_evidence(r) for r in rows):return False
if spec.period and spec.period.field=='record_date' and any(
not r.get('record_date') or not spec.period.start<=r['record_date']<=spec.period.end for r in rows):return False
return True