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
|
@@ -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)
|