"""Analyze a knowledge graph built from OCR pipeline output.""" from __future__ import annotations from dataclasses import dataclass from typing import Any LOW_CONFIDENCE_THRESHOLD = 0.4 HUB_MENTIONS_THRESHOLD = 20 @dataclass(frozen=True) class Anomaly: kind: str node: str detail: str def _nodes_of_kind(graph: Any, kind: str) -> list[tuple[str, dict]]: return [(n, d) for n, d in graph.nodes(data=True) if d.get("kind") == kind] def summary(graph: Any) -> dict[str, int]: """Node and edge counts grouped by kind.""" counts: dict[str, int] = {} for _, data in graph.nodes(data=True): kind = data.get("kind", "unknown") counts[f"nodes:{kind}"] = counts.get(f"nodes:{kind}", 0) + 1 for _, _, data in graph.edges(data=True): kind = data.get("kind", "unknown") counts[f"edges:{kind}"] = counts.get(f"edges:{kind}", 0) + 1 return counts def top_entities(graph: Any, limit: int = 10) -> list[tuple[str, int]]: """Most-mentioned entities as (text, mentions), descending.""" entities = [ (data.get("text", ""), int(data.get("mentions", 0))) for _, data in _nodes_of_kind(graph, "entity") ] entities.sort(key=lambda item: item[1], reverse=True) return entities[:limit] def top_cooccurrences(graph: Any, limit: int = 10) -> list[tuple[str, str, int]]: """Strongest entity co-occurrence pairs as (left, right, weight).""" pairs = [] for left, right, data in graph.edges(data=True): if data.get("kind") != "CO_OCCURS": continue left_text = graph.nodes[left].get("text", left) right_text = graph.nodes[right].get("text", right) pairs.append((left_text, right_text, int(data.get("weight", 1)))) pairs.sort(key=lambda item: item[2], reverse=True) return pairs[:limit] def top_citations(graph: Any, limit: int = 10) -> list[tuple[str, str, int]]: """Most-cited identifiers as (type, identifier, citing chunks).""" rows = [] for node, data in _nodes_of_kind(graph, "citation"): citing = sum( 1 for _, _, edge in graph.in_edges(node, data=True) if edge.get("kind") == "CITES" ) rows.append((data.get("citation_type", ""), data.get("identifier", node), citing)) rows.sort(key=lambda item: item[2], reverse=True) return rows[:limit] def detect_anomalies( graph: Any, low_confidence: float = LOW_CONFIDENCE_THRESHOLD, hub_mentions: int = HUB_MENTIONS_THRESHOLD, ) -> list[Anomaly]: """Flag suspicious graph regions worth re-checking. - documents with very low OCR confidence (likely OCR failure) - chunks with empty extracted text - documents with zero entities and zero citations (nothing extracted) - entity hubs (mentioned everywhere; often a junk pattern) """ anomalies: list[Anomaly] = [] documents = _nodes_of_kind(graph, "document") for node, data in documents: confidence = float(data.get("ocr_confidence_mean") or 0.0) if confidence < low_confidence: anomalies.append( Anomaly( "low_confidence", node, f"ocr_confidence_mean={confidence:.2f} < {low_confidence}", ) ) meaningful_out = 0 for _, target, edge in graph.out_edges(node, data=True): if edge.get("kind") != "CONTAINS": continue for _, _, chunk_edge in graph.out_edges(target, data=True): if chunk_edge.get("kind") in ("MENTIONS", "CITES"): meaningful_out += 1 if meaningful_out == 0: anomalies.append(Anomaly("empty_document", node, "no entities or citations extracted")) for node, data in _nodes_of_kind(graph, "chunk"): if not (data.get("text") or "").strip(): anomalies.append(Anomaly("empty_chunk", node, "extracted text is empty")) for node, data in _nodes_of_kind(graph, "entity"): mentions = int(data.get("mentions", 0)) if mentions >= hub_mentions: anomalies.append( Anomaly("entity_hub", node, f"mentioned {mentions} times; possible junk pattern") ) return anomalies