"""Entity relationship graph (journalism suite layer 5). Deterministic proper-noun extraction + co-occurrence edges. Entities that share a sentence repeatedly are surfaced as a RELATIONSHIP for the human to investigate. No model call; no hidden inference. This is the graph half of "who is connected to whom" the suit materializes while the brain reasons. Usage: from research.entitygraph import EntityGraph g = EntityGraph() g.add_doc("s1", "The Central Bank met Delta Corp. Delta Corp hired Smith.") g.report() """ import re from collections import defaultdict from itertools import combinations _PHRASE = re.compile(r"\b[A-Z][a-zA-Z]{1,25}(?:\s+[A-Z][a-zA-Z]{1,25}){0,3}\b") _ORG_SUFFIX = re.compile(r"\b(?:Inc|Corp|Corporation|Ltd|Agency|Department|" r"Committee|Commission|University|Institute|Bureau|" r"Administration|Bank|Fund|Office|Council|Force|" r"Group|Industries|Systems|Media|News|Post|Times)\b") _STOP = {"The", "This", "That", "These", "Those", "A", "An", "One", "Two", "Mr", "Mrs", "Ms", "Dr", "January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"} class EntityGraph: def __init__(self): self.docs = {} # source_id -> text self.sentences = [] # (source_id, sentence_text) def add_doc(self, source_id, text): self.docs[source_id] = text for s in re.split(r"(?<=[.!?])\s+", text): s = s.strip() if s: self.sentences.append((source_id, s)) def extract(self, text): """Proper-noun phrases; drop stop-word-led matches (deterministic).""" out = [] for m in _PHRASE.finditer(text): p = m.group(0).strip() if p.split()[0] in _STOP: continue out.append(p) return sorted(set(out)) def _nodes(self): nodes = defaultdict(int) for _, sent in self.sentences: for e in self.extract(sent): nodes[e] += 1 return nodes def edges(self, min_cooccur=2): """Entity pairs sharing a sentence; weight = co-occurrence count.""" pair_w = defaultdict(int) for _, sent in self.sentences: ents = sorted(set(self.extract(sent))) for a, b in combinations(ents, 2): pair_w[(a, b)] += 1 return {k: v for k, v in pair_w.items() if v >= min_cooccur} def central(self, top=10): """Degree centrality: entities with most distinct graph neighbors.""" deg = defaultdict(int) for (a, b) in self.edges(): deg[a] += 1 deg[b] += 1 return sorted(deg.items(), key=lambda kv: -kv[1])[:top] def report(self, min_cooccur=2): lines = ["# Entity Relationship Graph", ""] lines.append("## Entities (mentions)") nodes = self._nodes() for e, n in sorted(nodes.items(), key=lambda kv: -kv[1])[:30]: lines.append(f"- {e}: x{n}") lines.append("") lines.append("## Relationships (co-occurrence)") edges = self.edges(min_cooccur) for (a, b), w in sorted(edges.items(), key=lambda kv: -kv[1])[:25]: lines.append(f"- {a} <-> {b} (x{w})") if not edges: lines.append(f"- none above min_cooccur={min_cooccur}") lines.append("") lines.append("## Central entities (degree)") for e, d in self.central(): lines.append(f"- {e}: {d} neighbors") return "\n".join(lines) def to_dot(self, min_cooccur=2): lines = ["digraph entities {"] for e in self._nodes(): lines.append(f' "{e}";') for (a, b), w in self.edges(min_cooccur).items(): lines.append(f' "{a}" -> "{b}" [label="{w}"];') lines.append("}") return "\n".join(lines)