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/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()