"""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']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