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
@@ -0,0 +1,726 @@
1
+ """Intelligence synthesis — turn gathered findings into an analyst's brief.
2
+
3
+ This is the step that separates Watson from a clipping service. The reasoning
4
+ loop gathers sources; synthesis READS them and produces:
5
+
6
+ - Executive summary (2-3 sentences: what's the bottom line)
7
+ - Key risk themes (grouped: antitrust, labor, tax, data/privacy, sanctions…)
8
+ - Severity per theme (HIGH / MEDIUM / LOW) with the evidence behind it
9
+ - Notable entities (people, orgs, regulators involved)
10
+ - Timeline (chronological events: career, criminal, legal, financial, education)
11
+ - Evidence gaps (what couldn't be verified)
12
+ - Recommended next steps
13
+ - Source list with credibility
14
+
15
+ Every claim is grounded in a finding — synthesis NEVER invents facts.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ import logging
22
+
23
+ # Try to import Finding from wherever it lives
24
+ try:
25
+ from watson.agents.protocol import Finding
26
+ except ImportError:
27
+ try:
28
+ from watson.engine import Finding
29
+ except ImportError:
30
+ from dataclasses import dataclass
31
+
32
+ @dataclass
33
+ class Finding:
34
+ title: str = ""
35
+ description: str = ""
36
+ source_url: str = ""
37
+ source_type: str = ""
38
+ confidence: float = 0.5
39
+ raw_data: dict | None = None
40
+
41
+ logger = logging.getLogger("watson.synthesis")
42
+
43
+ _SYNTHESIS_PROMPT = """You are an intelligence analyst writing a due-diligence brief.
44
+
45
+ TARGET: {query}
46
+ TARGET TYPE: {target_type}
47
+ FOCUS: {focus}
48
+
49
+ You have gathered the following raw findings (sources already read). Synthesize
50
+ them into a structured intelligence brief. Use ONLY information present in these
51
+ findings — do NOT add outside knowledge or invent facts. If something is unclear
52
+ or unverified, say so.
53
+
54
+ {target_specific_guidance}
55
+
56
+ FINDINGS:
57
+ {findings}
58
+
59
+ {resolved_entities}
60
+
61
+ {cross_platform_correlation}
62
+
63
+ Produce STRICT JSON (no markdown fences) with this shape:
64
+ {{
65
+ "executive_summary": "2-3 sentence bottom line for a decision-maker",
66
+ "risk_themes": [
67
+ {{"theme": "Antitrust / Competition", "severity": "HIGH|MEDIUM|LOW",
68
+ "summary": "what the evidence shows", "source_titles": ["..."]}}
69
+ ],
70
+ "notable_entities": [
71
+ {{"name": "...", "role": "regulator|executive|company|court|cybercriminal", "context": "..."}}
72
+ ],
73
+ "timeline": [
74
+ {{"date": "YYYY or YYYY-MM or YYYY-MM-DD", "category": "career|criminal|legal|education|personal|financial",
75
+ "event": "what happened", "source_title": "which finding this came from"}}
76
+ ],
77
+ "evidence_gaps": ["what could not be verified or is missing"],
78
+ "recommended_next_steps": [
79
+ {{"entity": "the specific target (wallet, domain, name, IP, etc.) — NOT the full instruction",
80
+ "action": "what to do with it (trace, investigate, search, etc.)",
81
+ "tool_hint": "best tool category (blockchain, sanctions, corporate, domain, social, web)"}}
82
+ ]
83
+ }}
84
+
85
+ Rules:
86
+ - 2-5 risk themes, ordered by severity.
87
+ - severity reflects evidence strength + impact, not your opinion.
88
+ - notable_entities: real orgs/people/regulators that appear in the findings.
89
+ - TIMELINE: extract every date-backed event from findings. Birth dates, employment
90
+ start/end, arrests, convictions, sentencing, sanctions designations, company
91
+ founding, acquisitions, lawsuits filed/resolved. Use partial dates when exact
92
+ unknown (e.g., "2025" or "2025-07"). Order chronologically. Max 15 events.
93
+ Categories: career, criminal, legal, education, personal, financial.
94
+ NEVER invent dates — if a finding says "sentenced in 2023", use "2023", not a guess.
95
+ - Be specific: name the regulator, the amount, the year when the finding states it.
96
+ - If findings are thin, say so in executive_summary and keep themes minimal.
97
+ - CRITICAL for recommended_next_steps: do NOT suggest steps that were ALREADY
98
+ executed. The tools listed below already ran — recommending them again is wrong.
99
+ Only suggest genuinely new follow-up vectors that haven't been tried.
100
+
101
+ TOOLS ALREADY EXECUTED (do NOT re-recommend these):
102
+ {executed_tools}
103
+ """
104
+
105
+ _TARGET_GUIDANCE = {
106
+ "person": (
107
+ "PERSON INVESTIGATION GUIDANCE:\n"
108
+ "- The goal is to IDENTIFY this individual: who they are, what they do, their affiliations,\n"
109
+ " professional background, and digital footprint.\n"
110
+ "- LinkedIn, ResearchGate, GitHub, Behance, Twitter profiles ARE substantive findings —\n"
111
+ " they establish identity, career, and expertise. Do NOT dismiss these as 'not intelligence.'\n"
112
+ "- Social media profiles, professional pages, and news mentions are the PRIMARY deliverables\n"
113
+ " for a person investigation — not a failure or thin result.\n"
114
+ "- Risk themes are OPTIONAL for persons. If no sanctions/criminal findings exist, that's\n"
115
+ " NORMAL for most people. Don't fabricate risk themes to fill space.\n"
116
+ "- The executive summary should state WHO this person is (role, org, location) based on\n"
117
+ " available findings, even if partial.\n"
118
+ "- ENTITY DISAMBIGUATION: If findings describe what appear to be MULTIPLE DIFFERENT PEOPLE\n"
119
+ " sharing the same name (e.g., a wanted fugitive AND an academic researcher), you MUST\n"
120
+ " flag this ambiguity. In notable_entities, list each distinct identity separately with\n"
121
+ " 'role' indicating what's known about EACH. Add a risk_theme with theme 'Entity Disambiguation'\n"
122
+ " explaining whether these are likely the same person or different individuals. This is\n"
123
+ " CRITICAL — conflating two people with the same name is a catastrophic intelligence failure."
124
+ ),
125
+ "email": (
126
+ "EMAIL INVESTIGATION GUIDANCE:\n"
127
+ "- Identify the person behind the email, their affiliations, breach history, and accounts.\n"
128
+ "- Breach data, social profiles, and username pivots are substantive findings."
129
+ ),
130
+ "domain": (
131
+ "DOMAIN INVESTIGATION GUIDANCE:\n"
132
+ "- Focus on infrastructure: WHOIS, DNS, TLS certs, subdomain enumeration, technologies.\n"
133
+ "- Identify the organization behind the domain and assess its legitimacy."
134
+ ),
135
+ "company": (
136
+ "COMPANY INVESTIGATION GUIDANCE:\n"
137
+ "- Focus on corporate structure, ownership, regulatory actions, sanctions, controversies.\n"
138
+ "- Executive leadership, subsidiaries, and legal risks are key.\n"
139
+ "- EMPLOYEE PIVOT FINDINGS: Findings prefixed with 👤 [Name] are key people detected\n"
140
+ " during the investigation. Cross-reference them in risk themes and notable entities.\n"
141
+ " If an executive has sanctions, lawsuits, or controversy findings, surface that in\n"
142
+ " the executive summary — it's often the most actionable intelligence in the report.\n"
143
+ "- Risk themes should include 'Executive / Leadership Risk' when key people have\n"
144
+ " adverse findings (lawsuits, sanctions, controversies)."
145
+ ),
146
+ "wallet": (
147
+ "WALLET INVESTIGATION GUIDANCE:\n"
148
+ "- Trace transactions, identify counterparties, check sanctions lists, and assess risk.\n"
149
+ "- Exchange interactions and token holdings are substantive."
150
+ ),
151
+ }
152
+
153
+ _PERSON_DEEP_GUIDANCE = (
154
+ "PERSON DEEP INVESTIGATION GUIDANCE:\n"
155
+ "- This is a CRIMINAL/LEGAL investigation. Look for: criminal charges, convictions, sentences,\n"
156
+ " court cases, arrests, indictments, Interpol notices, sanctions (OFAC/UN/EU), asset freezes,\n"
157
+ " travel bans, organized crime connections, cartel affiliations, prison records, wanted status.\n"
158
+ "- Wikipedia, sanctions databases, and crime/legal news articles ARE the primary evidence.\n"
159
+ "- Risk themes ARE expected — criminal/legal findings ARE the deliverable. Rank by severity.\n"
160
+ "- The executive summary should state who this person is, their criminal/legal status,\n"
161
+ " and key charges/sanctions with dates and jurisdictions.\n"
162
+ "- If no criminal findings were found, state that clearly — do not fabricate."
163
+ )
164
+
165
+ _PERSON_DUE_DILIGENCE_GUIDANCE = (
166
+ "PERSON DUE DILIGENCE GUIDANCE:\n"
167
+ "- This is a PROFESSIONAL/BUSINESS investigation. Focus on: employment history, board positions,\n"
168
+ " business affiliations, corporate connections, professional reputation.\n"
169
+ "- Also check for: lawsuits, regulatory actions, fines, penalties, adverse media,\n"
170
+ " fraud allegations, ethics violations, professional misconduct.\n"
171
+ "- Risk themes may include regulatory risk, reputational risk, litigation risk.\n"
172
+ "- The executive summary should state who this person is professionally, their role,\n"
173
+ " and any adverse findings that would concern a business partner or employer."
174
+ )
175
+
176
+
177
+ def _format_executed_tools(executed: set[tuple[str, str]] | None) -> str:
178
+ """Format executed tool+target pairs for injection into the synthesis prompt."""
179
+ if not executed:
180
+ return "(no tools were executed — this was a direct analysis)"
181
+ lines = []
182
+ for tool, target in sorted(executed):
183
+ lines.append(f" • {tool}: {target[:120]}")
184
+ return "\n".join(lines) if lines else "(no tools executed)"
185
+
186
+
187
+ def _format_resolved_entities(resolved: list | None) -> str:
188
+ """Format resolved entities for the synthesis prompt."""
189
+ if not resolved:
190
+ return ""
191
+ lines = ["RESOLVED ENTITIES (cross-referenced, deduplicated):"]
192
+ for e in resolved:
193
+ # ResolvedEntity is a dataclass — use getattr, not .get()
194
+ name = getattr(e, "canonical_name", None) or getattr(e, "canonical", str(e))
195
+ etype = getattr(e, "entity_type", None) or getattr(e, "etype", "unknown")
196
+ confidence = getattr(e, "confidence", 0)
197
+ src_count = getattr(e, "total_sources", None)
198
+ if src_count is None:
199
+ src_findings = getattr(e, "source_findings", None)
200
+ src_count = len(src_findings) if src_findings else len(getattr(e, "finding_ids", []))
201
+ aliases = getattr(e, "aliases", [])
202
+ lines.append(
203
+ f" • {name} [{etype}] — confidence {confidence:.0%}, "
204
+ f"{src_count} sources"
205
+ )
206
+ if aliases:
207
+ lines.append(f" aliases: {', '.join(list(aliases)[:5])}")
208
+ return "\n".join(lines)
209
+
210
+
211
+ def _findings_block(findings, max_chars: int = 6000) -> str:
212
+ """Build a condensed findings block for the synthesis prompt.
213
+ Caps at max_chars and prioritizes findings with source URLs."""
214
+ # Sort: findings with URLs first (higher quality), then by confidence
215
+ sorted_findings = sorted(
216
+ findings,
217
+ key=lambda f: (
218
+ not (getattr(f, "source_url", "") or ""),
219
+ -(getattr(f, "confidence", 0.5) or 0.5)
220
+ )
221
+ )
222
+ lines = []
223
+ total = 0
224
+ for i, f in enumerate(sorted_findings, 1):
225
+ title = getattr(f, "title", str(f)[:100]) or ""
226
+ desc = getattr(f, "description", "") or ""
227
+ desc = desc.replace("\n", " ").replace("**Extracted text", "").lstrip("(")
228
+ chunk = f"[{i}] {title}\n {desc[:400]}\n"
229
+ if total + len(chunk) > max_chars:
230
+ break
231
+ lines.append(chunk)
232
+ total += len(chunk)
233
+ return "\n".join(lines)
234
+
235
+
236
+ def _has_substantive_content(findings: list) -> bool:
237
+ """Check if findings contain more than social media noise.
238
+
239
+ Returns True if findings include Wikipedia articles, FBI data, criminal/legal
240
+ content — anything the deterministic brief can't handle. Prevents the class of
241
+ bugs where "verified digital footprint with 2 professional profiles" is
242
+ reported for a mafia boss or convicted murderer.
243
+ """
244
+ substantive_keywords = [
245
+ "wikipedia", "fbi", "wanted", "convicted", "sanctions",
246
+ "sentenced", "arrested", "indictment", "mafia", "cartel",
247
+ "money laundering", "fraud ", "racketeering", "murder",
248
+ "assassination", "trafficking", "conspiracy", "wire fraud",
249
+ "life imprisonment", "organized crime", "bratva",
250
+ "opensanctions", "interpol", "bureau of investigation",
251
+ "justice department", "court records", "prison",
252
+ ]
253
+ for f in findings:
254
+ title = (getattr(f, "title", "") or "").lower()
255
+ desc = (getattr(f, "description", "") or "").lower()
256
+ combined = title + " " + desc
257
+ for kw in substantive_keywords:
258
+ if kw in combined:
259
+ return True
260
+ return False
261
+
262
+
263
+ def _deterministic_person_brief(query: str, findings: list) -> dict:
264
+ """Build a person identity brief from findings WITHOUT an LLM.
265
+
266
+ Extracts: name variants, professional profiles (LinkedIn, GitHub, etc.),
267
+ content mentions (articles, posts), and gaps. Never gaslights.
268
+ """
269
+ import re as _re
270
+
271
+ profiles: list[dict] = []
272
+ mentions: list[dict] = []
273
+ name_variants = set()
274
+
275
+ for f in findings:
276
+ title = getattr(f, "title", "") or ""
277
+ desc = getattr(f, "description", "") or ""
278
+ url = getattr(f, "source_url", "") or ""
279
+ combined = f"{title} {desc}"
280
+
281
+ # Extract name variants — prioritize ones matching the query terms
282
+ query_parts = set(query.lower().split())
283
+ for match in _re.finditer(r'\b([A-Z][a-z]+(?:\s+[A-Z][a-z]+){1,3})\b', combined):
284
+ name = match.group(1)
285
+ name_parts = set(name.lower().split())
286
+ if name_parts & query_parts:
287
+ name_variants.add(name)
288
+
289
+ # Detect professional profiles by URL pattern
290
+ if any(domain in url for domain in [
291
+ 'linkedin.com/in/', 'linkedin.com/posts/',
292
+ 'github.com/', 'twitter.com/', 'x.com/',
293
+ 'researchgate.net/profile/', 'behance.net/',
294
+ ]):
295
+ followers_match = _re.search(r'([\d,]+)\s*(?:followers|Posts)', combined)
296
+ profiles.append({
297
+ "url": url, "title": title[:120],
298
+ "followers": followers_match.group(1) if followers_match else None,
299
+ })
300
+
301
+ # Detect content mentions by keyword
302
+ if any(kw in combined.lower() for kw in ['author', 'editor', 'analyst', 'journalist',
303
+ 'founder', 'defense', 'tech', 'security']):
304
+ mentions.append({"url": url, "title": title[:120], "snippet": desc[:200] if desc else ""})
305
+
306
+ # Determine best name: prefer longest variant matching all query words
307
+ name_str = query
308
+ if name_variants:
309
+ query_words = set(query.lower().split())
310
+ for v in sorted(name_variants, key=len, reverse=True):
311
+ if query_words.issubset(set(v.lower().split())):
312
+ name_str = v
313
+ break
314
+ else:
315
+ name_str = max(name_variants, key=len)
316
+ pc, mc = len(profiles), len(mentions)
317
+
318
+ if pc >= 2:
319
+ sources = [p['url'].split('/')[2] for p in profiles[:3]]
320
+ topics = set()
321
+ for m in mentions:
322
+ for kw in ['defense', 'security', 'tech', 'startup', 'founder', 'editor', 'analyst']:
323
+ if kw in m['title'].lower() or kw in m['snippet'].lower():
324
+ topics.add(kw)
325
+ summary = (f"{name_str} has a verified digital footprint with {pc} professional profiles "
326
+ f"and {mc} content mentions. Sources: {', '.join(sources)}. "
327
+ + (f"Topics: {', '.join(sorted(topics))}." if topics else ""))
328
+ elif pc == 1:
329
+ summary = (f"{name_str} has a limited digital footprint — one profile found. "
330
+ f"{mc} content mentions. Further investigation recommended.")
331
+ else:
332
+ # Count actual unique URLs checked (not total findings — those include tool output noise)
333
+ urls_checked = len(set(
334
+ f.source_url for f in findings
335
+ if hasattr(f, 'source_url') and f.source_url
336
+ ))
337
+ sources_text = f"{urls_checked} sources checked" if urls_checked else "sources checked"
338
+
339
+ # Detect location confusion — if findings look like business/geographic results
340
+ combined_text = " ".join(
341
+ (getattr(f, "title", "") + " " + getattr(f, "description", "")).lower()
342
+ for f in findings
343
+ )
344
+ location_keywords = ["comune", "municipality", "province", "via ", "p.iva",
345
+ "impresa", "telefono", "orari", "cap ", "business directory"]
346
+ looks_like_location = any(kw in combined_text for kw in location_keywords)
347
+
348
+ if looks_like_location:
349
+ summary = (f"No person found for '{name_str}'. "
350
+ f"Results suggest this may be a geographic location or business name, "
351
+ f"not an individual. {sources_text}. "
352
+ f"Try re-running with a different query or check for name variants.")
353
+ else:
354
+ summary = (f"No professional profiles found for {name_str}. "
355
+ f"{sources_text}. Possible private individual, pseudonym, or name collision.")
356
+
357
+ return {
358
+ "executive_summary": summary[:500],
359
+ "risk_themes": [],
360
+ "notable_entities": [{"name": name_str, "role": "person",
361
+ "context": f"Profiles: {pc}, Mentions: {mc}"}],
362
+ "evidence_gaps": [g for g in [
363
+ "No verified employer or organization" if not any(
364
+ 'company' in str(m).lower() or 'firm' in str(m).lower() for m in mentions
365
+ ) else None,
366
+ "Limited digital footprint — try alternative spellings" if pc < 2 else None,
367
+ ] if g],
368
+ "recommended_next_steps": (
369
+ [{"entity": p['url'], "action": "View profile", "tool_hint": "social"} for p in profiles[:3]]
370
+ + [{"entity": m['url'], "action": "Read article", "tool_hint": "web"} for m in mentions[:2]]
371
+ ),
372
+ "_synthesized": True, "_deterministic": True,
373
+ }
374
+
375
+ async def synthesize_brief(
376
+ query: str,
377
+ focus: str,
378
+ findings: list,
379
+ call_llm,
380
+ target_type: str = "",
381
+ executed_tools: set[tuple[str, str]] | None = None,
382
+ resolved_entities: list | None = None,
383
+ investigation_mode: str = "",
384
+ graph_context: dict | None = None,
385
+ correlation: dict | None = None,
386
+ ) -> dict | None:
387
+ """Produce a structured intelligence brief from findings. Returns dict or None."""
388
+ # Filter out pure fetch-failures
389
+ real = []
390
+ for f in findings:
391
+ title = getattr(f, "title", "")
392
+ if not title.lower().startswith(("could not read",)):
393
+ real.append(f)
394
+ usable = real or findings
395
+
396
+ if not usable:
397
+ return None
398
+
399
+ # ── Inject graph context into findings for cross-case intelligence ──
400
+ if graph_context and graph_context.get("known_entities"):
401
+ from ..core.models import Finding, FindingSeverity, FindingSource
402
+ known = graph_context["known_entities"]
403
+ cases = graph_context.get("relevant_cases", [])
404
+ for ent in known[:5]:
405
+ usable.append(Finding(
406
+ id=f"graph-{ent.get('id', ent.get('value', 'unknown'))[:20]}",
407
+ source=FindingSource.OSINT,
408
+ tool="knowledge_graph",
409
+ title=f"📊 [Graph] Known entity: {ent.get('value', '')[:120]}",
410
+ description=f"Previously found in case(s): {', '.join(cases[:3])}. "
411
+ f"Type: {ent.get('type', 'unknown')}. "
412
+ f"Confidence: PRIMARY (community graph — cross-case intelligence).",
413
+ evidence=[],
414
+ confidence=1.0,
415
+ severity=FindingSeverity.INFO,
416
+ ))
417
+
418
+ # Person targets: deterministic extraction for background_check and due_diligence.
419
+ # BUT: if findings contain Wikipedia, FBI, criminal, or legal content,
420
+ # the deterministic brief (which only knows LinkedIn/GitHub profiles) will
421
+ # produce garbage like "verified digital footprint with 2 professional profiles"
422
+ # for a mafia boss. Use LLM synthesis when there's substantive content.
423
+ if target_type == "person" and investigation_mode != "deep_investigation":
424
+ if not _has_substantive_content(usable):
425
+ return _deterministic_person_brief(query, usable)
426
+ # Criminal/legal content detected — fall through to LLM synthesis
427
+ logger.debug("synthesis: substantive content detected, using LLM for %s", query[:60])
428
+
429
+ # Non-person targets: LLM synthesis
430
+
431
+ target_guidance = _TARGET_GUIDANCE.get(target_type, "") if target_type else ""
432
+
433
+ # Deep investigation on persons: use criminal/legal guidance, not generic identity
434
+ if target_type == "person" and investigation_mode == "deep_investigation":
435
+ target_guidance = _PERSON_DEEP_GUIDANCE
436
+
437
+ prompt = _SYNTHESIS_PROMPT.format(
438
+ query=query,
439
+ target_type=target_type or "unknown",
440
+ focus=focus or "general due diligence",
441
+ target_specific_guidance=target_guidance,
442
+ findings=_findings_block(usable),
443
+ resolved_entities=_format_resolved_entities(resolved_entities),
444
+ cross_platform_correlation=_format_correlation(correlation),
445
+ executed_tools=_format_executed_tools(executed_tools),
446
+ )
447
+
448
+ try:
449
+ raw = await call_llm(prompt, timeout=180, max_tokens=4096)
450
+ if not raw or not raw.strip():
451
+ logger.info("synthesis: first LLM attempt empty, retrying with higher tokens")
452
+ raw = await call_llm(prompt, timeout=180, max_tokens=8192)
453
+ if not raw or not raw.strip():
454
+ logger.warning("synthesis: both LLM attempts returned empty — using fallback")
455
+ return _fallback_brief(query, usable)
456
+ except TypeError:
457
+ try:
458
+ raw = await call_llm(prompt, timeout=120, max_tokens=4096)
459
+ except TypeError:
460
+ raw = await call_llm(prompt, max_tokens=4096)
461
+ except Exception as e:
462
+ logger.warning("synthesis_llm_failed: %s", e)
463
+ raw = None
464
+
465
+ if not raw:
466
+ return _fallback_brief(query, usable)
467
+
468
+ brief = _parse_json(raw)
469
+ if not brief or "executive_summary" not in brief:
470
+ return _fallback_brief(query, usable)
471
+
472
+ brief["_synthesized"] = True
473
+ return brief
474
+
475
+
476
+ def _parse_json(text: str) -> dict | None:
477
+ import re
478
+ text = (text or "").strip()
479
+ if text.startswith("```"):
480
+ text = re.sub(r"^```(?:json)?\s*", "", text)
481
+ text = re.sub(r"\s*```$", "", text)
482
+ try:
483
+ return json.loads(text)
484
+ except json.JSONDecodeError:
485
+ m = re.search(r"\{.*\}", text, re.DOTALL)
486
+ if m:
487
+ try:
488
+ return json.loads(m.group(0))
489
+ except json.JSONDecodeError:
490
+ return None
491
+ return None
492
+
493
+
494
+ def _fallback_brief(query: str, findings: list) -> dict:
495
+ """Deterministic structural brief when the LLM is unavailable.
496
+ Extracts real data from findings instead of just giving up."""
497
+ import re as _re
498
+
499
+ sources = []
500
+ titles = []
501
+ entities_found: dict[str, list[str]] = {} # type -> [values]
502
+ for f in findings:
503
+ url = getattr(f, "source_url", "") or (getattr(f, "raw_data", {}) or {}).get("url", "")
504
+ if not url:
505
+ m = _re.search(r"https?://\S+", f"{getattr(f, 'title', '')} {getattr(f, 'description', '')}")
506
+ if m:
507
+ url = m.group(0).rstrip(".,)")
508
+ if url and url not in sources:
509
+ sources.append(url)
510
+ title = getattr(f, "title", "")
511
+ if title and not title.lower().startswith(("check_", "could not read")):
512
+ titles.append(title[:150])
513
+ # Extract entity types from graph findings
514
+ ft = getattr(f, "finding_type", "") or ""
515
+ fval = getattr(f, "source_type", "") or ""
516
+ if ft in ("domain", "ip_address", "person", "organization", "email", "location"):
517
+ if ft not in entities_found:
518
+ entities_found[ft] = []
519
+ entities_found[ft].append(title[:80])
520
+ # Employee pivot findings carry embedded entities
521
+ for ent in getattr(f, "entities", []) or []:
522
+ ent_type = ent.get("type", "person") if isinstance(ent, dict) else "person"
523
+ ent_name = ent.get("name", title[:60]) if isinstance(ent, dict) else str(ent)
524
+ if ent_type not in entities_found:
525
+ entities_found[ent_type] = []
526
+ entities_found[ent_type].append(str(ent_name)[:80])
527
+
528
+ # Build a real summary
529
+ confirmed = sum(1 for f in findings if getattr(f, "tier", "") == "CONFIRMED")
530
+ total = len(findings)
531
+
532
+ # Extract meaningful content from findings
533
+ people = entities_found.get("person", [])
534
+ orgs = entities_found.get("organization", [])
535
+ domains = entities_found.get("domain", [])
536
+ locations = entities_found.get("location", [])
537
+
538
+ parts = [f"Investigation of '{query}' gathered {total} findings ({confirmed} confirmed) from {len(sources)} verified sources."]
539
+
540
+ if people:
541
+ parts.append(f"Key individuals: {', '.join(people[:3])}.")
542
+ if orgs:
543
+ parts.append(f"Connected organizations: {', '.join(orgs[:3])}.")
544
+ if domains:
545
+ parts.append(f"Infrastructure mapped: {len(domains)} domains, {len(entities_found.get('ip_address', []))} IPs across {len(locations)} locations.")
546
+
547
+ # If no entities found, use titles
548
+ if not any([people, orgs, domains]):
549
+ top = [t for t in titles[:5] if not t.startswith("🔗")]
550
+ if top:
551
+ parts.append("Notable findings: " + "; ".join(top[:3]) + ".")
552
+
553
+ # Detect themes from finding content (more thorough)
554
+ theme_keywords = {
555
+ "Regulatory / Legal": ["sanction", "fine", "settlement", "regulator", "sec", "cftc", "doj",
556
+ "lawsuit", "indictment", "charged", "plead guilty", "convicted",
557
+ "class action", "arbitration", "court", "ruling", "securities"],
558
+ "Corporate / Leadership": ["ceo", "founder", "executive", "board", "chairman", "director",
559
+ "resigned", "appointed", "named", "co-founder"],
560
+ "Financial": ["revenue", "profit", "loss", "billion", "million", "funding", "valuation",
561
+ "stock", "share", "investor", "ipo", "acquisition"],
562
+ "Crime / Investigation": ["criminal", "arrest", "prison", "jail", "fraud", "money laundering",
563
+ "investigation", "probe", "allegation", "misconduct"],
564
+ "Infrastructure / Technical": ["dns", "ip address", "nameserver", "hosting", "cdn",
565
+ "subdomain", "resolves_to", "mx record"],
566
+ }
567
+ themes = []
568
+ all_text = " ".join(titles + [getattr(f, "description", "") or "" for f in findings]).lower()
569
+ for theme, keywords in theme_keywords.items():
570
+ matches = [kw for kw in keywords if kw in all_text]
571
+ if matches:
572
+ # Extract a relevant finding snippet
573
+ for f in findings:
574
+ desc = (getattr(f, "description", "") or "").lower()
575
+ title_l = (getattr(f, "title", "") or "").lower()
576
+ for kw in matches:
577
+ if kw in title_l or kw in desc:
578
+ themes.append({
579
+ "theme": theme,
580
+ "severity": "HIGH" if theme.startswith("Crime") else "MEDIUM",
581
+ "summary": getattr(f, "title", "")[:200] or f"Evidence of {kw} found",
582
+ "source_titles": [getattr(f, "title", "")[:100]],
583
+ })
584
+ break
585
+ if any(t["theme"] == theme for t in themes):
586
+ break
587
+ else:
588
+ themes.append({
589
+ "theme": theme,
590
+ "severity": "MEDIUM",
591
+ "summary": f"Keywords detected: {', '.join(matches[:4])}",
592
+ "source_titles": [],
593
+ })
594
+
595
+ # Extract timeline events from findings (dates in titles/descriptions)
596
+ timeline_events: list[dict] = []
597
+ date_pattern = _re.compile(
598
+ r'\b((?:19|20)\d{2}(?:-(?:0[1-9]|1[0-2])(?:-(?:0[1-9]|[12]\d|3[01]))?)?)\b'
599
+ )
600
+ seen_dates: set[str] = set()
601
+ for f in findings:
602
+ title = getattr(f, "title", "") or ""
603
+ desc = getattr(f, "description", "") or ""
604
+ combined = f"{title} {desc}"
605
+ for m in date_pattern.finditer(combined):
606
+ d = m.group(1)
607
+ if d in seen_dates or len(d) < 4:
608
+ continue
609
+ seen_dates.add(d)
610
+ # Categorize
611
+ cat = "personal"
612
+ lower = combined.lower()
613
+ if any(kw in lower for kw in ("sentenced", "convicted", "arrested", "charged", "murder", "prison")):
614
+ cat = "criminal"
615
+ elif any(kw in lower for kw in ("lawsuit", "court", "trial", "ruling", "indictment")):
616
+ cat = "legal"
617
+ elif any(kw in lower for kw in ("ceo", "founder", "joined", "hired", "appointed", "director", "board")):
618
+ cat = "career"
619
+ elif any(kw in lower for kw in ("graduated", "university", "degree", "phd", "bachelor", "master")):
620
+ cat = "education"
621
+ elif any(kw in lower for kw in ("revenue", "funding", "ipo", "acquired", "valuation", "million", "billion")):
622
+ cat = "financial"
623
+ snippet = (title or desc)[:120]
624
+ timeline_events.append({
625
+ "date": d, "category": cat, "event": snippet,
626
+ "source_title": title[:100] if title else "",
627
+ })
628
+ timeline_events.sort(key=lambda e: e["date"])
629
+
630
+ return {
631
+ "executive_summary": " ".join(parts),
632
+ "risk_themes": themes if themes else [],
633
+ "notable_entities": [
634
+ {"name": v[0][:80], "type": k, "role": "discovered", "confidence": 0.7}
635
+ for k, vals in entities_found.items() for v in vals[:1]
636
+ ],
637
+ "timeline": timeline_events[:15],
638
+ "evidence_gaps": [],
639
+ "recommended_next_steps": [{"entity": u, "action": "Review source", "tool_hint": "web"}
640
+ for u in sources[:5]],
641
+ "_synthesized": False,
642
+ "_sources": sources[:15],
643
+ }
644
+
645
+
646
+ def _format_correlation(correlation: dict | None) -> str:
647
+ """Format cross-platform identity correlation for the synthesis prompt."""
648
+ if not correlation or not correlation.get("total_matches"):
649
+ return ""
650
+ lines = ["CROSS-PLATFORM IDENTITY CORRELATION:"]
651
+ lines.append(correlation.get("summary", ""))
652
+ confirmed = correlation.get("confirmed", [])
653
+ if confirmed:
654
+ lines.append("\nConfirmed matches (confidence ≥ 0.9):")
655
+ for m in confirmed[:3]:
656
+ lines.append(f" • {m['finding_a'][:60]} ↔ {m['finding_b'][:60]}")
657
+ lines.append(f" Signals: {', '.join(m['signals'])} | Confidence: {m['confidence']}")
658
+ probable = correlation.get("probable", [])
659
+ if probable:
660
+ lines.append("\nProbable matches (confidence ≥ 0.7):")
661
+ for m in probable[:3]:
662
+ lines.append(f" • {m['finding_a'][:60]} ↔ {m['finding_b'][:60]}")
663
+ return "\n".join(lines)
664
+
665
+
666
+ def brief_to_markdown(brief: dict, query: str) -> str:
667
+ """Render a brief dict as a readable markdown report."""
668
+ out = [f"# Intelligence Brief: {query}", ""]
669
+ out.append(f"**Bottom line:** {brief.get('executive_summary', 'N/A')}")
670
+ out.append("")
671
+
672
+ themes = brief.get("risk_themes", [])
673
+ if themes:
674
+ out.append("## Risk Themes")
675
+ for t in themes:
676
+ sev = t.get("severity", "?")
677
+ badge = {"HIGH": "🔴", "MEDIUM": "🟠", "LOW": "🟡"}.get(sev, "⚪")
678
+ out.append(f"### {badge} {t.get('theme', 'Theme')} — {sev}")
679
+ out.append(t.get("summary", ""))
680
+ srcs = t.get("source_titles", [])
681
+ if srcs:
682
+ out.append("Sources: " + "; ".join(srcs[:4]))
683
+ out.append("")
684
+
685
+ ents = brief.get("notable_entities", [])
686
+ if ents:
687
+ out.append("## Notable Entities")
688
+ for e in ents:
689
+ out.append(f"- **{e.get('name','?')}** ({e.get('role','?')}): {e.get('context','')}")
690
+ out.append("")
691
+
692
+ timeline = brief.get("timeline", [])
693
+ if timeline:
694
+ out.append("## Timeline")
695
+ cat_icons = {
696
+ "criminal": "🔴", "legal": "⚖️", "career": "💼",
697
+ "education": "🎓", "personal": "👤", "financial": "💰",
698
+ }
699
+ for event in timeline:
700
+ icon = cat_icons.get(event.get("category", ""), "📌")
701
+ date = event.get("date", "?")
702
+ evt = event.get("event", "")
703
+ src = event.get("source_title", "")
704
+ out.append(f"- {icon} **{date}** — {evt}")
705
+ if src:
706
+ out.append(f" *Source: {src[:120]}*")
707
+ out.append("")
708
+
709
+ gaps = brief.get("evidence_gaps", [])
710
+ if gaps:
711
+ out.append("## Evidence Gaps")
712
+ for g in gaps:
713
+ out.append(f"- 🔍 {g}")
714
+ out.append("")
715
+
716
+ nxt = brief.get("recommended_next_steps", [])
717
+ if nxt:
718
+ out.append("## Recommended Next Steps")
719
+ for n in nxt:
720
+ if isinstance(n, dict):
721
+ out.append(f"- **{n.get('entity','?')}**: {n.get('action','?')} [{n.get('tool_hint','?')}]")
722
+ else:
723
+ out.append(f"- {n}")
724
+ out.append("")
725
+
726
+ return "\n".join(out)