from __future__ import annotations import argparse import json import re import unicodedata from typing import Any, Iterable GRAPH_SCOPE = "zpwiki" DEFAULT_LIMIT = 40 DEFAULT_MAX_ENTITIES = 8 RELATIONSHIP_TYPES = ( "HAS_DOCUMENT|AUTHORED|IN_CATEGORY|HAS_KEYWORD|" "DESCRIBES|HAS_WORK|EVIDENCED_BY|AUTHORED_DOCUMENT" ) CATALOG_QUERY = """ MATCH (n) WHERE n.graph_scope = $scope AND ( n:Person OR n:Thesis OR n:Work OR n:Keyword OR n:Topic OR n:Category OR n:DocumentAuthor ) RETURN elementId(n) AS element_id, CASE WHEN n:Person THEN 'Person' WHEN n:Thesis THEN 'Thesis' WHEN n:Work THEN 'Work' WHEN n:Keyword THEN 'Keyword' WHEN n:Topic THEN 'Topic' WHEN n:Category THEN 'Category' WHEN n:DocumentAuthor THEN 'DocumentAuthor' ELSE 'Other' END AS kind, n.id AS id, n.name AS name, n.title AS title, n.heading AS heading, n.start_year AS start_year, n.year AS year, n.thesis_type AS thesis_type, n.thesis_type_label AS thesis_type_label, n.work_type AS work_type, n.work_type_label AS work_type_label, n.person_kind AS person_kind, n.source_document AS source_document ORDER BY kind, name, title, heading """ NEIGHBORHOOD_QUERY = f""" MATCH (start) WHERE elementId(start) IN $start_ids MATCH p=(start)-[:{RELATIONSHIP_TYPES}*1..2]-(target) WHERE all(node IN nodes(p) WHERE node.graph_scope = $scope) AND ( size($target_kinds) = 0 OR any( node IN nodes(p) WHERE ('Person' IN $target_kinds AND node:Person) OR ('Thesis' IN $target_kinds AND node:Thesis) OR ('Work' IN $target_kinds AND node:Work) OR ('Keyword' IN $target_kinds AND node:Keyword) OR ('Topic' IN $target_kinds AND node:Topic) OR ('Category' IN $target_kinds AND node:Category) OR ( 'DocumentAuthor' IN $target_kinds AND node:DocumentAuthor ) OR ('Document' IN $target_kinds AND node:Document) ) ) RETURN [node IN nodes(p) | {{ element_id: elementId(node), kind: CASE WHEN node:Person THEN 'Person' WHEN node:Thesis THEN 'Thesis' WHEN node:Work THEN 'Work' WHEN node:Document THEN 'Document' WHEN node:Keyword THEN 'Keyword' WHEN node:Topic THEN 'Topic' WHEN node:Category THEN 'Category' WHEN node:DocumentAuthor THEN 'DocumentAuthor' ELSE 'Other' END, id: node.id, name: node.name, title: node.title, heading: node.heading, path: node.path, start_year: node.start_year, year: node.year, thesis_type: node.thesis_type, thesis_type_label: node.thesis_type_label, work_type: node.work_type, work_type_label: node.work_type_label, person_kind: node.person_kind, source_document: node.source_document, source_url: node.source_url }}] AS nodes, [relationship IN relationships(p) | type(relationship) ] AS relationships, length(p) AS hops ORDER BY hops LIMIT $limit """ PAIR_PATH_QUERY = f""" MATCH (left), (right) WHERE elementId(left) IN $start_ids AND elementId(right) IN $start_ids AND elementId(left) < elementId(right) MATCH p=allShortestPaths( (left)-[:{RELATIONSHIP_TYPES}*1..4]-(right) ) WHERE all(node IN nodes(p) WHERE node.graph_scope = $scope) RETURN [node IN nodes(p) | {{ element_id: elementId(node), kind: CASE WHEN node:Person THEN 'Person' WHEN node:Thesis THEN 'Thesis' WHEN node:Work THEN 'Work' WHEN node:Document THEN 'Document' WHEN node:Keyword THEN 'Keyword' WHEN node:Topic THEN 'Topic' WHEN node:Category THEN 'Category' WHEN node:DocumentAuthor THEN 'DocumentAuthor' ELSE 'Other' END, id: node.id, name: node.name, title: node.title, heading: node.heading, path: node.path, start_year: node.start_year, year: node.year, thesis_type: node.thesis_type, thesis_type_label: node.thesis_type_label, work_type: node.work_type, work_type_label: node.work_type_label, person_kind: node.person_kind, source_document: node.source_document, source_url: node.source_url }}] AS nodes, [relationship IN relationships(p) | type(relationship) ] AS relationships, length(p) AS hops ORDER BY hops LIMIT $limit """ THESIS_FACTS_QUERY = """ MATCH (person:Person) -[:AUTHORED]-> (thesis:Thesis) -[:EVIDENCED_BY]-> (document:Document) WHERE person.graph_scope = $scope AND thesis.graph_scope = $scope AND document.graph_scope = $scope AND ( size($person_ids) = 0 OR elementId(person) IN $person_ids ) AND ( size($thesis_ids) = 0 OR elementId(thesis) IN $thesis_ids ) AND ( size($years) = 0 OR thesis.year IN $years ) AND ( size($thesis_types) = 0 OR thesis.thesis_type IN $thesis_types ) RETURN elementId(person) AS person_element_id, person.name AS person, person.start_year AS start_year, elementId(thesis) AS thesis_element_id, thesis.id AS thesis_id, thesis.title AS title, thesis.heading AS heading, thesis.year AS year, thesis.thesis_type AS thesis_type, thesis.thesis_type_label AS thesis_type_label, document.path AS document_path, document.source_url AS source_url, thesis.source_chunk_ids AS source_chunk_ids ORDER BY person, year, thesis_type, title LIMIT $limit """ THESIS_PATHS_QUERY = """ MATCH p= (person:Person) -[:AUTHORED]-> (thesis:Thesis) -[:EVIDENCED_BY]-> (document:Document) WHERE person.graph_scope = $scope AND thesis.graph_scope = $scope AND document.graph_scope = $scope AND ( size($person_ids) = 0 OR elementId(person) IN $person_ids ) AND ( size($thesis_ids) = 0 OR elementId(thesis) IN $thesis_ids ) AND ( size($years) = 0 OR thesis.year IN $years ) AND ( size($thesis_types) = 0 OR thesis.thesis_type IN $thesis_types ) RETURN [node IN nodes(p) | { element_id: elementId(node), kind: CASE WHEN node:Person THEN 'Person' WHEN node:Thesis THEN 'Thesis' WHEN node:Document THEN 'Document' ELSE 'Other' END, id: node.id, name: node.name, title: node.title, heading: node.heading, path: node.path, start_year: node.start_year, year: node.year, thesis_type: node.thesis_type, thesis_type_label: node.thesis_type_label, source_document: node.source_document, source_url: node.source_url }] AS nodes, [relationship IN relationships(p) | type(relationship) ] AS relationships, length(p) AS hops ORDER BY person.name, thesis.year, thesis.thesis_type, thesis.title LIMIT $limit """ WORK_FACTS_QUERY = """ MATCH (person:Person) -[:HAS_WORK]-> (work:Work) -[:EVIDENCED_BY]-> (document:Document) WHERE person.graph_scope = $scope AND work.graph_scope = $scope AND document.graph_scope = $scope AND ( size($person_ids) = 0 OR elementId(person) IN $person_ids ) AND ( size($work_ids) = 0 OR elementId(work) IN $work_ids ) AND ( size($years) = 0 OR work.year IN $years ) AND ( size($work_types) = 0 OR work.work_type IN $work_types ) RETURN elementId(person) AS person_element_id, person.name AS person, person.start_year AS start_year, elementId(work) AS work_element_id, work.id AS work_id, work.title AS title, work.heading AS heading, work.year AS year, work.work_type AS work_type, work.work_type_label AS work_type_label, document.path AS document_path, document.source_url AS source_url, work.source_chunk_ids AS source_chunk_ids ORDER BY person, year, work_type, title LIMIT $limit """ WORK_PATHS_QUERY = """ MATCH p= (person:Person) -[:HAS_WORK]-> (work:Work) -[:EVIDENCED_BY]-> (document:Document) WHERE person.graph_scope = $scope AND work.graph_scope = $scope AND document.graph_scope = $scope AND ( size($person_ids) = 0 OR elementId(person) IN $person_ids ) AND ( size($work_ids) = 0 OR elementId(work) IN $work_ids ) AND ( size($years) = 0 OR work.year IN $years ) AND ( size($work_types) = 0 OR work.work_type IN $work_types ) RETURN [node IN nodes(p) | { element_id: elementId(node), kind: CASE WHEN node:Person THEN 'Person' WHEN node:Work THEN 'Work' WHEN node:Document THEN 'Document' ELSE 'Other' END, id: node.id, name: node.name, title: node.title, heading: node.heading, path: node.path, start_year: node.start_year, year: node.year, work_type: node.work_type, work_type_label: node.work_type_label, source_document: node.source_document, source_url: node.source_url }] AS nodes, [relationship IN relationships(p) | type(relationship) ] AS relationships, length(p) AS hops ORDER BY person.name, work.year, work.work_type, work.title LIMIT $limit """ START_YEAR_FACTS_QUERY = """ MATCH (person:Person) WHERE person.graph_scope = $scope AND ( size($person_ids) = 0 OR elementId(person) IN $person_ids ) AND ( size($years) = 0 OR person.start_year IN $years ) OPTIONAL MATCH (document:Document) WHERE document.graph_scope = $scope AND document.path = person.source_document RETURN elementId(person) AS person_element_id, person.name AS person, person.start_year AS start_year, person.source_document AS document_path, document.source_url AS source_url ORDER BY person LIMIT $limit """ PERSON_KEYWORD_FACTS_QUERY = """ MATCH (person:Person) -[:HAS_DOCUMENT]-> (document:Document) -[:HAS_KEYWORD]-> (keyword:Keyword) WHERE person.graph_scope = $scope AND document.graph_scope = $scope AND keyword.graph_scope = $scope AND ( size($person_ids) = 0 OR elementId(person) IN $person_ids ) AND ( size($keyword_ids) = 0 OR elementId(keyword) IN $keyword_ids ) WITH person, keyword, collect(DISTINCT document) AS documents WITH person, keyword, head( [ document IN documents WHERE document.path = person.source_document ] + documents ) AS document RETURN elementId(person) AS person_element_id, person.name AS person, elementId(keyword) AS keyword_element_id, keyword.id AS keyword_id, keyword.name AS keyword, document.path AS document_path, document.source_url AS source_url ORDER BY person, keyword LIMIT $limit """ PERSON_CATEGORY_FACTS_QUERY = """ MATCH (person:Person) -[:HAS_DOCUMENT]-> (document:Document) -[:IN_CATEGORY]-> (category:Category) WHERE person.graph_scope = $scope AND document.graph_scope = $scope AND category.graph_scope = $scope AND ( size($person_ids) = 0 OR elementId(person) IN $person_ids ) AND ( size($category_ids) = 0 OR elementId(category) IN $category_ids ) WITH person, category, collect(DISTINCT document) AS documents WITH person, category, head( [ document IN documents WHERE document.path = person.source_document ] + documents ) AS document RETURN elementId(person) AS person_element_id, person.name AS person, elementId(category) AS category_element_id, category.name AS category, category.category_type AS category_type, category.year AS category_year, document.path AS document_path, document.source_url AS source_url ORDER BY person, category LIMIT $limit """ SHARED_KEYWORD_FACTS_QUERY = """ MATCH (left:Person) -[:HAS_DOCUMENT]-> (left_document:Document) -[:HAS_KEYWORD]-> (keyword:Keyword) MATCH (right:Person) -[:HAS_DOCUMENT]-> (right_document:Document) -[:HAS_KEYWORD]-> (keyword) WHERE left.graph_scope = $scope AND right.graph_scope = $scope AND left_document.graph_scope = $scope AND right_document.graph_scope = $scope AND keyword.graph_scope = $scope AND elementId(left) IN $person_ids AND elementId(right) IN $person_ids AND elementId(left) < elementId(right) WITH left, right, keyword, collect(DISTINCT left_document) AS left_documents, collect(DISTINCT right_document) AS right_documents WITH left, right, keyword, head( [ document IN left_documents WHERE document.path = left.source_document ] + left_documents ) AS left_document, head( [ document IN right_documents WHERE document.path = right.source_document ] + right_documents ) AS right_document RETURN elementId(left) AS left_person_element_id, left.name AS left_person, left_document.path AS left_document_path, left_document.source_url AS left_source_url, elementId(right) AS right_person_element_id, right.name AS right_person, right_document.path AS right_document_path, right_document.source_url AS right_source_url, elementId(keyword) AS keyword_element_id, keyword.id AS keyword_id, keyword.name AS keyword ORDER BY left_person, right_person, keyword LIMIT $limit """ SHARED_CATEGORY_FACTS_QUERY = """ MATCH (left:Person) -[:HAS_DOCUMENT]-> (left_document:Document) -[:IN_CATEGORY]-> (category:Category) MATCH (right:Person) -[:HAS_DOCUMENT]-> (right_document:Document) -[:IN_CATEGORY]-> (category) WHERE left.graph_scope = $scope AND right.graph_scope = $scope AND left_document.graph_scope = $scope AND right_document.graph_scope = $scope AND category.graph_scope = $scope AND elementId(left) IN $person_ids AND elementId(right) IN $person_ids AND elementId(left) < elementId(right) WITH left, right, category, collect(DISTINCT left_document) AS left_documents, collect(DISTINCT right_document) AS right_documents WITH left, right, category, head( [ document IN left_documents WHERE document.path = left.source_document ] + left_documents ) AS left_document, head( [ document IN right_documents WHERE document.path = right.source_document ] + right_documents ) AS right_document RETURN elementId(left) AS left_person_element_id, left.name AS left_person, left_document.path AS left_document_path, left_document.source_url AS left_source_url, elementId(right) AS right_person_element_id, right.name AS right_person, right_document.path AS right_document_path, right_document.source_url AS right_source_url, elementId(category) AS category_element_id, category.name AS category, category.category_type AS category_type, category.year AS category_year ORDER BY left_person, right_person, category LIMIT $limit """ SHARED_DOCUMENT_AUTHOR_FACTS_QUERY = """ MATCH (author:DocumentAuthor) -[:AUTHORED_DOCUMENT]-> (left_document:Document) <-[:HAS_DOCUMENT]- (left:Person) MATCH (author) -[:AUTHORED_DOCUMENT]-> (right_document:Document) <-[:HAS_DOCUMENT]- (right:Person) WHERE author.graph_scope = $scope AND left.graph_scope = $scope AND right.graph_scope = $scope AND left_document.graph_scope = $scope AND right_document.graph_scope = $scope AND elementId(left) IN $person_ids AND elementId(right) IN $person_ids AND elementId(left) < elementId(right) WITH left, right, author, collect(DISTINCT left_document) AS left_documents, collect(DISTINCT right_document) AS right_documents WITH left, right, author, head( [ document IN left_documents WHERE document.path = left.source_document ] + left_documents ) AS left_document, head( [ document IN right_documents WHERE document.path = right.source_document ] + right_documents ) AS right_document RETURN elementId(left) AS left_person_element_id, left.name AS left_person, left_document.path AS left_document_path, left_document.source_url AS left_source_url, elementId(right) AS right_person_element_id, right.name AS right_person, right_document.path AS right_document_path, right_document.source_url AS right_source_url, elementId(author) AS document_author_element_id, author.id AS document_author_id, author.name AS document_author ORDER BY left_person, right_person, document_author LIMIT $limit """ def normalize_for_match( value: str, ) -> str: decomposed = unicodedata.normalize( "NFKD", str(value), ) without_marks = "".join( character for character in decomposed if not unicodedata.combining( character ) ) normalized = re.sub( r"[^a-zA-Z0-9]+", " ", without_marks.casefold(), ) return re.sub( r"\s+", " ", normalized, ).strip() def normalized_tokens( value: str, ) -> list[str]: normalized = normalize_for_match( value ) if not normalized: return [] return normalized.split() def has_stem( tokens: Iterable[str], stem: str, ) -> bool: return any( token.startswith( stem ) for token in tokens ) def explicit_keyword_phrase(query: str) -> str | None: """Identify a requested tag without treating 'ZP Wiki' as the tag ZP.""" match = re.search( r"\b(?:klucove\s+slovo|klucovym\s+slovom|klucoveho\s+slova|" r"tagom|tagu|tag)\s+(.+)", normalize_for_match(query), ) if not match: return None phrase = re.split( r"\s+(?:pri\s+osob\w*|u\s+osob\w*|a\s+(?:jej|jeho|ich|rok|pomenuj|uved\w*)|ak\s+|uved\w*\s+|" r"pomenuj\s+|maju\s+|ma\s+)", match.group(1), maxsplit=1, )[0].strip() return phrase or None def extract_query_constraints( query: str, ) -> dict[str, Any]: normalized = normalize_for_match( query ) tokens = normalized.split() token_set = set( tokens ) years = sorted( { int(value) for value in re.findall( r"\b(20\d{2})\b", normalized, ) } ) has_work_word = has_stem( tokens, "prac", ) has_project_word = has_stem( tokens, "projekt", ) thesis_types: list[str] = [] work_types: list[str] = [] if ( ( has_stem( tokens, "bakalar", ) and has_work_word ) or "bp" in token_set ): thesis_types.append( "bachelor_thesis" ) if ( ( has_stem( tokens, "diplom", ) and has_work_word ) or "dp" in token_set ): thesis_types.append( "diploma_thesis" ) if ( ( has_stem( tokens, "dizert", ) and has_work_word ) or "phd" in token_set ): thesis_types.append( "doctoral_thesis" ) if has_stem(tokens, 'bakalar') and has_project_word: work_types.append('bachelor_project') if ( has_stem( tokens, "diplom", ) and has_project_word ): work_types.append( "diploma_project" ) if ( has_stem( tokens, "timov", ) and has_project_word ): work_types.append( "team_project" ) if ( has_stem( tokens, "vyskum", ) and has_project_word ): work_types.append( "research_project" ) if "tp" in token_set: work_types.append( "team_project" ) if "vp" in token_set: work_types.append( "research_project" ) thesis_types = list( dict.fromkeys( thesis_types ) ) work_types = list( dict.fromkeys( work_types ) ) mentions_start_year = ( has_stem( tokens, "zaciat", ) and has_stem( tokens, "stud", ) ) mentions_keyword = ( has_stem( tokens, "kluc", ) or "tag" in token_set or "tagy" in token_set ) mentions_topic = ( has_stem( tokens, "tem", ) ) mentions_category = ( has_stem( tokens, "kategori", ) ) mentions_document_author = ( has_stem( tokens, "autor", ) and ( has_stem( tokens, "dokument", ) or has_stem( tokens, "strank", ) or "wiki" in token_set ) ) mentions_title = ( has_stem(tokens, "nazov") or has_stem(tokens, "nazv") or (has_work_word and "vola" in token_set) ) mentions_academic_year = ( has_work_word and bool(token_set & {"rok", "roka", "roku", "rokom", "roky", "rokoch"}) and not mentions_start_year ) mentions_shared = ( has_stem( tokens, "spoloc", ) or has_stem( tokens, "rovnak", ) or (mentions_academic_year and has_stem(tokens, "spaj")) ) mentions_person_answer = ( "kto" in token_set or ( has_stem( tokens, "ktor", ) and ( has_stem( tokens, "student", ) or has_stem( tokens, "osob", ) or has_stem( tokens, "clov", ) ) ) ) mentions_thesis = ( bool( thesis_types ) or ( has_stem( tokens, "zaverec", ) and has_work_word ) ) mentions_work = bool( work_types ) target_kinds: list[str] = [] if mentions_keyword: target_kinds.append( "Keyword" ) if mentions_topic: target_kinds.extend( [ "Keyword", "Topic", ] ) if mentions_category: target_kinds.append( "Category" ) if mentions_document_author: target_kinds.append( "DocumentAuthor" ) if mentions_thesis: target_kinds.append( "Thesis" ) if mentions_work: target_kinds.append( "Work" ) if ( mentions_title and not mentions_thesis and not mentions_work ): target_kinds.extend( [ "Thesis", "Work", ] ) if ( years and not mentions_start_year and not mentions_thesis and not mentions_work ): target_kinds.extend( [ "Thesis", "Work", ] ) if ( mentions_person_answer and not mentions_shared ): target_kinds = [ "Person" ] return { "years": years, "thesis_types": ( thesis_types ), "work_types": ( work_types ), "mentions_start_year": ( mentions_start_year ), "mentions_keyword": ( mentions_keyword ), "mentions_topic": ( mentions_topic ), "mentions_category": ( mentions_category ), "mentions_document_author": ( mentions_document_author ), "mentions_title": ( mentions_title ), "mentions_academic_year": mentions_academic_year, "keyword_phrase": explicit_keyword_phrase(query), "mentions_count": bool(token_set & {"kolko", "pocet", "spocitaj"}), "mentions_shared": ( mentions_shared ), "mentions_person_answer": ( mentions_person_answer ), "mentions_thesis": ( mentions_thesis ), "mentions_work": ( mentions_work ), "target_kinds": list( dict.fromkeys( target_kinds ) ), } def entity_aliases( entity: dict[str, Any], ) -> list[str]: kind = str( entity.get( "kind" ) or "" ) values: list[Any] = [] if kind in { "Person", "DocumentAuthor", }: values.append( entity.get( "name" ) ) elif kind in { "Keyword", "Topic", }: values.extend( [ entity.get( "name" ), entity.get( "id" ), ] ) elif kind == "Category": values.append( entity.get( "name" ) ) elif kind in { "Thesis", "Work", }: values.append( entity.get( "title" ) ) aliases: list[str] = [] for value in values: if value is None: continue normalized = normalize_for_match( str(value) ) if normalized: aliases.append( normalized ) return list( dict.fromkeys( aliases ) ) def name_token_variants(token: str) -> set[str]: """Conservative Slovak case endings; never accept arbitrary short prefixes.""" variants = {token} if len(token) < 3: return variants if token.endswith('ova'): variants.update(token[:-1] + suffix for suffix in ('ej', 'u', 'ou')) elif token.endswith('y'): variants.update(token[:-1] + suffix for suffix in ('eho', 'emu', 'ym', 'ych')) elif token.endswith('a'): variants.update(token[:-1] + suffix for suffix in ('y', 'e', 'u', 'ou', 'ovi', 'om')) elif token.endswith('o'): variants.update(token[:-1] + suffix for suffix in ('a', 'ovi', 'om')) else: variants.update(token + suffix for suffix in ('a', 'ovi', 'om')) return variants def person_name_match(query: str, alias: str) -> tuple[float, tuple[int, int] | None]: """Match the complete name in one contiguous window, preserving token order.""" words, name = normalize_for_match(query).split(), normalize_for_match(alias).split() if len(name) < 2: return 0.0, None best = (0.0, None) for start in range(len(words) - len(name) + 1): window = words[start:start + len(name)] if window == name: return 200.0 + len(name), (start, start + len(name)) if all(observed in name_token_variants(canonical) for canonical, observed in zip(name, window)): best = (180.0 + len(name), (start, start + len(name))) return best def requested_name_phrases(query: str) -> list[str]: """Recognise explicitly requested names, including unknown lower-case names. This guard is separate from catalog resolution: an unresolved subject must not turn the empty person filter into an unrestricted global listing. """ normalized = normalize_for_match(query.replace(',', ' separator ')) candidates = [] role = re.search( r'\b(?:osob(?:a|y|u|e|ach|ami|am|ou)?|student(?:a|ovi|om|i|ov|ka|ky|ke|ku)?|kandidat(?:ov|i|a))\s+(.+)', normalized, ) if role: tail = re.split( r'\s+(?:s(?:o)?\s+(?:nazv\w*|nazov\w*|rok\w*)|splna\w*|ma|maju|mal|mali|je|su|' r'ktory\w*|ktora\w*|pri\s+ktorych|v\s+roku|uved\w*|prepojen\w*|spojen\w*|' r'vyber|najdi|zisti|cez|pomenuj|uved\w*|vypis|ak)\b', role.group(1), maxsplit=1, )[0] candidates.extend(re.split(r'\s+(?:a|separator)\s+', tail)) verb = re.search(r'\b(?:ma|mal|robil|riesil|venuje|venoval)\s+(.+)', normalized) if verb: candidates.append(re.split(r'\s+(?:v|s|so|a|ak|ktory\w*)\b', verb.group(1), maxsplit=1)[0]) # Capitalised full names also cover genitive phrases such as 'práce X Y'. upper = 'A-ZÁÄČĎÉÍĹĽŇÓÔŔŠŤÚÝŽ' for match in re.finditer(rf'\b([{upper}][^\W\d_]*(?:\s+[{upper}][^\W\d_]*){{1,3}})\b', query): words = match.group(1).split() if all(not word.isupper() for word in words): candidates.append(normalize_for_match(match.group(1))) generic = {'ktore', 'ktori', 'ktora', 'ktory', 'vsetci', 'vsetky', 'vsetkych', 'maju', 'ma', 'praca', 'prace', 'pracu', 'projekt', 'projekty', 'dokument', 'dokumenty', 'rok', 'roku', 'klucove', 'klucovy', 'klucovym', 'tag', 'tagy', 'nazov', 'nazvy', 'temu', 'kategoriu', 'kategorie', 'uvedenu', 'uvedeny', 'priradeny', 'priradene', 'je', 'su', 'sa', 's', 'pri', 'pre', 'z', 'na', 'v', 'ake', 'aky', 'kto', 'kolko'} return list(dict.fromkeys( candidate.strip() for candidate in candidates if 1 <= len(candidate.split()) <= 5 and candidate.split()[0] not in generic )) def named_author_phrases(query: str) -> list[str]: normalized = normalize_for_match(query.replace(',', ' separator ')) match = re.search(r'\bautor(?:a|om)?(?:\s+dokument(?:u|ov))?\s+(.+)', normalized) if not match: return [] name = re.split(r'\s+(?:v|s|so|a|ak|ktory\w*|pri|spojen\w*)\b', match.group(1), maxsplit=1)[0] return [name] if 2 <= len(name.split()) <= 4 and name.split()[0] not in { 'dokumentu', 'dokumentov', 'je', 'sa', 'pri', 'osoby', 'osobe', 'osobu', 'osob', 'ktory', 'ktora', 'ktoreho', 'studentov', 'studenta', } else [] def explicit_keyword_entities(phrase: str, catalog: list[dict[str, Any]]) -> list[dict[str, Any]]: """Resolve a requested tag as a complete leading phrase, not unrelated words.""" normalized = normalize_for_match(phrase) matches = [] for entity in catalog: if entity.get('kind') != 'Keyword': continue aliases = entity_aliases(entity) matching = [alias for alias in aliases if normalized == alias] if matching: best = max(matching, key=len) matches.append({**entity, 'matched_alias': best, 'match_score': 200.0 + len(best)}) if not matches: return [] # Prefer the longest full tag when one tag is a prefix of another. longest = max(len(row['matched_alias']) for row in matches) return [row for row in matches if len(row['matched_alias']) == longest] def tokens_match( left: str, right: str, ) -> bool: if left == right: return True shorter = min( len(left), len(right), ) if shorter <= 2: return False required = ( 3 if shorter <= 4 else 4 if shorter <= 6 else 5 ) return ( left[:required] == right[:required] ) def alias_match_score( normalized_query: str, query_tokens: set[str], alias: str, ) -> float: alias_tokens = ( alias.split() ) if not alias_tokens: return 0.0 if len( alias_tokens ) == 1: if alias not in query_tokens: return 0.0 return ( 50.0 + min( len(alias), 20, ) / 100.0 ) padded_query = ( f" {normalized_query} " ) padded_alias = ( f" {alias} " ) if ( padded_alias in padded_query ): score = ( 100.0 + len( alias_tokens ) * 5.0 + min( len(alias), 100, ) / 100.0 ) if ( normalized_query == alias ): score += 50.0 return score if all( any( tokens_match( alias_token, query_token, ) for query_token in query_tokens ) for alias_token in alias_tokens ): return ( 95.0 + len( alias_tokens ) * 5.0 + min( len(alias), 100, ) / 100.0 ) return 0.0 def suppress_nested_entity_matches( matches: list[ dict[str, Any] ], ) -> list[dict[str, Any]]: result: list[ dict[str, Any] ] = [] for item in matches: kind = str( item.get( "kind" ) or "" ) if kind not in { "Thesis", "Work", }: result.append( item ) continue alias = str( item.get( "matched_alias" ) or "" ) score = float( item.get( "match_score" ) or 0.0 ) dominated = False for other in matches: if other is item: continue if ( other.get( "kind" ) != kind ): continue other_alias = str( other.get( "matched_alias" ) or "" ) other_score = float( other.get( "match_score" ) or 0.0 ) if ( not alias or not other_alias or alias == other_alias ): continue if ( alias in other_alias and len( other_alias ) > len( alias ) and other_score > score ): dominated = True break if not dominated: result.append( item ) return result def resolve_entities( query: str, catalog: list[ dict[str, Any] ], *, max_entities: int = ( DEFAULT_MAX_ENTITIES ), preferred_kinds: Iterable[ str ] = (), ) -> list[dict[str, Any]]: normalized_query = ( normalize_for_match( query ) ) query_tokens = set( normalized_query.split() ) preferred = set( preferred_kinds ) matches: list[ dict[str, Any] ] = [] for entity in catalog: best_score = 0.0 best_alias = "" for alias in entity_aliases( entity ): name_span = None if entity.get("kind") in {"Person", "DocumentAuthor"}: score, name_span = person_name_match(normalized_query, alias) else: score = alias_match_score( normalized_query, query_tokens, alias, ) if ( score > 0 and entity.get( "kind" ) in preferred ): score += 2.0 if score > best_score: best_score = score best_alias = alias best_name_span = name_span if best_score <= 0.0: continue item = dict( entity ) item[ "matched_alias" ] = best_alias if entity.get("kind") in {"Person", "DocumentAuthor"}: item["matched_name_span"] = best_name_span item[ "match_score" ] = round( best_score, 3, ) matches.append( item ) exact_name_spans = { tuple(item["matched_name_span"]) for item in matches if item.get("matched_name_span") is not None and item["match_score"] >= 200 } matches = [item for item in matches if not ( item.get("matched_name_span") is not None and tuple(item["matched_name_span"]) in exact_name_spans and item["match_score"] < 200 )] person_aliases = { str( item.get( "matched_alias" ) or "" ) for item in matches if item.get( "kind" ) == "Person" } matches = [ item for item in matches if not ( item.get( "kind" ) == "DocumentAuthor" and str( item.get( "matched_alias" ) or "" ) in person_aliases ) ] matches = ( suppress_nested_entity_matches( matches ) ) matches.sort( key=lambda item: ( -float( item[ "match_score" ] ), str( item.get( "kind" ) or "" ), str( item.get( "name" ) or item.get( "title" ) or item.get( "id" ) or "" ), ) ) selected: list[ dict[str, Any] ] = [] seen_ids: set[ str ] = set() for item in matches: element_id = str( item.get( "element_id" ) or "" ) if not element_id: continue if element_id in seen_ids: continue seen_ids.add( element_id ) selected.append( item ) if ( len(selected) >= max_entities ): break return selected def rows_from_result( result: Any, ) -> list[dict[str, Any]]: rows: list[ dict[str, Any] ] = [] for record in result: if isinstance( record, dict, ): rows.append( dict( record ) ) continue data = getattr( record, "data", None, ) if callable( data ): rows.append( dict( data() ) ) continue rows.append( dict( record ) ) return rows def run_rows( session: Any, query: str, **parameters: Any, ) -> list[dict[str, Any]]: return rows_from_result( session.run( query, **parameters, ) ) def deduplicate_paths( paths: list[ dict[str, Any] ], ) -> list[dict[str, Any]]: result: list[ dict[str, Any] ] = [] seen: set[ str ] = set() for path in paths: key = json.dumps( { "nodes": path.get( "nodes", [], ), "relationships": ( path.get( "relationships", [], ) ), }, ensure_ascii=False, sort_keys=True, default=str, ) if key in seen: continue seen.add( key ) result.append( path ) return result def deduplicate_rows( rows: list[ dict[str, Any] ], key_fields: tuple[ str, ..., ], ) -> list[dict[str, Any]]: result: list[ dict[str, Any] ] = [] seen: set[ tuple[str, ...] ] = set() for row in rows: key = tuple( str( row.get( field ) or "" ) for field in key_fields ) if key in seen: continue seen.add( key ) result.append( row ) return result def collect_documents( resolved_entities: list[ dict[str, Any] ], paths: list[ dict[str, Any] ], facts: list[ dict[str, Any] ], ) -> list[dict[str, Any]]: documents: dict[ str, dict[str, Any], ] = {} def add_document( path: Any, source_url: Any = None, ) -> None: value = str( path or "" ).strip() if not value: return current = ( documents.setdefault( value, { "document_path": ( value ), "source_url": None, }, ) ) if ( source_url and not current.get( "source_url" ) ): current[ "source_url" ] = str( source_url ) for entity in ( resolved_entities ): add_document( entity.get( "source_document" ) ) for path in paths: for node in path.get( "nodes", [], ): if not isinstance( node, dict, ): continue if ( node.get( "kind" ) == "Document" ): add_document( node.get( "path" ), node.get( "source_url" ), ) add_document( node.get( "source_document" ) ) for fact in facts: add_document( fact.get( "document_path" ), fact.get( "source_url" ), ) add_document( fact.get( "left_document_path" ), fact.get( "left_source_url" ), ) add_document( fact.get( "right_document_path" ), fact.get( "right_source_url" ), ) return sorted( documents.values(), key=lambda item: ( item[ "document_path" ] ), ) def build_facts( thesis_rows: list[ dict[str, Any] ], work_rows: list[ dict[str, Any] ], start_year_rows: list[ dict[str, Any] ], person_keyword_rows: list[ dict[str, Any] ], person_category_rows: list[ dict[str, Any] ], shared_keyword_rows: list[ dict[str, Any] ], shared_category_rows: list[ dict[str, Any] ], shared_document_author_rows: list[ dict[str, Any] ], *, mentions_shared: bool, ) -> list[dict[str, Any]]: facts: list[ dict[str, Any] ] = [] for row in thesis_rows: fact = dict( row ) fact[ "fact_type" ] = "person_thesis" facts.append( fact ) for row in work_rows: fact = dict( row ) fact[ "fact_type" ] = "person_work" facts.append( fact ) unique_start_rows = ( deduplicate_rows( start_year_rows, ( "person_element_id", ), ) ) for row in unique_start_rows: fact = dict( row ) fact[ "fact_type" ] = "person_start_year" facts.append( fact ) unique_person_keywords = ( deduplicate_rows( person_keyword_rows, ( "person_element_id", "keyword_element_id", ), ) ) for row in unique_person_keywords: fact = dict( row ) fact[ "fact_type" ] = "person_keyword" facts.append( fact ) unique_person_categories = ( deduplicate_rows( person_category_rows, ( "person_element_id", "category_element_id", ), ) ) for row in unique_person_categories: fact = dict( row ) fact[ "fact_type" ] = "person_category" facts.append( fact ) unique_shared_keywords = ( deduplicate_rows( shared_keyword_rows, ( "left_person_element_id", "right_person_element_id", "keyword_element_id", ), ) ) for row in unique_shared_keywords: fact = dict( row ) fact[ "fact_type" ] = "shared_keyword" facts.append( fact ) unique_shared_categories = ( deduplicate_rows( shared_category_rows, ( "left_person_element_id", "right_person_element_id", "category_element_id", ), ) ) for row in unique_shared_categories: fact = dict( row ) fact[ "fact_type" ] = "shared_category" facts.append( fact ) unique_shared_authors = ( deduplicate_rows( shared_document_author_rows, ( "left_person_element_id", "right_person_element_id", "document_author_element_id", ), ) ) for row in unique_shared_authors: fact = dict( row ) fact[ "fact_type" ] = ( "shared_document_author" ) facts.append( fact ) if ( mentions_shared and len( unique_start_rows ) >= 2 ): ordered = sorted( unique_start_rows, key=lambda row: str( row.get( "person" ) or "" ), ) years = [ row.get( "start_year" ) for row in ordered ] known_years = [ year for year in years if year is not None ] same = ( len( known_years ) == len( years ) and len( set( known_years ) ) == 1 ) facts.append( { "fact_type": ( "start_year_comparison" ), "people": [ str( row.get( "person" ) or "" ) for row in ordered ], "start_years": { str( row.get( "person" ) or "" ): row.get( "start_year" ) for row in ordered }, "same_start_year": ( same ), "shared_start_year": ( known_years[0] if same else None ), } ) return facts PERSON_DOCUMENT_AUTHOR_FACTS_QUERY = """ MATCH (person:Person)-[:HAS_DOCUMENT]->(document:Document) MATCH (author:DocumentAuthor)-[:AUTHORED_DOCUMENT]->(document) WHERE person.graph_scope = $scope AND document.graph_scope = $scope AND author.graph_scope = $scope AND (size($person_ids) = 0 OR elementId(person) IN $person_ids) AND (size($author_ids) = 0 OR elementId(author) IN $author_ids) OPTIONAL MATCH (document)-[:IN_CATEGORY]->(category:Category) WHERE category.graph_scope = $scope WITH person, document, author, category WHERE size($category_ids) = 0 OR elementId(category) IN $category_ids RETURN DISTINCT elementId(person) AS person_element_id, person.name AS person, elementId(author) AS document_author_element_id, author.name AS document_author, elementId(category) AS category_element_id, category.name AS category, document.path AS document_path, document.source_url AS source_url ORDER BY person, document_author, document_path LIMIT $limit """ DOCUMENT_SOURCES_QUERY = """ MATCH (document:Document) WHERE document.graph_scope = $scope AND document.path IN $paths RETURN document.path AS document_path, document.source_url AS source_url ORDER BY document_path """ def search_graph_with_session( session: Any, query: str, *, limit: int = DEFAULT_LIMIT, max_entities: int = DEFAULT_MAX_ENTITIES, ) -> dict[str, Any]: clean_query = str(query or "").strip() if not clean_query: raise ValueError("Query nesmie byť prázdny") if limit < 1: raise ValueError("Limit musí byť aspoň 1") if max_entities < 1: raise ValueError("max_entities musí byť aspoň 1") constraints = extract_query_constraints(clean_query) catalog = run_rows(session, CATALOG_QUERY, scope=GRAPH_SCOPE) # Explicit tag filters are resolved separately. Incidental names such as the # tag 'zp' in 'ZP Wiki' must never become additional query constraints. phrase = constraints["keyword_phrase"] entities = resolve_entities( clean_query, catalog, max_entities=max(len(catalog), max_entities), preferred_kinds=constraints["target_kinds"], ) if phrase: keywords = explicit_keyword_entities(phrase, catalog) entities = [row for row in entities if row.get("kind") != "Keyword"] + keywords constraints["unresolved_keyword"] = not bool(keywords) else: constraints["unresolved_keyword"] = False if constraints["mentions_keyword"]: entities = [row for row in entities if row.get("kind") != "Keyword"] elif "zp wiki" in normalize_for_match(clean_query): entities = [row for row in entities if not ( row.get("kind") == "Keyword" and row.get("matched_alias") == "zp" )] author_mentions = named_author_phrases(clean_query) if constraints["mentions_document_author"] else [] requested_authors = [ {**row, "matched_alias": normalize_for_match(row.get("name", "")), "match_score": score} for row in catalog if row.get("kind") == "DocumentAuthor" for requested in author_mentions for score, _ in [person_name_match(requested, row.get("name", ""))] if score > 0 ] if author_mentions: author_names = {row["matched_alias"] for row in requested_authors} entities = [row for row in entities if row.get("kind") != "DocumentAuthor" and not ( row.get("kind") == "Person" and row.get("matched_alias") in author_names )] + requested_authors requested_names = requested_name_phrases(clean_query) unknown_names = [] for requested in requested_names: if phrase and normalize_for_match(requested) in normalize_for_match(phrase): continue personal = [row for row in catalog if row.get("kind") in {"Person", "DocumentAuthor"}] known_person = any(person_name_match(requested, row.get("name", ""))[0] > 0 for row in personal) known_title = any( normalize_for_match(requested) in alias for row in catalog if row.get("kind") not in {"Person", "DocumentAuthor"} for alias in entity_aliases(row) ) if len(requested.split()) == 1: # A verb can precede the first inflected word of a fully resolved # thesis title. That is a title anchor, not an unknown person. known_title = known_title or any( tokens_match(normalize_for_match(requested), word) for row in entities if row.get('kind') in {'Thesis', 'Work'} for alias in entity_aliases(row) for word in alias.split() ) if not known_person and not known_title: unknown_names.append(requested) constraints["requested_person_names"] = requested_names constraints["unresolved_person_names"] = unknown_names constraints["unresolved_document_author"] = bool(author_mentions and not requested_authors) aliases_to_ids: dict[tuple[str, str], set[str]] = {} for row in entities: if row.get("kind") in {"Person", "DocumentAuthor"}: key = (row["kind"], row["matched_alias"]) aliases_to_ids.setdefault(key, set()).add(str(row["element_id"])) ambiguous_names = [name for (_, name), matches in aliases_to_ids.items() if len(matches) > 1] constraints["ambiguous_person_names"] = sorted(set(ambiguous_names)) # Preserve explicitly mentioned people when a longer topic/title also matches. entities.sort(key=lambda row: (row.get("kind") != "Person", -row["match_score"])) resolved_entities = entities[:max_entities] entity_limit_reached = len(entities) > max_entities def ids(kind: str) -> list[str]: return [str(row["element_id"]) for row in resolved_entities if row.get("kind") == kind and row.get("element_id")] person_ids = ids("Person") thesis_ids, work_ids = ids("Thesis"), ids("Work") keyword_ids, category_ids = ids("Keyword"), ids("Category") author_ids = ids("DocumentAuthor") year_specific = bool(constraints["years"] or constraints["mentions_academic_year"]) title_specific = constraints["mentions_title"] thesis_specific = bool( constraints["mentions_thesis"] or thesis_ids or ((title_specific or year_specific) and not constraints["mentions_work"]) ) and not constraints["mentions_start_year"] work_specific = bool( constraints["mentions_work"] or work_ids or ((title_specific or year_specific) and not constraints["mentions_thesis"]) ) and not constraints["mentions_start_year"] shared_keyword = bool( constraints["mentions_shared"] and len(person_ids) >= 2 and (constraints["mentions_keyword"] or constraints["mentions_topic"]) ) shared_category = bool( constraints["mentions_shared"] and len(person_ids) >= 2 and constraints["mentions_category"] ) shared_author = bool( constraints["mentions_shared"] and len(person_ids) >= 2 and constraints["mentions_document_author"] ) keyword_specific = bool( not shared_keyword and ( (keyword_ids and not (thesis_specific or work_specific)) or (person_ids and constraints["mentions_keyword"]) ) ) category_specific = bool( not shared_category and (category_ids or (person_ids and constraints["mentions_category"])) ) author_specific = bool(not shared_author and constraints["mentions_document_author"]) structured = any(( thesis_specific, work_specific, constraints["mentions_start_year"], keyword_specific, category_specific, author_specific, shared_keyword, shared_category, shared_author, )) # Fetch complete structured evidence before filtering/counting and applying # the presentation limit. The catalog bounds the number of academic nodes; # relational lookups use a conservative bound covering the managed graph. fetch_limit = max(limit, len(catalog) ** 2) common = {"scope": GRAPH_SCOPE, "person_ids": person_ids, "limit": fetch_limit} blocked_reasons = [] if constraints["unresolved_keyword"]: blocked_reasons.append("unknown_keyword") if unknown_names: blocked_reasons.append("unknown_person") if constraints["unresolved_document_author"]: blocked_reasons.append("unknown_document_author") if ambiguous_names: blocked_reasons.append("ambiguous_person") if constraints["mentions_count"] and not (thesis_specific or work_specific): blocked_reasons.append("unsupported_count_domain") # A quoted unknown work title is a failed exact lookup, not a global listing. quoted_titles = re.findall(r'[„“"«]([^„“”"»]+)[“”"»]', clean_query) if quoted_titles and (thesis_specific or work_specific) and not thesis_ids and not work_ids: blocked_reasons.append("unknown_title") constraints["unresolved_title"] = True blocked = bool(blocked_reasons) def fetch(enabled: bool, cypher: str, **params: Any) -> list[dict[str, Any]]: return run_rows(session, cypher, **common, **params) if enabled and not blocked else [] thesis_rows = fetch(thesis_specific, THESIS_FACTS_QUERY, thesis_ids=thesis_ids, years=constraints["years"], thesis_types=constraints["thesis_types"]) work_rows = fetch(work_specific, WORK_FACTS_QUERY, work_ids=work_ids, years=constraints["years"], work_types=constraints["work_types"]) if title_specific: thesis_rows = [row for row in thesis_rows if row.get("title")] work_rows = [row for row in work_rows if row.get("title")] start_rows = fetch(constraints["mentions_start_year"], START_YEAR_FACTS_QUERY, years=constraints["years"]) keyword_rows = fetch(keyword_specific, PERSON_KEYWORD_FACTS_QUERY, keyword_ids=keyword_ids) category_rows = fetch(category_specific, PERSON_CATEGORY_FACTS_QUERY, category_ids=category_ids) author_rows = fetch(author_specific, PERSON_DOCUMENT_AUTHOR_FACTS_QUERY, author_ids=author_ids, category_ids=category_ids) shared_keyword_rows = fetch(shared_keyword, SHARED_KEYWORD_FACTS_QUERY) if keyword_ids: shared_keyword_rows = [row for row in shared_keyword_rows if str(row.get("keyword_element_id")) in keyword_ids] shared_category_rows = fetch(shared_category, SHARED_CATEGORY_FACTS_QUERY) if category_ids: shared_category_rows = [row for row in shared_category_rows if str(row.get("category_element_id")) in category_ids] shared_author_rows = fetch(shared_author, SHARED_DOCUMENT_AUTHOR_FACTS_QUERY) # Conjunctions must be witnessed by the same person, not by unrelated rows. if keyword_specific and (thesis_specific or work_specific): matching_people = {row.get("person_element_id") for row in keyword_rows} thesis_rows = [row for row in thesis_rows if row.get("person_element_id") in matching_people] work_rows = [row for row in work_rows if row.get("person_element_id") in matching_people] academic_people = {row.get("person_element_id") for row in thesis_rows + work_rows} keyword_rows = [row for row in keyword_rows if row.get("person_element_id") in academic_people] shared_years: list[int] | None = None if (not blocked and constraints["mentions_academic_year"] and constraints["mentions_shared"] and len(person_ids) >= 2 and not (shared_keyword or shared_category or shared_author)): years_by_person = { person: {row["year"] for row in thesis_rows + work_rows if row.get("person_element_id") == person and row.get("year") is not None} for person in person_ids } shared_years = sorted(set.intersection(*years_by_person.values())) thesis_rows = [row for row in thesis_rows if row.get("year") in shared_years] work_rows = [row for row in work_rows if row.get("year") in shared_years] paths: list[dict[str, Any]] = [] for enabled, cypher, rows, kind, node_ids, types in ( (thesis_specific, THESIS_PATHS_QUERY, thesis_rows, "Thesis", thesis_ids, constraints["thesis_types"]), (work_specific, WORK_PATHS_QUERY, work_rows, "Work", work_ids, constraints["work_types"]), ): params = ({"thesis_ids": node_ids, "thesis_types": types} if kind == "Thesis" else {"work_ids": node_ids, "work_types": types}) returned = fetch(enabled, cypher, years=constraints["years"], **params) allowed = {str(row.get("thesis_element_id") or row.get("work_element_id")) for row in rows} paths.extend(path for path in returned if any( node.get("kind") == kind and str(node.get("element_id")) in allowed for node in path.get("nodes", []) )) if not structured and resolved_entities and not blocked: start_ids = [str(row["element_id"]) for row in resolved_entities] params = {"scope": GRAPH_SCOPE, "start_ids": start_ids, "limit": limit} if constraints["mentions_shared"] and len(start_ids) >= 2: paths.extend(run_rows(session, PAIR_PATH_QUERY, **params)) else: paths.extend(run_rows(session, NEIGHBORHOOD_QUERY, target_kinds=constraints["target_kinds"], **params)) paths = deduplicate_paths(paths) facts = build_facts( thesis_rows, work_rows, start_rows, keyword_rows, category_rows, shared_keyword_rows, shared_category_rows, shared_author_rows, mentions_shared=constraints["mentions_shared"], ) for row in author_rows: facts.append({**row, "fact_type": "person_document_author"}) if row.get("category") and not any( fact.get("fact_type") == "person_category" and fact.get("person_element_id") == row.get("person_element_id") and fact.get("document_path") == row.get("document_path") and fact.get("category") == row.get("category") for fact in facts ): facts.append({**row, "fact_type": "person_category"}) if shared_years is not None: facts.append({"fact_type": "academic_year_comparison", "shared_years": shared_years, "same_academic_year": bool(shared_years), "people": [row["name"] for row in resolved_entities if row.get("kind") == "Person"]}) aggregation = None if constraints["mentions_count"] and not blocked: matching = sorted({str(row["person"]) for row in thesis_rows + work_rows}) aggregation = {"count": len(matching), "people": matching, "candidate_count": len(person_ids), "complete": not entity_limit_reached} # Sources of rejected candidates still document the comparison. Fetch their # actual stored URLs rather than manufacturing URLs from expected answers. documents = collect_documents(resolved_entities, paths, facts) sources = run_rows(session, DOCUMENT_SOURCES_QUERY, scope=GRAPH_SCOPE, paths=[row["document_path"] for row in documents]) if documents else [] by_path = {row["document_path"]: row.get("source_url") for row in sources} for document in documents: if not document.get("source_url"): document["source_url"] = by_path.get(document["document_path"]) strategies = [] for active, name in ( (resolved_entities, "entity_resolution"), (paths, "graph_traversal"), (thesis_rows, "thesis_constraints"), (work_rows, "work_constraints"), (start_rows, "start_year_constraints"), (keyword_rows, "person_keyword_constraints"), (category_rows, "person_category_constraints"), (author_rows, "person_document_author_constraints"), (shared_keyword_rows, "shared_keyword_constraints"), (shared_category_rows, "shared_category_constraints"), (shared_author_rows, "shared_document_author_constraints"), ): if active: strategies.append(name) positive_facts = [row for row in facts if row.get("fact_type") not in { "academic_year_comparison", "start_year_comparison" }] status = "ambiguous" if ambiguous_names else ( "no_answer" if blocked or not (positive_facts or paths or aggregation is not None) else "ok" ) reason = blocked_reasons[0] if blocked_reasons else ( "no_matching_facts" if status == "no_answer" else None ) return { "status": status, "reason": reason, "reasons": blocked_reasons, "query": clean_query, "strategies": strategies, "constraints": constraints, "resolved_entities": resolved_entities, "paths": paths[:limit], "facts": facts[:limit], "documents": documents, "aggregation": aggregation, "checked_entities": resolved_entities, "rejection_evidence": [row for row in facts if row.get("fact_type") in { "academic_year_comparison", "start_year_comparison" } and not positive_facts], "truncated": entity_limit_reached or len(facts) > limit or len(paths) > limit, } def search_graph( query: str, *, limit: int = ( DEFAULT_LIMIT ), max_entities: int = ( DEFAULT_MAX_ENTITIES ), ) -> dict[str, Any]: from .graph_db import ( create_neo4j_driver, get_neo4j_settings, ) settings = ( get_neo4j_settings() ) with create_neo4j_driver( settings ) as driver: driver.verify_connectivity() with driver.session( database=( settings.database ) ) as session: return ( search_graph_with_session( session, query, limit=limit, max_entities=( max_entities ), ) ) def main() -> None: parser = ( argparse.ArgumentParser() ) parser.add_argument( "query" ) parser.add_argument( "--limit", type=int, default=DEFAULT_LIMIT, ) parser.add_argument( "--max-entities", type=int, default=( DEFAULT_MAX_ENTITIES ), ) args = parser.parse_args() result = search_graph( args.query, limit=args.limit, max_entities=( args.max_entities ), ) print( json.dumps( result, ensure_ascii=False, indent=2, ) ) if __name__ == "__main__": main()