162 lines
7.9 KiB
Python
162 lines
7.9 KiB
Python
"""Follow-up questions for /crzp: a signed, stateless conversation token.
|
|
|
|
A /crzp answer returns conversation_context.token holding the understood query
|
|
(who, role, title, topic, years, type, school, intent) and the permalink ids of
|
|
the shown works. The host sends it back with the next message of the same chat
|
|
("a v roku 2023?", "a z akých škôl boli tí študenti?", "o čom je tá druhá?").
|
|
The backend keeps no chat state: the token is HMAC-signed with a key derived
|
|
from SEARCH_API_KEY (domain-separated from the ZP Wiki tokens) and expires in
|
|
24 hours. Nothing in it is shown to the user; the answer header always states
|
|
the conditions actually applied.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
import hashlib
|
|
import hmac
|
|
import json
|
|
import os
|
|
import re
|
|
import time
|
|
|
|
from tools.crzp.answering.understand import ROLE_CUES, CrzpQuery, _intent
|
|
from tools.crzp.source.parse import fold
|
|
|
|
SCHEMA = "crzp-1"
|
|
LIFETIME = 24 * 60 * 60
|
|
MAX_TOKEN = 16000
|
|
MAX_SIDS = 30
|
|
CARRIED = {"intent", "people", "title", "topics", "surface", "phrases", "school_name", "works", "years", "work_types", "school", "unit",
|
|
"alternatives", "target_role", "from_model", "sids"}
|
|
|
|
CONTINUES = re.compile(r"^(?:a|aj|a co|co tak|a ako|a kto|a kolko|a ktore|a v|a z|a pre|a len|iba|len|teraz)\b")
|
|
LIST_CUE = re.compile(r"\b(?:ktor\w*|vypis\w*|zoznam\w*|ukaz\w*|ake prace|ake su|aku pracu|nazvy|list|which)\b")
|
|
ORDINALS = [(re.compile(r"\b(?:prv\w*|first)\b"), 0), (re.compile(r"\b(?:druh\w*|second)\b"), 1),
|
|
(re.compile(r"\b(?:tret\w*|third)\b"), 2), (re.compile(r"\b(?:stvrt\w*|fourth)\b"), 3),
|
|
(re.compile(r"\b(?:piat\w*|fifth)\b"), 4), (re.compile(r"\b(?:posledn\w*|last)\b"), -1)]
|
|
SINGLE = re.compile(r"\b(?:ta|tu|tej|tou|tato|tuto|tejto) (?:praca|pracu|prace|praci|pracou|bakalark\w*|diplomov\w*)\b"
|
|
r"|\bo com (?:je|bola|bolo)\b|\bjej (?:abstrakt|klucov|oponent|skolitel|vedu)")
|
|
|
|
|
|
class ContextError(ValueError):
|
|
"""invalid_context or expired_context (both are public, safe reasons)."""
|
|
|
|
|
|
def _key() -> bytes | None:
|
|
secret = os.getenv("SEARCH_API_KEY", "")
|
|
return hashlib.sha256(("zp-crzp-context-v1\0" + secret).encode()).digest() if secret else None
|
|
|
|
|
|
def _b64(value: bytes) -> str:
|
|
return base64.urlsafe_b64encode(value).decode().rstrip("=")
|
|
|
|
|
|
def _unb64(value: str) -> bytes:
|
|
return base64.b64decode(value + "=" * (-len(value) % 4), altchars=b"-_", validate=True)
|
|
|
|
|
|
def issue(question: str, query: CrzpQuery, shown: list[dict], options: list[dict] | None = None) -> dict | None:
|
|
"""conversation_context for the result, or None (no key configured, nothing to continue).
|
|
options: the namesakes offered in a clarification ({"name", "school"})."""
|
|
key = _key()
|
|
if key is None or not (query.people or query.title or query.topics or query.school):
|
|
return None
|
|
rows = [r for r in shown if r.get("sid")][:MAX_SIDS]
|
|
payload = {"schema": SCHEMA, "expires": int(time.time()) + LIFETIME, "question": question,
|
|
"query": query.model_dump(mode="json", include=CARRIED - {"sids"}),
|
|
"sids": [r["sid"] for r in rows], "options": (options or [])[:8]}
|
|
body = _b64(json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode())
|
|
token = body + "." + _b64(hmac.digest(key, body.encode(), "sha256"))
|
|
if len(token) > MAX_TOKEN:
|
|
return None
|
|
return {"token": token, "work_options": [{"id": r["sid"], "label": r["title"]} for r in rows[:10]],
|
|
"meeting_options": []}
|
|
|
|
|
|
def restore(token: str) -> dict:
|
|
key = _key()
|
|
try:
|
|
if key is None or not isinstance(token, str) or not 1 <= len(token) <= MAX_TOKEN:
|
|
raise ValueError
|
|
body, signature = token.split(".")
|
|
if not hmac.compare_digest(_unb64(signature), hmac.digest(key, body.encode(), "sha256")):
|
|
raise ValueError
|
|
value = json.loads(_unb64(body))
|
|
if value.get("schema") != SCHEMA or not isinstance(value.get("query"), dict):
|
|
raise ValueError
|
|
if value["expires"] < time.time():
|
|
raise ContextError("expired_context")
|
|
value["query"] = CrzpQuery.model_validate(value["query"])
|
|
return value
|
|
except ContextError:
|
|
raise
|
|
except (ValueError, KeyError, TypeError, UnicodeError):
|
|
raise ContextError("invalid_context") from None
|
|
|
|
|
|
def follow_up(question: str, new: CrzpQuery, previous: dict) -> tuple[CrzpQuery, str | None]:
|
|
"""(query to answer, clarification or None). A message with its own subject and no
|
|
reference to the previous answer is answered on its own; otherwise the previous
|
|
conditions are kept and the new ones (person, topic, year, type, school, intent) replace them."""
|
|
low = fold(question)
|
|
own_subject = bool(new.people or new.title or new.topics)
|
|
if own_subject and not CONTINUES.search(low):
|
|
return new, None # "Koľko prác viedol Staš?" stands on its own even with a token
|
|
old: CrzpQuery = previous["query"]
|
|
merged = old.model_copy(deep=True)
|
|
if new.people:
|
|
merged.people = new.people
|
|
role_named = any(re.search(cue, question.lower()) for cue in ROLE_CUES.values())
|
|
for person, before in zip(merged.people, old.people):
|
|
if not role_named:
|
|
person.role = before.role # "a čo Staš?" keeps the asked role (whatever the model guessed)
|
|
merged.from_model = new.from_model
|
|
if new.title:
|
|
merged.title = new.title
|
|
if new.topics:
|
|
merged.topics, merged.surface, merged.alternatives = new.topics, new.surface, new.alternatives
|
|
merged.phrases = new.phrases
|
|
merged.years = new.years or old.years
|
|
merged.work_types = new.work_types or old.work_types
|
|
if new.school or new.unit:
|
|
merged.school, merged.unit = new.school, new.unit
|
|
explicit = _intent(question.lower())
|
|
if explicit != "list" or LIST_CUE.search(low) or new.intent != "list" and not own_subject:
|
|
merged.intent = new.intent if explicit == "list" else explicit
|
|
merged.target_role = new.target_role
|
|
if merged.people and merged.target_role == merged.people[0].role:
|
|
merged.target_role = None
|
|
if merged.intent == "relation" and merged.target_role is None:
|
|
merged.intent = "list"
|
|
merged.sids, merged.searched, merged.relaxed_author, merged.unverified_only = [], [], False, []
|
|
sids = previous.get("sids") or []
|
|
number = re.fullmatch(r"\s*(?:cislo\s+)?(\d)\s*[.)]?\s*", low)
|
|
ordinal = int(number.group(1)) - 1 if number else next((index for cue, index in ORDINALS if cue.search(low)), None)
|
|
options = previous.get("options") or []
|
|
if options and merged.people and not new.people: # the answer to "Ktorú osobu myslíš?"
|
|
chosen = None
|
|
if ordinal is not None and 0 <= ordinal < len(options):
|
|
chosen = options[ordinal]
|
|
elif new.school:
|
|
from tools.crzp.answering.understand import SCHOOLS
|
|
matching = [o for o in options if fold(SCHOOLS[new.school][0]) == fold(o["school"])]
|
|
chosen = matching[0] if len(matching) == 1 else None
|
|
if chosen:
|
|
merged.people[0].name = merged.people[0].typed = chosen["name"]
|
|
merged.school_name = chosen["school"]
|
|
merged.works = list(chosen.get("works") or [])
|
|
merged.from_model = True # the exact CRZP name: no other name forms
|
|
return merged, None
|
|
if ordinal is not None and not own_subject:
|
|
if not sids:
|
|
return merged, "Predchádzajúca odpoveď nemala žiadnu prácu; uveď názov práce alebo osobu."
|
|
if ordinal >= len(sids):
|
|
return merged, f"Predchádzajúca odpoveď mala {len(sids)} {'práce' if len(sids) < 5 else 'prác'}; ktorú myslíš?"
|
|
merged.sids = [sids[ordinal]]
|
|
elif SINGLE.search(low) and not own_subject:
|
|
if len(sids) != 1:
|
|
return merged, ("Predchádzajúca odpoveď mala viac prác; ktorú myslíš? Napíš napr. „tá druhá“ "
|
|
"alebo názov práce.")
|
|
merged.sids = list(sids)
|
|
return merged, None
|