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