dp-zp-agent/test/test_person_names.py

377 lines
15 KiB
Python

"""Name correction must preserve constraints and fail closed before retrieval."""
import copy
import json
import sqlite3
from pathlib import Path
import pytest
from tools.retrieval.person_names import resolve_person_names, sqlite_people
from tools.graphrag import graph_search as gs
from tools.graphrag.tests.test_graph_search import FakeSession
def person(name, slug):
return {
"kind": "Person",
"name": name,
"element_id": slug,
"source_document": f"pages/students/2022/{slug}/README.md",
}
@pytest.fixture
def people():
return [
person("Ján Holp", "jan_holp"),
person("Ondrej Megela", "ondrej_megela"),
person("Ján Malinovský", "jan_malinovsky"),
person("Ján Pták", "jan_ptak"),
person("Martin Šarišský", "martin_sarissky"),
person("Martin Jančura", "martin_jancura"),
]
@pytest.mark.parametrize(
"mention,name",
[
("Jan Holp", "Ján Holp"),
("JÁN HOLP", "Ján Holp"),
("Jána Holpa", "Ján Holp"),
(" Jan Holp", "Ján Holp"),
("Holp Ján", "Ján Holp"),
("Holpa Jána", "Ján Holp"),
("Holp, Jan", "Ján Holp"),
("Ondreja Megelu", "Ondrej Megela"),
("Martina Šarišského", "Martin Šarišský"),
("Jána Malinovského", "Ján Malinovský"),
("Ján Holpp", "Ján Holp"),
("Ján Hlop", "Ján Holp"),
("Ondrej Megla", "Ondrej Megela"),
("Ján malinkvsý", "Ján Malinovský"),
("Ján Ptká", "Ján Pták"),
],
)
def test_variants_and_bounded_typos(people, mention, name):
q = f"Aké práce má {mention} v roku 1999?"
before = copy.deepcopy(people)
r = resolve_person_names(q, people)
assert r["status"] == "ok" and [m["name"] for m in r["matches"]] == [name]
assert (
r["resolved_query"]
== q[: q.index(mention.strip())]
+ name
+ q[q.index(mention.strip()) + len(mention.strip()) :]
)
assert "v roku 1999?" in r["resolved_query"] and people == before
def test_multiple_people_and_all_non_name_conditions_survive(people):
q = "Porovnaj Jána Holppa a Ondreja Megelu; nie diplomový projekt, iba práce z roku 2021 s tagom RAG."
r = resolve_person_names(q, people)
assert r["status"] == "ok"
assert (
r["resolved_query"]
== "Porovnaj Ján Holp a Ondrej Megela; nie diplomový projekt, iba práce z roku 2021 s tagom RAG."
)
assert len(r["notices"]) == 1 and "Ján Holp" in r["notices"][0]
def test_plural_keyword_question_does_not_hide_a_misspelled_person(people):
q = "Aké spoločné kľúčové slová majú Ján Ptká a Ján Malinovský?"
r = resolve_person_names(q, people)
assert r["status"] == "ok"
assert [m["name"] for m in r["matches"]] == ["Ján Pták", "Ján Malinovský"]
assert r["resolved_query"] == q.replace("Ptká", "Pták")
@pytest.mark.parametrize(
"instruction",
[
"potom pri každej osobe príslušné práce s typom a názvom, ak je uvedený",
"potom pri každej osobe relevantné práce",
"potom pri každom študentovi jednotlivé práce",
"potom pre obe osoby príslušné práce",
"potom pri vybraných osobách stručné zhrnutie",
],
)
def test_presentation_instructions_are_not_additional_people(people, instruction):
q = (
"V ktorých rokoch majú Ján Holp a Ondrej Megela obaja evidovanú prácu alebo projekt? "
"Najprv uveď spoločné roky, " + instruction + ". Pridaj zdroje."
)
r = resolve_person_names(q, people)
assert r["status"] == "ok"
assert [m["name"] for m in r["matches"]] == ["Ján Holp", "Ondrej Megela"]
assert r["resolved_query"] == q and r["notices"] == []
@pytest.mark.parametrize("mention", ["Xaver Neexistujúci", "xaver neexistujuci"])
def test_generic_person_reference_does_not_hide_another_unknown_subject(people, mention):
q = f"Porovnaj Ján Holp a {mention}. Pri každej osobe uveď príslušné práce."
r = resolve_person_names(q, people)
assert r["status"] == "no_answer" and r["reason"] == "unknown_person"
assert r["mention"] == mention
@pytest.mark.parametrize(
"q",
[
"Aké práce má Xaver Neexistujúci?",
"Aké práce má xaver neexistujuci?",
"Aký je názov práce xaver neexistujuci?",
"Porovnaj práce Jána Holpa a Xavera Neexistujúceho.",
"Porovnaj xaver neexistujuci a jan holp.",
"Porovnaj jan holp a xaver neexistujuci.",
"Aké práce má Ján Hxxxx?",
"Aké práce má Xaver?",
"Z kandidátov Ján Holp, neznama osoba, Ondrej Megela vyber osoby s prácou v roku 2024.",
"Z kandidátov Ján Holp a neznama osoba vyber osoby s prácou v roku 2024.",
],
)
def test_unknown_name_cannot_turn_into_global_query(people, q):
r = resolve_person_names(q, people)
assert r["status"] == "no_answer" and r["reason"] == "unknown_person" and not r["candidates"]
session = FakeSession({gs.CATALOG_QUERY: people})
graph = gs.search_graph_with_session(session, q)
assert graph["status"] == "no_answer" and graph["facts"] == []
assert all(statement == gs.CATALOG_QUERY for statement, _ in session.calls)
def test_nearby_catalog_names_require_selection_and_exact_name_wins():
catalog = [person("Martin Novák", "novak"), person("Martin Novík", "novik")]
r = resolve_person_names("Aké práce má Martin Novek?", catalog)
assert r["status"] == "ambiguous"
assert {c["name"] for c in r["candidates"]} == {"Martin Novák", "Martin Novík"}
assert (
resolve_person_names("Aké práce má Martin Novák?", catalog)["matches"][0]["name"]
== "Martin Novák"
)
def test_short_first_name_is_not_automatically_assigned(people):
people = [*people, {"kind": "DocumentAuthor", "name": "Ján Holp"}]
r = resolve_person_names("Aké práce má Ján?", people)
assert r["status"] == "ambiguous" and len(r["candidates"]) == 3
assert all(c["name"].startswith("Ján ") for c in r["candidates"])
def test_full_exact_name_wins_over_another_person_inflection():
catalog = [person("Ján Novák", "jan"), person("Jana Novák", "jana")]
r = resolve_person_names("Aké práce má Jana Novák?", catalog)
assert r["matches"][0]["name"] == "Jana Novák"
def test_competing_valid_inflections_require_selection():
catalog = [person("Ján Novák", "jan"), person("Jana Novák", "jana")]
r = resolve_person_names("Aký je názov práce Jana Novaka?", catalog)
assert r["status"] == "ambiguous"
assert {c["name"] for c in r["candidates"]} == {"Ján Novák", "Jana Novák"}
def test_rag_evidence_uses_resolved_identity_not_another_name_inflection():
from tools.answers.rag_evidence import rag_evidence_context
from tools.rag.rag_utils import build_context_text
catalog = [person("Ján Novák", "jan"), person("Jana Novák", "jana")]
q = "Aký je názov diplomovej práce Jana Novák?"
sources = [
{
"source_id": f"S{i}",
"title": row["name"],
"document_path": row["source_document"],
"source_url": f"https://example.test/{i}",
"text": "Sekcia: Diplomová práca 2024\nNázov: Výskum jazyka " + str(i) + ".",
}
for i, row in enumerate(catalog, 1)
]
payload = {
"sources": sources,
"context": build_context_text(sources),
"name_resolution": resolve_person_names(q, catalog),
}
c = rag_evidence_context(payload["name_resolution"]["resolved_query"], payload)
assert c["requirements"]["people"] == ["Jana Novák"]
assert {f["person"] for f in c["facts"]} == {"Jana Novák"}
def test_canonicalized_question_keeps_natural_answer_grammar():
from tools.answers.presentation import fact_sentences
claim = {
"person": "Ján Holp",
"work_type": "diploma_thesis",
"title": "Ukážkový výskum",
"year": 2021,
}
text = fact_sentences(claim, "Aký je názov diplomovej práce Ján Holp?")
assert text.startswith("Ján Holp má diplomovú prácu s názvom") and "2021" in text
assert fact_sentences(claim, "Aký je názov diplomovej práce Jána Holpa?").startswith(
"Diplomová práca Jána Holpa"
)
def test_two_damaged_tokens_are_never_guessed(people):
assert resolve_person_names("Aké práce má Jaann Hlop?", people)["status"] == "no_answer"
def test_document_author_is_not_implicitly_a_student(people):
catalog = [*people, {"kind": "DocumentAuthor", "name": "Daniel Hladek"}]
r = resolve_person_names("Aké práce má Daniel Hladek?", catalog)
assert r["status"] == "no_answer"
author = resolve_person_names("Ktoré dokumenty napísal autor Daniel Hladekk?", catalog)
assert author["status"] == "ok" and author["matches"][0]["name"] == "Daniel Hladek"
def test_duplicate_names_can_be_selected_by_actual_profile_path():
catalog = [person("Ján Novák", "novak_a"), person("Ján Novák", "novak_b")]
q = "Aké práce má Ján Novák?"
assert resolve_person_names(q, catalog)["status"] == "ambiguous"
selected = resolve_person_names(q + " Profil: " + catalog[1]["source_document"], catalog)
assert (
selected["status"] == "ok"
and selected["matches"][0]["source_document"] == catalog[1]["source_document"]
)
def test_multiword_name_supports_surname_first():
r = resolve_person_names(
"Aké práce má Chis Bogdan Paul?", [person("Bogdan Paul Chiș", "bogdan")]
)
assert r["status"] == "ok" and r["matches"][0]["name"] == "Bogdan Paul Chiș"
def test_literal_nonstandard_catalog_name_preserves_numeric_suffix():
catalog = [person("Martin NaN1", "nan")]
for mention in ("Martin NaN1", "NaN1 Martin"):
r = resolve_person_names("Aké práce má " + mention + " v roku 1999?", catalog)
assert (
r["status"] == "ok" and r["resolved_query"] == "Aké práce má Martin NaN1 v roku 1999?"
)
assert resolve_person_names("Aké práce má Martin NaN2?", catalog)["status"] == "no_answer"
@pytest.mark.parametrize(
"q",
[
"Ktorí študenti majú tag RAG a diplomový projekt v roku 2026?",
"Aké práce boli v roku 2020?",
"Vyhľadaj práce s názvom „Ján Holpp“.",
"Ktorí študenti majú tag Ján Holpp?",
"Ktorí študenti majú kľúčové slovo Ján Holpp a prácu v roku 2026?",
],
)
def test_global_queries_and_quoted_titles_are_not_corrected_as_people(people, q):
r = resolve_person_names(q, people)
assert r["status"] == "ok" and r["resolved_query"] == q and not r["matches"]
def test_malinovsky_sarissky_never_matches_martin_jancura(people):
r = resolve_person_names("Porovnaj Jána Malinovského a Martina Šarišského.", people)
assert {m["name"] for m in r["matches"]} == {"Ján Malinovský", "Martin Šarišský"}
@pytest.fixture
def names_db(tmp_path, people):
path = tmp_path / "index.sqlite"
with sqlite3.connect(path) as conn:
conn.execute("CREATE TABLE documents(path,title,published)")
for row in people:
conn.execute(
"INSERT INTO documents VALUES(?,?,?)", (row["source_document"], row["name"], 1)
)
conn.execute(
"INSERT INTO documents VALUES(?,?,?)",
("pages/students/2022/hidden/README.md", "Xaver Skrytý", 0),
)
return path
def test_catalog_honors_publication_policy(names_db):
assert not any(p["name"] == "Xaver Skrytý" for p in sqlite_people(names_db))
assert any(p["name"] == "Xaver Skrytý" for p in sqlite_people(names_db, published_only=False))
@pytest.mark.parametrize(
"q,status",
[("Aké práce má Xaver Neexistujúci?", "no_answer"), ("Aké práce má Ján?", "ambiguous")],
)
def test_rag_does_not_search_or_generate_for_unresolved_subject(names_db, monkeypatch, q, status):
from tools.rag import rag_utils
from tools.answers.answer_delivery import deliver_answer
monkeypatch.setattr(
rag_utils, "search_database", lambda *a, **kw: pytest.fail("Must not search globally")
)
r = deliver_answer(
q, engine="rag", db_file=names_db, complete=lambda _: pytest.fail("Must not call model")
)
assert r["status"] == status and r["accepted_for_delivery"] and r["source_urls"] == []
def test_graph_correction_retains_year_type_and_person_ids(people):
session = FakeSession({gs.CATALOG_QUERY: people})
q = "Aké diplomové práce má Jan Holpp v roku 1999?"
r = gs.search_graph_with_session(session, q)
assert r["query"] == q and "Ján Holp" in r["resolved_query"]
params = next(
params for statement, params in session.calls if statement == gs.THESIS_FACTS_QUERY
)
assert params["person_ids"] == ["jan_holp"] and params["years"] == [1999]
assert r["constraints"]["thesis_types"] == ["diploma_thesis"]
def test_both_engines_use_same_resolution_and_notice(names_db, people, monkeypatch):
from tools.answers import answer_delivery as delivery
from tools.rag import rag_utils
question = "Aké práce má Ján Holpp v roku 1999?"
resolution = resolve_person_names(question, people)
payload = {
"status": "ok",
"sources": [],
"context": "",
"name_resolution": resolution,
"resolved_query": resolution["resolved_query"],
"index_version": None,
}
checked = {
"status": "no_answer",
"reason": "no_matching_facts",
"answer": "Pre rok 1999 sa údaje nenašli.",
"source_urls": [],
"claims": [],
"accepted_for_delivery": True,
"complete": True,
"truncated": False,
"truncation_reasons": [],
}
monkeypatch.setattr(
rag_utils,
"search_database",
lambda *a, **k: {"results": [], "engine": "hybrid", "strategies": []},
)
monkeypatch.setattr(delivery, "build_rag_context", lambda *a, **k: payload)
monkeypatch.setattr(delivery, "expand_comparison_sources", lambda db, q, p: p)
monkeypatch.setattr(delivery, "prepare_named_content", lambda db, q, p: p)
monkeypatch.setattr(delivery, "compact_rag_payload", lambda p: p)
def rag(q, p, model):
assert q == resolution["resolved_query"]
return dict(checked)
monkeypatch.setattr(delivery, "generate_verified_rag", rag)
rag_result = delivery.deliver_answer(question, engine="rag", db_file=names_db)
monkeypatch.setattr(delivery, "search_graph", lambda *a, **k: payload)
monkeypatch.setattr(delivery, "graph_context", lambda p: {"name_resolution": resolution})
def graph(q, context, prompt, model):
assert q == resolution["resolved_query"]
return {"accepted_for_delivery": True, "answer": dict(checked), "attempts": []}
monkeypatch.setattr(delivery, "generate_checked_answer", graph)
graph_result = delivery.deliver_answer(question, engine="graphrag", db_file=names_db)
assert rag_result["answer"] == graph_result["answer"]
assert rag_result["answer"].startswith("Rozpoznané meno: **Ján Holp**")