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,621 @@
1
+ """Universal pre-synthesis pipeline for Watson OSINT.
2
+
3
+ Handles any target type and any language/script. Conservatively drops only
4
+ findings with zero relevance to the target — never risks losing real intel.
5
+
6
+ Architecture:
7
+ 1. TargetCanonicalizer → extracts canonical tokens from any target type
8
+ 2. RelevanceGrader → multilingual token + bigram scoring
9
+ 3. relevance_filter → safe drop-only gate (zero overlap + low confidence)
10
+
11
+ Target types supported:
12
+ person, organization, domain, email, wallet, phone, ip, username, unknown
13
+
14
+ Scripts supported:
15
+ Latin (all diacritic variants), Cyrillic, CJK, Arabic, Devanagari, Thai, etc.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import re
21
+ import unicodedata
22
+ from dataclasses import dataclass, field
23
+ from typing import Optional
24
+
25
+ # ---------------------------------------------------------------------------
26
+ # Script → language mapping (for language boost)
27
+ # ---------------------------------------------------------------------------
28
+
29
+ # Unicode script blocks for common writing systems
30
+ _SCRIPT_RANGES: list[tuple[int, int, str]] = [
31
+ # Latin blocks (various extensions)
32
+ (0x0041, 0x007A, "en"), # Basic Latin A-Z a-z
33
+ (0x00C0, 0x024F, "en"), # Latin-1 Supplement + Extended-A
34
+ # Specific diacritic patterns for language detection
35
+ (0x0150, 0x0151, "hu"), # Ő ő — Hungarian
36
+ (0x0170, 0x0171, "hu"), # Ű ű — Hungarian
37
+ (0x010C, 0x010D, "cs"), # Č č — Czech/Slovak/Slovene/Croatian
38
+ (0x0160, 0x0161, "cs"), # Š š
39
+ (0x017D, 0x017E, "cs"), # Ž ž
40
+ (0x0141, 0x0142, "pl"), # Ł ł — Polish
41
+ (0x0104, 0x0105, "pl"), # Ą ą
42
+ (0x0118, 0x0119, "pl"), # Ę ę
43
+ (0x0143, 0x0144, "pl"), # Ń ń
44
+ (0x015A, 0x015B, "pl"), # Ś ś
45
+ (0x0179, 0x017A, "pl"), # Ź ź
46
+ (0x017B, 0x017C, "pl"), # Ż ż
47
+ (0x00DF, 0x00DF, "de"), # ß — German
48
+ (0x00E4, 0x00E4, "de"), # ä
49
+ (0x00F6, 0x00F6, "de"), # ö
50
+ (0x00FC, 0x00FC, "de"), # ü
51
+ (0x00E0, 0x00E0, "it"), # à — Italian
52
+ (0x00E8, 0x00E8, "it"), # è
53
+ (0x00EC, 0x00EC, "it"), # ì
54
+ (0x00F2, 0x00F2, "it"), # ò
55
+ (0x00F9, 0x00F9, "it"), # ù
56
+ (0x00E7, 0x00E7, "pt"), # ç — Portuguese/French
57
+ (0x00F1, 0x00F1, "es"), # ñ — Spanish
58
+ # Cyrillic
59
+ (0x0400, 0x04FF, "ru"), # Cyrillic → default Russian
60
+ (0x0500, 0x052F, "ru"), # Cyrillic Supplement
61
+ # CJK
62
+ (0x4E00, 0x9FFF, "zh"), # CJK Unified → default Chinese
63
+ (0x3400, 0x4DBF, "zh"), # CJK Extension A
64
+ (0x3040, 0x309F, "ja"), # Hiragana → Japanese
65
+ (0x30A0, 0x30FF, "ja"), # Katakana
66
+ (0xAC00, 0xD7AF, "ko"), # Hangul → Korean
67
+ # Arabic
68
+ (0x0600, 0x06FF, "ar"), # Arabic
69
+ (0x0750, 0x077F, "ar"), # Arabic Supplement
70
+ (0xFB50, 0xFDFF, "ar"), # Arabic Presentation A
71
+ (0xFE70, 0xFEFF, "ar"), # Arabic Presentation B
72
+ # Devanagari (Hindi, Sanskrit, Marathi, Nepali)
73
+ (0x0900, 0x097F, "hi"), # Devanagari
74
+ # Thai
75
+ (0x0E00, 0x0E7F, "th"), # Thai
76
+ # Greek
77
+ (0x0370, 0x03FF, "el"), # Greek
78
+ # Hebrew
79
+ (0x0590, 0x05FF, "he"), # Hebrew
80
+ # Georgian
81
+ (0x10A0, 0x10FF, "ka"), # Georgian
82
+ # Armenian
83
+ (0x0530, 0x058F, "hy"), # Armenian
84
+ ]
85
+
86
+
87
+ def _detect_scripts(text: str) -> dict[str, float]:
88
+ """Count character frequencies per script/language.
89
+
90
+ Returns dict mapping language codes to proportion (0.0–1.0).
91
+ """
92
+ counts: dict[str, int] = {}
93
+ total = 0
94
+ for ch in text:
95
+ cp = ord(ch)
96
+ for lo, hi, lang in _SCRIPT_RANGES:
97
+ if lo <= cp <= hi:
98
+ counts[lang] = counts.get(lang, 0) + 1
99
+ total += 1
100
+ break
101
+ if total == 0:
102
+ return {}
103
+ return {lang: cnt / total for lang, cnt in counts.items()}
104
+
105
+
106
+ # ---------------------------------------------------------------------------
107
+ # Target canonicalization
108
+ # ---------------------------------------------------------------------------
109
+
110
+ # Known email provider domains — tokens to strip when canonicalizing
111
+ _EMAIL_PROVIDERS = {
112
+ "gmail.com", "googlemail.com", "yahoo.com", "ymail.com",
113
+ "outlook.com", "hotmail.com", "live.com", "msn.com",
114
+ "protonmail.com", "proton.me", "pm.me",
115
+ "icloud.com", "me.com", "mac.com",
116
+ "aol.com", "mail.com", "gmx.com", "gmx.de",
117
+ "zoho.com", "fastmail.com", "tutanota.com",
118
+ "yandex.com", "yandex.ru", "qq.com", "163.com",
119
+ }
120
+
121
+ # Common TLDs — not useful as canonical tokens for domain targets
122
+ _NOISE_TLDS = {"com", "org", "net", "io", "co", "ai", "dev", "app", "info", "biz"}
123
+
124
+ # Known crypto prefixes
125
+ _WALLET_PREFIXES = {"0x": "eth", "bc1": "btc", "1": "btc", "3": "btc",
126
+ "T": "trx", "L": "ltc", "M": "ltc", "X": "xmr"}
127
+
128
+
129
+ @dataclass
130
+ class TargetProfile:
131
+ """Canonical representation of an investigation target — any type, any language."""
132
+ raw: str # Original query string
133
+ target_type: str # person, organization, domain, email, wallet, phone, ip, username, unknown
134
+ canonical_tokens: set[str] # Meaningful, normalized tokens
135
+ name_parts: list[str] # For person/org targets (split by spaces, normalized)
136
+ likely_languages: set[str] # ISO 639-1 codes inferred from script
137
+ tld: str = "" # For domain/email targets
138
+ wallet_chain: str = "" # For wallet targets
139
+ metadata: dict = field(default_factory=dict)
140
+ name_part_tokens: list[set[str]] = field(default_factory=list) # Per-part ASCII-normalized tokens (for person targets)
141
+
142
+
143
+ def canonicalize_target(query: str) -> TargetProfile:
144
+ """Extract canonical tokens and metadata from ANY target type.
145
+
146
+ Handles: person names, organization names, domains, emails,
147
+ cryptocurrency wallets, phone numbers, IP addresses, usernames.
148
+
149
+ For multilingual targets: preserves diacritics in name_parts
150
+ but normalizes canonical_tokens to ASCII for matching.
151
+ """
152
+ if not query or not query.strip():
153
+ return TargetProfile(
154
+ raw=query, target_type="unknown",
155
+ canonical_tokens=set(), name_parts=[],
156
+ likely_languages={"en"}
157
+ )
158
+
159
+ query = query.strip()
160
+
161
+ # ── Type detection ──
162
+ # Email
163
+ if "@" in query and "." in query.split("@")[-1]:
164
+ return _canonicalize_email(query)
165
+
166
+ # Wallet
167
+ if re.match(r'^(0x[a-fA-F0-9]{40}|[13][a-km-zA-HJ-NP-Z1-9]{25,34}|bc1[a-zA-HJ-NP-Z0-9]{25,62}|T[a-zA-Z0-9]{33}|L[a-zA-Z0-9]{33}|4[0-9AB][1-9A-HJ-NP-Za-km-z]{93})$', query):
168
+ return _canonicalize_wallet(query)
169
+
170
+ # IP address
171
+ if re.match(r'^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$', query):
172
+ return _canonicalize_ip(query)
173
+
174
+ # Phone number (international format)
175
+ if re.match(r'^\+?\d{7,15}$', query.replace(" ", "").replace("-", "").replace("(", "").replace(")", "")):
176
+ return _canonicalize_phone(query)
177
+
178
+ # Domain
179
+ if "." in query and "/" not in query and " " not in query and not query.startswith("@"):
180
+ # Must have a valid TLD
181
+ parts = query.lower().rstrip(".").split(".")
182
+ if len(parts) >= 2 and parts[-1].isalpha() and 2 <= len(parts[-1]) <= 6:
183
+ return _canonicalize_domain(query)
184
+
185
+ # Username (no spaces, alphanumeric + common separators)
186
+ if re.match(r'^@?[a-zA-Z0-9][a-zA-Z0-9._-]{2,30}$', query.lstrip("@")):
187
+ return _canonicalize_username(query)
188
+
189
+ # Person vs Organization heuristic
190
+ # Person: usually 2-3 words, capitalized, no common org indicators
191
+ # Organization: often has Inc, Corp, Ltd, LLC, or is a single word
192
+ words = query.split()
193
+ org_indicators = {"inc", "corp", "ltd", "llc", "gmbh", "sa", "spa", "bv", "nv",
194
+ "ag", "kg", "plc", "lp", "llp", "co", "group", "holdings",
195
+ "corporation", "limited", "company", "bank", "university",
196
+ "institute", "foundation", "association", "ministry", "department"}
197
+
198
+ is_org = any(w.lower().rstrip(".,") in org_indicators for w in words)
199
+
200
+ if len(words) >= 2 and not is_org:
201
+ return _canonicalize_person(query)
202
+ else:
203
+ return _canonicalize_organization(query)
204
+
205
+
206
+ # ── Stop words: universal function words in major languages ──
207
+ # These generate false bigram/token matches and dilute relevance.
208
+ _STOP_WORDS: set[str] = {
209
+ # English
210
+ "the", "and", "for", "that", "this", "with", "from", "have", "are",
211
+ "was", "not", "but", "you", "all", "can", "had", "her", "his", "its",
212
+ "one", "our", "out", "she", "some", "than", "them", "then", "were",
213
+ "will", "would", "been", "being", "does", "did", "has", "just", "like",
214
+ "make", "more", "much", "must", "now", "only", "over", "said", "such",
215
+ "also", "any", "after", "about", "into", "other", "their", "there",
216
+ "which", "when", "what", "who", "how", "where", "may", "get", "got",
217
+ "new", "see", "use", "way", "well", "back", "come", "down", "each",
218
+ "even", "first", "good", "know", "last", "life", "long", "look", "many",
219
+ "most", "own", "part", "same", "still", "take", "tell", "these", "those",
220
+ "very", "year", "here", "every", "through", "before", "between",
221
+ # Hungarian
222
+ "az", "ez", "egy", "nem", "hogy", "van", "volt", "lesz", "mint",
223
+ "meg", "mert", "már", "még", "itt", "ott", "aki", "ami", "csak",
224
+ "ha", "is", "de", "el", "ki", "be", "ki", "fel", "le", "át",
225
+ # French
226
+ "le", "la", "les", "un", "une", "des", "est", "pas", "dans",
227
+ "pour", "avec", "sur", "par", "que", "qui", "ce", "se", "au",
228
+ "du", "en", "il", "elle", "nous", "vous", "ils", "elles", "mais",
229
+ # Spanish
230
+ "el", "los", "las", "del", "por", "con", "sin", "para", "como",
231
+ "más", "pero", "entre", "hasta", "desde", "porque", "cuando",
232
+ # German
233
+ "der", "die", "das", "den", "dem", "ein", "eine", "einen",
234
+ "nicht", "sich", "auch", "auf", "bei", "nach", "noch", "schon",
235
+ "um", "zu", "zur", "zum", "von", "vor", "wir", "sie", "er",
236
+ # Italian
237
+ "di", "che", "in", "lo", "gli", "sono", "per", "con", "su",
238
+ # Russian (transliterated)
239
+ "i", "v", "na", "ne", "chto", "kak", "eto", "ot", "po",
240
+ # Portuguese
241
+ "da", "das", "dos", "em", "na", "no", "nas", "nos", "ao", "aos",
242
+ # Dutch
243
+ "de", "het", "een", "op", "te", "zijn", "dat", "met", "van",
244
+ # Arabic (transliterated)
245
+ "al", "fi", "min", "ma", "la", "wa", "ya",
246
+ # Universal
247
+ "or", "if", "an", "as", "at", "be", "by", "do", "go", "he", "in",
248
+ "is", "it", "me", "my", "no", "of", "on", "so", "to", "up", "us",
249
+ "we", "oh", "ok", "hi", "am", "pm", "dr", "mr", "ms", "rs",
250
+ }
251
+
252
+ def _tokenize(text: str) -> set[str]:
253
+ """Normalize text to canonical ASCII tokens (min 2 chars). Filters stop words."""
254
+ # NFKD decomposes diacritics: ó → o + combining acute
255
+ s = unicodedata.normalize('NFKD', text.lower())
256
+ # Strip combining characters (diacritics)
257
+ s = ''.join(c for c in s if not unicodedata.combining(c))
258
+ # Extract alphanumeric tokens, filter stop words
259
+ tokens = set()
260
+ for token in s.split():
261
+ token = ''.join(c for c in token if c.isalnum())
262
+ if len(token) >= 2 and token not in _STOP_WORDS:
263
+ tokens.add(token)
264
+ return tokens
265
+
266
+
267
+ def _detect_languages(text: str) -> set[str]:
268
+ """Infer likely languages from script detection."""
269
+ scripts = _detect_scripts(text)
270
+ langs = set(scripts.keys())
271
+ # Always include English as fallback
272
+ langs.add("en")
273
+ return langs
274
+
275
+
276
+ def _canonicalize_person(query: str) -> TargetProfile:
277
+ """Canonicalize a person name."""
278
+ words = [w.strip(".,;:'\"") for w in query.split() if len(w.strip(".,;:'\"")) >= 2]
279
+ name_parts = [w.lower() for w in words]
280
+ tokens = _tokenize(query)
281
+ # Also add individual name parts as tokens (e.g., "bodi", "ildiko")
282
+ # And track per-part tokens for surname-aware relevance scoring
283
+ name_part_tokens: list[set[str]] = []
284
+ for part in name_parts:
285
+ tokenized = _tokenize(part)
286
+ tokens.update(tokenized)
287
+ name_part_tokens.append(tokenized)
288
+ langs = _detect_languages(query)
289
+ return TargetProfile(
290
+ raw=query, target_type="person",
291
+ canonical_tokens=tokens, name_parts=name_parts,
292
+ name_part_tokens=name_part_tokens,
293
+ likely_languages=langs, metadata={"word_count": len(words)}
294
+ )
295
+
296
+
297
+ def _canonicalize_organization(query: str) -> TargetProfile:
298
+ """Canonicalize an organization name."""
299
+ # Strip org indicators for cleaner tokens
300
+ stripped = re.sub(r'\b(Inc|Corp|Ltd|LLC|GmbH|SA|SpA|BV|NV|AG|KG|PLC|LP|LLP|Co)\b\.?',
301
+ '', query, flags=re.IGNORECASE)
302
+ tokens = _tokenize(stripped)
303
+ words = [w for w in stripped.split() if len(w) >= 2]
304
+ langs = _detect_languages(query)
305
+ return TargetProfile(
306
+ raw=query, target_type="organization",
307
+ canonical_tokens=tokens, name_parts=[w.lower() for w in words],
308
+ likely_languages=langs, metadata={"word_count": len(words)}
309
+ )
310
+
311
+
312
+ def _canonicalize_domain(query: str) -> TargetProfile:
313
+ """Canonicalize a domain name."""
314
+ parts = query.lower().rstrip(".").split(".")
315
+ tld = parts[-1] if len(parts) >= 2 else ""
316
+ # The meaningful part is the SLD (second-level domain)
317
+ sld = parts[-2] if len(parts) >= 2 else parts[0]
318
+ tokens = _tokenize(sld)
319
+ # Also add the full domain without TLD
320
+ if len(parts) >= 2:
321
+ tokens.add('.'.join(parts[:-1]))
322
+ langs = {"en"}
323
+ # Country-code TLD hints at language
324
+ _CC_LANG = {"hu": "hu", "de": "de", "fr": "fr", "es": "es", "it": "it",
325
+ "pt": "pt", "ru": "ru", "cn": "zh", "jp": "ja", "kr": "ko",
326
+ "pl": "pl", "cz": "cs", "sk": "cs", "nl": "nl", "se": "sv",
327
+ "no": "no", "dk": "da", "fi": "fi", "gr": "el", "il": "he",
328
+ "ar": "ar", "th": "th", "vn": "vi", "tr": "tr", "ua": "uk"}
329
+ if tld in _CC_LANG:
330
+ langs.add(_CC_LANG[tld])
331
+ return TargetProfile(
332
+ raw=query, target_type="domain",
333
+ canonical_tokens=tokens, name_parts=[sld],
334
+ likely_languages=langs, tld=tld
335
+ )
336
+
337
+
338
+ def _canonicalize_email(query: str) -> TargetProfile:
339
+ """Canonicalize an email address."""
340
+ local, domain = query.split("@", 1)
341
+ # Extract meaningful tokens from local part
342
+ # "baron.lorenzo99" → ["baron", "lorenzo"]
343
+ name_parts = re.split(r'[\d._\-+\s]+', local)
344
+ name_parts = [p for p in name_parts if len(p) >= 2]
345
+ tokens = set()
346
+ for part in name_parts:
347
+ tokens.update(_tokenize(part))
348
+ # Also add the local part as a whole for username matching
349
+ tokens.add(local.lower())
350
+ # Don't add domain tokens if it's a known provider
351
+ domain_parts = domain.lower().split(".")
352
+ if domain.lower() not in _EMAIL_PROVIDERS:
353
+ sld = domain_parts[0] if domain_parts else domain
354
+ tokens.update(_tokenize(sld))
355
+ tokens.add(domain.lower())
356
+ langs = _detect_languages(' '.join(name_parts))
357
+ langs.add("en")
358
+ return TargetProfile(
359
+ raw=query, target_type="email",
360
+ canonical_tokens=tokens, name_parts=[p.lower() for p in name_parts],
361
+ likely_languages=langs, metadata={
362
+ "local": local, "domain": domain,
363
+ "is_provider": domain.lower() in _EMAIL_PROVIDERS
364
+ }
365
+ )
366
+
367
+
368
+ def _canonicalize_wallet(query: str) -> TargetProfile:
369
+ """Canonicalize a cryptocurrency wallet address."""
370
+ clean = query.strip()
371
+ chain = "unknown"
372
+ for prefix, c in _WALLET_PREFIXES.items():
373
+ if clean.lower().startswith(prefix.lower()):
374
+ chain = c
375
+ break
376
+ # Use the full address as a single token
377
+ tokens = {clean.lower()}
378
+ # Also add first 8 and last 8 chars for partial matches
379
+ if len(clean) >= 16:
380
+ tokens.add(clean[:8].lower())
381
+ tokens.add(clean[-8:].lower())
382
+ return TargetProfile(
383
+ raw=query, target_type="wallet",
384
+ canonical_tokens=tokens, name_parts=[clean[:12]],
385
+ likely_languages={"en"}, wallet_chain=chain
386
+ )
387
+
388
+
389
+ def _canonicalize_phone(query: str) -> TargetProfile:
390
+ """Canonicalize a phone number."""
391
+ digits = ''.join(c for c in query if c.isdigit())
392
+ tokens = {digits}
393
+ # Last 6-10 digits as partial match
394
+ if len(digits) >= 6:
395
+ tokens.add(digits[-6:])
396
+ if len(digits) >= 10:
397
+ tokens.add(digits[-10:])
398
+ langs = {"en"}
399
+ # Country code detection (very rough)
400
+ if digits.startswith("36"):
401
+ langs.add("hu")
402
+ elif digits.startswith("33"):
403
+ langs.add("fr")
404
+ elif digits.startswith("39"):
405
+ langs.add("it")
406
+ return TargetProfile(
407
+ raw=query, target_type="phone",
408
+ canonical_tokens=tokens, name_parts=[digits],
409
+ likely_languages=langs
410
+ )
411
+
412
+
413
+ def _canonicalize_ip(query: str) -> TargetProfile:
414
+ """Canonicalize an IP address."""
415
+ tokens = {query.strip()}
416
+ # Individual octets
417
+ for octet in query.split("."):
418
+ if octet.isdigit():
419
+ tokens.add(octet)
420
+ return TargetProfile(
421
+ raw=query, target_type="ip",
422
+ canonical_tokens=tokens, name_parts=[query.strip()],
423
+ likely_languages={"en"}
424
+ )
425
+
426
+
427
+ def _canonicalize_username(query: str) -> TargetProfile:
428
+ """Canonicalize a username/handle."""
429
+ clean = query.lstrip("@").lower()
430
+ tokens = {clean}
431
+ # Split on common separators for sub-tokens
432
+ sub_tokens = re.split(r'[._\-]', clean)
433
+ for st in sub_tokens:
434
+ if len(st) >= 2:
435
+ tokens.update(_tokenize(st))
436
+ langs = _detect_languages(clean)
437
+ langs.add("en")
438
+ return TargetProfile(
439
+ raw=query, target_type="username",
440
+ canonical_tokens=tokens, name_parts=sub_tokens,
441
+ likely_languages=langs
442
+ )
443
+
444
+
445
+ # ---------------------------------------------------------------------------
446
+ # Multilingual Relevance Grading
447
+ # ---------------------------------------------------------------------------
448
+
449
+ def relevance_score(profile: TargetProfile, text: str) -> float:
450
+ """Multilingual relevance: 0.0 = no relation, 1.0 = exact match.
451
+
452
+ Strategies (applied cumulatively):
453
+ 1. Jaccard token overlap (diacritic-normalized) — primary
454
+ 2. Bigram overlap — catches partial name matches
455
+ 3. Script/language boost — reward matching scripts
456
+ 4. Target-type-specific heuristics
457
+ """
458
+ if not text or not profile.canonical_tokens:
459
+ return 0.0 if profile.canonical_tokens else 0.5
460
+
461
+ text_tokens = _tokenize(text)
462
+ if not text_tokens:
463
+ return 0.0
464
+
465
+ # ── Strategy 1: Jaccard token overlap ──
466
+ target_tokens = profile.canonical_tokens
467
+ intersection = target_tokens & text_tokens
468
+ union = target_tokens | text_tokens
469
+ jaccard = len(intersection) / len(union) if union else 0.0
470
+
471
+ # ── Strategy 2: Trigram overlap ──
472
+ # 3-character sequences are much less likely to randomly match across
473
+ # unrelated text than bigrams (e.g., "di" appears in ~30% of English words,
474
+ # but "bodi" → "bod" or "odi" appears in almost none).
475
+ # Catches "ildiko" matching "ildikó" (NFKD → "ildiko") and
476
+ # "Ildiko Bodi" matching "Bódi Ildikó" via shared trigrams in the name parts.
477
+ def _trigrams(tokens: set[str]) -> set[str]:
478
+ trigrams = set()
479
+ for token in tokens:
480
+ for i in range(len(token) - 2):
481
+ trigrams.add(token[i:i+3])
482
+ return trigrams
483
+
484
+ target_trigrams = _trigrams(target_tokens)
485
+ text_trigrams = _trigrams(text_tokens)
486
+ if target_trigrams:
487
+ trigram_overlap = len(target_trigrams & text_trigrams) / len(target_trigrams)
488
+ else:
489
+ trigram_overlap = 0.0
490
+
491
+ # ── Strategy 3: Language/script boost ──
492
+ # If the text uses scripts matching the target's likely languages,
493
+ # apply a small boost (helps Hungarian articles about Hungarian people)
494
+ text_scripts = _detect_scripts(text)
495
+ script_boost = 0.0
496
+ if text_scripts and profile.likely_languages:
497
+ matching_scripts = profile.likely_languages & set(text_scripts.keys())
498
+ if matching_scripts:
499
+ # Up to 0.10 boost based on script match ratio
500
+ script_boost = 0.10 * sum(text_scripts[lang] for lang in matching_scripts)
501
+
502
+ # ── Strategy 2.5: Surname-aware penalty (person targets only) ──
503
+ # For multi-word person names, a finding that matches only ONE name part
504
+ # (e.g., "Ildiko Szabo" matching only "Ildiko" from "BÓDI Ildikó") is a
505
+ # completely different person. Given names are too common; the surname is
506
+ # the primary disambiguator. This penalty prevents the trigram/Jaccard
507
+ # overlap from giving high scores to clearly irrelevant findings.
508
+ name_penalty = 1.0
509
+ if profile.target_type == "person" and profile.name_part_tokens and len(profile.name_part_tokens) >= 2:
510
+ parts_matched = 0
511
+ for part_tokens in profile.name_part_tokens:
512
+ if part_tokens & text_tokens:
513
+ parts_matched += 1
514
+
515
+ match_ratio = parts_matched / len(profile.name_part_tokens)
516
+
517
+ if match_ratio >= 1.0:
518
+ # Full name match — all parts present → slight bonus
519
+ name_penalty = 1.15
520
+ elif match_ratio <= 0.5:
521
+ # Only half or fewer name parts matched → heavy penalty
522
+ # 1/2 → 0.25x, 1/3 → 0.15x, 2/4 → 0.35x
523
+ name_penalty = max(0.10, match_ratio * 0.50)
524
+ else:
525
+ # Most parts matched (e.g., 2/3) → moderate penalty
526
+ name_penalty = match_ratio
527
+
528
+ # ── Composite score ──
529
+ # Jaccard is the primary signal (most reliable). Trigram overlap is a
530
+ # secondary fallback — weighted low, and 3-char sequences avoid the
531
+ # false-match problem of bigrams. Script boost is tiny.
532
+ composite = (0.70 * jaccard) + (0.12 * trigram_overlap) + (0.05 * script_boost)
533
+
534
+ # Apply surname-aware penalty / bonus
535
+ composite *= name_penalty
536
+
537
+ # ── Target-type-specific boosts ──
538
+ if profile.target_type == "wallet":
539
+ # Wallet addresses are unique — exact match is very strong
540
+ if profile.raw.lower() in text.lower():
541
+ composite = max(composite, 0.95)
542
+ elif profile.target_type == "ip":
543
+ if profile.raw in text:
544
+ composite = max(composite, 0.95)
545
+ elif profile.target_type == "phone":
546
+ digits = ''.join(c for c in profile.raw if c.isdigit())
547
+ if digits and digits in ''.join(c for c in text if c.isdigit()):
548
+ composite = max(composite, 0.90)
549
+
550
+ return min(composite, 1.0)
551
+
552
+
553
+ # ---------------------------------------------------------------------------
554
+ # Safe Relevance Filter
555
+ # ---------------------------------------------------------------------------
556
+
557
+ def relevance_filter(findings: list, query: str, min_score: float = 0.03) -> tuple[list, list]:
558
+ """Filter out clearly irrelevant findings.
559
+
560
+ SAFETY RULES (never drops legitimate intelligence):
561
+ 1. Findings WITH source URLs are always kept (assume scraper validated)
562
+ EXCEPT: person targets with 2+ name parts → if only 1 name part matches
563
+ and confidence < 0.80, drop it (given-name-only matches are noise).
564
+ 2. Findings with high confidence (≥0.65) are always kept
565
+ 3. Only drops findings with ZERO composite relevance score
566
+ 4. Only drops if confidence < 0.65
567
+
568
+ Returns: (kept, dropped) — dropped list is for audit logging.
569
+ """
570
+ if not query or not findings:
571
+ return (list(findings), [])
572
+
573
+ profile = canonicalize_target(query)
574
+ if not profile.canonical_tokens:
575
+ return (list(findings), []) # Can't assess, keep all
576
+
577
+ kept = []
578
+ dropped = []
579
+ for f in findings:
580
+ confidence = f.confidence or 0.5
581
+
582
+ # Rule 1: Has source URL → keep UNLESS it's a person target
583
+ # where ≤1 of 2+ name parts matches (given-name-only noise).
584
+ # Given names (like "Ildikó") are too common; a finding that
585
+ # matches ONLY the given name and not the surname is a different
586
+ # person. No confidence threshold — scrapers assign uniform
587
+ # high confidence, so we gate on name-part overlap instead.
588
+ has_source = f.source_url and f.source_url.startswith("http")
589
+ if has_source:
590
+ if profile.target_type == "person" and profile.name_part_tokens and len(profile.name_part_tokens) >= 2:
591
+ # Surname check: count how many name parts have token overlap
592
+ combined_text = f"{f.title or ''} {f.description or ''}"
593
+ text_tokens = _tokenize(combined_text)
594
+ parts_matched = sum(
595
+ 1 for pt in profile.name_part_tokens if pt & text_tokens
596
+ )
597
+ # If ≤1 name part matches out of 2+ → it's a different person
598
+ # (unless ALL parts matched, which means full name overlap)
599
+ if parts_matched <= 1:
600
+ dropped.append(f)
601
+ continue
602
+ kept.append(f)
603
+ continue
604
+
605
+ # Rule 2: High confidence → always keep (don't risk false positives)
606
+ if confidence >= 0.65:
607
+ kept.append(f)
608
+ continue
609
+
610
+ # Rule 3: Compute relevance
611
+ combined_text = f"{f.title or ''} {f.description or ''}"
612
+ score = relevance_score(profile, combined_text)
613
+
614
+ # Rule 4: Only drop if ZERO relevance + low confidence
615
+ if score <= min_score:
616
+ dropped.append(f)
617
+ continue
618
+
619
+ kept.append(f)
620
+
621
+ return (kept, dropped)