"""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)(? 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)