dp-zp-agent/tools/answers/conversation.py

268 lines
15 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')
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']!='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):
latest=max(m['date'] for m in meetings if m['date'])
meetings=[m for m in meetings if m['date']==latest]
# 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
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
short_followup=bool(context and (explicit_date or re.match(r'^(?:a |ale |čo |co |aké |ake |kedy |čo sa |co sa )',n)))
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 _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