dp-zp-agent/tools/answers/combined.py
2026-11-15 16:27:08 +01:00

601 lines
32 KiB
Python

"""ZP Wiki and CRZP in one answer for /rag and /graphrag.
Every question is answered from ZP Wiki (the normal RAG / GraphRAG path) and,
at the same time, from CRZP when CRZP can add something (see crzp_plan):
- a person ZP Wiki knows: that person's works in CRZP at Technická univerzita
v Košiciach (supervisor, opponent, abstract, keywords the wiki may lack);
- a topic or "similar works": CRZP works on the same topic, with their schools;
- a question naming CRZP, another school or opponents: CRZP is the main source.
Meetings, tasks, progress and results stay ZP Wiki only. The wiki part comes
first, the CRZP part is marked with its source; nothing is mixed into one fact.
When a person has different works in the two sources (a diploma thesis in the
wiki, a bachelor thesis in CRZP) and the question does not say which one, the
answer asks; the reply ("2", "bakalársku") continues through a signed token
that also carries the wiki's own token and the people of the last answer
(for "a z akých škôl boli tí študenti?").
"""
from __future__ import annotations
import base64
import hashlib
import hmac
import json
import os
import re
import threading
import time
from concurrent.futures import ThreadPoolExecutor, TimeoutError as FutureTimeout
from functools import lru_cache
from tools.answers.context_policy import normalized
from tools.answers.delivery_contract import fingerprint
CRZP_SECONDS = float(os.getenv("CRZP_COMBINED_SECONDS", "45"))
LIFETIME = 24 * 3600
PREFIX = "zc1."
TUKE = "Technická univerzita v Košiciach"
WIKI_SCHOOL = "Technická univerzita v Košiciach (KEMT FEI)"
# What only the wiki records: CRZP is not asked.
WIKI_ONLY = re.compile(r"\b(?:stretnut\w*|pokrok\w*|progres\w*|uloh\w*|zadan\w*|plan\w*|termin\w*|tyzd\w*|"
r"repozitar\w*|git\w*|stav\w*|poznam\w*|konzult\w*|zaciat\w*|studi\w*|obhaj\w*|ukonc\w*|"
r"dokonc\w*|uspes\w*|statnic\w*|hodnot\w*|znamk\w*|problem\w*|vtedy|posledn\w*)\b")
EXPLICIT = re.compile(r"\b(?:crzp|registr\w*|register|celoslovensk\w*|na slovensku|inych skol|inej skole|"
r"vsetkych skol|ostatnych skol|oponent\w*|oponoval\w*)\b")
METHODS = re.compile(r"\b(?:technolog\w*|nastroj\w*|metod\w*|pouziv\w*|pouzil\w*|vyuziv\w*|vyuzil\w*|framework\w*)\b")
ABOUT = re.compile(r"\b(?:o com|zhrn\w*|abstrakt\w*|ciel\w*)\b") # not "čomu sa venuje": the current work
# A question about a thesis (not "na čom pracuje", which CRZP's submitted works cannot answer).
THESIS = re.compile(r"\b(?:prac(?:a|e|u|i|ou|ach|ami)?|bakalar\w*|diplom\w*|dizerta\w*|habilita\w*|tem(?:a|u|y|ou)|"
r"oponent\w*|vedu\w*|skolitel\w*|abstrakt\w*|klucov\w*|tagy?)\b")
WIKI_CLARIFICATION_YIELDS = {"missing_subject", "unsupported_content_question"}
REFUSED = {"request_out_of_scope", "irrelevant", "unsupported_request", "out_of_scope"}
RELATED = re.compile(r"\b(?:suvis\w*|pribuzn\w*)\b")
SIMILAR = re.compile(r"\b(?:podobn\w*|suvisiac\w*|pribuzn\w*|spolupra\w*|rovnak\w* tem\w*)\b")
TOPIC_LIST = re.compile(r"^\W*(?:kto|ktor\w*|ak\w*)\b")
SCHOOLS = re.compile(r"\b(?:z|zo|na) (?:akych|akej|ktorych|ktorej) (?:skol|univerzit|fakult)\w*|\bkde (?:studoval\w*|studuj\w*)"
r"|\b(?:ktor|ak)\w* (?:skol|univerzit)\w*")
CHOICE_WIKI = re.compile(r"\b(?:wiki|diplom\w*|dp)\b")
CHOICE_CRZP = re.compile(r"\b(?:crzp|bakalar\w*|bp)\b")
_shared = {"pool": None}
_shared_lock = threading.Lock()
def _pool() -> ThreadPoolExecutor:
"""One pool for all questions (no new threads per request)."""
with _shared_lock:
if _shared["pool"] is None:
_shared["pool"] = ThreadPoolExecutor(max_workers=int(os.getenv("ANSWER_COMBINED_WORKERS", "6")),
thread_name_prefix="answer")
return _shared["pool"]
def enabled() -> bool:
return os.getenv("ANSWER_WITH_CRZP", "1") != "0"
# ------------------------------------------------------------------ token
def _key() -> bytes | None:
secret = os.getenv("SEARCH_API_KEY", "")
return hashlib.sha256(("zp-combined-v1\0" + secret).encode()).digest() if secret else None
def _b64(value: bytes) -> str:
return base64.urlsafe_b64encode(value).decode().rstrip("=")
def seal(payload: dict) -> str | None:
key = _key()
if key is None:
return None
body = _b64(json.dumps({**payload, "expires": int(time.time()) + LIFETIME}, ensure_ascii=False,
separators=(",", ":")).encode())
token = PREFIX + body + "." + _b64(hmac.digest(key, body.encode(), "sha256"))
return token if len(token) <= 16000 else None
def unseal(token: str | None) -> dict | None:
"""The combined context, or None for a token that is not ours (a plain wiki token)."""
if not isinstance(token, str) or not token.startswith(PREFIX) or _key() is None:
return None
try:
body, signature = token[len(PREFIX):].split(".")
expected = hmac.digest(_key(), body.encode(), "sha256")
if not hmac.compare_digest(base64.b64decode(signature + "=" * (-len(signature) % 4), altchars=b"-_"), expected):
raise ValueError
value = json.loads(base64.b64decode(body + "=" * (-len(body) % 4), altchars=b"-_"))
if value.get("expires", 0) < time.time():
return {"error": "expired_context"}
return value
except (ValueError, TypeError, UnicodeError):
return {"error": "invalid_context"}
# ------------------------------------------------------------------ wiki facts
_index_cache: dict = {}
_index_lock = threading.Lock()
def _index():
"""The published knowledge index, reloaded only when its version changes."""
from tools.knowledge.store import load_index
from tools.rag.common import DB_FILE
from tools.sync.index_state import read_version
version = read_version(DB_FILE.parent)
key = (version or {}).get("version")
with _index_lock:
if _index_cache.get("key") != key:
_index_cache.update(key=key, index=load_index(DB_FILE.parent, version))
return _index_cache["index"]
def wiki_units(person: str) -> list[dict]:
try:
return [u for u in (_index() or {}).get("units", []) if u.get("person") == person]
except Exception:
return []
def wiki_works(person: str) -> dict[str, dict]:
"""work label -> {"type", "topic"} of the person's ZP Wiki works."""
works = {}
for unit in wiki_units(person):
label = unit.get("work_label")
if not label:
continue
work = works.setdefault(label, {"type": unit.get("work_type"), "topic": None})
if unit.get("kind") == "topic" and not work["topic"] and not unit["text"].strip().endswith(":"):
work["topic"] = re.sub(r"^\s*(?:Téma|Názov)\s*:\s*", "", unit["text"]).strip()
return works
FRAMEWORKS = {"python", "fastapi", "flask", "django", "streamlit", "react", "docker", "kubernetes", "git", "github",
"gitlab", "pandas", "numpy", "jupyter", "colab", "linux", "javascript", "typescript", "java", "html", "css",
"llm", "ai", "nlp", "veľký jazykový model", "umelá inteligencia", "zpwiki", "openwebui", "skills", "skill"}
def wiki_terms(person: str) -> list[str]:
"""The person's most specific topics and tools (reviewed topic groups first, frameworks never):
the words to look for in CRZP to find similar works."""
from tools.answers.content_selection import named_tools
from tools.retrieval.synonyms import base_name, group_of
counts: dict[str, float] = {}
for unit in wiki_units(person):
if unit.get("phase") not in {"result", "progress", "assignment"} and unit.get("kind") != "topic":
continue
for concept in unit.get("concepts") or []:
if not isinstance(concept, str):
continue
if len(group_of(concept)) > 1:
name, weight = base_name(concept), 2.0
elif named_tools(concept) and len(concept.split()) <= 2 and not re.search(r"[./]", concept):
name, weight = concept, 1.0
else:
continue
if normalized(name) not in FRAMEWORKS:
counts[name] = counts.get(name, 0) + weight
return sorted(counts, key=lambda n: (-counts[n], n))[:4]
def wiki_profile(person: str) -> str:
"""Topic titles and terms of the person's works: the text similar CRZP works are ranked against."""
works = wiki_works(person)
return ". ".join([*(w["topic"] for w in works.values() if w.get("topic")), ", ".join(wiki_terms(person))])
# ------------------------------------------------------------------ CRZP part
def crzp_plan(question: str, person: str | None, complete=None, student: bool = True):
"""(mode, CrzpQuery) or None when CRZP adds nothing to this question."""
from tools.crzp.answering.understand import Person, understand
low = normalized(question)
explicit = bool(EXPLICIT.search(low))
if WIKI_ONLY.search(low) and not explicit:
return None
query = understand(question, complete=complete)
if query.intent == "refused":
return None
other_school = query.school not in (None, "tuke") or explicit
if person and not other_school:
if SIMILAR.search(low) and student:
return "similar", query
role = query.people[0].role if query.people else "any"
if student:
role = "author" if role == "author" else "any" # "Kto vedie Jána Ptáka?": his own works
elif role not in ("supervisor", "opponent"):
role = "supervisor" # a supervisor's works are those he or she led
query.people = [Person(name=person, role=role, typed=person)]
query.school, query.unit, query.from_model, query.title = "tuke", None, True, None
query.topics, query.alternatives = [], []
return "person", query
if query.people and not person and query.search_text() and THESIS.search(low):
return "explicit", query # a person ZP Wiki does not know: CRZP knows the theses of all schools
if query.topics and not query.people and RELATED.search(low):
query.intent = "keywords" # "Ktoré témy súvisia s X?": the tags of CRZP works about X
return "explicit", query
if query.topics and not query.people and query.intent in ("relation", "count", "keywords", "schools"):
return "explicit", query # "Kto viedol najviac prác o X?", "Koľko prác o X…": computed over CRZP
if (other_school or ((query.school or query.unit) and not query.people)) and query.search_text():
return "explicit", query # another school, CRZP itself, opponents, or a school/department as a whole
if query.topics and not query.people and (TOPIC_LIST.search(low) or SIMILAR.search(low)):
return "topic", query
return None
def clean_crzp(text: str) -> str:
"""CRZP answer text inside a combined answer: its own source lines go to the common "Zdroje CRZP" line."""
lines = [l for l in text.split("\n") if not re.match(r"^\s*(?:-\s*)?Zdroj:\s", l)]
return re.sub(r"\n{3,}", "\n\n", "\n".join(lines)).strip()
def explicit_part(question: str, result: dict) -> dict | None:
"""A whole CRZP answer (CRZP is the main source), with its conversation token for follow-ups."""
from tools.crzp.answering import answer as crzp, context as crzp_context
if result.get("status") not in ("ok", "ambiguous"):
return None
rows = result.pop("_shown", None) or []
asked = result.pop("_asked", None)
options = result.pop("_options", None)
token = None
if asked is not None:
issued = crzp_context.issue(question, asked, rows, options)
token = issued["token"] if issued else None
return {"mode": "explicit", "status": result["status"], "text": clean_crzp(result["answer"]), "rows": rows,
"urls": result.get("source_urls", []), "crzp_token": token,
"people": [(_names(r.get("authors", [])), crzp.university(r)) for r in rows]}
def _kind(row) -> str:
from tools.crzp.answering.answer import _kind as kind
return kind(row)
def _names(values) -> str:
from tools.crzp.source.parse import display_name
return ", ".join(display_name(v) for v in values) or "neuvedené"
def _work(row) -> str:
return f"{_kind(row)} {row.get('year') or ''}".strip() + f" „{row['title']}“"
def crzp_methods(person: str, row: dict, detail: dict | None) -> str:
""""Ján Pták vo svojej bakalárskej práci „…“ (2025) využíva: ESP32, MQTT, InfluxDB, Home Assistant." —
the work's keywords and the tools its abstract names."""
from tools.answers.content_selection import named_tools
record = detail or row
abstract = record.get("abstract") or ""
found = []
for keyword in record.get("keywords", []):
spelled = re.search(r"(?i)(?<!\w)" + re.escape(keyword) + r"(?!\w)", abstract)
name = spelled.group(0) if spelled else keyword
if named_tools(name) or re.search(r"[A-Z]", name[1:] if spelled else name): # "Home Assistant", not "časová značka"
found.append(name)
found += [t.rstrip(".,;:") for t in named_tools(abstract)]
tools = []
for name in found:
key = normalized(name)
if key and len(key) > 1 and not any(f" {key} " in f" {normalized(t)} " for t in tools):
tools = [t for t in tools if f" {normalized(t)} " not in f" {key} "]
tools.append(name)
where = {"bachelor_thesis": "vo svojej bakalárskej práci", "diploma_thesis": "vo svojej diplomovej práci",
"dissertation": "vo svojej dizertačnej práci"}.get(row.get("work_type"), "vo svojej práci")
return f"{person} {where} „{row['title']}“ ({row.get('year')}) využíva: {', '.join(tools[:12])}."
def remember(rows: list[dict]) -> None:
"""Project the used CRZP works into Neo4j, linked to ZP Wiki people and topics (in the background)."""
from tools.crzp.storage import graph
rows = [r for r in rows if r.get("sid")]
if rows and os.getenv("CRZP_GRAPH_LINKS", "1") != "0":
_pool().submit(graph.project, rows)
def crzp_part(question: str, person: str | None, *, complete=None, student: bool = True) -> dict | None:
"""The CRZP contribution: {"mode", "text", "rows", "people", "works"} or None."""
part = _crzp_part(question, person, complete=complete, student=student)
if part:
remember(part.get("rows") or [])
return part
def _crzp_part(question: str, person: str | None, *, complete=None, student: bool = True) -> dict | None:
from tools.crzp.answering import answer as crzp
from tools.crzp.source.client import CrzpUnavailable
try:
planned = crzp_plan(question, person, complete, student)
if planned is None:
return None
mode, query = planned
low = normalized(question)
if mode == "similar":
return crzp_similar(person, complete=complete)
if mode == "explicit":
return explicit_part(question, crzp._answer_query(question, query, complete))
found, rows = crzp.find(query)
if not rows:
return None
rows = crzp.ranked(question, query, rows)
if mode == "topic":
rows = rows[:5]
lines = [f"- {_names(r.get('authors', []))} — {crzp.university(r)}: „{r['title']}“ ({_kind(r)}, {r.get('year')})"
for r in rows]
return {"mode": mode, "text": "Práce na túto tému v CRZP (najbližšie podľa obsahu):\n\n" + "\n".join(lines),
"rows": rows, "urls": [r["url"] for r in rows if r.get("url")],
"people": [(_names(r.get("authors", [])), crzp.university(r)) for r in rows]}
# person: that person's works at TUKE
rows = [r for r in rows if crzp.university(r) == TUKE]
if not rows:
return None
if not student: # a supervisor: the works he or she led, newest first
rows = sorted(rows, key=lambda r: -(r.get("year") or 0))
lines = [f"- {_names(r.get('authors', []))} — „{r['title']}“ ({_kind(r)}, {r.get('year')})" for r in rows[:8]]
more = f"\n\nSpolu {len(rows)} prác v CRZP na TUKE; zobrazených je 8 najnovších." if len(rows) > 8 else ""
return {"mode": mode, "text": f"Práce, ktoré {person} viedol alebo oponoval, v CRZP (TUKE):\n\n"
+ "\n".join(lines) + more, "rows": rows[:8], "urls": [r["url"] for r in rows[:8] if r.get("url")],
"people": [(_names(r.get("authors", [])), crzp.university(r)) for r in rows[:8]]}
lines, details = [], {}
for row in rows[:3]:
if ABOUT.search(low) or METHODS.search(low):
details[row.get("sid")] = crzp.fetch.detail(row["sid"]) if row.get("sid") else None
detail = details.get(row.get("sid"))
if METHODS.search(low):
lines.append(crzp_methods(person, row, detail))
elif ABOUT.search(low) and detail:
label, text = crzp.summary(detail, complete)
lines.append(f"**{row['title']}** ({_kind(row)}, {row.get('year')})\n\n{text}")
else:
lines.append(f"- {_work(row)} — vedúci {_names(row.get('supervisors', []))}; "
f"oponent {_names(row.get('opponents', []))}")
return {"mode": mode, "text": "\n\n".join(lines), "rows": rows[:3],
"urls": [r["url"] for r in rows[:3] if r.get("url")], "people": [],
"works": [{"source": "crzp", "label": _work(r), "type": r.get("work_type"), "key": crzp.work_key(r)}
for r in rows[:3]]}
except CrzpUnavailable:
return None
def crzp_similar(person: str, *, complete=None) -> dict | None:
"""CRZP works sharing the person's specific tools/topics (searched in CRZP by them)."""
from tools.crzp import fetch
from tools.crzp.answering import answer as crzp
from tools.crzp.source.parse import fold
terms = wiki_terms(person)
if not terms:
return None
scored: dict = {}
for term in terms:
for row in fetch.search(term)["rows"]:
if any(fold(person.split()[-1]) in fold(a) for a in row.get("authors", [])):
continue
text = fold(row["title"] + " " + " ".join(row.get("keywords", [])))
shared = [t for t in terms if fold(t) in text]
if shared:
key = crzp.work_key(row)
scored[key] = (row, sorted(set(shared) | set(scored.get(key, (None, []))[1])))
if not scored:
return None
candidates = list(scored.values())
from tools.retrieval import rerank
profile = wiki_profile(person)
order = rerank.scores(profile, [r["title"] + ". " + ", ".join(r.get("keywords", [])) for r, _ in candidates]) \
or [0] * len(candidates)
# Closest by content first; more shared topics/tools break ties.
best = [c for _, c in sorted(zip(order, candidates), key=lambda i: (-i[0], -len(i[1][1])))][:5]
lines = [f"- {_names(r.get('authors', []))} — {crzp.university(r)}: „{r['title']}“ ({_kind(r)}, {r.get('year')}); "
f"spoločné: {', '.join(shared)}" for r, shared in best]
return {"mode": "similar", "text": "Podobné práce v CRZP (spoločné témy a nástroje):\n\n" + "\n".join(lines),
"rows": [r for r, _ in best], "urls": [r["url"] for r, _ in best if r.get("url")],
"people": [(_names(r.get("authors", [])), crzp.university(r)) for r, _ in best]}
# ------------------------------------------------------------------ composition
def _wiki_people(result: dict) -> list[tuple[str, str]]:
names = re.findall(r"^\*\*([^*\n—]+?)(?:\s+—[^*\n]*)?\*\*\s*$", result.get("answer") or "", re.M)
return [(n.strip(), WIKI_SCHOOL) for n in dict.fromkeys(names) if len(n.split()) >= 2]
def _schools_answer(question: str, engine: str, people: list) -> dict:
lines = [f"- {name} — {school}" for name, school in people]
text = "Školy študentov z predchádzajúcej odpovede:\n\n" + "\n".join(lines)
return _ok(question, engine, text, [], {})
def _ok(question, engine, text, urls, base) -> dict:
keep = {k: v for k, v in base.items() if k not in ("answer", "status", "reason", "delivery_check",
"source_urls", "conversation_context")}
return {**keep, "status": "ok", "reason": None, "accepted_for_delivery": True, "answer": text,
"source_urls": list(dict.fromkeys(urls)), "claims": base.get("claims", []),
"index_version": base.get("index_version"), "complete": base.get("complete", True),
"truncated": base.get("truncated", False), "truncation_reasons": base.get("truncation_reasons", []),
"delivery_check": {"layer": "delivery", "code": "complete", "request_fingerprint": fingerprint(question, engine)}}
def _with_token(result: dict, *, wiki_context=None, people=(), options=(), question="", crzp=None,
primary="wiki") -> dict:
wiki_context = wiki_context or {}
token = seal({"wiki": wiki_context.get("token"), "people": list(people)[:30], "options": list(options)[:6],
"question": question, "crzp": crzp, "primary": primary})
if token:
result["conversation_context"] = {**{k: v for k, v in wiki_context.items() if k != "token"},
"token": token, "work_options": wiki_context.get("work_options", []),
"meeting_options": wiki_context.get("meeting_options", [])}
return result
def answer(question: str, *, engine: str, limit: int = 5, context_token: str | None = None, complete=None,
deliver=None) -> dict:
"""deliver: the ZP Wiki answer function (the API passes its own, tests replace it)."""
from tools.answers.answer_delivery import deliver_answer as default_deliver
from tools.answers.finalize import finalize
deliver_answer = deliver or default_deliver
from tools.crzp.answering.wiki_bridge import wiki_person
question = question.strip()
previous = unseal(context_token)
if previous and previous.get("error"):
from tools.answers.answer_errors import public_failure
failure = public_failure({"reason": previous["error"], "engine": engine})
failure["delivery_check"] = {"layer": "context", "code": "unavailable",
"request_fingerprint": fingerprint(question, engine)}
return failure
wiki_token = previous.get("wiki") if previous else context_token
low = normalized(question)
if previous and previous.get("people") and SCHOOLS.search(low) and not wiki_person(question):
return _with_token(_schools_answer(question, engine, previous["people"]), people=previous["people"],
question=question)
if previous and previous.get("options"):
chosen = _choice(low, previous["options"])
if chosen is not None:
return _answer_choice(previous["question"], chosen, engine=engine, limit=limit, complete=complete,
deliver=deliver_answer)
from tools.crzp.answering.wiki_bridge import wiki_match
match = wiki_match(question)
if previous and previous.get("primary") == "crzp" and previous.get("crzp") and not match:
# The last answer came from CRZP ("O čom je práca Jána Nováka?" -> "2", "a kto bol oponent?").
from tools.crzp.answering import answer as crzp
followed = crzp._answer(question, complete, context_token=previous["crzp"])
rows = followed.pop("_shown", None) or []
followed.pop("_asked", None), followed.pop("_options", None)
if followed.get("accepted_for_delivery") is True:
urls = followed.get("source_urls") or []
text = clean_crzp(followed["answer"]) + ("\n\nZdroje CRZP: " + ", ".join(urls[:8]) if urls else "")
result = {**followed, "answer": text, "delivery_check": {**followed["delivery_check"],
"request_fingerprint": fingerprint(question, engine)}}
token = (followed.get("conversation_context") or {}).get("token")
result.pop("conversation_context", None)
return _with_token(result, people=previous.get("people", []), question=question,
crzp=token or previous["crzp"], primary="crzp")
person, student = (match["name"], match["student"]) if match else (None, True)
started = time.monotonic()
pool = _pool()
try:
wiki_future = pool.submit(deliver_answer, question, engine=engine, limit=limit,
**({"context_token": wiki_token} if wiki_token else {}))
crzp_future = pool.submit(crzp_part, question, person, complete=complete, student=student) if enabled() else None
wiki = wiki_future.result()
try:
part = crzp_future.result(timeout=max(1.0, CRZP_SECONDS - (time.monotonic() - started))) if crzp_future else None
except FutureTimeout:
part = None # CRZP too slow this time: the wiki answer is not held back (its search stays cached)
except Exception:
part = None
finally:
pass # the shared pool stays; a slow CRZP part finishes in it and its search stays cached
if wiki.get("reason") in REFUSED or wiki.get("status") == "ambiguous" and (
person or wiki.get("reason") not in WIKI_CLARIFICATION_YIELDS):
part = None # refused/out of scope, or the wiki asks first (which person, which work): no CRZP
wiki_ok = wiki.get("accepted_for_delivery") is True and wiki.get("status") == "ok"
if part is not None and part["mode"] == "explicit" and wiki.get("status") == "ambiguous":
wiki_ok = False
if wiki.get("accepted_for_delivery") is True and isinstance(wiki.get("answer"), str):
wiki = {**wiki, "answer": finalize(wiki["answer"], wiki.get("source_urls"))}
wiki_context = wiki.get("conversation_context")
if part is None or wiki.get("status") == "ambiguous" and part["mode"] != "explicit":
# (a wiki clarification such as "Uveď meno študenta" yields to a question CRZP answers as a whole)
people = _wiki_people(wiki) if wiki_ok else []
return _with_token(wiki, wiki_context=wiki_context, people=people, question=question) if wiki.get(
"accepted_for_delivery") is True else wiki
# Different works in the two sources and the question names none: ask which one.
if person and part["mode"] == "person" and (METHODS.search(low) or ABOUT.search(low)):
options = _work_options(person, part, low)
if options:
text = (f"{person} má v podkladoch viac prác. Ktorú myslíš?\n\n" +
"\n".join(f"{i}. {o['label']} ({'ZP Wiki' if o['source'] == 'wiki' else 'CRZP'})"
for i, o in enumerate(options, 1)) + "\n\nOdpovedz číslom alebo typom práce.")
result = _ok(question, engine, text, [], wiki)
result.update(status="ambiguous", reason="clarification_required",
delivery_check={"layer": "planning", "code": "clarification_required",
"request_fingerprint": fingerprint(question, engine)})
return _with_token(result, wiki_context=wiki_context, options=options, question=question)
if part["mode"] == "explicit" and part.get("status") == "ambiguous": # CRZP asks which namesake
result = _ok(question, engine, part["text"], [], wiki if wiki_ok else {"index_version": wiki.get("index_version")})
result.update(status="ambiguous", reason="clarification_required",
delivery_check={"layer": "planning", "code": "clarification_required",
"request_fingerprint": fingerprint(question, engine)})
return _with_token(result, question=question, crzp=part.get("crzp_token"), primary="crzp")
crzp_text = "**V CRZP** (Centrálny register záverečných prác):\n\n" + part["text"]
if not wiki_ok and (part["mode"] == "explicit" or wiki.get("status") == "error"):
text, urls = part["text"], part["urls"]
elif wiki_ok:
text = wiki["answer"] + "\n\n" + crzp_text
urls = [*(wiki.get("source_urls") or []), *part["urls"]]
else:
text, urls = "V ZP Wiki k tomu nie je záznam.\n\n" + crzp_text, part["urls"]
if part["urls"]:
text += "\n\nZdroje CRZP: " + ", ".join(part["urls"][:8])
result = _ok(question, engine, text, urls, wiki if wiki_ok else {"index_version": wiki.get("index_version")})
people = (_wiki_people(wiki) if wiki_ok else []) + list(part.get("people") or [])
return _with_token(result, wiki_context=wiki_context, people=people, question=question,
crzp=part.get("crzp_token"), primary="crzp" if part["mode"] == "explicit" and not wiki_ok else "wiki")
def _work_options(person: str, part: dict, low: str) -> list[dict]:
"""The person's works when the two sources hold works of different types and the question names none."""
if re.search(r"\b(?:bakalar\w*|diplom\w*|dizerta\w*|projekt\w*)\b", low):
return []
wiki = wiki_works(person)
wiki_types = {w["type"] for w in wiki.values()}
thesis = {"diploma_thesis": "diplom", "bachelor_thesis": "bakalar"}
crzp_only = [w for w in part.get("works", []) if w["type"] in thesis and w["type"] not in wiki_types]
if not crzp_only:
return []
wiki_options = [{"source": "wiki", "label": label + (f" „{w['topic']}“" if w.get("topic") else ""), "work": label}
for label, w in wiki.items() if w["type"] in thesis][:2]
return wiki_options + crzp_only if wiki_options else []
def _choice(low: str, options: list[dict]) -> dict | None:
number = re.fullmatch(r"\s*(?:cislo\s+)?(\d)\s*[.)]?\s*", low)
if number and 1 <= int(number.group(1)) <= len(options):
return options[int(number.group(1)) - 1]
if CHOICE_CRZP.search(low):
return next((o for o in options if o["source"] == "crzp"), None)
if CHOICE_WIKI.search(low):
return next((o for o in options if o["source"] == "wiki"), None)
return None
def _answer_choice(question: str, chosen: dict, *, engine: str, limit: int, complete=None, deliver=None) -> dict:
from tools.answers.finalize import finalize
deliver_answer = deliver
from tools.crzp.answering.wiki_bridge import wiki_person
if chosen["source"] == "wiki":
adjective = {"diplom": "diplomovej", "bakal": "bakalárskej"}.get(normalized(chosen["work"])[:6].rstrip("ao"), "")
adjective = "diplomovej" if normalized(chosen["work"]).startswith("diplom") else \
"bakalárskej" if normalized(chosen["work"]).startswith("bakal") else adjective
asked = re.sub(r"\b(svojej\s+|jeho\s+|jej\s+)?(práci|práce|prácu)\b", lambda m: (m.group(1) or "") + adjective + " " + m.group(2),
question, count=1) if adjective else question
if asked == question:
asked = question.rstrip(" ?.") + f" ({chosen['work']})?"
result = deliver_answer(asked, engine=engine, limit=limit)
if result.get("accepted_for_delivery") is True and isinstance(result.get("answer"), str):
result = {**result, "answer": finalize(result["answer"], result.get("source_urls"))}
result["delivery_check"] = {**(result.get("delivery_check") or {}),
"request_fingerprint": fingerprint(question, engine)}
return result
person = wiki_person(question)
part = crzp_part(question, person, complete=complete)
rows = [r for r in (part or {}).get("rows", []) if chosen.get("key") and _key_of(r) == chosen["key"]]
if not part or not rows:
return _ok(question, engine, "Vybranú prácu sa v CRZP nepodarilo znova načítať. Skús otázku zopakovať.", [], {})
from tools.crzp.answering import answer as crzp
row = rows[0]
detail = crzp.fetch.detail(row["sid"]) if row.get("sid") else None
low = normalized(question)
if METHODS.search(low):
text = crzp_methods(person, row, detail)
else:
label, summary = crzp.summary(detail, complete) if detail else ("", "")
text = f"**{row['title']}** ({_kind(row)}, {row.get('year')})\n\n{summary}"
urls = [row["url"]] if row.get("url") else []
return _ok(question, engine, text + ("\n\nZdroj: " + urls[0] if urls else ""), urls, {})
def _key_of(row: dict) -> str:
from tools.crzp.answering import answer as crzp
return crzp.work_key(row)