237 lines
9.2 KiB
Python
237 lines
9.2 KiB
Python
import json
|
|
import sqlite3
|
|
from types import SimpleNamespace
|
|
|
|
import numpy as np
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
|
|
import app.main as main
|
|
import app.routes as routes
|
|
import tools.runtime.tool_health as health
|
|
import tools.rag.build_sqlite_index as indexer
|
|
import tools.rag.search_core as search_core
|
|
from tools.sync.index_state import durable_json, PENDING_FILE, VERSION_FILE
|
|
from tools.retrieval.publication import PUBLICATION_POLICY_VERSION, effective_published
|
|
from tools.rag.search_utils import search_database
|
|
from tools.rag.rag_utils import build_rag_context
|
|
|
|
|
|
@pytest.fixture
|
|
def corpus(tmp_path, monkeypatch):
|
|
docs = []
|
|
chunks = []
|
|
paths = [
|
|
"pages/students/2020/eva/README.md",
|
|
"pages/topics/missing/README.md",
|
|
"pages/topics/hidden/README.md",
|
|
"pages/students/2020/eva/private/README.md",
|
|
]
|
|
for index, (path, published) in enumerate(zip(paths, [True, None, False, False])):
|
|
title = ["Verejný profil", "Chýbajúca značka", "Skrytý dokument", "Skrytá poznámka"][index]
|
|
text = "spoločný overovací výraz " + (
|
|
"SKRYTY_OVEROVACI_OBSAH" if published is False else title
|
|
)
|
|
docs.append({"path": path, "title": title, "published": published})
|
|
chunks.append(
|
|
{
|
|
"chunk_id": path + "::0",
|
|
"document_path": path,
|
|
"title": title,
|
|
"published": published,
|
|
"chunk_index": 0,
|
|
"heading_paths": [["Úvod"]],
|
|
"text": text,
|
|
"text_length": len(text),
|
|
"token_count": 10,
|
|
"content_hash": str(index),
|
|
"tags": [],
|
|
"categories": [],
|
|
}
|
|
)
|
|
data = tmp_path / "data"
|
|
data.mkdir()
|
|
for name, rows in [("documents.json", docs), ("chunks.json", chunks)]:
|
|
(data / name).write_text(json.dumps(rows))
|
|
monkeypatch.setattr(
|
|
indexer, "embed_passages", lambda texts: np.ones((len(texts), 4), dtype=np.float32)
|
|
)
|
|
monkeypatch.setattr(
|
|
search_core, "embed_query", lambda *args, **kwargs: np.ones(4, dtype=np.float32)
|
|
)
|
|
db = data / "zp_index.sqlite"
|
|
indexer.build_database(
|
|
documents_file=data / "documents.json", chunks_file=data / "chunks.json", db_file=db
|
|
)
|
|
version = {"version": "test-generation", "publication_policy": PUBLICATION_POLICY_VERSION}
|
|
with sqlite3.connect(db) as conn:
|
|
conn.execute("CREATE TABLE index_state (id INTEGER PRIMARY KEY, metadata_json TEXT)")
|
|
conn.execute("INSERT INTO index_state VALUES (1,?)", (json.dumps(version),))
|
|
durable_json(data / VERSION_FILE, version)
|
|
monkeypatch.setattr(routes, "DB_FILE", db)
|
|
monkeypatch.setattr(main, "embed_query", lambda *args: None)
|
|
return db, version, paths
|
|
|
|
|
|
@pytest.mark.parametrize("value,visible", [(True, True), (None, True), (False, False)])
|
|
def test_publication_policy(value, visible):
|
|
assert effective_published(value) is visible
|
|
|
|
|
|
def test_rag_default_filters_both_fts_and_vectors_but_keeps_null(corpus):
|
|
db, _, paths = corpus
|
|
result = search_database(db, "spoločný overovací výraz", limit=10)
|
|
assert {r["document_path"] for r in result["results"]} == set(paths[:2])
|
|
assert any(r["published"] is None for r in result["results"])
|
|
all_rows = search_database(db, "spoločný overovací výraz", limit=10, published_only=False)
|
|
assert {r["document_path"] for r in all_rows["results"]} == set(paths)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"query", ["spoločný overovací výraz", "Skrytý dokument", "Skrytá poznámka"]
|
|
)
|
|
def test_rag_expansions_do_not_reintroduce_hidden_text(corpus, query):
|
|
db, _, paths = corpus
|
|
result = build_rag_context(db, query, limit=10)
|
|
assert all(s["document_path"] not in paths[2:] for s in result["sources"])
|
|
assert "SKRYTY_OVEROVACI_OBSAH" not in result["context"]
|
|
all_rows = build_rag_context(db, query, limit=10, published_only=False)
|
|
assert "SKRYTY_OVEROVACI_OBSAH" in all_rows["context"]
|
|
|
|
|
|
def healthy_graph(version):
|
|
return health.readiness(True), health.readiness(True, index_version=version, nodes=2)
|
|
|
|
|
|
def test_neo4j_outage_does_not_disable_rag(corpus, monkeypatch):
|
|
_, _, paths = corpus
|
|
monkeypatch.setattr(
|
|
health,
|
|
"neo4j_readiness",
|
|
lambda v: (
|
|
health.readiness(False, "unreachable"),
|
|
health.readiness(False, "neo4j_unavailable"),
|
|
),
|
|
)
|
|
with TestClient(main.app) as client:
|
|
payload = client.get("/health").json()
|
|
assert payload["tools"]["rag"]["ready"] is True
|
|
assert payload["tools"]["neo4j"]["ready"] is False
|
|
assert payload["tools"]["graphrag"]["ready"] is False
|
|
assert payload["ready"] is False
|
|
response = client.post(
|
|
"/rag",
|
|
json={"query": "spoločný overovací výraz"},
|
|
headers={"Authorization": "Bearer " + "a" * 64},
|
|
)
|
|
assert response.status_code == 200
|
|
assert {s["document_path"] for s in response.json()["sources"]} == set(paths[:2])
|
|
|
|
|
|
def test_rag_index_outage_does_not_disable_graph(corpus, monkeypatch):
|
|
db, version, _ = corpus
|
|
monkeypatch.setattr(health, "neo4j_readiness", healthy_graph)
|
|
db.unlink()
|
|
with TestClient(main.app) as client:
|
|
payload = client.get("/health").json()
|
|
assert payload["tools"]["rag"]["ready"] is False
|
|
assert payload["tools"]["neo4j"]["ready"] is True
|
|
assert payload["tools"]["graphrag"]["ready"] is True
|
|
assert payload["ready"] is False
|
|
|
|
|
|
def test_pending_sync_blocks_both_tools_but_reports_neo4j_service(corpus, monkeypatch):
|
|
db, _, _ = corpus
|
|
durable_json(db.parent / PENDING_FILE, {"target_version": "next"})
|
|
monkeypatch.setattr(health, "neo4j_readiness", healthy_graph)
|
|
with TestClient(main.app) as client:
|
|
tools = client.get("/health").json()["tools"]
|
|
assert tools["rag"]["reason"] == tools["graphrag"]["reason"] == "snapshot_unavailable"
|
|
assert tools["neo4j"]["ready"] is True
|
|
|
|
|
|
def test_embedding_startup_failure_and_successful_query_recovery(corpus, monkeypatch):
|
|
monkeypatch.setattr(health, "neo4j_readiness", healthy_graph)
|
|
|
|
def broken(*args):
|
|
raise RuntimeError("transient warmup failure")
|
|
|
|
monkeypatch.setattr(main, "embed_query", broken)
|
|
with TestClient(main.app) as client:
|
|
tools = client.get("/health").json()["tools"]
|
|
assert tools["rag"]["reason"] == "embedding_not_ready"
|
|
assert tools["graphrag"]["ready"] is True
|
|
response = client.post(
|
|
"/rag",
|
|
json={"query": "spoločný overovací výraz"},
|
|
headers={"Authorization": "Bearer " + "a" * 64},
|
|
)
|
|
assert response.status_code == 200
|
|
assert client.get("/health").json()["tools"]["rag"]["ready"] is True
|
|
|
|
|
|
def test_neo4j_probe_is_bounded_and_does_not_expose_errors(monkeypatch):
|
|
monkeypatch.setattr(health, "get_neo4j_settings", lambda: SimpleNamespace(database="neo4j"))
|
|
captured = {}
|
|
|
|
def fail(settings, **options):
|
|
captured.update(options)
|
|
raise RuntimeError("password=never-return-this")
|
|
|
|
monkeypatch.setattr(health, "create_neo4j_driver", fail)
|
|
result = health.neo4j_readiness(None)
|
|
assert not result[0]["ready"] and not result[1]["ready"]
|
|
assert captured["connection_timeout"] == captured["connection_acquisition_timeout"] == 2.0
|
|
assert captured["max_transaction_retry_time"] == 0
|
|
assert "never-return-this" not in json.dumps(result)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"actual,nodes,reason",
|
|
[
|
|
({"version": "different"}, 2, "version_mismatch"),
|
|
(None, 0, "graph_empty"),
|
|
({"version": "old"}, 2, "publication_sync_required"),
|
|
],
|
|
)
|
|
def test_connection_readiness_is_distinct_from_graph_readiness(monkeypatch, actual, nodes, reason):
|
|
class Result:
|
|
def single(self):
|
|
return {"nodes": nodes, "metadata": json.dumps(actual) if actual else None}
|
|
|
|
class Connection:
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, *args):
|
|
pass
|
|
|
|
def verify_connectivity(self):
|
|
pass
|
|
|
|
def session(self, **kwargs):
|
|
return self
|
|
|
|
def run(self, query, **params):
|
|
assert query.timeout == 2.0
|
|
return Result()
|
|
|
|
monkeypatch.setattr(health, "get_neo4j_settings", lambda: SimpleNamespace(database="neo4j"))
|
|
monkeypatch.setattr(health, "create_neo4j_driver", lambda *args, **kwargs: Connection())
|
|
expected = {"version": "expected"} if reason == "version_mismatch" else actual
|
|
neo, graph = health.neo4j_readiness(expected)
|
|
assert neo["ready"] is True and graph["ready"] is False and graph["reason"] == reason
|
|
|
|
|
|
def test_model_probe_requires_authentication_and_returns_sanitized_status(corpus, monkeypatch):
|
|
import tools.answers.model_client as model
|
|
seen = []
|
|
monkeypatch.setattr(model, "probe_generation", lambda: seen.append(1) or
|
|
{"ready": True, "status": "ready", "reason": None})
|
|
with TestClient(main.app) as client:
|
|
assert client.post("/health/generation").status_code == 401
|
|
assert not seen
|
|
response = client.post("/health/generation", headers={"X-API-Key": "a" * 64})
|
|
assert response.status_code == 200 and response.json()["ready"] is True
|
|
assert len(seen) == 1
|