kg: graph build, traversal queries, neo4j export - kg_ocr.graph builds a networkx graph (docs, chunks, entities, citations, co-occurrence) from chunk markdown - analyzer for summaries, top entities/citations, anomaly checks - traversal: chunks_for_entity/citation, related_entities, expand_context - export: JSON round-trip, GraphML, batched MERGE into neo4j - new CLI: ocr-pipeline kg build|stats|query|export - lazy kg_ocr imports, networkx/neo4j behind extras - dropped dead watch.py shim, added KgConfig stub - trimmed README, updated TODO
Some checks failed
tests / core (macos-latest, py3.11) (push) Has been cancelled
tests / core (macos-latest, py3.12) (push) Has been cancelled
tests / core (macos-latest, py3.13) (push) Has been cancelled
tests / core (ubuntu-latest, py3.11) (push) Has been cancelled
tests / core (ubuntu-latest, py3.12) (push) Has been cancelled
tests / core (ubuntu-latest, py3.13) (push) Has been cancelled
tests / doctor CLI smoke test (push) Has been cancelled
Some checks failed
tests / core (macos-latest, py3.11) (push) Has been cancelled
tests / core (macos-latest, py3.12) (push) Has been cancelled
tests / core (macos-latest, py3.13) (push) Has been cancelled
tests / core (ubuntu-latest, py3.11) (push) Has been cancelled
tests / core (ubuntu-latest, py3.12) (push) Has been cancelled
tests / core (ubuntu-latest, py3.13) (push) Has been cancelled
tests / doctor CLI smoke test (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,267 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
nx = pytest.importorskip("networkx", reason="kg extra not installed")
|
||||
|
||||
from kg_ocr.export import export_graphml, export_json, load_json # noqa: E402
|
||||
from kg_ocr.graph import ( # noqa: E402
|
||||
build_from_directory,
|
||||
build_graph,
|
||||
detect_anomalies,
|
||||
find_chunk_files,
|
||||
parse_chunk_file,
|
||||
summary,
|
||||
top_citations,
|
||||
top_cooccurrences,
|
||||
top_entities,
|
||||
)
|
||||
|
||||
CHUNK_MD = """---
|
||||
source_path: /shots/SCR-001.png
|
||||
source_hash: aaaabbbbccccdddd
|
||||
timestamp: '2026-07-19T10:00:00+00:00'
|
||||
ocr_engine: paddleocr
|
||||
ocr_confidence_mean: 0.92
|
||||
language: en
|
||||
detected_entities:
|
||||
- BRCA1
|
||||
- PARP
|
||||
citations_found:
|
||||
- doi:10.1038/nature12345
|
||||
chunk_index: 0
|
||||
total_chunks: 1
|
||||
---
|
||||
|
||||
# Screenshot: SCR-001.png
|
||||
|
||||
## Extracted Text
|
||||
|
||||
BRCA1 and PARP inhibition in ovarian cancer.
|
||||
|
||||
## Citations Found
|
||||
|
||||
| Type | Identifier |
|
||||
"""
|
||||
|
||||
CHUNK_MD_2 = """---
|
||||
source_path: /shots/SCR-002.png
|
||||
source_hash: eeeeffff00001111
|
||||
timestamp: '2026-07-19T10:01:00+00:00'
|
||||
ocr_engine: tesseract
|
||||
ocr_confidence_mean: 0.2
|
||||
language: en
|
||||
detected_entities:
|
||||
- BRCA1
|
||||
citations_found: []
|
||||
chunk_index: 0
|
||||
total_chunks: 1
|
||||
---
|
||||
|
||||
# Screenshot: SCR-002.png
|
||||
|
||||
## Extracted Text
|
||||
|
||||
BRCA1 western blot.
|
||||
"""
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def chunk_dir(tmp_path: Path) -> Path:
|
||||
run_dir = tmp_path / "out" / "2026-07-19" / "run_001"
|
||||
run_dir.mkdir(parents=True)
|
||||
(run_dir / "SCR-001_aaaabbbbcccc.md").write_text(CHUNK_MD, encoding="utf-8")
|
||||
(run_dir / "SCR-002_eeeeffff0000.md").write_text(CHUNK_MD_2, encoding="utf-8")
|
||||
return tmp_path / "out"
|
||||
|
||||
|
||||
def test_parse_chunk_file(chunk_dir: Path) -> None:
|
||||
path = next(chunk_dir.rglob("SCR-001_*.md"))
|
||||
record = parse_chunk_file(path)
|
||||
assert record is not None
|
||||
assert record.source_hash == "aaaabbbbccccdddd"
|
||||
assert record.ocr_confidence_mean == pytest.approx(0.92)
|
||||
assert record.entities == ["BRCA1", "PARP"]
|
||||
assert record.citations == ["doi:10.1038/nature12345"]
|
||||
assert "BRCA1 and PARP" in record.text
|
||||
|
||||
|
||||
def test_parse_chunk_file_rejects_plain_markdown(tmp_path: Path) -> None:
|
||||
plain = tmp_path / "notes.md"
|
||||
plain.write_text("# just notes\n", encoding="utf-8")
|
||||
assert parse_chunk_file(plain) is None
|
||||
|
||||
|
||||
def test_find_chunk_files_skips_consolidated(chunk_dir: Path) -> None:
|
||||
(chunk_dir / "all_ocr.md").write_text("# consolidated\n", encoding="utf-8")
|
||||
names = [p.name for p in find_chunk_files(chunk_dir)]
|
||||
assert "all_ocr.md" not in names
|
||||
assert len(names) == 2
|
||||
|
||||
|
||||
def test_build_graph_structure(chunk_dir: Path) -> None:
|
||||
graph = build_from_directory(chunk_dir)
|
||||
counts = summary(graph)
|
||||
|
||||
assert counts["nodes:document"] == 2
|
||||
assert counts["nodes:chunk"] == 2
|
||||
assert counts["nodes:entity"] == 2 # brca1, parp (normalized)
|
||||
assert counts["nodes:citation"] == 1
|
||||
assert counts["edges:CONTAINS"] == 2
|
||||
assert counts["edges:MENTIONS"] == 3
|
||||
assert counts["edges:CITES"] == 1
|
||||
assert counts["edges:CO_OCCURS"] == 1 # brca1 <-> parp
|
||||
|
||||
assert graph.has_edge("doc:aaaabbbbccccdddd", "chunk:aaaabbbbccccdddd:0")
|
||||
assert graph.has_edge("chunk:aaaabbbbccccdddd:0", "entity:brca1")
|
||||
|
||||
|
||||
def test_top_entities_and_cooccurrence(chunk_dir: Path) -> None:
|
||||
graph = build_from_directory(chunk_dir)
|
||||
top = top_entities(graph)
|
||||
assert top[0] == ("BRCA1", 2)
|
||||
pairs = top_cooccurrences(graph)
|
||||
assert pairs == [("BRCA1", "PARP", 1)]
|
||||
|
||||
|
||||
def test_top_citations(chunk_dir: Path) -> None:
|
||||
graph = build_from_directory(chunk_dir)
|
||||
rows = top_citations(graph)
|
||||
assert rows == [("doi", "10.1038/nature12345", 1)]
|
||||
|
||||
|
||||
def test_detect_anomalies(chunk_dir: Path) -> None:
|
||||
graph = build_from_directory(chunk_dir)
|
||||
kinds = {a.kind for a in detect_anomalies(graph)}
|
||||
# SCR-002 has confidence 0.2 (< 0.4) -> low_confidence
|
||||
assert "low_confidence" in kinds
|
||||
|
||||
|
||||
def test_json_round_trip(chunk_dir: Path, tmp_path: Path) -> None:
|
||||
graph = build_from_directory(chunk_dir)
|
||||
out = export_json(graph, tmp_path / "g.json")
|
||||
restored = load_json(out)
|
||||
assert summary(restored) == summary(graph)
|
||||
assert restored.nodes["entity:brca1"]["mentions"] == 2
|
||||
|
||||
|
||||
def test_graphml_export(chunk_dir: Path, tmp_path: Path) -> None:
|
||||
graph = build_from_directory(chunk_dir)
|
||||
out = export_graphml(graph, tmp_path / "g.graphml")
|
||||
content = out.read_text(encoding="utf-8")
|
||||
assert "graphml" in content
|
||||
|
||||
|
||||
def test_empty_directory(tmp_path: Path) -> None:
|
||||
graph = build_from_directory(tmp_path)
|
||||
assert summary(graph) == {}
|
||||
|
||||
|
||||
def test_build_graph_from_records_directly() -> None:
|
||||
from kg_ocr.graph import ChunkRecord
|
||||
|
||||
record = ChunkRecord(
|
||||
source_hash="x" * 16,
|
||||
source_path="/a.png",
|
||||
chunk_index=0,
|
||||
total_chunks=1,
|
||||
text="hello",
|
||||
entities=["TP53", "tp53"], # dedupe via normalization
|
||||
citations=["pmid:PMID:12345678"],
|
||||
)
|
||||
graph = build_graph([record])
|
||||
counts = summary(graph)
|
||||
assert counts["nodes:entity"] == 1
|
||||
assert graph.nodes["entity:tp53"]["mentions"] == 2
|
||||
assert graph.nodes["citation:PMID:12345678"]["citation_type"] == "pmid"
|
||||
|
||||
|
||||
def test_kg_cli_build_and_stats(chunk_dir: Path, tmp_path: Path) -> None:
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from ocr_pipeline.cli import app
|
||||
|
||||
runner = CliRunner()
|
||||
save = tmp_path / "g.json"
|
||||
|
||||
result = runner.invoke(app, ["kg", "build", "-d", str(chunk_dir), "-s", str(save)])
|
||||
assert result.exit_code == 0, result.output
|
||||
assert save.is_file()
|
||||
|
||||
result = runner.invoke(app, ["kg", "stats", str(save)])
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "BRCA1" in result.output
|
||||
assert "low_confidence" in result.output
|
||||
|
||||
out_graphml = tmp_path / "g.graphml"
|
||||
result = runner.invoke(
|
||||
app, ["kg", "export", str(save), "-f", "graphml", "-o", str(out_graphml)]
|
||||
)
|
||||
assert result.exit_code == 0, result.output
|
||||
assert out_graphml.is_file()
|
||||
|
||||
|
||||
def test_chunks_for_entity(chunk_dir: Path) -> None:
|
||||
from kg_ocr.graph import chunks_for_entity
|
||||
|
||||
graph = build_from_directory(chunk_dir)
|
||||
chunks = chunks_for_entity(graph, "brca1") # case-insensitive
|
||||
assert len(chunks) == 2
|
||||
assert {c["source_path"] for c in chunks} == {"/shots/SCR-001.png", "/shots/SCR-002.png"}
|
||||
assert chunks_for_entity(graph, "NONEXISTENT") == []
|
||||
|
||||
|
||||
def test_chunks_for_citation(chunk_dir: Path) -> None:
|
||||
from kg_ocr.graph import chunks_for_citation
|
||||
|
||||
graph = build_from_directory(chunk_dir)
|
||||
chunks = chunks_for_citation(graph, "10.1038/nature12345")
|
||||
assert len(chunks) == 1
|
||||
assert "BRCA1 and PARP" in chunks[0]["text"]
|
||||
assert chunks_for_citation(graph, "nope") == []
|
||||
|
||||
|
||||
def test_related_entities(chunk_dir: Path) -> None:
|
||||
from kg_ocr.graph import related_entities
|
||||
|
||||
graph = build_from_directory(chunk_dir)
|
||||
assert related_entities(graph, "BRCA1") == [("PARP", 1)]
|
||||
assert related_entities(graph, "PARP") == [("BRCA1", 1)]
|
||||
assert related_entities(graph, "NONEXISTENT") == []
|
||||
|
||||
|
||||
def test_expand_context(chunk_dir: Path) -> None:
|
||||
from kg_ocr.graph import expand_context
|
||||
|
||||
graph = build_from_directory(chunk_dir)
|
||||
chunks = expand_context(graph, "PARP")
|
||||
# PARP's own chunk + BRCA1 neighbor chunks (SCR-001 deduped)
|
||||
assert len(chunks) == 2
|
||||
via = {c["via_entity"] for c in chunks}
|
||||
assert "PARP" in via
|
||||
assert "BRCA1" in via
|
||||
|
||||
|
||||
def test_kg_cli_query(chunk_dir: Path, tmp_path: Path) -> None:
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from ocr_pipeline.cli import app
|
||||
|
||||
runner = CliRunner()
|
||||
save = tmp_path / "g.json"
|
||||
result = runner.invoke(app, ["kg", "build", "-d", str(chunk_dir), "-s", str(save)])
|
||||
assert result.exit_code == 0, result.output
|
||||
|
||||
result = runner.invoke(app, ["kg", "query", str(save), "--entity", "PARP"])
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "SCR-001.png" in result.output
|
||||
assert "Related entities" in result.output
|
||||
|
||||
result = runner.invoke(app, ["kg", "query", str(save), "--citation", "10.1038/nature12345"])
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "SCR-001.png" in result.output
|
||||
|
||||
result = runner.invoke(app, ["kg", "query", str(save)])
|
||||
assert result.exit_code == 2 # neither --entity nor --citation
|
||||
|
||||
Reference in New Issue
Block a user