dp-zp-agent/test/test_publication_health.py
2026-10-26 19:23:02 +01:00

167 lines
8.1 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