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