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