dp-zp-agent/test/test_graph_query_regressions.py

361 lines
14 KiB
Python

"""Regressions for actual Claude questions and adversarial same-person joins."""
import copy
import json
from pathlib import Path
import pytest
from tools.answers.checked_generation import generate_checked_answer
from tools.answers.context_policy import answer_context_issues
from tools.answers.graph_context import graph_context
from tools.answers.render_answer import render_graph_answer
from tools.answers.answer_delivery import deliver_answer
from tools.graphrag import graph_search as gs
from tools.graphrag.tests.test_graph_search import FakeSession
from tools.retrieval.question_constraints import unsupported_constraint
PEOPLE = ["Ján Pták", "Ján Malinovský", "Valerii Kutsenko", "Jakub Schwarc"]
PROMPT = Path("tools/answers/graph_answer_prompt.txt").read_text()
def person(index, name):
return {
"kind": "Person",
"name": name,
"element_id": f"p{index}",
"source_document": f"pages/students/2022/p{index}/README.md",
}
def source(index):
return {
"document_path": f"pages/students/2022/p{index}/README.md",
"source_url": f"https://example.test/p{index}",
}
@pytest.fixture
def tagged_projects():
catalog = [person(i, name) for i, name in enumerate(PEOPLE, 1)]
catalog.append({"kind": "Keyword", "name": "RAG", "id": "rag", "element_id": "krag"})
projects = [
{
"person": name,
"person_element_id": f"p{i}",
"year": 2026,
"work_type": "diploma_project",
"work_id": f"work:p{i}",
"heading": "Diplomový projekt 2026",
"title": f"Téma projektu {name}",
**source(i),
}
for i, name in enumerate(PEOPLE, 1)
]
tags = [
{
"person": name,
"person_element_id": f"p{i}",
"keyword": "RAG",
"keyword_id": "rag",
"keyword_element_id": "krag",
**source(i),
}
for i, name in enumerate(PEOPLE[:3], 1)
]
# A fifth person has the tag, but no work in the requested year/type.
tags.append(
{
"person": "Iná Osoba",
"person_element_id": "p5",
"keyword": "RAG",
"keyword_id": "rag",
"keyword_element_id": "krag",
**source(5),
}
)
return FakeSession(
{
gs.CATALOG_QUERY: catalog,
gs.WORK_FACTS_QUERY: projects,
gs.PERSON_KEYWORD_FACTS_QUERY: tags,
}
)
@pytest.mark.parametrize(
"question",
[
"Ktorí študenti majú tag RAG a zároveň diplomový projekt z roku 2026?",
"Ktorí študenti s tagom RAG majú diplomový projekt v roku 2026?",
"Ktorí študenti majú tag RAG a diplomový projekt z roku 2026?",
"Ktorí študenti majú tag „RAG“ a zároveň diplomový projekt z roku 2026?",
"Ktorí študenti s tagom RAG a zároveň diplomovým projektom z roku 2026?",
"Ktorí študenti s tagom RAG a diplomovým projektom z roku 2026?",
],
)
def test_tag_and_project_are_witnessed_by_the_same_person(tagged_projects, question):
r = gs.search_graph_with_session(tagged_projects, question, limit=50)
assert r["status"] == "ok" and r["constraints"]["keyword_phrase"] == "rag"
assert {f["person"] for f in r["facts"]} == set(PEOPLE[:3])
work = [params for q, params in tagged_projects.calls if q == gs.WORK_FACTS_QUERY][0]
tags = [params for q, params in tagged_projects.calls if q == gs.PERSON_KEYWORD_FACTS_QUERY][0]
assert work["years"] == [2026] and work["work_types"] == ["diploma_project"]
assert tags["keyword_ids"] == ["krag"]
context = graph_context(r)
assert {c["person"] for c in context["answer_candidates"]} == set(PEOPLE[:3])
assert all(c["work_type"] == "diploma_project" for c in context["answer_candidates"])
def test_diploma_thesis_cannot_be_replaced_by_a_project(tagged_projects):
r = gs.search_graph_with_session(
tagged_projects,
"Ktorí študenti majú tag RAG a zároveň diplomovú prácu z roku 2026?",
limit=50,
)
assert r["status"] == "no_answer" and r["reason"] == "no_matching_facts"
assert not r["facts"] and not r["constraints"]["unresolved_keyword"]
@pytest.mark.parametrize("limit", [1, 2, 3, 4, 5, 6])
def test_tag_predicate_stays_bound_to_visible_records_after_truncation(tagged_projects, limit):
q = "Ktorí študenti majú tag RAG a zároveň diplomový projekt z roku 2026?"
raw = gs.search_graph_with_session(tagged_projects, q, limit=limit)
assert len(raw["facts"]) <= limit and raw["truncated"] == (limit < 3)
context = graph_context(raw)
visible = {r["person"] for r in raw["facts"] if r["fact_type"] == "person_work"}
assert {c["person"] for c in context["answer_candidates"]} == visible
assert all(r["person"] in visible for r in raw["supporting_facts"])
assert all(r["fact_type"] == "person_keyword" for r in raw["supporting_facts"])
claims = [
{k: c[k] for k in ["person", "year", "keyword", "work_type"]}
for c in context["answer_candidates"]
]
answer = {
"status": "ok",
"claims": claims,
"source_urls": [s["source_url"] for s in context["sources"]],
}
rendered = render_graph_answer(answer, context)
assert not answer_context_issues(rendered, context)
assert "Jakub Schwarc" not in rendered["answer"] and "Iná Osoba" not in rendered["answer"]
assert ("Výpis je neúplný." in rendered["answer"]) == (limit < 3)
def test_count_model_input_keeps_actual_total_and_only_claim_fields(tagged_projects):
import json
from tools.answers.answer_format import Claim
q = "Koľko študentov má tag RAG a zároveň diplomový projekt z roku 2026?"
context = graph_context(gs.search_graph_with_session(tagged_projects, q, limit=1))
before = copy.deepcopy(context)
def select(messages):
sent = json.loads(messages[1]["content"])["graph_context"]
assert sent["aggregation"]["count"] == 3 and "candidate_count" not in sent["aggregation"]
assert "count=3" in messages[0]["content"]
assert len(sent["answer_candidates"]) == 1
assert set(sent["answer_candidates"][0]) <= set(Claim.model_fields)
a = {
"status": "ok",
"answer": "",
"count": sent["aggregation"]["count"],
"claims": sent["answer_candidates"],
"source_urls": context["answer_candidates"][0]["source_urls"],
}
return {"choices": [{"message": {"content": json.dumps(a)}}]}
r = generate_checked_answer(q, context, PROMPT, select)
assert r["accepted_for_delivery"] and r["answer"]["count"] == 3 and context == before
def test_unknown_tag_cannot_trigger_a_global_project_listing(tagged_projects):
r = gs.search_graph_with_session(
tagged_projects, "Ktorí študenti majú tag XNEZNAMY a zároveň diplomový projekt z roku 2026?"
)
assert r["status"] == "no_answer" and r["reason"] == "unknown_keyword" and not r["facts"]
@pytest.mark.parametrize(
"question,expected",
[
("Ľudia s tagom strojové učenie a zároveň diplomovou prácou.", "strojove ucenie"),
(
"Ľudia s tagom „výskum a aplikácie“ a zároveň diplomovým projektom.",
"vyskum a aplikacie",
),
("Ľudia s tagom RAG z roku 2026.", "rag"),
],
)
def test_multiword_tag_and_condition_boundary(question, expected):
assert gs.explicit_keyword_phrase(question) == expected
@pytest.mark.parametrize(
"question,code",
[
("Ktorí študenti majú aspoň tri práce?", "quantity_condition"),
("Ktorí študenti majú aspoň 3 práce?", "quantity_condition"),
("Kto má najmenej tri práce?", "quantity_condition"),
("Študenti s najviac dvoma projektmi?", "quantity_condition"),
("Študenti s minimálne piatimi prácami?", "quantity_condition"),
(
"ktorý študenti týmto tempom ukončia svoje diplomové práce v roku 2027?",
"completion_prediction",
),
("Ktorí študenti ukončia diplomové práce v roku 2027?", "completion_prediction"),
("Odhadni termín dokončenia diplomovej práce Jána Holpa.", "completion_prediction"),
("Kedy Ján Holp ukončil diplomovú prácu?", "work_event_date"),
("Aké metódy predikcie počasia používa diplomová práca?", None),
("Aký je názov práce „Kto dokončí prácu v roku 2027“?", None),
],
)
def test_unsupported_condition_wording(question, code):
assert unsupported_constraint(question) == code
@pytest.mark.parametrize("engine", ["rag", "graphrag"])
@pytest.mark.parametrize(
"question,word",
[
("Ktorí študenti majú aspoň tri práce?", "počet"),
("Ktorí študenti týmto tempom ukončia diplomové práce v roku 2027?", "predpovedať"),
],
)
def test_unsupported_condition_never_calls_a_model(engine, question, word):
r = deliver_answer(
question,
engine=engine,
complete=lambda _: pytest.fail("No model for unsupported condition"),
)
assert r["status"] == "ambiguous" and r["accepted_for_delivery"] and word in r["answer"]
assert not r["claims"] and not r["source_urls"]
def selected(context):
fields = [
"person",
"record_id",
"title",
"year",
"work_type",
"work_label",
"keyword",
"category",
"document_author",
]
rows = context.get("required_academic_records") or context["facts"]
claims = [{k: r[k] for k in fields if r.get(k) is not None} for r in rows]
return {
"status": "ok",
"answer": "",
"claims": claims,
"source_urls": [s["source_url"] for s in context["sources"]],
}
def generated(answer, context):
return generate_checked_answer(
context.get("question", ""),
context,
PROMPT,
lambda _: {"choices": [{"message": {"content": json.dumps(answer)}}]},
)
def test_titleless_records_survive_and_are_delivered_without_invented_titles():
catalog = [person(1, "Ján Holp")]
records = [
{
"person": "Ján Holp",
"person_element_id": "p1",
"work_id": f"project-{i}",
"title": None,
"heading": label,
"work_type": "diploma_project",
"year": 2020,
**source(1),
}
for i, label in enumerate(["Diplomový projekt 2020", "Diplomový projekt 2 2020"])
]
s = FakeSession({gs.CATALOG_QUERY: catalog, gs.WORK_FACTS_QUERY: records})
raw = gs.search_graph_with_session(s, "Aký názov má diplomový projekt Jána Holpa z roku 2020?")
assert raw["status"] == "ok" and len(raw["facts"]) == 2
context = graph_context(raw)
a = selected(context)
result = generated(a, context)
assert result["accepted_for_delivery"]
assert result["answer"]["answer"].count("názov v zdroji nie je uvedený") == 2
assert len(result["answer"]["claims"]) == 2 and not any(
c.get("title") for c in result["answer"]["claims"]
)
bad = copy.deepcopy(a)
bad["claims"][0]["title"] = "Vymyslený názov"
assert "unsupported_academic_binding" in answer_context_issues(bad, context, check_text=False)
bad = copy.deepcopy(a)
bad["claims"].pop()
assert "missing_academic_record_details" in answer_context_issues(
bad, context, check_text=False
)
def shared_context(three=False):
people = PEOPLE[:3] if three else PEOPLE[:2]
raw = {
"status": "ok",
"reason": None,
"complete": True,
"truncated": False,
"aggregation": None,
"query": "Aké spoločné kľúčové slová majú " + " a ".join(people) + "?",
"constraints": gs.extract_query_constraints("spoločné kľúčové slová"),
"resolved_entities": [person(i, p) for i, p in enumerate(people, 1)],
"sources": [source(i) for i in range(1, len(people) + 1)],
"facts": [],
}
for keyword, left, right in [("NLP", 1, 2), ("RAG", 1, 2)] + ([("NLP", 1, 3)] if three else []):
raw["facts"].append(
{
"fact_type": "shared_keyword",
"keyword": keyword,
"left_person": people[left - 1],
"right_person": people[right - 1],
"left_document_path": source(left)["document_path"],
"right_document_path": source(right)["document_path"],
"left_source_url": source(left)["source_url"],
"right_source_url": source(right)["source_url"],
}
)
return graph_context(raw)
def test_shared_tags_are_summarized_once_and_require_all_peoples_evidence():
context = shared_context()
a = selected(context)
result = generated(a, context)
assert result["accepted_for_delivery"]
text = result["answer"]["answer"]
assert "Spoločné kľúčové slová: **NLP**, **RAG**." in text
assert text.count("NLP") == 1 and text.count("RAG") == 1 and "Pri osobe" not in text
bad = copy.deepcopy(a)
bad["claims"].pop()
assert "missing_shared_property_evidence" in answer_context_issues(
bad, context, check_text=False
)
def test_pairwise_shared_tag_does_not_mean_shared_by_everyone():
context = shared_context(three=True)
assert {r["value"] for r in context["shared_properties"]} == {"NLP"}
text = render_graph_answer(selected(context), context)["answer"]
assert "Spoločné kľúčové slová: **NLP**." in text and "**RAG**" not in text
def test_incomplete_shared_tags_cannot_claim_exhaustive_results():
context = shared_context()
context.update(complete=False, truncated=True, truncation_reasons=["fact_limit"])
result = generated(selected(context), context)
assert result["accepted_for_delivery"]
assert "Doložené spoločné kľúčové slová" in result["answer"]["answer"]
assert "Výpis je neúplný." in result["answer"]["answer"]