osintengine 1.0.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. osintengine-1.0.2.dist-info/METADATA +311 -0
  2. osintengine-1.0.2.dist-info/RECORD +103 -0
  3. osintengine-1.0.2.dist-info/WHEEL +5 -0
  4. osintengine-1.0.2.dist-info/entry_points.txt +2 -0
  5. osintengine-1.0.2.dist-info/licenses/LICENSE +202 -0
  6. osintengine-1.0.2.dist-info/top_level.txt +2 -0
  7. src/watson/__init__.py +10 -0
  8. src/watson/agent/__init__.py +96 -0
  9. src/watson/agents/__init__.py +101 -0
  10. src/watson/agents/protocol.py +196 -0
  11. src/watson/auth/__init__.py +68 -0
  12. src/watson/auth/store.py +145 -0
  13. src/watson/conversation.py +68 -0
  14. src/watson/core/__init__.py +0 -0
  15. src/watson/core/models.py +96 -0
  16. src/watson/ethics.py +205 -0
  17. src/watson/exports.py +182 -0
  18. src/watson/graph/__init__.py +69 -0
  19. src/watson/graph/entities.py +207 -0
  20. src/watson/graph/graph.py +489 -0
  21. src/watson/graph/osint_framework.py +266 -0
  22. src/watson/graph/relationships.py +116 -0
  23. src/watson/graph/transforms.py +787 -0
  24. src/watson/infra/__init__.py +43 -0
  25. src/watson/infra/cache.py +171 -0
  26. src/watson/infra/ratelimit.py +125 -0
  27. src/watson/infra/resilience.py +239 -0
  28. src/watson/infra/retry.py +186 -0
  29. src/watson/metrics.py +128 -0
  30. src/watson/opsec/__init__.py +290 -0
  31. src/watson/orchestration/__init__.py +23 -0
  32. src/watson/orchestration/engine.py +5587 -0
  33. src/watson/orchestration/executor.py +96 -0
  34. src/watson/orchestration/intent.py +139 -0
  35. src/watson/orchestration/intent_classifier.py +45 -0
  36. src/watson/orchestration/llm_config.py +160 -0
  37. src/watson/orchestration/resolution.py +555 -0
  38. src/watson/orchestration/scraper.py +94 -0
  39. src/watson/orchestration/synthesis.py +726 -0
  40. src/watson/orchestration/target_profile.py +748 -0
  41. src/watson/persistence/__init__.py +18 -0
  42. src/watson/persistence/models.py +161 -0
  43. src/watson/persistence/store.py +230 -0
  44. src/watson/pipeline/__init__.py +16 -0
  45. src/watson/pipeline/pre_synthesis.py +621 -0
  46. src/watson/search.py +103 -0
  47. src/watson/serializers/stix.py +451 -0
  48. src/watson/tools/__init__.py +31 -0
  49. src/watson/tools/base.py +97 -0
  50. src/watson/tools/blockchain.py +359 -0
  51. src/watson/tools/captcha.py +276 -0
  52. src/watson/tools/conflict.py +107 -0
  53. src/watson/tools/corporate.py +287 -0
  54. src/watson/tools/darkweb.py +144 -0
  55. src/watson/tools/geolocation.py +347 -0
  56. src/watson/tools/image_video.py +108 -0
  57. src/watson/tools/marinetraffic.py +142 -0
  58. src/watson/tools/people.py +476 -0
  59. src/watson/tools/registry.py +56 -0
  60. src/watson/tools/satellite.py +118 -0
  61. src/watson/tools/scraper.py +745 -0
  62. src/watson/tools/shodan.py +129 -0
  63. src/watson/tools/social_media.py +160 -0
  64. src/watson/tools/websites.py +291 -0
  65. src/watson/tools/wikidata.py +398 -0
  66. src/watson/utils/__init__.py +1 -0
  67. src/watson/utils/helpers.py +49 -0
  68. src/watson/utils/http.py +119 -0
  69. src/watson/verification/__init__.py +245 -0
  70. watson/__init__.py +6 -0
  71. watson/agents/__init__.py +20 -0
  72. watson/agents/base.py +103 -0
  73. watson/agents/direct.py +225 -0
  74. watson/agents/hermes.py +275 -0
  75. watson/agents/hermes_mcp.py +292 -0
  76. watson/api_keys.py +176 -0
  77. watson/browser_scraper.py +481 -0
  78. watson/cli.py +836 -0
  79. watson/crossref.py +175 -0
  80. watson/ethics.py +20 -0
  81. watson/graph.py +406 -0
  82. watson/mcp_server.py +601 -0
  83. watson/memory.py +552 -0
  84. watson/neo4j_graph.py +124 -0
  85. watson/opsec/__init__.py +307 -0
  86. watson/reporter.py +537 -0
  87. watson/scheduler.py +486 -0
  88. watson/serializers/stix.py +451 -0
  89. watson/terminal.py +410 -0
  90. watson/toolkit.py +252 -0
  91. watson/toolkit_api.py +347 -0
  92. watson/toolkit_automation.py +95 -0
  93. watson/toolkit_registry.py +345 -0
  94. watson/verification/__init__.py +251 -0
  95. watson/web/__init__.py +1 -0
  96. watson/web/app.py +1650 -0
  97. watson/web/middleware.py +301 -0
  98. watson/web/static/assets/index-B7hPOc0z.js +278 -0
  99. watson/web/static/assets/index-CJ6FP8Mp.css +1 -0
  100. watson/web/static/assets/watson-logo-Dk9tawHb.png +0 -0
  101. watson/web/static/index.html +14 -0
  102. watson/web/templates/chat.html +2 -0
  103. watson/web/templates/investigation-map.html +429 -0
