844 lines
46 KiB
Python
844 lines
46 KiB
Python
"""Verified answers from CRZP metadata (/crzp).
|
||
|
||
Flow: question -> CrzpQuery -> CRZP searches (years and work types filtered by
|
||
CRZP itself, cached) -> rows filtered by person role, school and verified
|
||
workplace -> answer by intent. A topic is also searched under up to two
|
||
alternative terms (synonyms) and the answer names every searched term. Every
|
||
listed fact is copied from a parsed CRZP record and every work links to its
|
||
CRZP permalink. The model only (1) proposes base forms of names/topics and
|
||
alternative terms, checked by code, and (2) summarizes an abstract; the
|
||
summary is shown only after a separate check that it is supported by the
|
||
abstract, otherwise the abstract itself is shown.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import re
|
||
import threading
|
||
import time
|
||
from collections import Counter, OrderedDict
|
||
|
||
from tools.answers.delivery_contract import fingerprint
|
||
from tools.crzp import fetch
|
||
from tools.crzp.answering import context
|
||
from tools.crzp.answering.names import name_variants
|
||
from tools.crzp.answering.terms import is_reviewed
|
||
from tools.crzp.answering.understand import SCHOOLS, CrzpQuery, in_question, stem_match, understand
|
||
from tools.crzp.answering.units import UNITS, in_unit, unit_label
|
||
from tools.crzp.source.client import CrzpUnavailable
|
||
from tools.crzp.source.parse import WORK_TYPE_LABELS, display_name, fold
|
||
from tools.crzp.storage import graph
|
||
|
||
ENGINE = "crzp"
|
||
LIST_LIMIT = 30
|
||
ROLE_FIELD = {"author": "authors", "supervisor": "supervisors", "opponent": "opponents"}
|
||
ROLE_LABEL = {"author": "autor", "supervisor": "školiteľ", "opponent": "oponent"}
|
||
ROLE_PLURAL = {"author": "Autori", "supervisor": "Školitelia", "opponent": "Oponenti"}
|
||
FACET = {"author": "Autor", "supervisor": "Školiteľ"}
|
||
_cache: OrderedDict = OrderedDict()
|
||
_cache_lock = threading.Lock()
|
||
CACHE_SECONDS = 600
|
||
|
||
|
||
# ------------------------------------------------------------------ matching
|
||
|
||
def name_matches(query_name: str, crzp_name: str) -> bool:
|
||
"""Every word of the asked name is a word of the CRZP name (up to inflection)."""
|
||
words = re.findall(r"[^\W\d_]+", crzp_name.split("(")[0])
|
||
asked = re.findall(r"[^\W\d_]+", query_name)
|
||
return bool(asked) and all(any(stem_match(a, w) for w in words) for a in asked)
|
||
|
||
|
||
def people_in_role(row: dict, name: str, role: str) -> list[str]:
|
||
fields = [ROLE_FIELD[role]] if role in ROLE_FIELD else list(ROLE_FIELD.values())
|
||
return [p for f in fields for p in row.get(f, []) if name_matches(name, p)]
|
||
|
||
|
||
def keep(row: dict, query: CrzpQuery) -> bool:
|
||
"""Conditions CRZP cannot filter (roles, school, workplace) plus a repeat of
|
||
the year/type filters (a year list may have gaps inside the searched range)."""
|
||
if query.sids and row.get("sid") not in query.sids:
|
||
return False # follow-up about chosen works of the previous answer
|
||
if query.school_name and university(row) != query.school_name:
|
||
return False # the chosen namesake
|
||
if query.works and work_key(row) not in query.works:
|
||
return False # the chosen namesake's works (two students of one name at one university)
|
||
if any(not people_in_role(row, p.name, p.role) for p in query.people):
|
||
return False
|
||
if query.years and row.get("year") not in query.years:
|
||
return False
|
||
if query.work_types and row.get("work_type") not in query.work_types:
|
||
return False
|
||
if query.school and SCHOOLS[query.school][1] not in fold(university(row)): # "Slovenská technická univ."
|
||
return False
|
||
if query.unit and not in_unit(row, query.unit):
|
||
return False
|
||
return True
|
||
|
||
|
||
def filters(query: CrzpQuery) -> dict:
|
||
"""Filters CRZP applies itself: a range of submission years and work types."""
|
||
return {"years": (min(query.years), max(query.years)) if query.years else None,
|
||
"work_types": list(query.work_types) or None}
|
||
|
||
|
||
def suffixes(query: CrzpQuery) -> list[str]:
|
||
"""What a topic search appends for a school or workplace: the school name, or every
|
||
form CRZP writes the workplace in (code "104005" and, in old records, the department name)."""
|
||
if query.unit:
|
||
return [" " + text for text in UNITS[query.unit]["search"]]
|
||
return [" " + SCHOOLS[query.school][0]] if query.school else [""]
|
||
|
||
|
||
def strip_suffix(query: CrzpQuery, text: str) -> str:
|
||
return next((text[: -len(s)] for s in suffixes(query) if s and text.endswith(s) and text != s.strip()), text)
|
||
|
||
|
||
def search_groups(query: CrzpQuery) -> list[list[str]]:
|
||
"""Search texts in order of preference; the texts of one group are searched
|
||
together and their rows merged (a topic with its alternative terms). A
|
||
verified workplace is searched by its code (CRZP indexes it, "104005") and
|
||
by its old written name."""
|
||
ends = suffixes(query)
|
||
each = lambda terms: [t + end for t in terms for end in ends]
|
||
if query.people:
|
||
names = [query.people[0].name] if query.from_model else name_variants(query.people[0].name)
|
||
title = " " + query.title if query.title else ""
|
||
return ([[f"{n} {query.topics[0]}{title}"] for n in names if query.topics] + [[n + title] for n in names])
|
||
if query.title:
|
||
return [[query.title]]
|
||
if not query.topics:
|
||
if query.unit:
|
||
return [list(UNITS[query.unit]["search"]), [SCHOOLS[query.school][0]]]
|
||
return [[SCHOOLS[query.school][0]]] if query.school else []
|
||
main = " ".join(query.topics[:2])
|
||
if query.phrases and not query.from_model: # rules: the likely written phrase first
|
||
groups = [each([query.phrases[0], *query.alternatives])] + [each([p]) for p in query.phrases[1:]]
|
||
groups.append(each([main]))
|
||
else:
|
||
groups = [each([main, *query.alternatives])]
|
||
if len(query.topics) > 1: # two topic words did not occur together: the main one alone
|
||
groups.append(each([query.topics[0]]))
|
||
groups += [each([t]) for t in query.surface[:1]] # words as written (rules without the model)
|
||
if ends != [""]:
|
||
groups += [[t] for t in [main, *query.alternatives]]
|
||
unique, seen = [], set()
|
||
for group in groups:
|
||
group = [t for t in dict.fromkeys(group) if t.strip() and t not in seen]
|
||
seen.update(group)
|
||
if group:
|
||
unique.append(group)
|
||
return unique
|
||
|
||
|
||
def _exact(name: str, crzp_name: str) -> bool:
|
||
"""Same words including diacritics, up to inflection ("Jána" is "Ján", not "Jan")."""
|
||
words = re.findall(r"[^\W\d_]+", crzp_name)
|
||
return all(any(stem_match(a, w, diacritics=True) for w in words) for a in re.findall(r"[^\W\d_]+", name))
|
||
|
||
|
||
def prefer_exact(rows: list[dict], query: CrzpQuery) -> list[dict]:
|
||
"""A name typed with diacritics keeps only the exact spellings when CRZP has them."""
|
||
for person in query.people:
|
||
typed = person.typed or person.name
|
||
if fold(typed) == typed.lower():
|
||
continue # typed without diacritics: every spelling matches
|
||
exact = [r for r in rows if any(_exact(typed, n) for n in people_in_role(r, person.name, person.role))]
|
||
if exact:
|
||
rows = exact
|
||
return rows
|
||
|
||
|
||
LEVEL = {"bachelor_thesis": 1, "diploma_thesis": 2, "doctoral_thesis": 3, "dissertation": 3, "habilitation_thesis": 4}
|
||
|
||
|
||
def work_key(row: dict) -> str:
|
||
return f"{' '.join(fold(row['title']).rstrip(' .').split())}|{row.get('year')}"
|
||
|
||
|
||
def _split_students(group: dict) -> list[dict]:
|
||
"""One student writes one work of each kind: two bachelor theses of "Ján Novák" at one
|
||
university are two persons. Works are given, oldest first, to the first person who
|
||
has no work of that kind yet and whose last work is at most 8 years older."""
|
||
persons = []
|
||
for row in sorted(group["rows"], key=lambda r: r.get("year") or 0):
|
||
kind, year = row.get("work_type"), row.get("year") or 0
|
||
target = next((p for p in persons if kind not in p["kinds"] and year - p["last"] <= 8), None)
|
||
if target is None:
|
||
target = {"kinds": set(), "last": year, "rows": []}
|
||
persons.append(target)
|
||
target["kinds"].add(kind)
|
||
target["last"] = max(target["last"], year)
|
||
target["rows"].append(row)
|
||
return [{**group, "rows": p["rows"], "count": len(p["rows"]), "years": {r.get("year") for r in p["rows"]}}
|
||
for p in persons]
|
||
|
||
|
||
def identities(rows: list[dict], person) -> list[dict]:
|
||
"""The asked name split into probable persons (CRZP has no person identifiers):
|
||
- the same written name (with diacritics) at the same university is one person,
|
||
another spelling or university another one;
|
||
- a name that is only an author there is split when it has two works of one kind
|
||
(two bachelor theses are two students);
|
||
- an author at another university whose earlier work leads to this one
|
||
(bachelor -> diploma within 4 years) is marked as possibly the same person."""
|
||
groups: dict = {}
|
||
seen = set()
|
||
for row in rows:
|
||
if work_key(row) in seen:
|
||
continue # the same work held twice in CRZP
|
||
seen.add(work_key(row))
|
||
for name in people_in_role(row, person.name, person.role):
|
||
key = (display_name(name), university(row))
|
||
group = groups.setdefault(key, {"name": key[0], "school": key[1], "count": 0, "years": set(),
|
||
"roles": Counter(), "rows": []})
|
||
group["count"] += 1
|
||
group["years"].add(row.get("year"))
|
||
group["rows"].append(row)
|
||
group["roles"].update(role for role, field in ROLE_FIELD.items() if name in row.get(field, []))
|
||
result = []
|
||
for group in groups.values():
|
||
result += _split_students(group) if set(group["roles"]) == {"author"} else [group]
|
||
for group in result:
|
||
group["hint"] = None
|
||
if set(group["roles"]) != {"author"}:
|
||
continue
|
||
first = min(group["rows"], key=lambda r: r.get("year") or 0)
|
||
for other in result:
|
||
if other is group or other["name"] != group["name"] or set(other["roles"]) != {"author"}:
|
||
continue
|
||
last = max(other["rows"], key=lambda r: r.get("year") or 0)
|
||
if (other["school"] != group["school"]
|
||
and LEVEL.get(last.get("work_type"), 0) < LEVEL.get(first.get("work_type"), 0)
|
||
and 0 < (first.get("year") or 0) - (last.get("year") or 0) <= 4):
|
||
group["hint"] = f"možno tá istá osoba ako {other['school']} ({last.get('year')})"
|
||
return sorted(result, key=lambda g: (-g["count"], -max((y or 0) for y in g["years"]), g["name"], g["school"]))
|
||
|
||
|
||
def _identity_line(group: dict) -> str:
|
||
years = sorted(y for y in group["years"] if y)
|
||
span = (f"{years[0]}–{years[-1]}" if len(years) > 1 else str(years[0])) if years else "rok neuvedený"
|
||
roles = ", ".join(ROLE_LABEL[r] for r, _ in group["roles"].most_common())
|
||
works = f"{group['count']} {'práca' if group['count'] == 1 else 'práce' if group['count'] < 5 else 'prác'}"
|
||
hint = f"; {group['hint']}" if group.get("hint") else ""
|
||
return f"{group['name']} — {group['school']} ({roles}; {span}; {works}{hint})"
|
||
|
||
|
||
def namesakes(rows: list[dict], query: CrzpQuery) -> str:
|
||
"""Notice when the asked name belongs to several probable CRZP persons."""
|
||
for person in query.people:
|
||
groups = identities(rows, person)
|
||
if len(groups) > 1:
|
||
return ("\n\nUpozornenie: menu zodpovedá v CRZP viac pravdepodobných osôb (CRZP ich nerozlišuje "
|
||
"identifikátorom):\n" + "\n".join("- " + _identity_line(g) for g in groups[:6]) +
|
||
"\nSpresni školu alebo rok.")
|
||
return ""
|
||
|
||
|
||
def clarification(rows: list[dict], query: CrzpQuery) -> tuple[str, list[dict]] | None:
|
||
"""Count, summary, keywords, relations and similar works of one person must not mix
|
||
namesakes: ask which person is meant (a list shows all with a notice instead)."""
|
||
if not query.people or query.intent in ("list", "schools") or query.school_name or query.sids or query.works:
|
||
return None
|
||
groups = identities(rows, query.people[0])
|
||
if len(groups) < 2:
|
||
return None
|
||
groups = groups[:8]
|
||
text = ("Menu „" + query.people[0].name + "“ zodpovedá v CRZP viac pravdepodobných osôb (CRZP ich nerozlišuje "
|
||
"identifikátorom). Ktorú myslíš?\n\n" + "\n".join(f"{i}. {_identity_line(g)}" for i, g in
|
||
enumerate(groups, 1)) +
|
||
"\n\nOdpovedz číslom, školou alebo celým menom.")
|
||
return text, [{"name": g["name"], "school": g["school"], "works": [work_key(r) for r in g["rows"]][:30]}
|
||
for g in groups]
|
||
|
||
|
||
def _merge(results: list[dict]) -> dict:
|
||
rows, seen = [], {}
|
||
for result in results:
|
||
for row in result["rows"]:
|
||
key = (fold(row["title"]).rstrip(" ."), row.get("year"), tuple(row.get("authors", [])))
|
||
if key not in seen:
|
||
seen[key] = row
|
||
rows.append(row)
|
||
elif row.get("sid") and not seen[key].get("sid"): # the same work read with its permalink
|
||
seen[key].update(sid=row["sid"], url=row.get("url"))
|
||
return {"texts": [r["query"] for r in results], "totals": [r["total"] for r in results],
|
||
"total": results[0]["total"] if len(results) == 1 else sum(r["total"] for r in results),
|
||
"facets": results[0]["facets"], "rows": rows, "complete": all(r["complete"] for r in results),
|
||
"incomplete": [(r["query"], r["total"], len(r["rows"])) for r in results if not r["complete"]],
|
||
"parts": results}
|
||
|
||
|
||
def _unverified(query: CrzpQuery, text: str) -> str | None:
|
||
"""The model-proposed alternative term the search text stands for (not in the reviewed table)."""
|
||
term = strip_suffix(query, text)
|
||
for alternative in query.alternatives:
|
||
if fold(alternative) == fold(term) and query.topics and not is_reviewed(alternative, query.topic_key()):
|
||
return alternative
|
||
return None
|
||
|
||
|
||
def _used_with_topic(term: str, rows: list[dict]) -> bool:
|
||
"""CRZP itself writes the term in a work found for the topic (title or keywords)."""
|
||
return any(in_question(term, r["title"] + " " + " ".join(r.get("keywords", []))) for r in rows)
|
||
|
||
|
||
AGGREGATES = ("count", "keywords", "schools", "relation")
|
||
|
||
|
||
def by_facets(query: CrzpQuery) -> bool:
|
||
"""A count, or the supervisors of works on a topic, that CRZP computes itself: every
|
||
condition is in the search (text, years, work type) and one text per meaning, so the
|
||
CRZP total and the full year/type/supervisor facets are exact without reading rows."""
|
||
counted = query.intent == "count" or query.intent == "relation" and query.target_role == "supervisor"
|
||
return (counted and not query.people and not query.title and not query.sids and not query.school_name
|
||
and not query.alternatives and len(query.topics) <= 1)
|
||
|
||
|
||
def reader(query: CrzpQuery):
|
||
"""How one search text is read for this question:
|
||
- counted by CRZP (by_facets): the first page with the full facets only;
|
||
- summaries (tags, schools, opponents, a person's works): every row up to
|
||
DEEP_ROWS, in year ranges at once, permalinks only where a work is shown;
|
||
- a list of a school or workplace: the newest LINKED_ROWS works, all with permalinks;
|
||
- a topic list, a summary of a work, similar works: the LINKED_ROWS most relevant rows."""
|
||
window = filters(query)
|
||
if by_facets(query):
|
||
return lambda text: fetch.search(text, links=False, depth=20, **window)
|
||
if query.intent in ("keywords", "schools", "count"):
|
||
return lambda text: fetch.search_deep(text, links=False, **window)
|
||
if query.intent == "relation" or query.people:
|
||
return lambda text: fetch.search_deep(text, links=True, **window)
|
||
if query.intent == "list" and (query.school or query.unit) and not query.topics and not query.title:
|
||
return lambda text: fetch.search_deep(text, links=True, limit=fetch.LINKED_ROWS, **window)
|
||
return lambda text: fetch.search(text, links=query.intent != "count", **window)
|
||
|
||
|
||
def find(query: CrzpQuery) -> tuple[dict | None, list[dict]]:
|
||
"""First search group whose rows satisfy all conditions; (merged result, rows).
|
||
A model-proposed alternative term is searched only if CRZP uses it in a work
|
||
already found for the topic, or if nothing was found yet."""
|
||
last, seen = None, []
|
||
query.searched = []
|
||
read = reader(query)
|
||
for group in search_groups(query):
|
||
results, verified_rows, unverified = [], 0, []
|
||
for text in group:
|
||
term = _unverified(query, text)
|
||
if term and seen and not _used_with_topic(term, seen):
|
||
continue # "rozložená kniha" for blockchain: CRZP does not use it, no search
|
||
results.append(read(text))
|
||
query.searched.append(text)
|
||
seen += results[-1]["rows"]
|
||
if term:
|
||
unverified.append(term)
|
||
else:
|
||
verified_rows += len(results[-1]["rows"])
|
||
if not results:
|
||
continue
|
||
found = _merge(results)
|
||
rows = prefer_exact([r for r in found["rows"] if keep(r, query)], query)
|
||
if by_facets(query) and not found["complete"]:
|
||
if len(rows) == len(found["rows"]):
|
||
found["facet_only"] = True # CRZP itself counted it; every read row meets the conditions
|
||
else: # some found works are elsewhere (e.g. a school name in a title): count the rows themselves
|
||
found = _merge([fetch.search_deep(r["query"], links=False, **filters(query)) for r in found["parts"]])
|
||
rows = prefer_exact([r for r in found["rows"] if keep(r, query)], query)
|
||
last = (found, rows)
|
||
if rows and unverified and not verified_rows:
|
||
query.unverified_only = unverified # the topic itself found nothing: say so in the answer
|
||
if rows:
|
||
if query.people: # show the name as CRZP writes it ("hladek" -> "Daniel Hládek") when it is one person
|
||
names = Counter(n for r in rows for n in people_in_role(r, query.people[0].name, query.people[0].role))
|
||
if len(names) == 1 or not query.from_model:
|
||
query.people[0].name = display_name(names.most_common(1)[0][0])
|
||
return last
|
||
if last and query.people and query.people[0].role == "author":
|
||
# "práce X" may mean the works X supervised or reviewed: X is no author in the read rows.
|
||
relaxed = query.model_copy(deep=True)
|
||
relaxed.people[0].role = "any"
|
||
rows = prefer_exact([r for r in last[0]["rows"] if keep(r, relaxed)], relaxed)
|
||
if rows:
|
||
query.people[0].role = "any"
|
||
query.relaxed_author = True
|
||
return last[0], rows
|
||
return last or (None, [])
|
||
|
||
|
||
def ranked(question: str, query: CrzpQuery, rows: list[dict]) -> list[dict]:
|
||
"""Topic questions: cross-encoder relevance over title + CRZP keywords; else newest first."""
|
||
rows = sorted(rows, key=lambda r: (-(r.get("year") or 0), fold(r["title"])))
|
||
if query.topics and len(rows) > 1:
|
||
from tools.retrieval import rerank
|
||
# The searched terms tell the cross-encoder what an abbreviation means ("LLM jazykový model GPT").
|
||
terms = [t for t in [*query.topics, *query.alternatives] if fold(t) not in fold(question)]
|
||
rows = rerank.order(" ".join([question, *terms]), rows, key=lambda r: r["title"] + ". " + ", ".join(r.get("keywords", [])))
|
||
return rows
|
||
|
||
|
||
# ------------------------------------------------------------------ rendering
|
||
|
||
def _names(values: list[str]) -> str:
|
||
return ", ".join(display_name(v) for v in values) or "neuvedené"
|
||
|
||
|
||
def _kind(row: dict) -> str:
|
||
return WORK_TYPE_LABELS.get(row.get("work_type"), "práca")
|
||
|
||
|
||
def _link(row: dict) -> str:
|
||
return f"[CRZP]({row['url']})" if row.get("url") else "záznam bez trvalého odkazu"
|
||
|
||
|
||
def school(row: dict) -> str:
|
||
unit = unit_label(row)
|
||
return (row.get("school") or "škola neuvedená") + (f", {unit}" if unit else "")
|
||
|
||
|
||
def work_block(row: dict, detail: dict | None = None) -> str:
|
||
record = detail or row
|
||
head = f"**{record['title']}** — {_kind(record)}, {record.get('year') or 'rok neuvedený'}, {school(record)}"
|
||
lines = [head, f"- Autor: {_names(record.get('authors', []))}; školiteľ: {_names(record.get('supervisors', []))}; "
|
||
f"oponent: {_names(record.get('opponents', []))}"]
|
||
if detail and detail.get("consultants"):
|
||
lines.append(f"- Konzultant: {_names(detail['consultants'])}")
|
||
if record.get("keywords"):
|
||
lines.append("- Kľúčové slová: " + ", ".join(record["keywords"]))
|
||
if detail:
|
||
extra = [f"{detail['pages']} strán" if detail.get("pages") else "",
|
||
f"jazyk: {detail['language']}" if detail.get("language") else "",
|
||
f"odbor: {detail['field_of_study']}" if detail.get("field_of_study") else ""]
|
||
if any(extra):
|
||
lines.append("- " + "; ".join(e for e in extra if e))
|
||
lines.append("- Zdroj: " + _link(record))
|
||
return "\n".join(lines)
|
||
|
||
|
||
def work_line(row: dict) -> str:
|
||
return (f"- {_names(row.get('authors', []))} — „{row['title']}“ ({_kind(row)}, {row.get('year') or '?'}, "
|
||
f"{school(row)}) · {_link(row)}")
|
||
|
||
|
||
def describe(query: CrzpQuery) -> str:
|
||
parts = []
|
||
for person in query.people:
|
||
parts.append(f"{ROLE_LABEL.get(person.role, 'osoba')} {person.name}" if person.role != "any" else person.name)
|
||
if query.title:
|
||
parts.append(f"názov „{query.title}“")
|
||
if query.topics and not query.people:
|
||
parts.append("téma „" + " ".join(query.topics[:2]) + "“")
|
||
if query.work_types:
|
||
parts.append(", ".join(WORK_TYPE_LABELS[t] for t in query.work_types))
|
||
if query.years:
|
||
parts.append("rok " + ", ".join(map(str, query.years)))
|
||
if query.sids:
|
||
parts.append("vybraná práca z predchádzajúcej odpovede" if len(query.sids) == 1
|
||
else "práce z predchádzajúcej odpovede")
|
||
if query.unit:
|
||
parts.append(UNITS[query.unit]["label"] + " " + SCHOOLS[query.school][0])
|
||
elif query.school:
|
||
parts.append(SCHOOLS[query.school][0])
|
||
return "; ".join(parts)
|
||
|
||
|
||
# ------------------------------------------------------------------ model steps
|
||
|
||
SUMMARY_PROMPT = ("Si odborník, ktorý stručne vysvetľuje záverečné práce. Z abstraktu a kľúčových slov napíš po "
|
||
"slovensky 2 až 3 vlastné vety: o čom práca je, čo v nej autor urobil a s akými nástrojmi alebo "
|
||
"metódami. Píš vlastnými slovami a súvislo, neopisuj vety abstraktu doslova a nezačínaj slovami "
|
||
"„Táto práca“ ani „Abstrakt“. Použi iba informácie z abstraktu a kľúčových slov; nepridávaj "
|
||
"výsledky, čísla, nástroje ani hodnotenia, ktoré v nich nie sú. Bez nadpisu a bez odrážok.")
|
||
CHECK_PROMPT = ("Over, či je každé tvrdenie zhrnutia podložené abstraktom alebo kľúčovými slovami (preformulovanie "
|
||
"a zovšeobecnenie je v poriadku, nový fakt, číslo alebo nástroj nie). "
|
||
"Odpovedz iba JSON {\"supported\": true} alebo {\"supported\": false}.")
|
||
|
||
|
||
def _ask(system: str, payload: dict, complete=None, max_tokens: int = 400) -> str | None:
|
||
from tools.answers.model_client import complete_with_configured_model
|
||
try:
|
||
response = (complete or complete_with_configured_model)(
|
||
[{"role": "system", "content": system}, {"role": "user", "content": json.dumps(payload, ensure_ascii=False)}],
|
||
max_tokens=max_tokens)
|
||
return response["choices"][0]["message"]["content"].strip()
|
||
except Exception:
|
||
return None
|
||
|
||
|
||
def summary(detail: dict, complete=None) -> tuple[str, str]:
|
||
"""("Zhrnutie", text): the model's own 2-3 sentences after a passed check (second try with
|
||
the check's verdict), else a sentence built from the CRZP keywords — never the copied abstract."""
|
||
abstract = detail.get("abstract") or detail.get("abstract_secondary") or ""
|
||
keywords = detail.get("keywords", [])
|
||
if not abstract:
|
||
return "Zhrnutie", _keyword_sentence(detail) if keywords else "CRZP pri tejto práci abstrakt neuvádza."
|
||
payload = {"title": detail["title"], "abstract": abstract, "keywords": keywords}
|
||
for attempt in range(2):
|
||
text = _ask(SUMMARY_PROMPT if attempt == 0 else SUMMARY_PROMPT + " Drž sa výlučne faktov z abstraktu.",
|
||
payload, complete)
|
||
if not text or len(text) > 1200:
|
||
continue
|
||
verdict = _ask(CHECK_PROMPT, {"abstract": abstract, "keywords": keywords, "summary": text}, complete, max_tokens=50)
|
||
if verdict and re.search(r'"supported"\s*:\s*true', verdict):
|
||
return "Zhrnutie", text
|
||
return "Zhrnutie", _keyword_sentence(detail)
|
||
|
||
|
||
def _keyword_sentence(detail: dict) -> str:
|
||
keywords = [k for k in detail.get("keywords", []) if len(k) < 40][:6]
|
||
kind = WORK_TYPE_LABELS.get(detail.get("work_type"), "práca").capitalize()
|
||
if not keywords:
|
||
return f"{kind} sa podľa CRZP zaoberá témou „{detail['title']}“."
|
||
return f"{kind} „{detail['title']}“ sa zaoberá témami: {', '.join(keywords)}."
|
||
|
||
|
||
# ------------------------------------------------------------------ answers by intent
|
||
|
||
def _list(question, query, found, rows) -> tuple[str, list[dict]]:
|
||
rows = ranked(question, query, rows)
|
||
header = f"V CRZP som našiel {len(rows)} {'prácu' if len(rows) == 1 else 'práce' if len(rows) < 5 else 'prác'} ({describe(query)}):"
|
||
if not found["complete"] and found["total"] > len(found["rows"]):
|
||
newest = not query.topics and not query.title and not query.people
|
||
header = (f"V CRZP je {found['total']} výsledkov hľadania ({describe(query)}); prehľadaných bolo "
|
||
f"{len(found['rows'])} {'najnovších' if newest else 'najrelevantnejších'}, z nich zodpovedá {len(rows)}:")
|
||
if len(rows) <= 5:
|
||
return header + "\n\n" + "\n\n".join(work_block(r) for r in rows), rows
|
||
shown = rows[:LIST_LIMIT]
|
||
text = header + "\n\n" + "\n".join(work_line(r) for r in shown)
|
||
if len(rows) > LIST_LIMIT:
|
||
text += f"\n\nZobrazených je {LIST_LIMIT} z {len(rows)}; spresni rok, typ práce alebo tému."
|
||
return text, shown
|
||
|
||
|
||
TYPE_FACET = {"bakalárska": "bakalárska práca", "diplomová": "diplomová práca", "dizertačná": "dizertačná práca",
|
||
"habilitačná": "habilitačná práca", "rigorózna": "rigorózna práca"}
|
||
|
||
|
||
def _facet_type(label: str) -> str:
|
||
return next((v for k, v in TYPE_FACET.items() if k in label.lower()), label)
|
||
|
||
|
||
def _count(question, query, found, rows) -> tuple[str, list[dict]]:
|
||
count, note = len(rows), ""
|
||
person = query.people[0] if query.people else None
|
||
types = Counter(_kind(r) for r in rows)
|
||
years = Counter(r.get("year") for r in rows if r.get("year"))
|
||
if found.get("facet_only"):
|
||
# CRZP's own total and full facets (texts of one workplace are disjoint: code vs. old written name)
|
||
count, types, years = found["total"], Counter(), Counter()
|
||
for part in found["parts"]:
|
||
years.update(fetch.year_counts(part))
|
||
types.update({_facet_type(f["label"]): f["count"] for f in part["facets"].get("Typ práce", [])})
|
||
note = " (súhrnný počet z CRZP)"
|
||
elif not found["complete"]:
|
||
facet = FACET.get(person.role) if person else None
|
||
values = [f["count"] for f in found["facets"].get(facet, []) if name_matches(person.name, f["label"])] if facet else []
|
||
if values and len(found["texts"]) == 1 and not (query.school or query.unit or len(query.people) > 1):
|
||
count, note = sum(values), " (súhrnný počet z CRZP)"
|
||
else:
|
||
note = f" (aspoň; prehľadaných {len(found['rows'])} z {found['total']} výsledkov hľadania)"
|
||
text = f"Počet prác v CRZP ({describe(query)}): **{count}**{note}."
|
||
if count:
|
||
text += "\n\n- Podľa typu: " + ", ".join(f"{k} {v}" for k, v in types.most_common())
|
||
text += "\n- Podľa roku: " + ", ".join(f"{y}: {n}" for y, n in sorted(years.items(), reverse=True))
|
||
return text, rows
|
||
|
||
|
||
def _about(question, query, found, rows, complete=None) -> tuple[str, list[dict]]:
|
||
candidates = rows
|
||
unique, seen = [], set()
|
||
for row in ranked(question, query, rows): # CRZP sometimes holds one work twice (two records)
|
||
key = (fold(row["title"]).rstrip(" ."), row.get("year"), tuple(row.get("authors", [])))
|
||
if key not in seen:
|
||
seen.add(key)
|
||
unique.append(row)
|
||
rows = unique[:2]
|
||
details = [fetch.detail(row["sid"]) if row.get("sid") else None for row in rows]
|
||
# The model summaries (and their checks) of the two works run at the same time.
|
||
from concurrent.futures import ThreadPoolExecutor
|
||
with ThreadPoolExecutor(max_workers=2) as pool:
|
||
summaries = list(pool.map(lambda d: summary(d, complete) if d else None, details))
|
||
blocks = []
|
||
for row, detail, done in zip(rows, details, summaries):
|
||
head = (f"**{row['title']}** — {_kind(row)}, {row.get('year') or 'rok neuvedený'}, {university(row)}; "
|
||
f"autor {_names(row.get('authors', []))}")
|
||
if re.search(r"oponent|ved[uú]c|[sš]kolite", question.lower()): # "Kto bol oponentom … a o čom je?"
|
||
head += f"; vedúci {_names(row.get('supervisors', []))}; oponent {_names(row.get('opponents', []))}"
|
||
if detail is None:
|
||
blocks.append(head + "\n\nCRZP k tejto práci neposkytuje trvalý odkaz, preto abstrakt nemám.")
|
||
continue
|
||
_, text = done
|
||
blocks.append(head + f"\n\n{text}\n\nZdroj: {_link(row)}")
|
||
more = len(candidates) > len(rows)
|
||
tail = "\n\nZodpovedá viac prác; zobrazené sú najrelevantnejšie. Spresni typ práce alebo rok." if more else ""
|
||
return "\n\n".join(blocks) + tail, rows
|
||
|
||
|
||
def _keywords(question, query, found, rows) -> tuple[str, list[dict]]:
|
||
rows = ranked(question, query, rows)
|
||
if len(rows) <= 3:
|
||
lines = [f"**{r['title']}** ({_kind(r)}, {r.get('year') or '?'}): " +
|
||
(", ".join(r["keywords"]) if r.get("keywords") else "CRZP kľúčové slová neuvádza") + f" · {_link(r)}"
|
||
for r in rows]
|
||
return "Kľúčové slová (tagy) v CRZP:\n\n" + "\n\n".join(lines), rows
|
||
counts = Counter(k.lower() for r in rows for k in r.get("keywords", []))
|
||
lines = [f"- {k} ({n})" for k, n in counts.most_common(20)]
|
||
return (f"Najčastejšie kľúčové slová (tagy) v {len(rows)} prácach ({describe(query)}):\n\n" + "\n".join(lines),
|
||
rows)
|
||
|
||
|
||
def _relation(question, query, found, rows) -> tuple[str, list[dict]]:
|
||
target = query.target_role
|
||
if query.title: # the asked work itself, if CRZP has that exact title
|
||
exact = [r for r in rows if fold(r["title"]).rstrip(" .") == fold(query.title).rstrip(" .")]
|
||
rows = exact or rows
|
||
if len(rows) <= 3:
|
||
lines = [f"- **{r['title']}** ({_kind(r)}, {r.get('year') or '?'}): {ROLE_LABEL[target]} "
|
||
f"{_names(r.get(ROLE_FIELD[target], []))} · {_link(r)}" for r in rows]
|
||
return f"{ROLE_PLURAL[target]} prác ({describe(query)}):\n\n" + "\n".join(lines), rows
|
||
plural = lambda c: f"{c} {'práca' if c == 1 else 'práce' if c < 5 else 'prác'}"
|
||
if not query.people: # "Kto viedol práce o X?": the target role over the found works
|
||
counts = Counter(n for r in rows for n in r.get(ROLE_FIELD[target], []))
|
||
if (found.get("facet_only") and target == "supervisor" and not found["complete"]
|
||
and all(part["facets"].get("Školiteľ") for part in found["parts"])): # CRZP's full supervisor facet
|
||
counts = Counter()
|
||
for part in found["parts"]:
|
||
counts.update({f["label"]: f["count"] for f in part["facets"].get("Školiteľ", [])})
|
||
total = found["total"]
|
||
lines = [f"- {display_name(n)} ({plural(c)})" for n, c in sorted(counts.items(), key=lambda i: (-i[1], i[0]))[:15]]
|
||
return (f"{ROLE_PLURAL[target]} prác ({describe(query)}; {total} prác v CRZP, súhrn CRZP):\n\n" +
|
||
("\n".join(lines) or "- CRZP ich neuvádza")), ranked(question, query, rows)[:10]
|
||
lines = [f"- {display_name(n)} ({plural(c)})" for n, c in sorted(counts.items(), key=lambda i: (-i[1], i[0]))[:15]]
|
||
return (f"{ROLE_PLURAL[target]} prác ({describe(query)}; {len(rows)} prác v CRZP):\n\n" +
|
||
("\n".join(lines) or "- CRZP ich neuvádza")), ranked(question, query, rows)[:10]
|
||
person = query.people[0]
|
||
source = person.role if person.role in ROLE_FIELD else max(
|
||
ROLE_FIELD, key=lambda role: sum(bool(people_in_role(r, person.name, role)) for r in rows))
|
||
names = sorted({n for r in rows for n in people_in_role(r, person.name, source)})
|
||
pairs = graph.relation(rows, names, source, target)
|
||
lines = [f"- {display_name(n)} ({plural(c)})" for n, c in pairs[:15]]
|
||
conditions = describe(query.model_copy(update={"people": []}))
|
||
text = (f"{ROLE_PLURAL[target]} prác, pri ktorých je {display_name(names[0]) if names else person.name} "
|
||
f"{ROLE_LABEL[source]} ({conditions + '; ' if conditions else ''}{len(rows)} prác v CRZP):\n\n" + ("\n".join(lines) or "- CRZP ich neuvádza"))
|
||
return text, rows
|
||
|
||
|
||
def university(row: dict) -> str:
|
||
"""The school without its faculty/department ("Slovenská technická univ. v Bratislave FEI ÚMIKT")."""
|
||
name = row.get("school") or "škola neuvedená"
|
||
folded = fold(name).replace("univ.", "univerzita").replace("p.j. safarika", "pavla jozefa safarika")
|
||
folded = folded.replace("a.dubceka", "alexandra dubceka")
|
||
known = next((full for full, part in SCHOOLS.values() if part in folded), None)
|
||
if known:
|
||
return known
|
||
town = re.match(r"^(.*?\bv[eo]?\s+[^\W\d_]+)", name) # "… univerzita v Trnave FF KSJ" -> up to the town
|
||
return town.group(1) if town else name
|
||
|
||
|
||
def _faculty(raw: str, name: str) -> str:
|
||
"""The part of a raw CRZP school after the university's town ("… v Bratislave FEI ÚMIKT" -> "FEI ÚMIKT")."""
|
||
town = fold(name.split()[-1])
|
||
at = fold(raw).find(town)
|
||
return raw[at + len(town):] if at >= 0 else ""
|
||
|
||
|
||
def _schools(question, query, found, rows) -> tuple[str, list[dict]]:
|
||
""""Z akých škôl boli tí študenti?": schools of the found works, with their workplaces."""
|
||
plural = lambda c: f"{c} {'práca' if c == 1 else 'práce' if c < 5 else 'prác'}"
|
||
by_school: dict[str, Counter] = {}
|
||
for r in rows:
|
||
unit = unit_label(r) if university(r) == r.get("school") else _faculty(r.get("school") or "", university(r))
|
||
by_school.setdefault(university(r), Counter())[unit.strip(" ,") or ""] += 1
|
||
order = sorted(by_school.items(), key=lambda i: (-sum(i[1].values()), i[0]))
|
||
lines = []
|
||
for name, units in order[:20]:
|
||
parts = [f"{u} {c}" for u, c in units.most_common() if u][:4]
|
||
lines.append(f"- {name} ({plural(sum(units.values()))}" + (": " + "; ".join(parts) if parts else "") + ")")
|
||
more = f"\n- … a ďalšie ({len(order) - 20})" if len(order) > 20 else ""
|
||
return (f"Školy prác v CRZP ({describe(query)}; {plural(len(rows))}):\n\n"
|
||
+ "\n".join(lines) + more), ranked(question, query, rows)[:10]
|
||
|
||
|
||
GENERIC_TAGS = {fold(w.strip()) for w in """detekcia, analýza, systém, aplikácia, návrh, implementácia, model, modelovanie,
|
||
metóda, metódy, dáta, údaje, databáza, simulácia, optimalizácia, riadenie, meranie, testovanie, vývoj, softvér, hardvér,
|
||
internet, web, python, java, c#, matlab, korpus, korpus dát, dataset, klasifikácia, algoritmus, algoritmy""".split(",")}
|
||
|
||
|
||
def specific_tags(work: dict) -> list[str]:
|
||
"""The work's keywords most likely to find related works: multi-word and non-generic first."""
|
||
tags = [k for k in work.get("keywords", []) if fold(k) not in GENERIC_TAGS]
|
||
return sorted(tags, key=lambda k: (-min(len(k.split()), 3), -len(k)))
|
||
|
||
|
||
def _similar(question, query, found, rows) -> tuple[str, list[dict]]:
|
||
work = ranked(question, query, rows)[0]
|
||
if query.title:
|
||
work = next((r for r in rows if fold(r["title"]).rstrip(" .") == fold(query.title).rstrip(" .")), work)
|
||
searched, candidates = [], []
|
||
# Most specific tags first; a tag only this work has ("rasa forms") gives nothing,
|
||
# so the next one is searched until five related works are found (at most four searches).
|
||
for term in specific_tags(work)[:4]:
|
||
try:
|
||
candidates += fetch.search(term)["rows"]
|
||
except CrzpUnavailable:
|
||
break
|
||
searched.append(term)
|
||
if len(graph.shared_keywords(work, candidates)) >= 5:
|
||
break
|
||
pairs = graph.shared_keywords(work, candidates)
|
||
if len(pairs) > 1: # equal numbers of shared tags: the closer title and tags first (cross-encoder)
|
||
from tools.retrieval import rerank
|
||
profile = work["title"] + ". " + ", ".join(work.get("keywords", []))
|
||
scores = rerank.scores(profile, [c["title"] + ". " + ", ".join(c.get("keywords", [])) for c, _ in pairs]) or [0] * len(pairs)
|
||
pairs = [p for _, p in sorted(zip(scores, pairs), key=lambda item: (-len(item[1][1]), -item[0]))]
|
||
pairs = pairs[:5]
|
||
if not pairs:
|
||
return (f"K práci **{work['title']}** som medzi prácami s jej kľúčovými slovami nenašiel inú so spoločnými "
|
||
f"kľúčovými slovami. · {_link(work)}"), [work]
|
||
lines = [f"- {_names(c.get('authors', []))} — {university(c)}: „{c['title']}“ ({_kind(c)}, {c.get('year') or '?'}); "
|
||
f"spoločné: {', '.join(shared)} · {_link(c)}" for c, shared in pairs]
|
||
text = (f"Práce so spoločnými kľúčovými slovami CRZP ako **{work['title']}** ({_link(work)}); porovnané s prácami "
|
||
f"nájdenými podľa kľúčových slov „{'“, „'.join(searched)}“:\n\n" + "\n".join(lines))
|
||
return text, [work] + [c for c, _ in pairs]
|
||
|
||
|
||
# ------------------------------------------------------------------ entry point
|
||
|
||
def _result(question: str, status: str, text: str, *, reason=None, rows=(), complete=None, truncated=False,
|
||
reasons=(), count=None) -> dict:
|
||
urls = list(dict.fromkeys(r["url"] for r in rows if r.get("url")))
|
||
result = {"status": status, "reason": reason, "accepted_for_delivery": status in ("ok", "no_answer", "ambiguous"),
|
||
"answer": text, "source_urls": urls, "claims": [], "index_version": None,
|
||
"complete": complete, "truncated": truncated, "truncation_reasons": list(reasons),
|
||
"delivery_check": {"layer": {"ok": "delivery", "ambiguous": "planning"}.get(status, "retrieval")
|
||
if reason not in ("invalid_context", "expired_context") else "context",
|
||
"code": ("partial" if truncated else "complete") if status == "ok"
|
||
else "evidence_not_found" if status == "no_answer"
|
||
else "clarification_required" if status == "ambiguous" else "unavailable",
|
||
"request_fingerprint": fingerprint(question, ENGINE)}}
|
||
if count is not None:
|
||
result["count"] = count
|
||
return result
|
||
|
||
|
||
def answer(question: str, *, limit: int = 5, complete=None, context_token: str | None = None) -> dict:
|
||
key = (question.strip(), limit, context_token or "")
|
||
with _cache_lock:
|
||
hit = _cache.get(key)
|
||
if hit and time.monotonic() - hit[0] < CACHE_SECONDS and complete is None:
|
||
return hit[1]
|
||
result = _answer(question.strip(), complete, context_token)
|
||
if result["status"] == "ok" and complete is None:
|
||
with _cache_lock:
|
||
_cache[key] = (time.monotonic(), result)
|
||
while len(_cache) > 256:
|
||
_cache.popitem(last=False)
|
||
return result
|
||
|
||
|
||
def other_roles(rows: list[dict], query: CrzpQuery) -> str:
|
||
""""Hládek viedol 0 bakalárok v 2023" may still be his opponent role in such works."""
|
||
if not query.people or query.people[0].role not in ROLE_FIELD:
|
||
return ""
|
||
person = query.people[0]
|
||
relaxed = query.model_copy(deep=True)
|
||
relaxed.people[0].role = "any"
|
||
roles = Counter(role for r in rows if keep(r, relaxed) for role, field in ROLE_FIELD.items()
|
||
if role != person.role and any(name_matches(person.name, n) for n in r.get(field, [])))
|
||
if not roles:
|
||
return ""
|
||
parts = ", ".join(f"{ROLE_LABEL[role]} pri {c} {'práci' if c == 1 else 'prácach'}" for role, c in roles.most_common())
|
||
return f"\n\nS rovnakými ostatnými podmienkami je {person.name} v CRZP v inej role: {parts}."
|
||
|
||
|
||
def _without_suffix(query: CrzpQuery, texts: list[str]) -> list[str]:
|
||
"""Searched texts without the school or workplace appended to each of them."""
|
||
return list(dict.fromkeys(strip_suffix(query, t) for t in texts))
|
||
|
||
|
||
def _answer(question: str, complete=None, context_token: str | None = None) -> dict:
|
||
previous = None
|
||
if context_token:
|
||
try:
|
||
previous = context.restore(context_token)
|
||
except context.ContextError as error:
|
||
return _result(question, "error", "Kontext predchádzajúcej otázky už neplatí; polož otázku celú "
|
||
"(meno, téma, rok).", reason=str(error))
|
||
if complete is None:
|
||
fetch.prepare() # CRZP home page loads while the model reads the question
|
||
query = understand(question, complete=complete)
|
||
if query.intent == "refused":
|
||
return _result(question, "no_answer", "Pokyny na zmenu pravidiel nevykonávam a celý obsah CRZP, prihlasovacie "
|
||
"údaje ani systémové nastavenia nevypisujem. Opýtaj sa na konkrétnu prácu, osobu, školu, tému "
|
||
"alebo rok.", reason="request_out_of_scope")
|
||
if previous:
|
||
query, clarification = context.follow_up(question, query, previous)
|
||
if clarification:
|
||
result = _result(question, "ambiguous", clarification, reason="clarification_required")
|
||
result["conversation_context"] = {"token": context_token, "work_options": [], "meeting_options": []}
|
||
return result
|
||
result = _answer_query(question, query, complete)
|
||
if result["status"] in ("ok", "no_answer", "ambiguous") and result.get("reason") != "request_out_of_scope":
|
||
conversation = context.issue(question, result.pop("_asked", query), result.pop("_shown", []),
|
||
result.pop("_options", None))
|
||
if conversation:
|
||
result["conversation_context"] = conversation
|
||
result.pop("_shown", None), result.pop("_asked", None), result.pop("_options", None)
|
||
return result
|
||
|
||
|
||
def _answer_query(question: str, query: CrzpQuery, complete=None) -> dict:
|
||
asked = query.model_copy(deep=True) # before find() adapts names and roles: the follow-up repeats this
|
||
if not query.search_text():
|
||
return _result(question, "no_answer", "Na vyhľadanie v CRZP uveď meno študenta, vedúceho alebo oponenta, "
|
||
"názov práce alebo tému.", reason="missing_search_terms")
|
||
try:
|
||
found, rows = find(query)
|
||
if found is None:
|
||
return _result(question, "no_answer", "Otázke nezodpovedá žiadne vyhľadávanie v CRZP.",
|
||
reason="missing_search_terms")
|
||
searched = "„" + "“, „".join(found["texts"]) + "“"
|
||
if not rows:
|
||
tried = "„" + "“, „".join(_without_suffix(query, query.searched)) + "“"
|
||
result = _result(question, "no_answer",
|
||
f"V CRZP som nenašiel prácu, ktorá by zodpovedala otázke ({describe(query)}). "
|
||
f"Hľadal som {tried}; žiadny nájdený záznam nespĺňa všetky podmienky. "
|
||
"CRZP hľadá presné tvary slov, preto to nemusí znamenať, že taká práca neexistuje."
|
||
+ other_roles(found["rows"], query),
|
||
reason="no_matching_record", complete=found["complete"])
|
||
result["_shown"], result["_asked"] = [], asked
|
||
return result
|
||
asked_who = clarification(rows, query)
|
||
if asked_who:
|
||
result = _result(question, "ambiguous", asked_who[0], reason="clarification_required")
|
||
result["_shown"], result["_asked"], result["_options"] = [], asked, asked_who[1]
|
||
return result
|
||
handler = {"list": _list, "count": _count, "keywords": _keywords, "relation": _relation,
|
||
"similar": _similar, "schools": _schools}.get(query.intent)
|
||
if query.intent == "about":
|
||
body, used = _about(question, query, found, rows, complete)
|
||
else:
|
||
body, used = handler(question, query, found, rows)
|
||
except CrzpUnavailable:
|
||
return _result(question, "error", "CRZP momentálne neodpovedá alebo obmedzil počet požiadaviek. Skús to o chvíľu.",
|
||
reason="crzp_unavailable")
|
||
if getattr(query, "relaxed_author", False):
|
||
body = (f"{query.people[0].name} nie je v CRZP autorom žiadnej z nájdených prác; uvedené sú práce, pri ktorých "
|
||
"je školiteľom alebo oponentom.\n\n" + body)
|
||
if query.intent in ("about", "keywords", "list"):
|
||
body += namesakes(used, query)
|
||
if query.unverified_only:
|
||
body = (f"Výraz „{query.topic_key()}“ sa v CRZP nenašiel; uvedené práce sú nájdené pod navrhnutým "
|
||
f"príbuzným výrazom „{'“, „'.join(query.unverified_only)}“ (návrh, nie overené synonymum).\n\n" + body)
|
||
terms = _without_suffix(query, found["texts"])
|
||
if len(terms) > 1 and query.topics:
|
||
body += "\n\nHľadané výrazy v CRZP: „" + "“, „".join(terms) + "“ (vrátane príbuzných výrazov témy)."
|
||
truncated = not found["complete"] and not found.get("facet_only") # a CRZP total needs no read rows
|
||
reasons = ["crzp_result_limit"] if truncated else []
|
||
for text, total, read in (found["incomplete"] if truncated else []):
|
||
body += (f"\n\nPoznámka: hľadanie „{text}“ vrátilo v CRZP {total} záznamov; prehľadaných bolo prvých {read}.")
|
||
body += "\n\nZdroj: Centrálny register záverečných prác (CRZP), metadáta záznamov."
|
||
result = _result(question, "ok", body, rows=used, complete=not truncated, truncated=truncated, reasons=reasons,
|
||
count=len(rows) if query.intent == "count" else None)
|
||
result["_shown"], result["_asked"] = used, asked
|
||
return result
|