242 lines
9.2 KiB
Python
242 lines
9.2 KiB
Python
"""Source-link availability, lifecycle qualifications and supervisor semantics."""
|
|
|
|
import io
|
|
import json
|
|
import urllib.error
|
|
|
|
import pytest
|
|
|
|
from tools.answers import reference_links as links
|
|
from tools.answers.content_selection import reference_only, evidence_phase, facets_for
|
|
from tools.knowledge.sources import profile_units
|
|
from tools.knowledge.query import answer_from_index
|
|
from tools.retrieval.person_names import resolve_person_names
|
|
|
|
REPO = "https://git.kemt.fei.tuke.sk/sh662er/Rasa"
|
|
SOURCE = "https://zp.kemt.fei.tuke.sk/students/2019/samuel_horani"
|
|
|
|
|
|
def result(text):
|
|
return {
|
|
"accepted_for_delivery": True,
|
|
"status": "ok",
|
|
"answer": text,
|
|
"source_urls": [SOURCE],
|
|
"claims": [],
|
|
}
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"text",
|
|
[
|
|
"- [Repozitár s chatbotom](" + REPO + ")",
|
|
"Repozitár: " + REPO,
|
|
"- Výstup je tu " + REPO,
|
|
],
|
|
)
|
|
def test_a_bare_reference_is_not_a_completed_result(text):
|
|
assert reference_only(text)
|
|
assert evidence_phase(text, "progress", "result") == "unknown"
|
|
assert facets_for(text, "progress", "Aké výsledky dosiahol?", "result") == []
|
|
|
|
|
|
def test_real_implementation_with_a_reference_remains_an_implementation():
|
|
text = "Implementovaný chatbot používa RASA. Kód: " + REPO
|
|
assert not reference_only(text)
|
|
assert evidence_phase(text, "progress") == "result"
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"text",
|
|
[
|
|
"[Repozitár](" + REPO + ")",
|
|
REPO,
|
|
"[Repozitár][r]\n\n[r]: " + REPO,
|
|
'<a href="' + REPO + '">Repozitár</a>',
|
|
],
|
|
)
|
|
def test_unverified_repository_is_not_delivered_as_a_clickable_link(text):
|
|
value = links.checked_repository_links(result(text), check=lambda _: "not_public")
|
|
assert REPO not in value["answer"] and value["source_urls"] == [SOURCE]
|
|
assert value["accepted_for_delivery"] and "súkromný" in value["answer"]
|
|
assert value["suppressed_repository_links"] == 1
|
|
|
|
|
|
def test_verified_repository_preserves_the_exact_answer_and_sources():
|
|
value = result("[Repozitár](" + REPO + ")")
|
|
assert links.checked_repository_links(value, check=lambda _: "verified") == value
|
|
|
|
|
|
def test_html_escaped_markdown_reference_cannot_bypass_link_suppression():
|
|
url = "https://github.com/example/missing?x=1&y=2"
|
|
value = links.checked_repository_links(
|
|
result("[Repozitár](" + url + ")"), check=lambda _: "unverified"
|
|
)
|
|
assert "github.com" not in value["answer"] and "](" not in value["answer"]
|
|
|
|
|
|
def test_unverifiable_repository_cannot_be_the_only_factual_citation():
|
|
value = {**result("Práca je hotová."), "source_urls": [REPO]}
|
|
checked = links.checked_repository_links(value, check=lambda _: "unverified")
|
|
assert not checked["accepted_for_delivery"] and checked["reason"] == "reference_unavailable"
|
|
assert not checked["source_urls"]
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"url",
|
|
[
|
|
"http://git.kemt.fei.tuke.sk/a/b",
|
|
"https://git.kemt.fei.tuke.sk:8443/a/b",
|
|
"https://user:password@git.kemt.fei.tuke.sk/a/b",
|
|
"https://git.internal/a/b",
|
|
"https://github.com/a/b?access_token=private",
|
|
"https://git.kemt.fei.tuke.sk/../admin",
|
|
],
|
|
)
|
|
def test_repository_probe_never_contacts_unapproved_destinations(monkeypatch, url):
|
|
monkeypatch.setattr(
|
|
links.urllib.request, "build_opener", lambda *a: pytest.fail("Unsafe network request")
|
|
)
|
|
assert links._probe.__wrapped__(url, 0) == "unverified"
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"status,expected",
|
|
[
|
|
(404, "not_public"),
|
|
(401, "not_public"),
|
|
(403, "not_public"),
|
|
(302, "unverified"),
|
|
(503, "unverified"),
|
|
],
|
|
)
|
|
def test_repository_api_failures_are_not_treated_as_success(monkeypatch, status, expected):
|
|
class Opener:
|
|
def open(self, request, **kw):
|
|
assert request.full_url.endswith("/api/v1/repos/sh662er/Rasa")
|
|
assert "Authorization" not in request.headers
|
|
raise urllib.error.HTTPError(request.full_url, status, "Unavailable", {}, None)
|
|
|
|
monkeypatch.setattr(links.urllib.request, "build_opener", lambda *a: Opener())
|
|
assert links._probe.__wrapped__(REPO, 0) == expected
|
|
|
|
|
|
def test_public_repo_api_metadata_is_required(monkeypatch):
|
|
class Opener:
|
|
def open(self, request, **kw):
|
|
return io.BytesIO(json.dumps({"private": False, "full_name": "sh662er/Rasa"}).encode())
|
|
|
|
monkeypatch.setattr(links.urllib.request, "build_opener", lambda *a: Opener())
|
|
assert links._probe.__wrapped__(REPO, 0) == "verified"
|
|
|
|
|
|
def index():
|
|
doc = {
|
|
"title": "Alena Nová",
|
|
"path": "pages/students/2022/alena/README.md",
|
|
"content": "# Diplomová práca 2024\nNázov: Overené grafy\nVedúci: Daniel Hládek\n"
|
|
"Stav:\n- Implementovaný systém RAG.\nUkončil predčasne\n",
|
|
}
|
|
rows = profile_units(doc)
|
|
return {"units": rows, "version": "fixture", "index_version": {"version": "fixture"}}
|
|
|
|
|
|
def test_lifecycle_qualification_is_preserved_even_when_it_has_only_two_words():
|
|
rows = index()["units"]
|
|
note = next(r for r in rows if r["kind"] == "lifecycle")
|
|
assert note["text"] == "Ukončil predčasne" and note["work_year"] == 2024
|
|
r = answer_from_index("Ako pokročila Alena Nová?", index(), engine="rag")
|
|
assert "Ukončil predčasne" in r["answer"] and any(
|
|
e["evidence_role"] == "lifecycle" for e in r["evidence"]
|
|
)
|
|
|
|
|
|
def test_supervisor_year_is_never_satisfied_by_a_historical_work():
|
|
r = answer_from_index("Ktoré práce vedie Daniel Hladek v roku 2026?", index(), engine="rag")
|
|
assert r["status"] == "no_answer" and r["reason"] == "supervision_not_documented"
|
|
assert "2026" in r["answer"] and "autor dokumentu" in r["answer"].lower()
|
|
assert not r["evidence"] and not r["source_urls"]
|
|
|
|
|
|
def test_current_supervision_is_not_inferred_from_a_formal_supervisor_line():
|
|
r = answer_from_index("Ktoré práce aktuálne vedie Daniel Hladek?", index(), engine="graphrag")
|
|
assert r["status"] == "no_answer" and r["reason"] == "active_supervision_unknown"
|
|
assert "historickom" in r["answer"] and not r["evidence"]
|
|
|
|
|
|
def test_missing_supervisor_in_followup_requires_a_name_and_keeps_the_year():
|
|
r = answer_from_index("Ktoré práce sú teraz vedené v roku 2026?", index(), engine="rag")
|
|
assert r["status"] == "ambiguous" and r["reason"] == "missing_supervisor"
|
|
assert "2026" in r["answer"]
|
|
|
|
|
|
def test_title_typo_is_not_misclassified_as_a_person():
|
|
catalog = [{"name": "Daniel Hládek", "kind": "Person"}]
|
|
r = resolve_person_names("Ktoré práce aktuálne vedie Daniel Hladek a nazv prác?", catalog)
|
|
assert r["status"] == "ok" and [m["name"] for m in r["matches"]] == ["Daniel Hládek"]
|
|
|
|
|
|
def test_dated_status_overrides_previous_meeting_instead_of_inheriting_its_year():
|
|
from tools.knowledge.sources import meeting_date
|
|
|
|
assert meeting_date("Stav 8.11.2024:")["date"] == "2024-11-08"
|
|
rows = profile_units(
|
|
{
|
|
"title": "Alena Nová",
|
|
"path": "pages/students/2022/alena/README.md",
|
|
"content": "# Bakalárska práca 2025\nStretnutie 4.2.2025\nStav:\n- Implementovaný systém RAG.\n\n"
|
|
"Stav 8.11.2024:\n- Urobené poznámky o RAG.\n",
|
|
}
|
|
)
|
|
assert next(r for r in rows if "poznámky" in r["text"])["date"] == "2024-11-08"
|
|
|
|
|
|
def test_new_lifecycle_note_does_not_reextract_unchanged_later_profiles(tmp_path):
|
|
import hashlib
|
|
from tools.knowledge.build import build
|
|
from tools.sync.sync_indexes import source_files
|
|
from tools.sync.index_state import durable_json
|
|
|
|
wiki = tmp_path / "wiki"
|
|
data = tmp_path / "data"
|
|
data.mkdir()
|
|
paths = []
|
|
for slug, name in [("alena", "Alena Nová"), ("boris", "Boris Starý")]:
|
|
p = wiki / f"pages/students/2022/{slug}/README.md"
|
|
p.parent.mkdir(parents=True)
|
|
p.write_text(
|
|
f"---\ntitle: {name}\n---\n# Diplomová práca 2026\nStav:\n- Implementovaný systém RAG.\n"
|
|
)
|
|
paths.append(p)
|
|
|
|
def version(number):
|
|
return {
|
|
"version": str(number),
|
|
"source_sha256": hashlib.sha256(
|
|
json.dumps(source_files(wiki), sort_keys=True).encode()
|
|
).hexdigest(),
|
|
}
|
|
|
|
def complete(messages):
|
|
payload = json.loads(messages[1]["content"])
|
|
return {"choices": [{"message": {"content": json.dumps({"items": [
|
|
{"evidence_id":row["evidence_id"], "decision":"knowledge", "concepts":["RAG"], "relations":[]}
|
|
for row in payload
|
|
]})}}]}
|
|
|
|
durable_json(data / "index_version.json", version(1))
|
|
build(wiki, data, complete, {"model": "fixture"}, graph=False, vectors=False)
|
|
old = json.loads((data / "knowledge/index.json").read_text())
|
|
old_ids = {(u["person"], u["text"]): u["id"] for u in old["units"]}
|
|
paths[0].write_text(paths[0].read_text() + "\nUkončil predčasne\n")
|
|
durable_json(data / "index_version.json", version(2))
|
|
report = build(wiki, data, complete, {"model": "fixture"}, graph=False, vectors=False)
|
|
new = json.loads((data / "knowledge/index.json").read_text())
|
|
assert report["llm_batches"] == 0
|
|
assert all(
|
|
u["id"] == old_ids[(u["person"], u["text"])]
|
|
for u in new["units"]
|
|
if u["kind"] != "lifecycle"
|
|
)
|
|
assert any(u["kind"] == "lifecycle" for u in new["units"])
|