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.
- osintengine-1.0.2.dist-info/METADATA +311 -0
- osintengine-1.0.2.dist-info/RECORD +103 -0
- osintengine-1.0.2.dist-info/WHEEL +5 -0
- osintengine-1.0.2.dist-info/entry_points.txt +2 -0
- osintengine-1.0.2.dist-info/licenses/LICENSE +202 -0
- osintengine-1.0.2.dist-info/top_level.txt +2 -0
- src/watson/__init__.py +10 -0
- src/watson/agent/__init__.py +96 -0
- src/watson/agents/__init__.py +101 -0
- src/watson/agents/protocol.py +196 -0
- src/watson/auth/__init__.py +68 -0
- src/watson/auth/store.py +145 -0
- src/watson/conversation.py +68 -0
- src/watson/core/__init__.py +0 -0
- src/watson/core/models.py +96 -0
- src/watson/ethics.py +205 -0
- src/watson/exports.py +182 -0
- src/watson/graph/__init__.py +69 -0
- src/watson/graph/entities.py +207 -0
- src/watson/graph/graph.py +489 -0
- src/watson/graph/osint_framework.py +266 -0
- src/watson/graph/relationships.py +116 -0
- src/watson/graph/transforms.py +787 -0
- src/watson/infra/__init__.py +43 -0
- src/watson/infra/cache.py +171 -0
- src/watson/infra/ratelimit.py +125 -0
- src/watson/infra/resilience.py +239 -0
- src/watson/infra/retry.py +186 -0
- src/watson/metrics.py +128 -0
- src/watson/opsec/__init__.py +290 -0
- src/watson/orchestration/__init__.py +23 -0
- src/watson/orchestration/engine.py +5587 -0
- src/watson/orchestration/executor.py +96 -0
- src/watson/orchestration/intent.py +139 -0
- src/watson/orchestration/intent_classifier.py +45 -0
- src/watson/orchestration/llm_config.py +160 -0
- src/watson/orchestration/resolution.py +555 -0
- src/watson/orchestration/scraper.py +94 -0
- src/watson/orchestration/synthesis.py +726 -0
- src/watson/orchestration/target_profile.py +748 -0
- src/watson/persistence/__init__.py +18 -0
- src/watson/persistence/models.py +161 -0
- src/watson/persistence/store.py +230 -0
- src/watson/pipeline/__init__.py +16 -0
- src/watson/pipeline/pre_synthesis.py +621 -0
- src/watson/search.py +103 -0
- src/watson/serializers/stix.py +451 -0
- src/watson/tools/__init__.py +31 -0
- src/watson/tools/base.py +97 -0
- src/watson/tools/blockchain.py +359 -0
- src/watson/tools/captcha.py +276 -0
- src/watson/tools/conflict.py +107 -0
- src/watson/tools/corporate.py +287 -0
- src/watson/tools/darkweb.py +144 -0
- src/watson/tools/geolocation.py +347 -0
- src/watson/tools/image_video.py +108 -0
- src/watson/tools/marinetraffic.py +142 -0
- src/watson/tools/people.py +476 -0
- src/watson/tools/registry.py +56 -0
- src/watson/tools/satellite.py +118 -0
- src/watson/tools/scraper.py +745 -0
- src/watson/tools/shodan.py +129 -0
- src/watson/tools/social_media.py +160 -0
- src/watson/tools/websites.py +291 -0
- src/watson/tools/wikidata.py +398 -0
- src/watson/utils/__init__.py +1 -0
- src/watson/utils/helpers.py +49 -0
- src/watson/utils/http.py +119 -0
- src/watson/verification/__init__.py +245 -0
- watson/__init__.py +6 -0
- watson/agents/__init__.py +20 -0
- watson/agents/base.py +103 -0
- watson/agents/direct.py +225 -0
- watson/agents/hermes.py +275 -0
- watson/agents/hermes_mcp.py +292 -0
- watson/api_keys.py +176 -0
- watson/browser_scraper.py +481 -0
- watson/cli.py +836 -0
- watson/crossref.py +175 -0
- watson/ethics.py +20 -0
- watson/graph.py +406 -0
- watson/mcp_server.py +601 -0
- watson/memory.py +552 -0
- watson/neo4j_graph.py +124 -0
- watson/opsec/__init__.py +307 -0
- watson/reporter.py +537 -0
- watson/scheduler.py +486 -0
- watson/serializers/stix.py +451 -0
- watson/terminal.py +410 -0
- watson/toolkit.py +252 -0
- watson/toolkit_api.py +347 -0
- watson/toolkit_automation.py +95 -0
- watson/toolkit_registry.py +345 -0
- watson/verification/__init__.py +251 -0
- watson/web/__init__.py +1 -0
- watson/web/app.py +1650 -0
- watson/web/middleware.py +301 -0
- watson/web/static/assets/index-B7hPOc0z.js +278 -0
- watson/web/static/assets/index-CJ6FP8Mp.css +1 -0
- watson/web/static/assets/watson-logo-Dk9tawHb.png +0 -0
- watson/web/static/index.html +14 -0
- watson/web/templates/chat.html +2 -0
- 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
|