watson/crossref.py ADDED
@@ -0,0 +1,175 @@
1
+ """Cross-Referencer — LLM-powered correlation of investigation findings.
2
+
3
+ Replaces naive word-overlap with deep semantic analysis.
4
+ Works with dict findings — no model dependency.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import os
11
+ import re
12
+ from dataclasses import dataclass, field
13
+ from typing import Optional
14
+
15
+
16
+ @dataclass
17
+ class CrossReference:
18
+ title: str
19
+ description: str
20
+ sources: list[str] = field(default_factory=list)
21
+ evidence: list[str] = field(default_factory=list)
22
+ confidence: float = 0.5
23
+ connection_type: str = "corroboration"
24
+
25
+
26
+ class CrossReferencer:
27
+ """LLM-powered cross-referencing engine."""
28
+
29
+ CROSS_REF_PROMPT = """You are a senior OSINT intelligence analyst reviewing findings from multiple sources.
30
+ Identify connections between findings that a human analyst would notice.
31
+
32
+ {findings_text}
33
+
34
+ Look for:
35
+ 1. Corroboration: Two sources confirming the same fact
36
+ 2. Contradiction: Two sources disagreeing
37
+ 3. Pattern: A pattern emerging across multiple sources
38
+ 4. Link: An entity/domain/person appearing in multiple unrelated findings
39
+ 5. Gap: Something conspicuously missing
40
+
41
+ Return ONLY valid JSON array of connections:
42
+ [{{"title":"...","description":"...","sources":["source_a","source_b"],"connection_type":"corroboration|contradiction|pattern|link|gap","confidence":0.0-1.0,"evidence":["url"]}}]
43
+ If no connections exist, return []."""
44
+
45
+ def __init__(self, api_key: str | None = None, model: str = "deepseek-chat"):
46
+ self._api_key = api_key
47
+ self._model = model
48
+ if not self._api_key:
49
+ for var in ("DEEPSEEK_API_KEY", "OPENAI_API_KEY"):
50
+ self._api_key = os.environ.get(var, "")
51
+ if self._api_key:
52
+ break
53
+ if not self._api_key:
54
+ env_path = os.path.expanduser("~/.hermes/.env")
55
+ if os.path.exists(env_path):
56
+ with open(env_path) as f:
57
+ for line in f:
58
+ if "=" in line and not line.startswith("#"):
59
+ k, v = line.strip().split("=", 1)
60
+ if k.strip() in ("DEEPSEEK_API_KEY", "OPENAI_API_KEY"):
61
+ self._api_key = v.strip().strip('"').strip("'")
62
+ break
63
+
64
+ @property
65
+ def available(self) -> bool:
66
+ return bool(self._api_key)
67
+
68
+ def cross_reference(self, findings: list[dict], max_findings: int = 30) -> list[CrossReference]:
69
+ if not findings or len(findings) < 2:
70
+ return []
71
+ if self.available:
72
+ return self._llm_cross_reference(findings[:max_findings])
73
+ return self._word_overlap(findings)
74
+
75
+ def _llm_cross_reference(self, findings: list[dict]) -> list[CrossReference]:
76
+ blocks = []
77
+ for f in findings:
78
+ src = f.get("source_type", f.get("source", "unknown"))
79
+ blocks.append(
80
+ f"[SOURCE: {src}]\n"
81
+ f"TITLE: {f.get('title', '')}\n"
82
+ f"DESC: {f.get('description', '')[:300]}\n"
83
+ )
84
+ prompt = self.CROSS_REF_PROMPT.format(findings_text="\n".join(blocks))
85
+
86
+ try:
87
+ raw = self._call_llm(prompt)
88
+ if not raw:
89
+ return self._word_overlap(findings)
90
+ parsed = self._parse_json_array(raw)
91
+ if not parsed:
92
+ return self._word_overlap(findings)
93
+ return [
94
+ CrossReference(
95
+ title=item.get("title", ""),
96
+ description=item.get("description", ""),
97
+ sources=item.get("sources", []),
98
+ evidence=item.get("evidence", []),
99
+ confidence=float(item.get("confidence", 0.5)),
100
+ connection_type=item.get("connection_type", "link"),
101
+ )
102
+ for item in parsed
103
+ ]
104
+ except Exception:
105
+ return self._word_overlap(findings)
106
+
107
+ def _call_llm(self, prompt: str, timeout: int = 30) -> str | None:
108
+ import concurrent.futures
109
+ api_base = os.environ.get("DEEPSEEK_API_BASE", "https://api.deepseek.com/v1")
110
+
111
+ def _call_sync():
112
+ import urllib.request as ur
113
+ body = json.dumps({
114
+ "model": self._model,
115
+ "messages": [{"role": "user", "content": prompt}],
116
+ "temperature": 0.3,
117
+ "max_tokens": 1500,
118
+ }).encode()
119
+ req = ur.Request(
120
+ f"{api_base}/chat/completions", data=body,
121
+ headers={"Content-Type": "application/json", "Authorization": f"Bearer {self._api_key}"},
122
+ )
123
+ try:
124
+ resp = ur.urlopen(req, timeout=timeout)
125
+ return json.loads(resp.read())["choices"][0]["message"]["content"]
126
+ except Exception:
127
+ return None
128
+
129
+ with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
130
+ return pool.submit(_call_sync).result(timeout=timeout + 5)
131
+
132
+ def _parse_json_array(self, text: str) -> list[dict] | None:
133
+ text = text.strip()
134
+ if text.startswith("```"):
135
+ text = re.sub(r"^```(?:json)?\s*", "", text)
136
+ text = re.sub(r"\s*```$", "", text)
137
+ try:
138
+ parsed = json.loads(text)
139
+ return parsed if isinstance(parsed, list) else []
140
+ except json.JSONDecodeError:
141
+ match = re.search(r"\[.*\]", text, re.DOTALL)
142
+ if match:
143
+ try:
144
+ return json.loads(match.group(0))
145
+ except json.JSONDecodeError:
146
+ pass
147
+ return None
148
+
149
+ def _word_overlap(self, findings: list[dict]) -> list[CrossReference]:
150
+ refs = []
151
+ stopwords = {"the","a","an","is","was","are","were","be","been","in","on","at","to","for","of","and","or","not","this","that","with","from","by","as","it","its","found","search","result","error","failed","timeout"}
152
+ by_source: dict[str, list[dict]] = {}
153
+ for f in findings:
154
+ src = f.get("source_type", f.get("source", "unknown"))
155
+ by_source.setdefault(src, []).append(f)
156
+ sources = list(by_source.keys())
157
+ for i in range(len(sources)):
158
+ for j in range(i + 1, len(sources)):
159
+ for fa in by_source[sources[i]]:
160
+ for fb in by_source[sources[j]]:
161
+ wa = {w.lower() for w in fa.get("title","").split() if len(w) > 3 and w.lower() not in stopwords}
162
+ wb = {w.lower() for w in fb.get("title","").split() if len(w) > 3 and w.lower() not in stopwords}
163
+ overlap = wa & wb
164
+ meaningful = overlap - {"http","https","www","com","org"}
165
+ if len(meaningful) >= 1:
166
+ refs.append(CrossReference(
167
+ title=f"Link: {fa.get('title','')[:60]} ↔ {fb.get('title','')[:60]}",
168
+ description=f"Findings from {sources[i]} and {sources[j]} share: {', '.join(sorted(overlap))}",
169
+ sources=[sources[i], sources[j]],
170
+ evidence=fa.get("evidence",[])[:1] + fb.get("evidence",[])[:1],
171
+ confidence=min(fa.get("confidence",0.5), fb.get("confidence",0.5)),
172
+ ))
173
+ seen = set()
174
+ unique = [r for r in refs if not (r.title in seen or seen.add(r.title))]
175
+ return unique[:15]
watson/ethics.py ADDED
@@ -0,0 +1,20 @@
1
+ """Re-export from src.watson.ethics — the Sidney Ledger.
2
+
3
+ The real implementation lives in src/watson/ethics.py.
4
+ This module bridges the import path so watson.* imports work.
5
+ """
6
+
7
+ from src.watson.ethics import (
8
+ SidneyLedger,
9
+ apply_editorial_checks,
10
+ get_ledger,
11
+ BELLINGCAT_DATA_ETHICS_APPENDIX,
12
+ EditorialFramework,
13
+ generate_compliance_header,
14
+ )
15
+
16
+ __all__ = [
17
+ "SidneyLedger", "apply_editorial_checks", "get_ledger",
18
+ "BELLINGCAT_DATA_ETHICS_APPENDIX", "EditorialFramework",
19
+ "generate_compliance_header",
20
+ ]
watson/graph.py ADDED
@@ -0,0 +1,406 @@
1
+ """
2
+ Knowledge graph engine — the Watson moat.
3
+
4
+ Every investigation writes to a persistent entity-relationship graph.
5
+ Future investigations auto-surface connections from past cases.
6
+ This is what makes Watson smarter over time — no general agent has this.
7
+
8
+ Graph structure:
9
+ Node = Entity (person, company, domain, email, location, etc.)
10
+ Edge = Relationship (registered_by, director_of, hosted_on, etc.)
11
+ Each edge carries: provenance (case_id), source URL, confidence, timestamp
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import hashlib
17
+ import json
18
+ import os
19
+ from dataclasses import dataclass, field
20
+ from datetime import datetime, timezone
21
+ from pathlib import Path
22
+ from typing import Optional
23
+
24
+
25
+ @dataclass
26
+ class Entity:
27
+ id: str # sha256 of (type + value)
28
+ type: str # person, company, domain, email, location, ip, etc.
29
+ value: str # canonical value
30
+ label: str = "" # display name
31
+ first_seen: str = "" # ISO timestamp of first discovery
32
+ last_seen: str = "" # ISO timestamp of most recent discovery
33
+ case_ids: list[str] = field(default_factory=list) # which cases found this
34
+
35
+ def __post_init__(self):
36
+ if not self.id:
37
+ self.id = self._hash(self.type, self.value)
38
+ if not self.label:
39
+ self.label = self.value
40
+ now = datetime.now(timezone.utc).isoformat()
41
+ if not self.first_seen:
42
+ self.first_seen = now
43
+ if not self.last_seen:
44
+ self.last_seen = now
45
+
46
+ def to_dict(self) -> dict:
47
+ return {
48
+ "id": self.id,
49
+ "type": self.type,
50
+ "value": self.value,
51
+ "label": self.label,
52
+ "first_seen": self.first_seen,
53
+ "last_seen": self.last_seen,
54
+ "case_ids": self.case_ids,
55
+ }
56
+
57
+ @staticmethod
58
+ def _hash(type_: str, value: str) -> str:
59
+ raw = f"{type_.lower()}:{value.lower().strip()}"
60
+ return hashlib.sha256(raw.encode()).hexdigest()[:16]
61
+
62
+
63
+ @dataclass
64
+ class Relation:
65
+ id: str
66
+ source_entity: str # entity ID
67
+ target_entity: str # entity ID
68
+ relation_type: str # registered_by, director_of, hosted_on, uses_email, etc.
69
+ case_id: str # which case discovered this
70
+ source_url: str = "" # URL or tool that produced this
71
+ confidence: float = 0.5
72
+ timestamp: str = ""
73
+ evidence: str = ""
74
+
75
+ def __post_init__(self):
76
+ if not self.id:
77
+ raw = f"{self.source_entity}|{self.relation_type}|{self.target_entity}|{self.case_id}"
78
+ self.id = hashlib.sha256(raw.encode()).hexdigest()[:16]
79
+ if not self.timestamp:
80
+ self.timestamp = datetime.now(timezone.utc).isoformat()
81
+
82
+ def to_dict(self) -> dict:
83
+ return {
84
+ "id": self.id,
85
+ "source_entity": self.source_entity,
86
+ "target_entity": self.target_entity,
87
+ "relation_type": self.relation_type,
88
+ "case_id": self.case_id,
89
+ "source_url": self.source_url,
90
+ "confidence": self.confidence,
91
+ "timestamp": self.timestamp,
92
+ "evidence": self.evidence,
93
+ }
94
+
95
+
96
+ class KnowledgeGraph:
97
+ """Persistent entity-relationship graph with case provenance."""
98
+
99
+ def __init__(self, data_dir: str | Path = "~/.watson/graph"):
100
+ self.data_dir = Path(data_dir).expanduser().resolve()
101
+ self.data_dir.mkdir(parents=True, exist_ok=True)
102
+ self._entities: dict[str, Entity] = {}
103
+ self._relations: dict[str, Relation] = {}
104
+ self._loaded = False
105
+
106
+ # ── Persistence ──────────────────────────────────────────────
107
+
108
+ def load(self) -> None:
109
+ """Load graph from disk. Resilient to corrupted lines."""
110
+ if self._loaded:
111
+ return
112
+
113
+ import logging
114
+ _log = logging.getLogger("watson.graph")
115
+
116
+ entities_path = self.data_dir / "entities.jsonl"
117
+ if entities_path.exists():
118
+ for i, line in enumerate(entities_path.read_text().splitlines(), 1):
119
+ line = line.strip()
120
+ if not line:
121
+ continue
122
+ try:
123
+ d = json.loads(line)
124
+ except json.JSONDecodeError:
125
+ _log.warning("graph: skipping corrupt entities.jsonl line %d", i)
126
+ continue
127
+ e = Entity(**d)
128
+ self._entities[e.id] = e
129
+
130
+ relations_path = self.data_dir / "relations.jsonl"
131
+ if relations_path.exists():
132
+ for i, line in enumerate(relations_path.read_text().splitlines(), 1):
133
+ line = line.strip()
134
+ if not line:
135
+ continue
136
+ try:
137
+ d = json.loads(line)
138
+ except json.JSONDecodeError:
139
+ _log.warning("graph: skipping corrupt relations.jsonl line %d", i)
140
+ continue
141
+ r = Relation(**d)
142
+ self._relations[r.id] = r
143
+
144
+ self._loaded = True
145
+
146
+ def save(self) -> None:
147
+ """Persist graph to disk."""
148
+ self.data_dir.mkdir(parents=True, exist_ok=True)
149
+
150
+ with open(self.data_dir / "entities.jsonl", "w") as f:
151
+ for e in self._entities.values():
152
+ f.write(json.dumps(e.to_dict()) + "\n")
153
+
154
+ with open(self.data_dir / "relations.jsonl", "w") as f:
155
+ for r in self._relations.values():
156
+ f.write(json.dumps(r.to_dict()) + "\n")
157
+
158
+ # ── Entity CRUD ──────────────────────────────────────────────
159
+
160
+ def upsert_entity(
161
+ self,
162
+ type_: str,
163
+ value: str,
164
+ case_id: str,
165
+ label: str = "",
166
+ ) -> Entity:
167
+ """Create or update an entity, linking it to a case."""
168
+ self.load()
169
+ entity_id = Entity._hash(type_, value) # type: ignore[arg-type]
170
+
171
+ if entity_id in self._entities:
172
+ entity = self._entities[entity_id]
173
+ entity.last_seen = datetime.now(timezone.utc).isoformat()
174
+ if case_id not in entity.case_ids:
175
+ entity.case_ids.append(case_id)
176
+ if label and label != entity.value:
177
+ entity.label = label
178
+ else:
179
+ entity = Entity(
180
+ id=entity_id,
181
+ type=type_,
182
+ value=value,
183
+ label=label or value,
184
+ case_ids=[case_id],
185
+ )
186
+ self._entities[entity_id] = entity
187
+
188
+ self.save()
189
+ return entity
190
+
191
+ def get_entity(self, entity_id: str) -> Optional[Entity]:
192
+ """Get entity by ID."""
193
+ self.load()
194
+ return self._entities.get(entity_id)
195
+
196
+ def find_entity(self, type_: str, value: str) -> Optional[Entity]:
197
+ """Find entity by type + value (case-insensitive)."""
198
+ self.load()
199
+ entity_id = Entity._hash(type_, value) # type: ignore[arg-type]
200
+ return self._entities.get(entity_id)
201
+
202
+ def add_entity(self, entity_type: str, value: str, case_id: str, label: str = "") -> Entity:
203
+ """Alias for upsert_entity."""
204
+ return self.upsert_entity(entity_type, value, case_id, label)
205
+
206
+ def add_case(self, case_id: str, query: str):
207
+ """Track a case in the graph (no-op, cases tracked via entity links)."""
208
+ pass
209
+
210
+ def search_entities(self, query: str, limit: int = 20) -> list[Entity]:
211
+ self.load()
212
+ q = query.lower()
213
+ results = []
214
+ for e in self._entities.values():
215
+ if q in e.value.lower() or q in e.label.lower():
216
+ results.append(e)
217
+ if len(results) >= limit:
218
+ break
219
+ return results
220
+
221
+ # ── Relations ────────────────────────────────────────────────
222
+
223
+ def add_relation(
224
+ self,
225
+ source_type: str,
226
+ source_value: str,
227
+ relation_type: str,
228
+ target_type: str,
229
+ target_value: str,
230
+ case_id: str,
231
+ source_url: str = "",
232
+ confidence: float = 0.5,
233
+ evidence: str = "",
234
+ ) -> Relation:
235
+ """Add a directed relationship between two entities."""
236
+ self.load()
237
+
238
+ # Upsert both entities
239
+ source = self.upsert_entity(source_type, source_value, case_id)
240
+ target = self.upsert_entity(target_type, target_value, case_id)
241
+
242
+ relation = Relation(
243
+ id="",
244
+ source_entity=source.id,
245
+ target_entity=target.id,
246
+ relation_type=relation_type,
247
+ case_id=case_id,
248
+ source_url=source_url,
249
+ confidence=confidence,
250
+ evidence=evidence,
251
+ )
252
+
253
+ # Deduplicate
254
+ if relation.id in self._relations:
255
+ existing = self._relations[relation.id]
256
+ if confidence > existing.confidence:
257
+ existing.confidence = confidence
258
+ existing.evidence = evidence
259
+ return existing
260
+
261
+ self._relations[relation.id] = relation
262
+ self.save()
263
+ return relation
264
+
265
+ # ── Traversal ────────────────────────────────────────────────
266
+
267
+ def traverse(
268
+ self,
269
+ entity_value: str,
270
+ entity_type: str | None = None,
271
+ hops: int = 1,
272
+ ) -> dict:
273
+ """Traverse the graph from an entity, returning N-hop neighborhood."""
274
+ self.load()
275
+ entity = None
276
+
277
+ if entity_type:
278
+ entity = self.find_entity(entity_type, entity_value)
279
+ if not entity:
280
+ # Try all types
281
+ for e in self._entities.values():
282
+ if e.value.lower() == entity_value.lower():
283
+ entity = e
284
+ break
285
+
286
+ if not entity:
287
+ return {"entity": None, "relations": [], "neighbors": [], "error": "Entity not found"}
288
+
289
+ relations = []
290
+ neighbor_ids: set[str] = set()
291
+
292
+ for r in self._relations.values():
293
+ if r.source_entity == entity.id:
294
+ relations.append(r.to_dict())
295
+ neighbor_ids.add(r.target_entity)
296
+ elif r.target_entity == entity.id:
297
+ # Reverse relation
298
+ rev = r.to_dict()
299
+ rev["direction"] = "incoming"
300
+ relations.append(rev)
301
+ neighbor_ids.add(r.source_entity)
302
+
303
+ neighbors = []
304
+ for nid in neighbor_ids:
305
+ if nid in self._entities:
306
+ e = self._entities[nid]
307
+ neighbor_info = e.to_dict()
308
+ # Collect all relations for context
309
+ neighbor_info["relations"] = [
310
+ r.to_dict()
311
+ for r in self._relations.values()
312
+ if r.source_entity == e.id or r.target_entity == e.id
313
+ ]
314
+ neighbors.append(neighbor_info)
315
+
316
+ return {
317
+ "entity": entity.to_dict(),
318
+ "relations": relations,
319
+ "neighbors": neighbors,
320
+ "hop_count": hops,
321
+ }
322
+
323
+ def context_for_investigation(
324
+ self,
325
+ query: str,
326
+ max_entities: int = 10,
327
+ ) -> dict:
328
+ """Before investigating, check if any entities are already in the graph.
329
+ Returns relevant prior findings to prime the new investigation."""
330
+ self.load()
331
+
332
+ relevant_entities = self.search_entities(query, limit=max_entities)
333
+ if not relevant_entities:
334
+ return {"known_entities": [], "prior_relations": [], "relevant_cases": []}
335
+
336
+ prior_relations: list[dict] = []
337
+ relevant_cases: set[str] = set()
338
+ entity_ids = {e.id for e in relevant_entities}
339
+
340
+ for r in self._relations.values():
341
+ if r.source_entity in entity_ids or r.target_entity in entity_ids:
342
+ prior_relations.append(r.to_dict())
343
+ relevant_cases.add(r.case_id)
344
+
345
+ return {
346
+ "known_entities": [e.to_dict() for e in relevant_entities],
347
+ "prior_relations": prior_relations,
348
+ "relevant_cases": sorted(relevant_cases),
349
+ }
350
+
351
+ # ── Stats ────────────────────────────────────────────────────
352
+
353
+ def stats(self) -> dict:
354
+ """Graph statistics."""
355
+ self.load()
356
+ type_counts: dict[str, int] = {}
357
+ case_set: set[str] = set()
358
+ for e in self._entities.values():
359
+ type_counts[e.type] = type_counts.get(e.type, 0) + 1
360
+ case_set.update(e.case_ids)
361
+
362
+ return {
363
+ "entity_count": len(self._entities),
364
+ "relation_count": len(self._relations),
365
+ "case_count": len(case_set),
366
+ "entity_types": type_counts,
367
+ "top_entities": sorted(
368
+ [
369
+ {"value": e.value, "type": e.type, "case_count": len(e.case_ids)}
370
+ for e in self._entities.values()
371
+ if len(e.case_ids) > 1
372
+ ],
373
+ key=lambda x: -x["case_count"],
374
+ )[:10],
375
+ }
376
+
377
+ def remove_case(self, case_id: str) -> int:
378
+ """Remove all entities belonging to a case from the graph.
379
+
380
+ Returns the number of entities removed.
381
+ Call this when unpublishing a case from the community graph.
382
+ """
383
+ self.load()
384
+ removed = 0
385
+ to_remove = []
386
+ for eid, entity in self._entities.items():
387
+ if case_id in entity.case_ids:
388
+ entity.case_ids = [c for c in entity.case_ids if c != case_id]
389
+ if not entity.case_ids:
390
+ # Entity has no remaining cases — fully remove
391
+ to_remove.append(eid)
392
+ removed += 1
393
+
394
+ for eid in to_remove:
395
+ del self._entities[eid]
396
+
397
+ # Also remove relations for this case
398
+ rel_to_remove = []
399
+ for rid, rel in self._relations.items():
400
+ if rel.case_id == case_id:
401
+ rel_to_remove.append(rid)
402
+ for rid in rel_to_remove:
403
+ del self._relations[rid]
404
+
405
+ self.save()
406
+ return removed