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/memory.py
ADDED
|
@@ -0,0 +1,552 @@
|
|
|
1
|
+
"""Memory Engine — persistent cross-session memory for Watson.
|
|
2
|
+
|
|
3
|
+
Stores investigations, findings, and entities in a SQLite database
|
|
4
|
+
at ~/.watson/memory.db. Enables:
|
|
5
|
+
- Full-text search (FTS5) across past investigations
|
|
6
|
+
- Entity tracking across cases (same person/domain appearing in multiple investigations)
|
|
7
|
+
- Context injection: when investigating a target, surface relevant past findings
|
|
8
|
+
|
|
9
|
+
Inspired by Hermes Agent's memory system — durable facts that survive restarts.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import sqlite3
|
|
16
|
+
import uuid
|
|
17
|
+
from datetime import datetime, timezone
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Optional
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class MemoryEngine:
|
|
23
|
+
"""Persistent investigation memory with full-text search.
|
|
24
|
+
|
|
25
|
+
Usage:
|
|
26
|
+
mem = MemoryEngine()
|
|
27
|
+
mem.save_investigation("target", findings, reasoning)
|
|
28
|
+
|
|
29
|
+
# Later session:
|
|
30
|
+
results = mem.search("Elon Musk")
|
|
31
|
+
context = mem.get_context_for_target("shadowy-company.com")
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
def __init__(self, db_path: str | Path | None = None):
|
|
35
|
+
if db_path is None:
|
|
36
|
+
db_path = Path.home() / ".watson" / "memory.db"
|
|
37
|
+
self._db_path = Path(db_path)
|
|
38
|
+
self._db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
39
|
+
self._init_db()
|
|
40
|
+
|
|
41
|
+
def _init_db(self):
|
|
42
|
+
"""Create tables if they don't exist."""
|
|
43
|
+
with sqlite3.connect(str(self._db_path)) as conn:
|
|
44
|
+
conn.execute("PRAGMA journal_mode=WAL")
|
|
45
|
+
conn.execute("PRAGMA foreign_keys=ON")
|
|
46
|
+
|
|
47
|
+
conn.execute("""
|
|
48
|
+
CREATE TABLE IF NOT EXISTS investigations (
|
|
49
|
+
id TEXT PRIMARY KEY,
|
|
50
|
+
query TEXT NOT NULL,
|
|
51
|
+
target_type TEXT,
|
|
52
|
+
investigation_goal TEXT,
|
|
53
|
+
reasoning_json TEXT,
|
|
54
|
+
findings_count INTEGER DEFAULT 0,
|
|
55
|
+
sources_count INTEGER DEFAULT 0,
|
|
56
|
+
risk_level TEXT,
|
|
57
|
+
created_at TEXT NOT NULL,
|
|
58
|
+
metadata_json TEXT DEFAULT '{}'
|
|
59
|
+
)
|
|
60
|
+
""")
|
|
61
|
+
|
|
62
|
+
conn.execute("""
|
|
63
|
+
CREATE TABLE IF NOT EXISTS findings (
|
|
64
|
+
id TEXT PRIMARY KEY,
|
|
65
|
+
investigation_id TEXT NOT NULL,
|
|
66
|
+
title TEXT NOT NULL,
|
|
67
|
+
description TEXT,
|
|
68
|
+
source TEXT,
|
|
69
|
+
tool TEXT,
|
|
70
|
+
severity TEXT DEFAULT 'info',
|
|
71
|
+
confidence REAL DEFAULT 0.5,
|
|
72
|
+
evidence_json TEXT DEFAULT '[]',
|
|
73
|
+
created_at TEXT NOT NULL,
|
|
74
|
+
FOREIGN KEY (investigation_id) REFERENCES investigations(id)
|
|
75
|
+
)
|
|
76
|
+
""")
|
|
77
|
+
|
|
78
|
+
conn.execute("""
|
|
79
|
+
CREATE TABLE IF NOT EXISTS entities (
|
|
80
|
+
id TEXT PRIMARY KEY,
|
|
81
|
+
name TEXT NOT NULL,
|
|
82
|
+
type TEXT NOT NULL,
|
|
83
|
+
first_seen TEXT NOT NULL,
|
|
84
|
+
last_seen TEXT NOT NULL,
|
|
85
|
+
mention_count INTEGER DEFAULT 1,
|
|
86
|
+
metadata_json TEXT DEFAULT '{}'
|
|
87
|
+
)
|
|
88
|
+
""")
|
|
89
|
+
|
|
90
|
+
conn.execute("""
|
|
91
|
+
CREATE TABLE IF NOT EXISTS entity_mentions (
|
|
92
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
93
|
+
entity_id TEXT NOT NULL,
|
|
94
|
+
investigation_id TEXT NOT NULL,
|
|
95
|
+
finding_id TEXT,
|
|
96
|
+
created_at TEXT NOT NULL,
|
|
97
|
+
FOREIGN KEY (entity_id) REFERENCES entities(id),
|
|
98
|
+
FOREIGN KEY (investigation_id) REFERENCES investigations(id)
|
|
99
|
+
)
|
|
100
|
+
""")
|
|
101
|
+
|
|
102
|
+
# FTS5 index for full-text search across investigations and findings
|
|
103
|
+
conn.execute("""
|
|
104
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS memory_fts USING fts5(
|
|
105
|
+
query,
|
|
106
|
+
findings_text,
|
|
107
|
+
entities_text,
|
|
108
|
+
content='investigations',
|
|
109
|
+
content_rowid='rowid'
|
|
110
|
+
)
|
|
111
|
+
""")
|
|
112
|
+
|
|
113
|
+
conn.commit()
|
|
114
|
+
|
|
115
|
+
# ── Save ──────────────────────────────────────────────────────
|
|
116
|
+
|
|
117
|
+
def save_investigation(
|
|
118
|
+
self,
|
|
119
|
+
query: str,
|
|
120
|
+
findings: list[dict],
|
|
121
|
+
reasoning: dict | None = None,
|
|
122
|
+
target_type: str = "unknown",
|
|
123
|
+
risk_level: str = "low",
|
|
124
|
+
metadata: dict | None = None,
|
|
125
|
+
) -> str:
|
|
126
|
+
"""Save an investigation and its findings to memory.
|
|
127
|
+
|
|
128
|
+
Returns the investigation ID.
|
|
129
|
+
"""
|
|
130
|
+
inv_id = str(uuid.uuid4())[:12]
|
|
131
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
132
|
+
|
|
133
|
+
# Extract entities from findings
|
|
134
|
+
entities = self._extract_entities(findings)
|
|
135
|
+
|
|
136
|
+
with sqlite3.connect(str(self._db_path)) as conn:
|
|
137
|
+
# Save investigation
|
|
138
|
+
conn.execute(
|
|
139
|
+
"""INSERT INTO investigations
|
|
140
|
+
(id, query, target_type, investigation_goal, reasoning_json,
|
|
141
|
+
findings_count, sources_count, risk_level, created_at, metadata_json)
|
|
142
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
|
143
|
+
(
|
|
144
|
+
inv_id,
|
|
145
|
+
query,
|
|
146
|
+
target_type,
|
|
147
|
+
reasoning.get("investigation_goal", "") if reasoning else "",
|
|
148
|
+
json.dumps(reasoning) if reasoning else "{}",
|
|
149
|
+
len(findings),
|
|
150
|
+
len({f.get("source", "unknown") for f in findings}),
|
|
151
|
+
risk_level,
|
|
152
|
+
now,
|
|
153
|
+
json.dumps(metadata or {}),
|
|
154
|
+
),
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
# Save findings
|
|
158
|
+
for f in findings:
|
|
159
|
+
f_id = f.get("id", str(uuid.uuid4())[:8])
|
|
160
|
+
conn.execute(
|
|
161
|
+
"""INSERT OR IGNORE INTO findings
|
|
162
|
+
(id, investigation_id, title, description, source, tool,
|
|
163
|
+
severity, confidence, evidence_json, created_at)
|
|
164
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
|
165
|
+
(
|
|
166
|
+
f_id,
|
|
167
|
+
inv_id,
|
|
168
|
+
f.get("title", "")[:300],
|
|
169
|
+
f.get("description", "")[:2000],
|
|
170
|
+
f.get("source", "unknown"),
|
|
171
|
+
f.get("tool", "unknown"),
|
|
172
|
+
f.get("severity", "info"),
|
|
173
|
+
f.get("confidence", 0.5),
|
|
174
|
+
json.dumps(f.get("evidence", [])),
|
|
175
|
+
now,
|
|
176
|
+
),
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
# Save entities
|
|
180
|
+
for entity in entities:
|
|
181
|
+
e_id = self._upsert_entity(conn, entity, inv_id, now)
|
|
182
|
+
|
|
183
|
+
# Update FTS index
|
|
184
|
+
findings_text = " ".join(
|
|
185
|
+
f"{f.get('title','')} {f.get('description','')[:500]}"
|
|
186
|
+
for f in findings
|
|
187
|
+
)
|
|
188
|
+
entities_text = " ".join(
|
|
189
|
+
f"{e['name']} {e['type']}" for e in entities
|
|
190
|
+
)
|
|
191
|
+
conn.execute(
|
|
192
|
+
"""INSERT INTO memory_fts (query, findings_text, entities_text)
|
|
193
|
+
VALUES (?, ?, ?)""",
|
|
194
|
+
(query, findings_text, entities_text),
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
conn.commit()
|
|
198
|
+
|
|
199
|
+
return inv_id
|
|
200
|
+
|
|
201
|
+
def _upsert_entity(
|
|
202
|
+
self, conn: sqlite3.Connection, entity: dict, inv_id: str, now: str
|
|
203
|
+
) -> str:
|
|
204
|
+
"""Insert or update an entity, linking to the investigation."""
|
|
205
|
+
name = entity["name"].lower().strip()
|
|
206
|
+
etype = entity["type"]
|
|
207
|
+
|
|
208
|
+
# Check if entity exists (case-insensitive name match)
|
|
209
|
+
row = conn.execute(
|
|
210
|
+
"SELECT id, mention_count FROM entities WHERE LOWER(name) = ? AND type = ?",
|
|
211
|
+
(name, etype),
|
|
212
|
+
).fetchone()
|
|
213
|
+
|
|
214
|
+
if row:
|
|
215
|
+
e_id = row[0]
|
|
216
|
+
count = row[1]
|
|
217
|
+
conn.execute(
|
|
218
|
+
"UPDATE entities SET last_seen = ?, mention_count = ? WHERE id = ?",
|
|
219
|
+
(now, count + 1, e_id),
|
|
220
|
+
)
|
|
221
|
+
else:
|
|
222
|
+
e_id = str(uuid.uuid4())[:12]
|
|
223
|
+
conn.execute(
|
|
224
|
+
"""INSERT INTO entities (id, name, type, first_seen, last_seen, mention_count)
|
|
225
|
+
VALUES (?, ?, ?, ?, ?, 1)""",
|
|
226
|
+
(e_id, entity["name"], etype, now, now),
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
# Link to investigation
|
|
230
|
+
conn.execute(
|
|
231
|
+
"""INSERT INTO entity_mentions (entity_id, investigation_id, created_at)
|
|
232
|
+
VALUES (?, ?, ?)""",
|
|
233
|
+
(e_id, inv_id, now),
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
return e_id
|
|
237
|
+
|
|
238
|
+
def _extract_entities(self, findings: list[dict]) -> list[dict]:
|
|
239
|
+
"""Extract entities (people, domains, orgs) from findings."""
|
|
240
|
+
import re
|
|
241
|
+
|
|
242
|
+
entities: list[dict] = []
|
|
243
|
+
seen: set[tuple[str, str]] = set()
|
|
244
|
+
|
|
245
|
+
# Known entity patterns
|
|
246
|
+
for f in findings:
|
|
247
|
+
text = f"{f.get('title', '')} {f.get('description', '')}"
|
|
248
|
+
|
|
249
|
+
# Extract domains
|
|
250
|
+
for match in re.finditer(
|
|
251
|
+
r"\b([\w-]+\.(com|org|net|io|gov|edu|uk|de|fr|ru|cn|jp|ai|dev))\b",
|
|
252
|
+
text,
|
|
253
|
+
re.IGNORECASE,
|
|
254
|
+
):
|
|
255
|
+
domain = match.group(1).lower()
|
|
256
|
+
key = (domain, "domain")
|
|
257
|
+
if key not in seen:
|
|
258
|
+
seen.add(key)
|
|
259
|
+
entities.append({"name": domain, "type": "domain"})
|
|
260
|
+
|
|
261
|
+
# Extract emails
|
|
262
|
+
for match in re.finditer(r"[\w.+-]+@[\w-]+\.\w+", text):
|
|
263
|
+
email = match.group(0).lower()
|
|
264
|
+
key = (email, "email")
|
|
265
|
+
if key not in seen:
|
|
266
|
+
seen.add(key)
|
|
267
|
+
entities.append({"name": email, "type": "email"})
|
|
268
|
+
|
|
269
|
+
# Extract capitalized names (2+ words)
|
|
270
|
+
for match in re.finditer(
|
|
271
|
+
r"\b([A-Z][a-z]+(?:\s+[A-Z][a-z]+)+)\b", text
|
|
272
|
+
):
|
|
273
|
+
name = match.group(1)
|
|
274
|
+
if len(name.split()) >= 2:
|
|
275
|
+
key = (name.lower(), "person")
|
|
276
|
+
if key not in seen:
|
|
277
|
+
seen.add(key)
|
|
278
|
+
entities.append({"name": name, "type": "person"})
|
|
279
|
+
|
|
280
|
+
return entities[:30] # cap
|
|
281
|
+
|
|
282
|
+
# ── Search ─────────────────────────────────────────────────────
|
|
283
|
+
|
|
284
|
+
def search(
|
|
285
|
+
self, query: str, limit: int = 10
|
|
286
|
+
) -> list[dict]:
|
|
287
|
+
"""Full-text search across past investigations.
|
|
288
|
+
|
|
289
|
+
Returns matching investigations with snippets.
|
|
290
|
+
"""
|
|
291
|
+
with sqlite3.connect(str(self._db_path)) as conn:
|
|
292
|
+
try:
|
|
293
|
+
rows = conn.execute(
|
|
294
|
+
"""SELECT i.id, i.query, i.target_type, i.investigation_goal,
|
|
295
|
+
i.findings_count, i.risk_level, i.created_at,
|
|
296
|
+
snippet(memory_fts, 2, '<mark>', '</mark>', '...', 40) as snippet
|
|
297
|
+
FROM memory_fts
|
|
298
|
+
JOIN investigations i ON memory_fts.rowid = i.rowid
|
|
299
|
+
WHERE memory_fts MATCH ?
|
|
300
|
+
ORDER BY rank
|
|
301
|
+
LIMIT ?""",
|
|
302
|
+
(query, limit),
|
|
303
|
+
).fetchall()
|
|
304
|
+
except sqlite3.OperationalError:
|
|
305
|
+
# FTS5 syntax error — try simple LIKE
|
|
306
|
+
like_q = f"%{query}%"
|
|
307
|
+
rows = conn.execute(
|
|
308
|
+
"""SELECT id, query, target_type, investigation_goal,
|
|
309
|
+
findings_count, risk_level, created_at, '' as snippet
|
|
310
|
+
FROM investigations
|
|
311
|
+
WHERE query LIKE ? OR investigation_goal LIKE ?
|
|
312
|
+
ORDER BY created_at DESC
|
|
313
|
+
LIMIT ?""",
|
|
314
|
+
(like_q, like_q, limit),
|
|
315
|
+
).fetchall()
|
|
316
|
+
|
|
317
|
+
return [
|
|
318
|
+
{
|
|
319
|
+
"id": r[0],
|
|
320
|
+
"query": r[1],
|
|
321
|
+
"target_type": r[2],
|
|
322
|
+
"goal": r[3],
|
|
323
|
+
"findings_count": r[4],
|
|
324
|
+
"risk_level": r[5],
|
|
325
|
+
"created_at": r[6],
|
|
326
|
+
"snippet": r[7] if len(r) > 7 else "",
|
|
327
|
+
}
|
|
328
|
+
for r in rows
|
|
329
|
+
]
|
|
330
|
+
|
|
331
|
+
def get_context_for_target(self, target: str) -> dict | None:
|
|
332
|
+
"""Get relevant past context when investigating a target.
|
|
333
|
+
|
|
334
|
+
If this target (or related entities) were investigated before,
|
|
335
|
+
returns past findings and entity history to inject into the new
|
|
336
|
+
investigation.
|
|
337
|
+
"""
|
|
338
|
+
results = self.search(target, limit=3)
|
|
339
|
+
if not results:
|
|
340
|
+
return None
|
|
341
|
+
|
|
342
|
+
with sqlite3.connect(str(self._db_path)) as conn:
|
|
343
|
+
# Get findings from the most relevant past investigation
|
|
344
|
+
best = results[0]
|
|
345
|
+
findings_rows = conn.execute(
|
|
346
|
+
"""SELECT title, description, source, severity, confidence
|
|
347
|
+
FROM findings
|
|
348
|
+
WHERE investigation_id = ?
|
|
349
|
+
ORDER BY confidence DESC
|
|
350
|
+
LIMIT 10""",
|
|
351
|
+
(best["id"],),
|
|
352
|
+
).fetchall()
|
|
353
|
+
|
|
354
|
+
# Get entity history for this target
|
|
355
|
+
entity_rows = conn.execute(
|
|
356
|
+
"""SELECT e.name, e.type, e.mention_count, e.first_seen, e.last_seen,
|
|
357
|
+
COUNT(em.id) as investigation_count
|
|
358
|
+
FROM entities e
|
|
359
|
+
JOIN entity_mentions em ON e.id = em.entity_id
|
|
360
|
+
WHERE LOWER(e.name) LIKE ?
|
|
361
|
+
GROUP BY e.id
|
|
362
|
+
ORDER BY e.last_seen DESC
|
|
363
|
+
LIMIT 5""",
|
|
364
|
+
(f"%{target.lower()}%",),
|
|
365
|
+
).fetchall()
|
|
366
|
+
|
|
367
|
+
return {
|
|
368
|
+
"past_investigations": results,
|
|
369
|
+
"relevant_findings": [
|
|
370
|
+
{
|
|
371
|
+
"title": r[0],
|
|
372
|
+
"description": r[1][:300] if r[1] else "",
|
|
373
|
+
"source": r[2],
|
|
374
|
+
"severity": r[3],
|
|
375
|
+
"confidence": r[4],
|
|
376
|
+
}
|
|
377
|
+
for r in findings_rows
|
|
378
|
+
],
|
|
379
|
+
"entity_history": [
|
|
380
|
+
{
|
|
381
|
+
"name": r[0],
|
|
382
|
+
"type": r[1],
|
|
383
|
+
"mention_count": r[2],
|
|
384
|
+
"first_seen": r[3],
|
|
385
|
+
"last_seen": r[4],
|
|
386
|
+
"investigation_count": r[5],
|
|
387
|
+
}
|
|
388
|
+
for r in entity_rows
|
|
389
|
+
],
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
def list_recent(self, limit: int = 20) -> list[dict]:
|
|
393
|
+
"""List recent investigations."""
|
|
394
|
+
with sqlite3.connect(str(self._db_path)) as conn:
|
|
395
|
+
rows = conn.execute(
|
|
396
|
+
"""SELECT id, query, target_type, findings_count,
|
|
397
|
+
risk_level, created_at
|
|
398
|
+
FROM investigations
|
|
399
|
+
ORDER BY created_at DESC
|
|
400
|
+
LIMIT ?""",
|
|
401
|
+
(limit,),
|
|
402
|
+
).fetchall()
|
|
403
|
+
|
|
404
|
+
return [
|
|
405
|
+
{
|
|
406
|
+
"id": r[0],
|
|
407
|
+
"query": r[1],
|
|
408
|
+
"target_type": r[2],
|
|
409
|
+
"findings_count": r[3],
|
|
410
|
+
"risk_level": r[4],
|
|
411
|
+
"created_at": r[5],
|
|
412
|
+
}
|
|
413
|
+
for r in rows
|
|
414
|
+
]
|
|
415
|
+
|
|
416
|
+
def get_investigation(self, inv_id: str) -> dict | None:
|
|
417
|
+
"""Retrieve a full investigation by ID."""
|
|
418
|
+
with sqlite3.connect(str(self._db_path)) as conn:
|
|
419
|
+
row = conn.execute(
|
|
420
|
+
"""SELECT id, query, target_type, investigation_goal,
|
|
421
|
+
reasoning_json, findings_count, sources_count,
|
|
422
|
+
risk_level, created_at, metadata_json
|
|
423
|
+
FROM investigations WHERE id = ?""",
|
|
424
|
+
(inv_id,),
|
|
425
|
+
).fetchone()
|
|
426
|
+
|
|
427
|
+
if not row:
|
|
428
|
+
return None
|
|
429
|
+
|
|
430
|
+
findings_rows = conn.execute(
|
|
431
|
+
"""SELECT id, title, description, source, tool, severity,
|
|
432
|
+
confidence, evidence_json
|
|
433
|
+
FROM findings WHERE investigation_id = ?
|
|
434
|
+
ORDER BY confidence DESC""",
|
|
435
|
+
(inv_id,),
|
|
436
|
+
).fetchall()
|
|
437
|
+
|
|
438
|
+
entity_rows = conn.execute(
|
|
439
|
+
"""SELECT e.name, e.type
|
|
440
|
+
FROM entities e
|
|
441
|
+
JOIN entity_mentions em ON e.id = em.entity_id
|
|
442
|
+
WHERE em.investigation_id = ?""",
|
|
443
|
+
(inv_id,),
|
|
444
|
+
).fetchall()
|
|
445
|
+
|
|
446
|
+
return {
|
|
447
|
+
"id": row[0],
|
|
448
|
+
"query": row[1],
|
|
449
|
+
"target_type": row[2],
|
|
450
|
+
"goal": row[3],
|
|
451
|
+
"reasoning": json.loads(row[4]) if row[4] else {},
|
|
452
|
+
"findings_count": row[5],
|
|
453
|
+
"sources_count": row[6],
|
|
454
|
+
"risk_level": row[7],
|
|
455
|
+
"created_at": row[8],
|
|
456
|
+
"metadata": json.loads(row[9]) if row[9] else {},
|
|
457
|
+
"findings": [
|
|
458
|
+
{
|
|
459
|
+
"id": fr[0],
|
|
460
|
+
"title": fr[1],
|
|
461
|
+
"description": fr[2],
|
|
462
|
+
"source": fr[3],
|
|
463
|
+
"tool": fr[4],
|
|
464
|
+
"severity": fr[5],
|
|
465
|
+
"confidence": fr[6],
|
|
466
|
+
"evidence": json.loads(fr[7]) if fr[7] else [],
|
|
467
|
+
}
|
|
468
|
+
for fr in findings_rows
|
|
469
|
+
],
|
|
470
|
+
"entities": [{"name": er[0], "type": er[1]} for er in entity_rows],
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
def get_entity_profile(self, name: str) -> dict | None:
|
|
474
|
+
"""Get full profile of an entity across all investigations."""
|
|
475
|
+
with sqlite3.connect(str(self._db_path)) as conn:
|
|
476
|
+
entity_row = conn.execute(
|
|
477
|
+
"""SELECT id, name, type, first_seen, last_seen, mention_count
|
|
478
|
+
FROM entities WHERE LOWER(name) = ?""",
|
|
479
|
+
(name.lower(),),
|
|
480
|
+
).fetchone()
|
|
481
|
+
|
|
482
|
+
if not entity_row:
|
|
483
|
+
return None
|
|
484
|
+
|
|
485
|
+
e_id = entity_row[0]
|
|
486
|
+
|
|
487
|
+
# Get all investigations mentioning this entity
|
|
488
|
+
inv_rows = conn.execute(
|
|
489
|
+
"""SELECT i.id, i.query, i.created_at
|
|
490
|
+
FROM investigations i
|
|
491
|
+
JOIN entity_mentions em ON i.id = em.investigation_id
|
|
492
|
+
WHERE em.entity_id = ?
|
|
493
|
+
ORDER BY i.created_at DESC""",
|
|
494
|
+
(e_id,),
|
|
495
|
+
).fetchall()
|
|
496
|
+
|
|
497
|
+
# Get related entities (co-occurring in same investigations)
|
|
498
|
+
related_rows = conn.execute(
|
|
499
|
+
"""SELECT e.name, e.type, COUNT(DISTINCT em2.investigation_id) as shared
|
|
500
|
+
FROM entities e
|
|
501
|
+
JOIN entity_mentions em2 ON e.id = em2.entity_id
|
|
502
|
+
WHERE em2.investigation_id IN (
|
|
503
|
+
SELECT investigation_id FROM entity_mentions WHERE entity_id = ?
|
|
504
|
+
)
|
|
505
|
+
AND e.id != ?
|
|
506
|
+
GROUP BY e.id
|
|
507
|
+
ORDER BY shared DESC
|
|
508
|
+
LIMIT 10""",
|
|
509
|
+
(e_id, e_id),
|
|
510
|
+
).fetchall()
|
|
511
|
+
|
|
512
|
+
return {
|
|
513
|
+
"name": entity_row[1],
|
|
514
|
+
"type": entity_row[2],
|
|
515
|
+
"first_seen": entity_row[3],
|
|
516
|
+
"last_seen": entity_row[4],
|
|
517
|
+
"mention_count": entity_row[5],
|
|
518
|
+
"investigations": [
|
|
519
|
+
{"id": r[0], "query": r[1], "created_at": r[2]}
|
|
520
|
+
for r in inv_rows
|
|
521
|
+
],
|
|
522
|
+
"related_entities": [
|
|
523
|
+
{"name": r[0], "type": r[1], "shared_investigations": r[2]}
|
|
524
|
+
for r in related_rows
|
|
525
|
+
],
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
def stats(self) -> dict:
|
|
529
|
+
"""Return memory statistics."""
|
|
530
|
+
with sqlite3.connect(str(self._db_path)) as conn:
|
|
531
|
+
inv_count = conn.execute(
|
|
532
|
+
"SELECT COUNT(*) FROM investigations"
|
|
533
|
+
).fetchone()[0]
|
|
534
|
+
finding_count = conn.execute(
|
|
535
|
+
"SELECT COUNT(*) FROM findings"
|
|
536
|
+
).fetchone()[0]
|
|
537
|
+
entity_count = conn.execute(
|
|
538
|
+
"SELECT COUNT(*) FROM entities"
|
|
539
|
+
).fetchone()[0]
|
|
540
|
+
db_size = self._db_path.stat().st_size if self._db_path.exists() else 0
|
|
541
|
+
|
|
542
|
+
return {
|
|
543
|
+
"investigations": inv_count,
|
|
544
|
+
"findings": finding_count,
|
|
545
|
+
"entities": entity_count,
|
|
546
|
+
"db_size_bytes": db_size,
|
|
547
|
+
"db_path": str(self._db_path),
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
# Singleton instance
|
|
552
|
+
memory = MemoryEngine()
|
watson/neo4j_graph.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Neo4j Graph Adapter — replaces JSON-file graph with Neo4j when available.
|
|
3
|
+
|
|
4
|
+
Auto-detects Neo4j at NEO4J_URI (default: bolt://localhost:7687).
|
|
5
|
+
Falls back to JSON-file KnowledgeGraph if Neo4j is unavailable.
|
|
6
|
+
Same API surface — drop-in replacement.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Optional
|
|
14
|
+
|
|
15
|
+
NEO4J_AVAILABLE = False
|
|
16
|
+
|
|
17
|
+
try:
|
|
18
|
+
from neo4j import GraphDatabase
|
|
19
|
+
NEO4J_AVAILABLE = True
|
|
20
|
+
except ImportError:
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Neo4jGraph:
|
|
25
|
+
"""Neo4j-backed knowledge graph. Same interface as watson.graph.KnowledgeGraph."""
|
|
26
|
+
|
|
27
|
+
def __init__(self, uri: str | None = None, user: str = "neo4j", password: str = ""):
|
|
28
|
+
if not NEO4J_AVAILABLE:
|
|
29
|
+
raise ImportError("neo4j package not installed. pip install neo4j")
|
|
30
|
+
|
|
31
|
+
self.uri = uri or os.environ.get("NEO4J_URI", "bolt://localhost:7687")
|
|
32
|
+
self.user = user
|
|
33
|
+
self.password = password
|
|
34
|
+
self._driver = None
|
|
35
|
+
|
|
36
|
+
def connect(self):
|
|
37
|
+
if self._driver is None:
|
|
38
|
+
self._driver = GraphDatabase.driver(self.uri, auth=(self.user, self.password))
|
|
39
|
+
# Verify connection
|
|
40
|
+
self._driver.verify_connectivity()
|
|
41
|
+
self._ensure_schema()
|
|
42
|
+
return self._driver
|
|
43
|
+
|
|
44
|
+
def _ensure_schema(self):
|
|
45
|
+
"""Create constraints and indexes if they don't exist."""
|
|
46
|
+
with self._driver.session() as session:
|
|
47
|
+
session.run("CREATE CONSTRAINT entity_id IF NOT EXISTS FOR (e:Entity) REQUIRE e.id IS UNIQUE")
|
|
48
|
+
session.run("CREATE INDEX entity_type IF NOT EXISTS FOR (e:Entity) ON (e.type)")
|
|
49
|
+
session.run("CREATE INDEX entity_value IF NOT EXISTS FOR (e:Entity) ON (e.value)")
|
|
50
|
+
|
|
51
|
+
def add_entity(self, entity_id: str, entity_type: str, value: str, label: str = "", case_id: str = ""):
|
|
52
|
+
with self._driver.session() as session:
|
|
53
|
+
session.run("""
|
|
54
|
+
MERGE (e:Entity {id: $id})
|
|
55
|
+
SET e.type = $type, e.value = $value, e.label = $label,
|
|
56
|
+
e.last_seen = datetime()
|
|
57
|
+
ON CREATE SET e.first_seen = datetime(), e.case_ids = [$case_id]
|
|
58
|
+
ON MATCH SET e.case_ids = CASE
|
|
59
|
+
WHEN $case_id IN e.case_ids THEN e.case_ids
|
|
60
|
+
ELSE e.case_ids + $case_id
|
|
61
|
+
END
|
|
62
|
+
""", id=entity_id, type=entity_type, value=value, label=label or value, case_id=case_id)
|
|
63
|
+
|
|
64
|
+
def add_relation(self, source_id: str, target_id: str, relation_type: str,
|
|
65
|
+
case_id: str = "", source_url: str = "", confidence: float = 0.5):
|
|
66
|
+
with self._driver.session() as session:
|
|
67
|
+
session.run("""
|
|
68
|
+
MATCH (a:Entity {id: $source_id})
|
|
69
|
+
MATCH (b:Entity {id: $target_id})
|
|
70
|
+
MERGE (a)-[r:RELATES {type: $rel_type}]->(b)
|
|
71
|
+
SET r.case_id = $case_id,
|
|
72
|
+
r.source_url = $source_url,
|
|
73
|
+
r.confidence = $confidence,
|
|
74
|
+
r.timestamp = datetime()
|
|
75
|
+
""", source_id=source_id, target_id=target_id, rel_type=relation_type,
|
|
76
|
+
case_id=case_id, source_url=source_url, confidence=confidence)
|
|
77
|
+
|
|
78
|
+
def context_for_investigation(self, query: str) -> dict:
|
|
79
|
+
"""Return prior graph context relevant to a new investigation."""
|
|
80
|
+
with self._driver.session() as session:
|
|
81
|
+
result = session.run("""
|
|
82
|
+
MATCH (e:Entity)
|
|
83
|
+
WHERE toLower(e.value) CONTAINS toLower($query)
|
|
84
|
+
OR toLower(e.label) CONTAINS toLower($query)
|
|
85
|
+
OPTIONAL MATCH (e)-[r:RELATES]-(other:Entity)
|
|
86
|
+
RETURN e, collect(DISTINCT {type: r.type, target: other.value, case: r.case_id}) as relations
|
|
87
|
+
LIMIT 20
|
|
88
|
+
""", query=query)
|
|
89
|
+
|
|
90
|
+
entities = []
|
|
91
|
+
for record in result:
|
|
92
|
+
e = record["e"]
|
|
93
|
+
entities.append({
|
|
94
|
+
"id": e["id"], "type": e["type"], "value": e["value"],
|
|
95
|
+
"label": e["label"], "case_ids": e.get("case_ids", []),
|
|
96
|
+
"relations": record["relations"],
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
return {"entities": entities, "count": len(entities)}
|
|
100
|
+
|
|
101
|
+
def stats(self) -> dict:
|
|
102
|
+
with self._driver.session() as session:
|
|
103
|
+
nodes = session.run("MATCH (e:Entity) RETURN count(e) as c").single()["c"]
|
|
104
|
+
rels = session.run("MATCH ()-[r:RELATES]->() RETURN count(r) as c").single()["c"]
|
|
105
|
+
return {"entities": nodes, "relations": rels, "backend": "neo4j"}
|
|
106
|
+
|
|
107
|
+
def close(self):
|
|
108
|
+
if self._driver:
|
|
109
|
+
self._driver.close()
|
|
110
|
+
self._driver = None
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def get_graph() -> "Neo4jGraph | KnowledgeGraph":
|
|
114
|
+
"""Return Neo4j graph if available, otherwise fall back to JSON-file graph."""
|
|
115
|
+
if NEO4J_AVAILABLE:
|
|
116
|
+
try:
|
|
117
|
+
graph = Neo4jGraph()
|
|
118
|
+
graph.connect()
|
|
119
|
+
return graph
|
|
120
|
+
except Exception:
|
|
121
|
+
pass
|
|
122
|
+
|
|
123
|
+
from watson.graph import KnowledgeGraph
|
|
124
|
+
return KnowledgeGraph()
|