268 lines
15 KiB
Python
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
|