106 lines
3.5 KiB
Python
106 lines
3.5 KiB
Python
|
|
"""Phase 5 projection and GraphRAG boundary tests."""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import asyncio
|
||
|
|
from datetime import datetime
|
||
|
|
|
||
|
|
import pytest
|
||
|
|
from ont_platform.core.graph.cypher_guard import ReadOnlyCypherGuard, UnsafeCypherError
|
||
|
|
from ont_platform.core.graph.search import CandidateGraphSearchService
|
||
|
|
from ont_platform.core.projection.rdf_to_neo4j import RDFToNeo4jProjector
|
||
|
|
from ont_platform.storage.candidate_repository import CandidateRepository
|
||
|
|
from ont_platform.storage.models import Base, SourceDocument
|
||
|
|
from sqlalchemy import create_engine
|
||
|
|
from sqlalchemy.orm import Session, sessionmaker
|
||
|
|
|
||
|
|
|
||
|
|
def _session() -> Session:
|
||
|
|
engine = create_engine("sqlite:///:memory:")
|
||
|
|
Base.metadata.create_all(engine)
|
||
|
|
return sessionmaker(bind=engine)()
|
||
|
|
|
||
|
|
|
||
|
|
def test_rdf_projection_keeps_neo4j_as_projection_store() -> None:
|
||
|
|
async def run():
|
||
|
|
projector = RDFToNeo4jProjector(project_id="proj_graph")
|
||
|
|
return await projector.preview_projection(
|
||
|
|
[
|
||
|
|
("http://example.test/Alice", "http://example.test/knows", "http://example.test/Bob"),
|
||
|
|
("http://example.test/Alice", "http://www.w3.org/2000/01/rdf-schema#label", "Alice"),
|
||
|
|
],
|
||
|
|
provenance={"source_url": "https://example.test/source", "evidence_ids": ["EV_graph"]},
|
||
|
|
)
|
||
|
|
|
||
|
|
result = asyncio.run(run())
|
||
|
|
payload = result.to_dict()
|
||
|
|
|
||
|
|
assert payload["contract"]["canonical_store"] == "rdf_fuseki"
|
||
|
|
assert payload["contract"]["projection_store"] == "neo4j"
|
||
|
|
assert payload["node_count"] >= 2
|
||
|
|
assert payload["relationships"][0]["provenance"]["evidence_ids"] == ["EV_graph"]
|
||
|
|
assert payload["source_graph_hash"]
|
||
|
|
|
||
|
|
|
||
|
|
def test_read_only_cypher_guard_blocks_writes_and_enforces_limit() -> None:
|
||
|
|
guard = ReadOnlyCypherGuard(max_limit=25)
|
||
|
|
sanitized = guard.sanitize("MATCH (n:Entity) RETURN n", limit=100)
|
||
|
|
|
||
|
|
assert "LIMIT 25" in sanitized.query
|
||
|
|
|
||
|
|
with pytest.raises(UnsafeCypherError):
|
||
|
|
guard.sanitize("MATCH (n) DETACH DELETE n")
|
||
|
|
|
||
|
|
|
||
|
|
def test_candidate_graph_search_returns_source_provenance() -> None:
|
||
|
|
db = _session()
|
||
|
|
document = SourceDocument(
|
||
|
|
id="doc_graph",
|
||
|
|
project_id="proj_graph",
|
||
|
|
source_url="https://example.test/source",
|
||
|
|
document_type="html",
|
||
|
|
title="Graph Source",
|
||
|
|
text="Alice works at Acme.",
|
||
|
|
content_hash="hash_graph",
|
||
|
|
fingerprint="fp_graph",
|
||
|
|
retrieved_at=datetime.utcnow(),
|
||
|
|
extracted_by="trafilatura",
|
||
|
|
)
|
||
|
|
db.add(document)
|
||
|
|
repository = CandidateRepository(db)
|
||
|
|
repository.save_lightweight_result(
|
||
|
|
project_id="proj_graph",
|
||
|
|
document_id="doc_graph",
|
||
|
|
result={
|
||
|
|
"entities": [
|
||
|
|
{
|
||
|
|
"id": "E_alice_graph",
|
||
|
|
"label": "Alice",
|
||
|
|
"type": "person",
|
||
|
|
"confidence": 0.9,
|
||
|
|
"evidence_ids": ["EV_graph"],
|
||
|
|
}
|
||
|
|
],
|
||
|
|
"relations": [],
|
||
|
|
"evidence_spans": [
|
||
|
|
{
|
||
|
|
"id": "EV_graph",
|
||
|
|
"text": "Alice works at Acme.",
|
||
|
|
"start_offset": 0,
|
||
|
|
"end_offset": 20,
|
||
|
|
}
|
||
|
|
],
|
||
|
|
},
|
||
|
|
)
|
||
|
|
|
||
|
|
results = CandidateGraphSearchService(db).search(
|
||
|
|
project_id="proj_graph",
|
||
|
|
query="alice",
|
||
|
|
limit=10,
|
||
|
|
)
|
||
|
|
|
||
|
|
assert len(results) == 1
|
||
|
|
payload = results[0].to_dict()
|
||
|
|
assert payload["provenance"]["source_url"] == "https://example.test/source"
|
||
|
|
assert payload["provenance"]["evidence_spans"][0]["id"] == "EV_graph"
|