dp-zp-agent/tools/graphrag/graph_search.py

2426 lines
69 KiB
Python

from __future__ import annotations
import argparse
import json
import re
import unicodedata
from typing import Any, Iterable
from tools.retrieval.publication import DEFAULT_PUBLISHED_ONLY, PUBLICATION_POLICY_VERSION
from .publication import public_query
from tools.retrieval.retrieval_contract import result_fields, source_references, error_result
from tools.retrieval.question_constraints import unsupported_constraint, constraint_message
from tools.retrieval.person_names import resolve_person_names, name_token_variants
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."""
quoted = re.search(
r"\b(?:kľúčové\s+slovo|kľúčovým\s+slovom|kľúčového\s+slova|"
r"klucove\s+slovo|klucovym\s+slovom|klucoveho\s+slova|tagom|tagu|tag)"
r'\s+[„“"«]([^„“”"»]+)[“”"»]',
query,
flags=re.I,
)
if quoted:
return normalize_for_match(quoted[1])
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+(?:zaroven|sucasne|diplomov\w*|bakalarsk\w*|"
r"dizertacn\w*|timov\w*|vyskumn\w*|projekt\w*|prac\w*|jej|jeho|ich|rok|pomenuj|uved\w*)|"
r"zaroven\b|sucasne\b|(?:v|z|pre)\s+rok\w*\b|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(\d{4})\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"))
or (
mentions_academic_year
and bool(token_set & {"obaja", "obidvaja", "obe", "obidve", "vsetci"})
)
)
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",
)
)
)
# Asking *which works* requests records, even without the word "title".
# Keep this separate from title-only lookup: untitled projects must survive.
academic_noun = r"(?:prac(?:a|e|u)?|projekt(?:y|ov|u)?)"
modifiers = (
r"(?:(?:vsetk\w*|(?:akademick|zaverecn|bakalarsk|diplomov|timov|vyskumn|dizertacn)\w*)\s+)*"
)
mentions_work_listing = (
bool(
re.search(
rf"\b(?:ake|aku|aky|ktore|ktoru|ktory|vypis|vymenuj|uved|zobraz|ukaz|zoznam|prehlad)\s+{modifiers}{academic_noun}\b",
normalized,
)
or re.search(
r"\b(?:ake|ktore)\s+(?:ma|mal|maju|mali)\b.*?\b(?:prace|projekty)\b", normalized
)
)
and not bool(token_set & {"kolko", "pocet", "spocitaj"})
and not mentions_person_answer
)
mentions_thesis = bool(thesis_types) or (
has_stem(
tokens,
"zaverec",
)
and has_work_word
)
mentions_work = bool(work_types) or (
mentions_work_listing and has_project_word and not has_work_word
)
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 or mentions_work_listing) 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,
"mentions_work_listing": mentions_work_listing,
"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 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 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, document.title AS title
ORDER BY document_path
"""
# Restrict the complete retrieval pipeline, including vocabulary and traversal.
CATALOG_QUERY = public_query(CATALOG_QUERY, nodes=("n",))
NEIGHBORHOOD_QUERY = public_query(NEIGHBORHOOD_QUERY, path=True)
PAIR_PATH_QUERY = public_query(PAIR_PATH_QUERY, path=True)
THESIS_FACTS_QUERY = public_query(THESIS_FACTS_QUERY, nodes=("person",), documents=("document",))
WORK_FACTS_QUERY = public_query(WORK_FACTS_QUERY, nodes=("person",), documents=("document",))
THESIS_PATHS_QUERY = public_query(THESIS_PATHS_QUERY, nodes=("person",), documents=("document",))
WORK_PATHS_QUERY = public_query(WORK_PATHS_QUERY, nodes=("person",), documents=("document",))
START_YEAR_FACTS_QUERY = public_query(START_YEAR_FACTS_QUERY, nodes=("person",))
PERSON_KEYWORD_FACTS_QUERY = public_query(
PERSON_KEYWORD_FACTS_QUERY, nodes=("person",), documents=("document",)
)
PERSON_CATEGORY_FACTS_QUERY = public_query(
PERSON_CATEGORY_FACTS_QUERY, nodes=("person",), documents=("document",)
)
PERSON_DOCUMENT_AUTHOR_FACTS_QUERY = public_query(
PERSON_DOCUMENT_AUTHOR_FACTS_QUERY, nodes=("person",), documents=("document",)
)
SHARED_KEYWORD_FACTS_QUERY = public_query(
SHARED_KEYWORD_FACTS_QUERY,
nodes=("left", "right"),
documents=("left_document", "right_document"),
)
SHARED_CATEGORY_FACTS_QUERY = public_query(
SHARED_CATEGORY_FACTS_QUERY,
nodes=("left", "right"),
documents=("left_document", "right_document"),
)
SHARED_DOCUMENT_AUTHOR_FACTS_QUERY = public_query(
SHARED_DOCUMENT_AUTHOR_FACTS_QUERY,
nodes=("left", "right"),
documents=("left_document", "right_document"),
)
DOCUMENT_SOURCES_QUERY = public_query(DOCUMENT_SOURCES_QUERY, documents=("document",))
def search_graph_with_session(
session: Any,
query: str,
*,
limit: int = DEFAULT_LIMIT,
max_entities: int = DEFAULT_MAX_ENTITIES,
published_only: bool = DEFAULT_PUBLISHED_ONLY,
question_plan=None,
) -> 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)
unsupported = unsupported_constraint(clean_query)
if unsupported:
return {
**result_fields(
status="ambiguous", sources=[], reason="unsupported_constraint", complete=False
),
"query": clean_query,
"constraints": constraints,
"clarification": constraint_message(unsupported),
"facts": [],
"paths": [],
"documents": [],
"aggregation": None,
"resolved_entities": [],
"checked_entities": [],
"rejection_evidence": [],
"strategies": [],
"reasons": ["unsupported_constraint"],
}
catalog = run_rows(session, CATALOG_QUERY, scope=GRAPH_SCOPE, published_only=published_only)
original_query = clean_query
resolution = resolve_person_names(clean_query, catalog)
if resolution["status"] != "ok":
return {
**result_fields(
status=resolution["status"], sources=[], reason=resolution["reason"], complete=False
),
"query": original_query,
"constraints": constraints,
"name_resolution": resolution,
"clarification": resolution["message"] if resolution["status"] == "ambiguous" else None,
"facts": [],
"paths": [],
"documents": [],
"aggregation": None,
"resolved_entities": [],
"checked_entities": [],
"rejection_evidence": [],
"strategies": [],
"reasons": [resolution["reason"]],
}
clean_query = resolution["resolved_query"]
constraints = extract_query_constraints(clean_query)
if question_plan is not None:
from tools.answers.question_plan import QuestionPlan
spec=question_plan if isinstance(question_plan,QuestionPlan) else QuestionPlan.model_validate(question_plan)
if normalize_for_match(spec.question)!=normalize_for_match(clean_query):
raise ValueError('Question plan does not match the graph request')
if spec.intent!='metadata' or spec.unsupported:
raise ValueError('Only validated metadata plans belong to this executor')
# Graph templates and projections stay fixed; semantic work filters
# come from the same plan as the text and content-graph adapters.
constraints['years']=sorted({year for work in spec.works for year in work.years})
kinds={work.work_type for work in spec.works if work.work_type}
constraints['thesis_types']=sorted(k for k in kinds if k.endswith('_thesis'))
constraints['work_types']=sorted(k for k in kinds if not k.endswith('_thesis'))
constraints["published_only"] = published_only
# 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"],
)
identity_paths = {
normalize_for_match(m["name"]): m["source_document"]
for m in resolution["matches"]
if m.get("source_document")
}
entities = [
row
for row in entities
if row.get("kind") != "Person"
or normalize_for_match(row.get("name", "")) not in identity_paths
or row.get("source_document") == identity_paths[normalize_for_match(row["name"])]
]
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
# The shared resolver has already rejected unknown/ambiguous subjects.
# Re-parsing prose here with a second role regex used to invent additional
# names from presentation instructions, despite successful resolution.
constraints["requested_person_names"] = list(
dict.fromkeys(normalize_for_match(match["name"]) for match in resolution["matches"])
)
constraints["unresolved_person_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"]
academic_specific = title_specific or year_specific or constraints["mentions_work_listing"]
thesis_specific = (
bool(
constraints["mentions_thesis"]
or thesis_ids
or (academic_specific and not constraints["mentions_work"])
)
and not constraints["mentions_start_year"]
)
work_specific = (
bool(
constraints["mentions_work"]
or work_ids
or (academic_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 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,
"published_only": published_only,
}
blocked_reasons = []
# A written year outside the current corpus is still a real constraint.
# Malformed/range notation is explicitly unsupported, never a global query.
explicit_year_tokens = re.findall(r"\b(?:roku|rokoch)\s+([^\s,?!]+)", clean_query, flags=re.I)
if any(
re.match(r"\d", token) and not re.fullmatch(r"\d{4}[.]?", token)
for token in explicit_year_tokens
):
blocked_reasons.append("unsupported_year_constraint")
if constraints["mentions_start_year"] and any(
row.get("kind") == "Person" and row.get("start_year") is None for row in resolved_entities
):
blocked_reasons.append("missing_documented_start_year")
if constraints["unresolved_keyword"]:
blocked_reasons.append("unknown_keyword")
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 = [
match[1]
for match in re.finditer(r'[„“"«]([^„“”"»]+)[“”"»]', clean_query)
if not re.search(
r"\b(?:tag|tagom|tagu|klucov\w*\s+slov\w*)$",
normalize_for_match(clean_query[: match.start()]),
)
]
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)
fetch_capped = False
def fetch(enabled: bool, cypher: str, **params: Any) -> list[dict[str, Any]]:
nonlocal fetch_capped
if not enabled or blocked:
return []
rows = run_rows(session, cypher, **{**common, "limit": fetch_limit + 1}, **params)
fetch_capped = fetch_capped or len(rows) > fetch_limit
return rows[:fetch_limit]
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 question_plan is not None:
thesis_rows=[row for row in thesis_rows if spec.matches_work(row.get('person'),row.get('heading'),row.get('thesis_type'),row.get('year'))]
work_rows=[row for row in work_rows if spec.matches_work(row.get('person'),row.get('heading'),row.get('work_type'),row.get('year'))]
from tools.answers.question_plan import ambiguous_work_choice
message=ambiguous_work_choice(spec,[{'person':r.get('person'),'work_label':r.get('heading')} for r in thesis_rows+work_rows])
if message:
return {**result_fields(status='ambiguous',sources=[],reason='ambiguous_work',complete=False),
'query':original_query,'constraints':constraints,'clarification':message,
'name_resolution':resolution,'facts':[],'paths':[],'documents':[],'aggregation':None,
'resolved_entities':entities,'strategies':[],'checked_entities':[],
'rejection_evidence':[],'reasons':['ambiguous_work']}
if title_specific and not constraints["mentions_work_listing"] and not person_ids:
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]] = []
path_capped = False
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 + 1,
"published_only": published_only,
}
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
)
)
path_capped = len(paths) > limit
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 or fetch_capped),
}
# 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],
published_only=published_only,
)
if documents
else []
)
by_path = {row["document_path"]: row.get("source_url") for row in sources}
if published_only:
documents = [row for row in documents if row["document_path"] in by_path]
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)
)
truncation_reasons = []
for active, name in (
(entity_limit_reached, "entity_limit"),
(fetch_capped, "evidence_limit"),
(len(facts) > limit, "fact_limit"),
(path_capped or len(paths) > limit, "path_limit"),
):
if active:
truncation_reasons.append(name)
titles = {row["document_path"]: row.get("title") for row in sources}
citations = source_references(
[{**doc, "title": titles.get(doc["document_path"])} for doc in documents]
)
visible_facts = facts[:limit]
# A display cap must not separate an academic row from the tag proof that
# licensed its selection. These are supporting predicates for visible
# records, never additional people/academic records in the visible list.
visible_academic_people = {
row.get("person_element_id")
for row in visible_facts
if row.get("fact_type") in {"person_thesis", "person_work"} and row.get("person_element_id")
}
supporting_facts = [
row
for row in facts[limit:]
if keyword_specific
and (thesis_specific or work_specific)
and row.get("fact_type") == "person_keyword"
and row.get("person_element_id") in visible_academic_people
]
if len(visible_facts) + len(supporting_facts) == len(facts):
# Moving predicate evidence to its support field loses no result.
truncation_reasons = [reason for reason in truncation_reasons if reason != "fact_limit"]
return {
**result_fields(
status=status,
sources=citations,
reason=reason,
complete=True,
truncation_reasons=truncation_reasons,
),
"reasons": blocked_reasons,
"query": original_query,
"resolved_query": clean_query,
"name_resolution": resolution,
"strategies": strategies,
"constraints": constraints,
"resolved_entities": resolved_entities,
"paths": paths[:limit],
"facts": visible_facts,
"supporting_facts": supporting_facts,
"documents": documents,
"aggregation": aggregation,
"academic_year_comparison": (
{
"shared_years": shared_years,
"people": [row["name"] for row in resolved_entities if row.get("kind") == "Person"],
"complete": not (entity_limit_reached or fetch_capped),
}
if shared_years is not None
else None
),
"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
],
}
def search_graph(
query: str,
*,
limit: int = (DEFAULT_LIMIT),
max_entities: int = (DEFAULT_MAX_ENTITIES),
published_only: bool = DEFAULT_PUBLISHED_ONLY,
question_plan=None,
) -> dict[str, Any]:
from .graph_db import (
create_neo4j_driver,
get_neo4j_settings,
)
from tools.sync.index_state import DATA_DIR, IndexUnavailableError, index_snapshot
from .build_graphrag import graph_version
settings = get_neo4j_settings()
with index_snapshot(DATA_DIR) as version:
if published_only and (
version is None or version.get("publication_policy") != PUBLICATION_POLICY_VERSION
):
raise IndexUnavailableError("Graf vyžaduje synchronizáciu pravidiel publikovania.")
with create_neo4j_driver(settings) as driver:
driver.verify_connectivity()
with driver.session(database=settings.database) as session:
if version is not None and graph_version(session) != version:
raise IndexUnavailableError("Neo4j graf nezodpovedá aktívnej verzii dát.")
result = search_graph_with_session(
session,
query,
limit=limit,
max_entities=max_entities,
published_only=published_only,
question_plan=question_plan,
)
if version is not None:
result["index_version"] = version
return result
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),
)
parser.add_argument(
"--include-unpublished",
action="store_true",
help="Výslovne zahrnie aj nepublikované dokumenty.",
)
args = parser.parse_args()
try:
result = search_graph(
args.query,
limit=args.limit,
max_entities=args.max_entities,
published_only=not args.include_unpublished,
)
except Exception as error:
result = error_result(error)
print(
json.dumps(
result,
ensure_ascii=False,
indent=2,
)
)
if result["status"] == "error":
raise SystemExit(1)
if __name__ == "__main__":
main()