From 05aed7366d2782cd67db3a6a1642c15d5c6ece49 Mon Sep 17 00:00:00 2001 From: Aman Nalakath Date: Fri, 18 Sep 2026 16:40:21 +0200 Subject: [PATCH] feat(kg): add markdown export format for knowledge graph - Add kg_ocr/export/markdown_exporter.py with export_markdown() - Cluster entity co-occurrences using Louvain community detection - Wire -f markdown into `ocr-pipeline kg export` CLI command with KG_REPORT.md default - Add unit and CLI tests in tests/test_graph.py - Document markdown export in README.md - Untrack pycache binaries and ignore graphify-out and KG_REPORT.md --- .gitignore | 4 +- README.md | 2 +- kg_ocr/__init__.py | 1 + kg_ocr/export/__init__.py | 3 +- kg_ocr/export/markdown_exporter.py | 270 ++++++++++++++++++ .../__pycache__/__init__.cpython-313.pyc | Bin 381 -> 0 bytes .../__pycache__/cli.cpython-313.pyc | Bin 28077 -> 0 bytes .../__pycache__/config.cpython-313.pyc | Bin 18122 -> 0 bytes .../__pycache__/pipeline.cpython-313.pyc | Bin 9462 -> 0 bytes .../__pycache__/watch.cpython-313.pyc | Bin 308 -> 0 bytes src/ocr_pipeline/cli.py | 150 ++++++---- tests/test_graph.py | 46 ++- 12 files changed, 416 insertions(+), 60 deletions(-) create mode 100644 kg_ocr/export/markdown_exporter.py delete mode 100644 src/ocr_pipeline/__pycache__/__init__.cpython-313.pyc delete mode 100644 src/ocr_pipeline/__pycache__/cli.cpython-313.pyc delete mode 100644 src/ocr_pipeline/__pycache__/config.cpython-313.pyc delete mode 100644 src/ocr_pipeline/__pycache__/pipeline.cpython-313.pyc delete mode 100644 src/ocr_pipeline/__pycache__/watch.cpython-313.pyc diff --git a/.gitignore b/.gitignore index 26987af..8ba922b 100644 --- a/.gitignore +++ b/.gitignore @@ -80,4 +80,6 @@ Thumbs.db # Project specific data/ notebooks/ -.env \ No newline at end of file +.env +graphify-out/ +KG_REPORT.md diff --git a/README.md b/README.md index da44084..82ada60 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,7 @@ uv run ocr-pipeline kg query data/ocr_output/kg_graph.json --entity BRCA1 --expa uv run ocr-pipeline kg query data/ocr_output/kg_graph.json --citation 10.1038/nature12345 # export +uv run ocr-pipeline kg export data/ocr_output/kg_graph.json -f markdown uv run ocr-pipeline kg export data/ocr_output/kg_graph.json -f graphml uv run ocr-pipeline kg export data/ocr_output/kg_graph.json -f neo4j ``` @@ -239,4 +240,3 @@ Gene/protein names go through scispaCy's `en_core_sci_lg`. Chemical formulas and units (µM, ng/mL, kb/Mb/Gb, °C, ×g) get normalized, scientific notation gets cleaned up (`1.5×10⁻³` → `1.5×10^-3`), and gel/blot figures get their captions pulled out separately. - diff --git a/kg_ocr/__init__.py b/kg_ocr/__init__.py index 28d03dc..f07e811 100644 --- a/kg_ocr/__init__.py +++ b/kg_ocr/__init__.py @@ -25,6 +25,7 @@ _LAZY: dict[str, tuple[str, str]] = { "expand_context": (".graph", "expand_context"), "export_json": (".export", "export_json"), "export_graphml": (".export", "export_graphml"), + "export_markdown": (".export", "export_markdown"), "load_json": (".export", "load_json"), "Neo4jExporter": (".export", "Neo4jExporter"), } diff --git a/kg_ocr/export/__init__.py b/kg_ocr/export/__init__.py index 4f0f980..0d87d1e 100644 --- a/kg_ocr/export/__init__.py +++ b/kg_ocr/export/__init__.py @@ -1,3 +1,4 @@ +from .markdown_exporter import export_markdown from .neo4j_exporter import Neo4jExporter, export_graphml, export_json, load_json -__all__ = ["Neo4jExporter", "export_graphml", "export_json", "load_json"] +__all__ = ["Neo4jExporter", "export_graphml", "export_json", "export_markdown", "load_json"] diff --git a/kg_ocr/export/markdown_exporter.py b/kg_ocr/export/markdown_exporter.py new file mode 100644 index 0000000..6b31338 --- /dev/null +++ b/kg_ocr/export/markdown_exporter.py @@ -0,0 +1,270 @@ +"""Export a knowledge graph to a Markdown report.""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +from kg_ocr.graph.analyzer import ( + Anomaly, + detect_anomalies, + summary, + top_citations, + top_cooccurrences, + top_entities, +) + + +def _load_graph(graph_input: Any) -> Any: + """Load a NetworkX graph from a file path or return it directly.""" + import networkx as nx + + if isinstance(graph_input, (str, Path)): + path = Path(graph_input) + data = json.loads(path.read_text(encoding="utf-8")) + try: + return nx.node_link_graph(data, edges="edges") + except TypeError: + return nx.node_link_graph(data) + return graph_input + + +def _cluster_entity_cooccurrences(graph: Any) -> list[list[dict[str, Any]]]: + """Find concept clusters over the entity co-occurrence subgraph.""" + import networkx as nx + + cooccur_graph = nx.Graph() + for node, data in graph.nodes(data=True): + if data.get("kind") == "entity": + cooccur_graph.add_node( + node, + text=data.get("text", node.replace("entity:", "")), + mentions=int(data.get("mentions", 0)), + ) + + for u, v, data in graph.edges(data=True): + if data.get("kind") == "CO_OCCURS": + cooccur_graph.add_edge(u, v, weight=data.get("weight", 1)) + + if cooccur_graph.number_of_nodes() == 0: + return [] + + # Community detection + communities: list[set[str]] = [] + if cooccur_graph.number_of_edges() > 0: + try: + import networkx.algorithms.community as nx_comm # type: ignore[import-untyped] + + communities = list( + nx_comm.louvain_communities(cooccur_graph, weight="weight", seed=42) + ) + except Exception: + try: + import networkx.algorithms.community as nx_comm # type: ignore[import-untyped] + + communities = list( + nx_comm.greedy_modularity_communities(cooccur_graph, weight="weight") + ) + except Exception: + try: + import community as community_louvain # type: ignore[import-not-found] + + partition = community_louvain.best_partition( + cooccur_graph, weight="weight", random_state=42 + ) + comm_dict: dict[int, set[str]] = {} + for n, cid in partition.items(): + comm_dict.setdefault(cid, set()).add(n) + communities = list(comm_dict.values()) + except Exception: + communities = list(nx.connected_components(cooccur_graph)) + else: + # No edges: each entity is an isolated node + communities = [{n} for n in cooccur_graph.nodes()] + + # Format and sort clusters + clusters: list[list[dict[str, Any]]] = [] + for comm in communities: + cluster_nodes = [] + for node in comm: + cluster_nodes.append( + { + "id": node, + "text": cooccur_graph.nodes[node].get("text", node), + "mentions": cooccur_graph.nodes[node].get("mentions", 0), + } + ) + # Sort entities inside cluster by mentions descending + cluster_nodes.sort(key=lambda item: item["mentions"], reverse=True) + clusters.append(cluster_nodes) + + # Sort clusters by size (number of entities) descending, then top entity mentions + clusters.sort( + key=lambda c: (len(c), c[0]["mentions"] if c else 0), + reverse=True, + ) + return clusters + + +def export_markdown(graph_path: str | Path, output_path: str | Path) -> Path: + """Render a knowledge graph into a Markdown summary report. + + Parameters + ---------- + graph_path : str | Path | nx.Graph + Path to `kg_graph.json` or an in-memory NetworkX graph. + output_path : str | Path + Target path for the exported Markdown report. + + Returns + ------- + Path + The output Path written to. + """ + graph = _load_graph(graph_path) + out = Path(output_path) + out.parent.mkdir(parents=True, exist_ok=True) + + counts = summary(graph) + doc_count = counts.get("nodes:document", 0) + chunk_count = counts.get("nodes:chunk", 0) + entity_count = counts.get("nodes:entity", 0) + citation_count = counts.get("nodes:citation", 0) + contains_edges = counts.get("edges:CONTAINS", 0) + mentions_edges = counts.get("edges:MENTIONS", 0) + cites_edges = counts.get("edges:CITES", 0) + cooccurs_edges = counts.get("edges:CO_OCCURS", 0) + + entities = top_entities(graph, limit=20) + cooccurrences = top_cooccurrences(graph, limit=20) + citations = top_citations(graph, limit=20) + clusters = _cluster_entity_cooccurrences(graph) + + # Anomalies detection + anomalies: list[Anomaly] = list(detect_anomalies(graph)) + # Detect isolated citations (citations with 0 citing chunks) + existing_anomaly_nodes = {a.node for a in anomalies} + for node, data in graph.nodes(data=True): + if data.get("kind") == "citation" and node not in existing_anomaly_nodes: + citing_count = sum( + 1 for _, _, edge in graph.in_edges(node, data=True) if edge.get("kind") == "CITES" + ) + if citing_count == 0: + ident = data.get("identifier", node) + anomalies.append( + Anomaly( + "isolated_citation", + node, + f"citation {ident} has no referencing chunks", + ) + ) + + lines: list[str] = [ + "# Knowledge Graph Report", + "", + "## Summary", + f"- **Documents**: {doc_count:,}", + f"- **Chunks**: {chunk_count:,}", + f"- **Entities**: {entity_count:,}", + f"- **Citations**: {citation_count:,}", + f"- **Relationships**: {contains_edges + mentions_edges + cites_edges + cooccurs_edges:,} " + f"(CONTAINS: {contains_edges:,}, MENTIONS: {mentions_edges:,}, CITES: {cites_edges:,}, CO_OCCURS: {cooccurs_edges:,})", + "", + ] + + # Section: Top Entities + lines.append("## Top Entities") + lines.append("") + if entities: + lines.append("| Entity | Mentions |") + lines.append("|:-------|:---------|") + for text, mention_count in entities: + clean_text = text.replace("|", "\\|") + lines.append(f"| {clean_text} | {mention_count:,} |") + else: + lines.append("No entities found.") + lines.append("") + + # Section: Top Co-occurrences + lines.append("## Top Co-occurrences") + lines.append("") + if cooccurrences: + lines.append("| Entity A | Entity B | Co-occurrence Weight |") + lines.append("|:---------|:---------|:---------------------|") + for left, right, weight in cooccurrences: + clean_left = left.replace("|", "\\|") + clean_right = right.replace("|", "\\|") + lines.append(f"| {clean_left} | {clean_right} | {weight:,} |") + else: + lines.append("No entity co-occurrences found.") + lines.append("") + + # Section: Top Citations + lines.append("## Top Citations") + lines.append("") + if citations: + lines.append("| Type | Identifier | Citing Chunks |") + lines.append("|:-----|:-----------|:--------------|") + for ctype, identifier, count in citations: + lines.append(f"| {ctype.upper() or 'UNKNOWN'} | `{identifier}` | {count:,} |") + else: + lines.append("No citations found.") + lines.append("") + + # Section: Concept Clusters + lines.append("## Concept Clusters") + lines.append("") + if clusters: + multi_entity_clusters = [c for c in clusters if len(c) > 1] + single_entity_clusters = [c for c in clusters if len(c) == 1] + + if multi_entity_clusters: + for idx, cluster in enumerate(multi_entity_clusters, start=1): + entity_names = [e["text"] for e in cluster[:3]] + cluster_label = ", ".join(entity_names) + if len(cluster) > 3: + cluster_label += f" (+{len(cluster) - 3} more)" + lines.append(f"### Cluster {idx}: {cluster_label}") + lines.append(f"**Size**: {len(cluster)} entities") + lines.append("") + lines.append("| Entity | Mentions |") + lines.append("|:-------|:---------|") + for item in cluster: + clean_name = item["text"].replace("|", "\\|") + lines.append(f"| {clean_name} | {item['mentions']:,} |") + lines.append("") + + if single_entity_clusters: + lines.append("### Isolated / Unclustered Entities") + lines.append( + f"*{len(single_entity_clusters)} entities with no co-occurrences across documents:*" + ) + lines.append("") + items_preview = [ + f"{c[0]['text']} ({c[0]['mentions']})" for c in single_entity_clusters[:30] + ] + lines.append(", ".join(items_preview)) + if len(single_entity_clusters) > 30: + lines.append(f"*(and {len(single_entity_clusters) - 30} more)*") + lines.append("") + else: + lines.append("No concept clusters detected.") + lines.append("") + + # Section: Anomalies + lines.append("## Anomalies") + lines.append("") + if anomalies: + lines.append("| Anomaly Type | Node | Detail |") + lines.append("|:-------------|:-----|:-------|") + for a in anomalies: + clean_node = a.node.replace("|", "\\|") + clean_detail = a.detail.replace("|", "\\|") + lines.append(f"| `{a.kind}` | `{clean_node}` | {clean_detail} |") + else: + lines.append("No anomalies detected.") + lines.append("") + + out.write_text("\n".join(lines), encoding="utf-8") + return out diff --git a/src/ocr_pipeline/__pycache__/__init__.cpython-313.pyc b/src/ocr_pipeline/__pycache__/__init__.cpython-313.pyc deleted file mode 100644 index e5757a47f633b0cfd25f0978126c1e3ef180e07d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 381 zcmYL^y-ve05XbE}Uo>jP(uJvF%Tl)+NQecdRN|u+6T>MArV1oZbxx3Af`{NCcoJR# zSwbKohHi+y01l<%hX4J~clSGa+3j|Sjs2JNt7n@(+F~8f%FyMGHIGCPK_?`kNC!kX z=m;j<2}@jbZOw!?@e&{XB*0)mI1&CS#c&qH;iARF)I_OLRh`y(rSzRQ8jgpf1zok= zGR;-&nCSRmdXmp&kt=C>Yd4j;De9P^Z>KS6#j|o0Zv)b<8| zVOf#?j+$`(NBw7*03a<2fG4uFo5bw$Gi_ISTBeF0R#{U@Rcn4dgDOL=G2=GF8pHu| ymDd2n`K<}}%IcyiLU$J= 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 diff --git a/src/ocr_pipeline/__pycache__/config.cpython-313.pyc b/src/ocr_pipeline/__pycache__/config.cpython-313.pyc deleted file mode 100644 index 9a618a454dd21a13191d36d14b678442697ade6a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 18122 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 ZqU?MA_`<)sn5#dOaWC(=FC5|||3Bw0aEJf^ diff --git a/src/ocr_pipeline/__pycache__/pipeline.cpython-313.pyc b/src/ocr_pipeline/__pycache__/pipeline.cpython-313.pyc deleted file mode 100644 index 8f390acb1d021a5167a170fc902004c5876dd17e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 9462 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 HY0ZBF;c3C{ diff --git a/src/ocr_pipeline/__pycache__/watch.cpython-313.pyc b/src/ocr_pipeline/__pycache__/watch.cpython-313.pyc deleted file mode 100644 index 2322f1673256731e8056726185949d28e21823aa..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 308 zcmey&%ge<81O*FYvK9jA#~=<2FhLog6@ZMX48aUV48e@SOx}!MOhrsy%tg!!48hF7 zEc#4EEa|M8Y*psY`MCv&C7DT?IhiGu3c2|yr8%hzY57G8B^jv-$%%RSd6~(HISS>8 zCCM47MG6Ip$=QkNsd_INf#z#6-4YB=E=olEp>I`uWL4@dcR$sX3W> zsrnF|1(mlrY# None: assert result.exit_code == 0, result.output assert out_graphml.is_file() + out_md = tmp_path / "g_report.md" + result = runner.invoke(app, ["kg", "export", str(save), "-f", "markdown", "-o", str(out_md)]) + assert result.exit_code == 0, result.output + assert out_md.is_file() + content = out_md.read_text(encoding="utf-8") + assert "## Top Entities" in content + assert "## Top Co-occurrences" in content + assert "## Top Citations" in content + assert "## Concept Clusters" in content + assert "## Anomalies" in content + assert "BRCA1" in content + assert "10.1038/nature12345" in content + + +def test_export_markdown(chunk_dir: Path, tmp_path: Path) -> None: + graph = build_from_directory(chunk_dir) + save_json = tmp_path / "graph.json" + export_json(graph, save_json) + + out_md = tmp_path / "report.md" + # Test with string paths as per signature requirement + written = export_markdown(str(save_json), str(out_md)) + assert written == out_md + assert out_md.is_file() + + text = out_md.read_text(encoding="utf-8") + assert "# Knowledge Graph Report" in text + assert "## Summary" in text + assert "## Top Entities" in text + assert "## Top Co-occurrences" in text + assert "## Top Citations" in text + assert "## Concept Clusters" in text + assert "## Anomalies" in text + assert "BRCA1" in text + assert "PARP" in text + assert "10.1038/nature12345" in text + assert "low_confidence" in text + + # Also test passing in-memory graph directly + out_direct = tmp_path / "report_direct.md" + export_markdown(graph, out_direct) + assert out_direct.is_file() + assert "## Top Entities" in out_direct.read_text(encoding="utf-8") + def test_chunks_for_entity(chunk_dir: Path) -> None: from kg_ocr.graph import chunks_for_entity