"""Question -> CrzpQuery: who, in which role, which work, topic, year, type, school. CRZP full-text search joins words with AND and does not inflect, so names and topics must be sent in their base form ("Jána Ptáka" -> "Ján Pták", "chatbotoch" -> "chatbot"). The configured model proposes the base forms; every proposed value must still be present in the question (same word stem), so the model cannot add a person, topic or year. Without the model, rules and the Slovak lemmatizer extract the same fields. """ from __future__ import annotations import json import re from typing import Literal from pydantic import BaseModel, Field from tools.crzp.answering.terms import alternatives from tools.crzp.answering.units import UNITS, unit_in_question from tools.crzp.source.parse import fold Role = Literal["author", "supervisor", "opponent", "any"] Intent = Literal["list", "count", "about", "keywords", "relation", "similar", "schools", "refused"] SCHOOLS = { # abbreviation -> (name sent to search, distinctive part of the CRZP school name) "tuke": ("Technická univerzita v Košiciach", "technicka univerzita v kosiciach"), "stu": ("Slovenská technická univerzita v Bratislave", "slovenska technicka univerzita"), "uniba": ("Univerzita Komenského v Bratislave", "univerzita komenskeho"), "uk": ("Univerzita Komenského v Bratislave", "univerzita komenskeho"), "upjs": ("Univerzita Pavla Jozefa Šafárika v Košiciach", "univerzita pavla jozefa safarika"), "zu": ("Žilinská univerzita v Žiline", "zilinska univerzita"), "uniza": ("Žilinská univerzita v Žiline", "zilinska univerzita"), "umb": ("Univerzita Mateja Bela v Banskej Bystrici", "univerzita mateja bela"), "ukf": ("Univerzita Konštantína Filozofa v Nitre", "univerzita konstantina filozofa"), "spu": ("Slovenská poľnohospodárska univerzita v Nitre", "slovenska polnohospodarska univerzita"), "euba": ("Ekonomická univerzita v Bratislave", "ekonomicka univerzita v bratislave"), "unipo": ("Prešovská univerzita v Prešove", "presovska univerzita"), "tnuad": ("Trenčianska univerzita Alexandra Dubčeka v Trenčíne", "trencianska univerzita"), "tuzvo": ("Technická univerzita vo Zvolene", "technicka univerzita vo zvolene"), "ucm": ("Univerzita sv. Cyrila a Metoda v Trnave", "univerzita sv. cyrila a metoda"), "truni": ("Trnavská univerzita v Trnave", "trnavska univerzita"), "ku": ("Katolícka univerzita v Ružomberku", "katolicka univerzita"), "uvlf": ("Univerzita veterinárneho lekárstva a farmácie v Košiciach", "univerzita veterinarneho lekarstva"), "vsmu": ("Vysoká škola múzických umení v Bratislave", "vysoka skola muzickych umeni"), } WORK_CUES = { "bachelor_thesis": r"bakal[aá]r|\bbachelor", "diploma_thesis": r"diplom|\bmaster", "dissertation": r"dizerta|doktorandsk|\bphd\b|dissertation", "habilitation_thesis": r"habilita", "doctoral_thesis": r"rigor[oó]z", } # Words that name the kind of work, never a topic ("dizertačné práce", "bachelor thesis"). WORK_WORDS = {"praca", "prace", "prac", "bakalarska", "bakalarske", "bakalarsku", "bakalarskej", "bakalarka", "bakalarke", "diplomova", "diplomove", "diplomovu", "diplomovej", "diplomovka", "dizertacna", "dizertacne", "habilitacna", "rigorozna", "zaverecna", "zaverecne", "thesis", "theses", "bachelor", "master", "dissertation", "work", "projekt"} # Rule overrides and bulk dumps are refused, never searched. OUT_OF_SCOPE = re.compile( r"\b(?:ignoruj|zabudni|obid|prepis|zrus)\w*\b[^?.!]*\b(?:pravidl|pokyn|instrukci|obmedzen|prompt)\w*" r"|\b(?:vypis|exportuj|stiahni|ukaz|daj|dump)\w*\b[^?.!]*\b(?:vsetk|cel|kompletn)\w*\s+(?:\w+\s+)?" r"(?:zaznam|databaz|register|crzp|prac)\w*" r"|\bsystem\w* prompt|\bhesl\w*|\bapi kluc|\.env\b") ROLE_CUES = { "supervisor": r"\b(?:ved[uú]c|viedol|viedla|vedie|veden|pod veden|[sš]kolite[lľ])", "opponent": r"\b(?:oponent|oponoval|oponovala|oponuje|oponentúr|posudzoval)", "author": r"\b(?:autor|nap[ií]sal|vypracoval|[sš]tudent|pracoval|robil)", } STOP = set("""a aj ako ale alebo ani bez bol bola boli by co čo čom do ešte ho i ich je jej jeho k kde kedy kto ktorý ktorá ktoré ktorí ktorých ku ma má mal mala mali na nad naj nájdi nie o od on ona po pod podľa pre pri práca práce prácu prácach prác práci prácou s sa si so svoje svojej svojom ta tak tam téma témy temu to tom tu u v vo vy z za zo že aký aká aké akú akých akej akom koľko počet daj ukáž vypíš zoznam všetky všetkých crzp registri register slovensku slovensko rok roku roky rokoch bakalárska bakalárskej bakalársku bakalárske diplomová diplomovej diplomovú diplomové dizertačná dizertačnej habilitačná vedúci vedúceho vedúcim viedol viedla vedie oponent oponenta oponentom oponoval oponovala školiteľ školiteľa študent študenti študentov autor autora autorom práce kľúčové slová tagy tag písal napísal pracoval pracovala robil robila zaoberal zaoberala zaoberá týka týkala týkali súvisí podobné súvisiace najčastejšie často kým kom bolo boli bude budú mali majú mal mala existuje existovali koľkokrát ktorými ktorou ktorej škola školy škôl škole školách univerzita univerzity univerzít fakulta fakulty fakúlt študoval študovala študovali tí tie tých tieto tejto tento nich toho prvá prvej prvú prvý druhá druhej druhú druhý tretia tretej tretiu posledná poslednej poslednú čo tak zaoberali venoval venovala venovali venuje venujú písali robili skúmal skúmala skúmali riešil riešila riešili """.split()) STOP_FOLDED = {fold(w) for w in STOP} class Person(BaseModel): name: str = Field(min_length=2, max_length=80) role: Role = "any" typed: str | None = None # the name as written in the question (decides whether diacritics matter) class CrzpQuery(BaseModel): intent: Intent = "list" people: list[Person] = Field(default_factory=list, max_length=2) title: str | None = Field(default=None, max_length=200) topics: list[str] = Field(default_factory=list, max_length=4) surface: list[str] = Field(default_factory=list, max_length=2) # topic words as written (rules) phrases: list[str] = Field(default_factory=list, max_length=3) # rules: the topic as CRZP likely writes it years: list[int] = Field(default_factory=list, max_length=6) work_types: list[str] = Field(default_factory=list, max_length=5) school: str | None = None # key of SCHOOLS unit: str | None = None # key of units.UNITS (verified workplace codes) alternatives: list[str] = Field(default_factory=list, max_length=2) # other search terms of topics[0] target_role: Role | None = None # relation: which role to aggregate from_model: bool = False # base forms proposed by the model (else rules) relaxed_author: bool = False # asked as author, found only in another role sids: list[str] = Field(default_factory=list, max_length=30) # follow-up: only these CRZP records school_name: str | None = None # follow-up: the chosen namesake's university (answer.university) searched: list[str] = Field(default_factory=list) # texts actually sent to CRZP (set by answer.find) unverified_only: list[str] = Field(default_factory=list) # found only under unverified proposed terms def topic_key(self) -> str: """The topic the alternative terms belong to (the rule phrase without the model).""" return (self.phrases or self.topics or [""])[0] def search_text(self) -> str: if self.people: return " ".join(p.name for p in self.people[:1]) + (" " + self.title if self.title else "") if self.title: return self.title if self.topics: return self.topics[0] if self.school: return SCHOOLS[self.school][0] return "" def stem_match(word: str, other: str, *, diacritics: bool = False) -> bool: """Same Slovak word up to inflection: shared prefix of len-2 (at least 3 letters). With diacritics=True, "Jána" matches "Ján" but not "Jan".""" a, b = (word.lower(), other.lower()) if diacritics else (fold(word), fold(other)) if a == b: return True n = max(3, min(len(a), len(b)) - 2) return len(a) >= 3 and len(b) >= 3 and a[:n] == b[:n] def _words(text: str) -> list[str]: return re.findall(r"[^\W\d_]+", text) def in_question(value: str, question: str) -> bool: words = _words(question) return all(any(stem_match(v, w) for w in words) for v in _words(value)) and bool(_words(value)) YEAR_RANGE = re.compile(r"\b(?:od\s+|medzi\s+)?((?:19|20)\d{2})\s*(?:až|az|-|–|do|a)\s*((?:19|20)\d{2})\b") def years_in(question: str) -> list[int]: """Years; "2022 až 2024", "od 2022 do 2024", "medzi 2022 a 2024" and "2022-2024" are ranges, "2022 a 2024" (without "medzi") are the two years.""" years = {int(y) for y in re.findall(r"\b(?:19|20)\d{2}\b", question)} for match in YEAR_RANGE.finditer(question.lower()): first, last = int(match.group(1)), int(match.group(2)) joiner = match.group(0) is_range = not re.search(r"\sa\s", joiner) or joiner.startswith("medzi") if is_range and 0 < last - first <= 30: years |= set(range(first, last + 1)) return sorted(years) # "z akých škôl boli", "ktoré univerzity", "kde študovali" (not "práce na Žilinskej univerzite"). SCHOOLS_CUE = re.compile(r"\b(?:z|zo|na) (?:akych|akej|akom|ktorych|ktorej|ktorom) (?:skol|univerzit|fakult)\w*" r"|\b(?:ktor|ak)\w* (?:skol|univerzit|fakult)\w*" r"|\bkde (?:studoval|studovali|studuj)\w*|\b(?:which|what) (?:schools?|universit)") def asks_for_works(low: str) -> bool: """"Ktoré práce sa zaoberali X?" asks for a list, not for a summary.""" return bool(re.search(r"^\W*(?:ktor|ak|vypis|ukaz|daj|najdi|nájdi)\w*\s+(?:\w+\s+){0,2}pr[aá]c", low)) def _intent(low: str) -> Intent: if OUT_OF_SCOPE.search(fold(low)): return "refused" if re.search(r"\bkoľk\w*|\bkolk\w*|\bpočet\b|\bpocet\b|\bhow many\b", low): return "count" if re.search(r"kľúčov|klucov|\btag|keyword", low): return "keywords" if re.search(r"podobn|súvisiac|suvisiac|príbuzn|similar|related", low): return "similar" if not asks_for_works(low) and re.search(r"o čom|o com|čo robil|co robil|na čom|na com|čím sa zaobera|cim sa zaobera|zaoberal|zhrň|zhrn|" r"abstrakt|abstract|cieľ|ciel|čo skúmal|co skumal|obsah|what did .* work|what is .* about|summar", low): return "about" if SCHOOLS_CUE.search(fold(low)): return "schools" if re.search(r"najčastejš|najcastejs|\bs kým\b|\bs kym\b|ktor[íi] oponent|kto (?:mu |jej )?oponoval|kto bol|" r"who (?:was|were) the", low): return "relation" return "list" def _rules(question: str) -> CrzpQuery: low = question.lower() years = years_in(question) work_types = [t for t, cue in WORK_CUES.items() if re.search(cue, low)] school = next((key for key in SCHOOLS if re.search(r"\b" + key + r"\b", fold(question))), None) if school is None: school = next((key for key, (_, part) in SCHOOLS.items() if part in fold(question)), None) intent = _intent(low) quoted = re.search(r"[\"„“']([^\"„“']{4,200})[\"“”']", question) unit = unit_in_question(question) if unit: school = UNITS[unit]["school"] query = CrzpQuery(intent=intent, years=years, work_types=work_types, school=school, unit=unit, title=quoted.group(1).strip() if quoted else None) asked = re.search(r"\bkto (?:bol|bola|je|boli|sú|su)\b[^?]*?\b(ved[uú]c|[sš]kolite|oponent|konzultant)", low) if asked and not query.target_role: query.target_role = {"v": "supervisor", "š": "supervisor", "s": "supervisor", "o": "opponent"}.get( asked.group(1)[0], None) return query def _role_near(question: str, name: str, asked: Role | None = None) -> Role: """Role cue nearest before (or right after) the name; the asked role ("Kto bol vedúci práce X?") belongs to somebody else, X is the author.""" if asked: return "author" low, first = question.lower(), fold(name).split()[0][:3] position = next((m.start() for m in re.finditer(r"[^\W\d_]+", low) if fold(m.group())[:3] == first), -1) best, distance = "any", 10**6 for role, cue in ROLE_CUES.items(): for match in re.finditer(cue, low): d = position - match.start() if match.start() <= position else (match.start() - position) * 2 if d < distance: best, distance = role, d return best def _rule_people_topics(question: str, query: CrzpQuery) -> None: from tools.retrieval import lemmatize unquoted = question.replace(query.title, " ") if query.title else question tokens = re.findall(r"[^\W\d_]+", unquoted) names, current = [], [] for i, token in enumerate(tokens): is_name = (token[:1].isupper() and not token.isupper() # "LLM", "IoT"-like acronyms are topics and fold(token) not in STOP_FOLDED and (i > 0 or len(tokens) == 1)) if is_name and fold(token) not in SCHOOLS and fold(token) not in UNITS: current.append(token) elif current: names.append(current); current = [] if current: names.append(current) for parts in names[:2]: query.people.append(Person(name=" ".join(parts), role=_role_near(question, parts[0], query.target_role), typed=" ".join(parts))) named = {fold(t) for parts in names for t in parts} topics, surface, runs, previous = [], [], [], None # Topic words may contain digits ("5G", "ns-3"); plain numbers (years) are not topics. for position, token in enumerate(t for t in re.findall(r"[^\W_][\w\-]*", unquoted) if not t.isdigit()): if (fold(token) in STOP_FOLDED or fold(token) in named or fold(token) in SCHOOLS or fold(token) in UNITS or len(token) < 2 or len(token) == 2 and not re.search(r"\d", token)): continue lemma = (lemmatize.lemmas(token) or [token])[0].lower() if (fold(lemma) not in STOP_FOLDED and fold(lemma) not in WORK_WORDS and not any(re.search(cue, token.lower()) for cue in WORK_CUES.values())): topics.append(lemma) surface.append(token) if previous is not None and position == previous + 1 and runs: runs[-1].append((token, lemma)) # adjacent words form one phrase ("rozpoznávaním reči") else: runs.append([(token, lemma)]) previous = position query.topics = list(dict.fromkeys(topics))[:4] query.surface = [" ".join(surface[:2])] if surface else [] query.phrases = topic_phrases(runs[0])[:3] if runs else [] PLURAL_NOUN = [("iach", "ie"), ("ach", "e"), ("och", "y"), ("ami", "e"), ("mi", "y"), ("ov", "y"), ("í", "e")] def _adjective(lemma: str) -> bool: return len(fold(lemma)) > 3 and fold(lemma).endswith("y") # adjective lemmas end in -ý def _gender(lemma: str) -> str: folded = fold(lemma) if folded.endswith(("a", "ost")) or lemma.endswith(("ť", "ď", "ň", "ľ", "c")): return "f" return "n" if folded.endswith(("o", "ie", "e", "um")) else "m" def topic_phrases(run: list[tuple[str, str]]) -> list[str]: """How CRZP titles likely write an inflected topic, without the model. Words before the first noun agree with it, the noun takes its base form and the words after it (genitive modifiers) stay as written: "rozpoznávaním reči" -> "rozpoznavanie reči", "detekcii toxického jazyka" -> "detekcia toxického jazyka"; plural adjectives also give the plural phrase: "neurónových sieťach" -> "neuronové siete".""" adjectives = [] for token, lemma in run: if not _adjective(lemma): break adjectives.append((token, lemma)) if len(adjectives) == len(run): return [] head_token, head = run[len(adjectives)] tail = [token for token, _ in run[len(adjectives) + 1:]] ending = {"m": "ý", "f": "á", "n": "é"}[_gender(head)] phrases = [" ".join([lemma[:-1] + ending for _, lemma in adjectives] + [head] + tail)] if adjectives and all(re.search(r"(?:ych|ymi|ym)$", fold(t)) for t, _ in adjectives): plural = next((head_token[: -len(e)] + r for e, r in PLURAL_NOUN if head_token.lower().endswith(e)), None) if plural: plural = re.sub(r"([ťďňľ])([ei])", lambda m: "tdnl"["ťďňľ".index(m.group(1))] + m.group(2), plural) # sieťe -> siete phrases.append(" ".join([lemma[:-1] + "é" for _, lemma in adjectives] + [plural.lower()] + tail)) return list(dict.fromkeys(p for p in phrases if len(p.split()) > 1 or p != head)) PROMPT = """Z otázky o záverečných prácach v CRZP vytiahni parametre vyhľadávania. Otázka môže byť aj v inom jazyku; mená a témy uveď tak, ako sa píšu v slovenských záznamoch. Odpovedz iba JSON: {"intent":"list|count|about|keywords|relation|similar|schools (about = o čom je práca / čo v nej robil; schools = z akých škôl)", "people":[{"name":"meno v 1. páde, napr. Ján Pták","role":"author|supervisor|opponent|any"}], "title":"presný názov práce, ak je v otázke, inak null", "topics":["téma alebo pojem v základnom tvare, najdôležitejší prvý"], "alternatives":["0 až 2 ustálené odborné výrazy, ktorými sa PRVÁ téma naozaj píše v názvoch a kľúčových slovách prác: rozpísaná skratka, bežné slovenské synonymum alebo anglický termín (napr. LLM -> jazykový model, GPT; blockchain -> distributed ledger). Nevymýšľaj doslovné preklady; ak si nie si istý, daj []"], "target_role":"rola, na ktorú sa pýta pri vzťahu (napr. kto oponoval prácam vedeným X -> opponent), inak null"} Pravidlá: meno uveď len ak je v otázke; vedúci/školiteľ/viedol = supervisor; oponent/oponoval = opponent; študent/autor/napísal/jeho práca = author. "Kto bol vedúci práce X" -> X je author a target_role je supervisor. Témy len z otázky, bez slov ako práca, téma, študent, škola, rok. Názov práce nie je meno osoby. Nepridávaj nič, čo v otázke nie je.""" def _model_fields(question: str, complete=None) -> dict | None: from tools.answers.model_client import complete_with_configured_model try: response = (complete or complete_with_configured_model)( [{"role": "system", "content": PROMPT}, {"role": "user", "content": question}], max_tokens=300) text = response["choices"][0]["message"]["content"].strip() match = re.search(r"\{[\s\S]*\}", text) return json.loads(match.group(0)) if match else None except Exception: return None def understand(question: str, *, complete=None, use_model: bool = True) -> CrzpQuery: query = _rules(question) if query.intent == "refused": return query # refused before any model call or search proposed = _model_fields(question, complete) if use_model else None if isinstance(proposed, dict): for person in proposed.get("people") or []: if not isinstance(person, dict) or not isinstance(person.get("name"), str): continue name = " ".join(person["name"].split()) title = proposed.get("title") if isinstance(proposed.get("title"), str) else query.title or "" if (not 2 <= len(name) <= 80 or len(name.split()) > 4 or not in_question(name, question) or (title and fold(name) in fold(title))): continue # a work title or an invented name is not a person role = person.get("role") if person.get("role") in ("author", "supervisor", "opponent", "any") else "any" query.people.append(Person(name=name, role=role, typed=typed_form(name, question))) query.people = query.people[:2] query.topics = [t.strip() for t in proposed.get("topics") or [] if isinstance(t, str) and t.strip() and in_question(t, question) and not work_words_only(t) and not names_a_school(t) and not any(in_question(t, p.name) for p in query.people)][:4] if (query.intent == "list" and proposed.get("intent") in ("count", "about", "keywords", "relation", "similar", "schools") and not (proposed["intent"] == "about" and asks_for_works(question.lower()))): query.intent = proposed["intent"] # rules found no cue (e.g. another language) title = proposed.get("title") if not query.title and isinstance(title, str) and len(title) >= 4 and fold(title) in fold(question): query.title = title.strip() if proposed.get("target_role") in ("author", "supervisor", "opponent"): query.target_role = proposed["target_role"] query.from_model = True offered = proposed.get("alternatives") if isinstance(proposed.get("alternatives"), list) else [] else: _rule_people_topics(question, query) offered = [] if query.topics and not query.people and not query.title: query.alternatives = alternatives(query.topic_key(), offered) if query.target_role is None and query.intent == "relation": asked = [r for r, cue in ROLE_CUES.items() if re.search(cue, question.lower()) and all(p.role != r for p in query.people)] query.target_role = asked[0] if asked else None if query.people and query.target_role == query.people[0].role: query.target_role = None # "Koľkokrát oponoval X" asks about X's own role, not about another person if query.intent == "relation" and query.target_role is None: query.intent = "list" return query def names_a_school(term: str) -> bool: """"KEMT", "TUKE", "FEI" or a school name is a filter, not a topic to search.""" folded = " ".join(fold(term).split()) return (folded in SCHOOLS or folded in UNITS or unit_in_question(term) is not None or any(part in folded or folded in part for _, part in SCHOOLS.values() if len(folded) > 5)) def work_words_only(term: str) -> bool: words = [fold(w) for w in _words(term)] return bool(words) and all(w in WORK_WORDS or w in STOP_FOLDED for w in words) def typed_form(name: str, question: str) -> str: """The question's own spelling of the name words ("jan ptak" stays without diacritics).""" words = _words(question) return " ".join(next((w for w in words if stem_match(part, w)), part) for part in _words(name))