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,245 @@
|
|
|
1
|
+
"""LLM Verification Layer — second-pass validation to filter false positives.
|
|
2
|
+
|
|
3
|
+
After Phase 6 synthesis, each finding is re-examined by the LLM with a strict
|
|
4
|
+
verification prompt. The LLM checks:
|
|
5
|
+
1. Does the source URL actually support the finding?
|
|
6
|
+
2. Is the tier (CONFIRMED/PROBABLE/POSSIBLE) appropriate?
|
|
7
|
+
3. Is anything hallucinated (fake entities, fabricated URLs, impossible claims)?
|
|
8
|
+
4. Should the finding be downgraded or dropped?
|
|
9
|
+
|
|
10
|
+
Output: adjusted tier + verification_notes for each finding.
|
|
11
|
+
|
|
12
|
+
Thresholds:
|
|
13
|
+
- Drop findings where verification_confidence < 30
|
|
14
|
+
- Downgrade findings where LLM flags overconfidence
|
|
15
|
+
- Keep findings that pass verification unchanged
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
import logging
|
|
22
|
+
import re
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger("watson.verify")
|
|
26
|
+
|
|
27
|
+
# ── Verification prompt ───────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
_VERIFY_SYSTEM = """You are a strict OSINT quality-assurance auditor. Your job is to validate
|
|
30
|
+
intelligence findings and catch false positives, hallucinations, and overconfident claims.
|
|
31
|
+
|
|
32
|
+
For each finding, check:
|
|
33
|
+
1. Does the source URL actually exist and support the claim? If the URL looks fabricated
|
|
34
|
+
(random-looking domain, 404 patterns, impossible paths), flag it.
|
|
35
|
+
2. Is the confidence tier appropriate? CONFIRMED needs multi-source corroboration.
|
|
36
|
+
PROBABLE needs a credible source. POSSIBLE is for weak signals.
|
|
37
|
+
3. Are there any hallucinated elements? Invented person names, fake organizations,
|
|
38
|
+
impossible technical details, fabricated numbers/SAR IDs.
|
|
39
|
+
4. Is the finding logically coherent? Does the description actually match the title?
|
|
40
|
+
|
|
41
|
+
Respond with ONLY a JSON object. No markdown, no explanation outside the JSON."""
|
|
42
|
+
|
|
43
|
+
_VERIFY_PROMPT = """Review these OSINT findings for quality. Return a JSON dict with:
|
|
44
|
+
- "verifications": list of { "finding_id": str, "pass": bool, "adjusted_tier": str,
|
|
45
|
+
"verification_confidence": int (0-100), "issues": [str], "notes": str }
|
|
46
|
+
|
|
47
|
+
Findings to verify:
|
|
48
|
+
{findings_json}
|
|
49
|
+
|
|
50
|
+
Rules:
|
|
51
|
+
- CONFIRMED tier requires: real source URL + specific verifiable claim + no hallucination risk
|
|
52
|
+
- PROBABLE tier requires: credible source URL + plausible claim
|
|
53
|
+
- POSSIBLE tier: weak signal, correlation, or unverified mention
|
|
54
|
+
- UNLIKELY/UNVERIFIED: should be dropped (pass=false)
|
|
55
|
+
- If source URL doesn't look real, flag and downgrade
|
|
56
|
+
- If finding contains invented names/numbers, drop it
|
|
57
|
+
- If description is generic/vague, downgrade to POSSIBLE
|
|
58
|
+
- If finding is about a different entity than the target, flag it
|
|
59
|
+
|
|
60
|
+
Return ONLY valid JSON."""
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# ── Verification engine ───────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
class FindingVerifier:
|
|
66
|
+
"""Second-pass LLM verification for OSINT findings."""
|
|
67
|
+
|
|
68
|
+
def __init__(self, call_llm=None):
|
|
69
|
+
"""call_llm: async function (prompt, timeout, max_tokens) → str | None."""
|
|
70
|
+
self._call_llm = call_llm
|
|
71
|
+
|
|
72
|
+
async def verify(
|
|
73
|
+
self,
|
|
74
|
+
findings: list[Any],
|
|
75
|
+
query: str = "",
|
|
76
|
+
batch_size: int = 15,
|
|
77
|
+
) -> list[dict]:
|
|
78
|
+
"""Verify findings in batches. Returns list of verification results."""
|
|
79
|
+
if not findings or not self._call_llm:
|
|
80
|
+
return []
|
|
81
|
+
|
|
82
|
+
results: list[dict] = []
|
|
83
|
+
|
|
84
|
+
# Batch findings to avoid token overflow
|
|
85
|
+
for i in range(0, len(findings), batch_size):
|
|
86
|
+
batch = findings[i : i + batch_size]
|
|
87
|
+
batch_results = await self._verify_batch(batch, query)
|
|
88
|
+
results.extend(batch_results)
|
|
89
|
+
|
|
90
|
+
return results
|
|
91
|
+
|
|
92
|
+
async def _verify_batch(self, findings: list[Any], query: str) -> list[dict]:
|
|
93
|
+
"""Verify a batch of findings."""
|
|
94
|
+
if not self._call_llm:
|
|
95
|
+
return []
|
|
96
|
+
# Serialize findings to compact JSON
|
|
97
|
+
findings_data = []
|
|
98
|
+
for f in findings:
|
|
99
|
+
fid = getattr(f, "id", str(hash(f)))
|
|
100
|
+
findings_data.append({
|
|
101
|
+
"id": fid,
|
|
102
|
+
"title": getattr(f, "title", "")[:200],
|
|
103
|
+
"description": getattr(f, "description", "")[:300],
|
|
104
|
+
"tier": getattr(f, "tier", "UNVERIFIED"),
|
|
105
|
+
"source_url": getattr(f, "source_url", "")[:200],
|
|
106
|
+
"source_type": getattr(f, "source_type", "osint"),
|
|
107
|
+
"confidence": getattr(f, "confidence", 0.5),
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
prompt = _VERIFY_PROMPT.format(
|
|
111
|
+
findings_json=json.dumps(findings_data, indent=2)
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
try:
|
|
115
|
+
raw = await self._call_llm(
|
|
116
|
+
prompt,
|
|
117
|
+
timeout=120,
|
|
118
|
+
max_tokens=4096,
|
|
119
|
+
system=_VERIFY_SYSTEM,
|
|
120
|
+
)
|
|
121
|
+
if not raw or not raw.strip():
|
|
122
|
+
logger.warning("verify: LLM returned empty — skipping verification")
|
|
123
|
+
return [{"finding_id": fd["id"], "pass": True, "verification_confidence": 50,
|
|
124
|
+
"adjusted_tier": fd["tier"], "issues": [], "notes": "Verification skipped (LLM unavailable)"}
|
|
125
|
+
for fd in findings_data]
|
|
126
|
+
|
|
127
|
+
parsed = self._parse_response(raw)
|
|
128
|
+
return self._merge_results(findings_data, parsed)
|
|
129
|
+
|
|
130
|
+
except Exception as e:
|
|
131
|
+
logger.warning("verify: batch failed: %s", e)
|
|
132
|
+
return [{"finding_id": fd["id"], "pass": True, "verification_confidence": 50,
|
|
133
|
+
"adjusted_tier": fd["tier"], "issues": [],
|
|
134
|
+
"notes": f"Verification error: {e}"}
|
|
135
|
+
for fd in findings_data]
|
|
136
|
+
|
|
137
|
+
def _parse_response(self, raw: str) -> dict:
|
|
138
|
+
"""Parse LLM verification response."""
|
|
139
|
+
# Strip markdown fences
|
|
140
|
+
text = raw.strip()
|
|
141
|
+
if text.startswith("```"):
|
|
142
|
+
text = re.sub(r"^```(?:json)?\s*", "", text)
|
|
143
|
+
text = re.sub(r"\s*```$", "", text)
|
|
144
|
+
|
|
145
|
+
# Try JSON parse
|
|
146
|
+
try:
|
|
147
|
+
return json.loads(text)
|
|
148
|
+
except json.JSONDecodeError:
|
|
149
|
+
# Try to extract JSON object
|
|
150
|
+
m = re.search(r"\{.*\}", text, re.DOTALL)
|
|
151
|
+
if m:
|
|
152
|
+
try:
|
|
153
|
+
return json.loads(m.group(0))
|
|
154
|
+
except json.JSONDecodeError:
|
|
155
|
+
pass
|
|
156
|
+
|
|
157
|
+
logger.warning("verify: could not parse response: %s", raw[:200])
|
|
158
|
+
return {}
|
|
159
|
+
|
|
160
|
+
def _merge_results(self, findings_data: list[dict], parsed: dict) -> list[dict]:
|
|
161
|
+
"""Merge parsed verifications with original finding data."""
|
|
162
|
+
verifications = parsed.get("verifications", [])
|
|
163
|
+
if not verifications:
|
|
164
|
+
# No structured verifications — pass everything
|
|
165
|
+
return [{"finding_id": fd["id"], "pass": True, "verification_confidence": 50,
|
|
166
|
+
"adjusted_tier": fd["tier"], "issues": [], "notes": "No verification data"}
|
|
167
|
+
for fd in findings_data]
|
|
168
|
+
|
|
169
|
+
# Build lookup
|
|
170
|
+
vmap: dict[str, dict] = {v.get("finding_id", ""): v for v in verifications}
|
|
171
|
+
|
|
172
|
+
results = []
|
|
173
|
+
for fd in findings_data:
|
|
174
|
+
fid = fd["id"]
|
|
175
|
+
v = vmap.get(fid, {})
|
|
176
|
+
results.append({
|
|
177
|
+
"finding_id": fid,
|
|
178
|
+
"pass": v.get("pass", True),
|
|
179
|
+
"verification_confidence": v.get("verification_confidence", 50),
|
|
180
|
+
"adjusted_tier": v.get("adjusted_tier", fd["tier"]),
|
|
181
|
+
"issues": v.get("issues", []),
|
|
182
|
+
"notes": v.get("notes", ""),
|
|
183
|
+
})
|
|
184
|
+
return results
|
|
185
|
+
|
|
186
|
+
# ── Apply verification results to findings ────────────────
|
|
187
|
+
|
|
188
|
+
@staticmethod
|
|
189
|
+
def apply(finding: Any, verification: dict) -> tuple[Any, bool]:
|
|
190
|
+
"""Apply verification to a finding.
|
|
191
|
+
|
|
192
|
+
Returns (possibly_modified_finding, was_dropped).
|
|
193
|
+
"""
|
|
194
|
+
if not verification:
|
|
195
|
+
return finding, False
|
|
196
|
+
|
|
197
|
+
vconf = verification.get("verification_confidence", 50)
|
|
198
|
+
passed = verification.get("pass", True)
|
|
199
|
+
issues = verification.get("issues", [])
|
|
200
|
+
|
|
201
|
+
# Drop findings with very low verification confidence
|
|
202
|
+
if vconf < 30:
|
|
203
|
+
logger.info("verify: dropping finding %s (confidence=%d, issues=%s)",
|
|
204
|
+
getattr(finding, "id", "?"), vconf, issues)
|
|
205
|
+
return finding, True
|
|
206
|
+
|
|
207
|
+
# Downgrade tier if LLM suggests it
|
|
208
|
+
adjusted = verification.get("adjusted_tier", "")
|
|
209
|
+
if adjusted and hasattr(finding, "tier"):
|
|
210
|
+
tier_order = ["CONFIRMED", "PROBABLE", "POSSIBLE", "UNLIKELY", "UNVERIFIED"]
|
|
211
|
+
old_idx = tier_order.index(finding.tier) if finding.tier in tier_order else 4
|
|
212
|
+
new_idx = tier_order.index(adjusted) if adjusted in tier_order else old_idx
|
|
213
|
+
if new_idx > old_idx:
|
|
214
|
+
logger.info("verify: downgrading finding %s %s→%s",
|
|
215
|
+
getattr(finding, "id", "?"), finding.tier, adjusted)
|
|
216
|
+
finding.tier = adjusted
|
|
217
|
+
|
|
218
|
+
# Attach verification notes
|
|
219
|
+
notes = verification.get("notes", "")
|
|
220
|
+
if notes and hasattr(finding, "description"):
|
|
221
|
+
finding.description = f"[Verified: {notes}] {finding.description}"
|
|
222
|
+
|
|
223
|
+
if issues and hasattr(finding, "description"):
|
|
224
|
+
finding.description = f"[⚠ Issues: {'; '.join(issues[:3])}] {finding.description}"
|
|
225
|
+
|
|
226
|
+
return finding, False
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
# ── Factory ───────────────────────────────────────────────────
|
|
230
|
+
|
|
231
|
+
def create_verifier(provider: str | None = None) -> FindingVerifier | None:
|
|
232
|
+
"""Create a verifier using the configured LLM provider.
|
|
233
|
+
|
|
234
|
+
Returns None if no LLM is available.
|
|
235
|
+
"""
|
|
236
|
+
try:
|
|
237
|
+
from watson.orchestration.llm_config import call_llm
|
|
238
|
+
|
|
239
|
+
async def _call(prompt, timeout=120, max_tokens=2000, system=""):
|
|
240
|
+
return await call_llm(prompt, timeout=timeout, max_tokens=max_tokens, system=system)
|
|
241
|
+
|
|
242
|
+
return FindingVerifier(call_llm=_call)
|
|
243
|
+
except ImportError:
|
|
244
|
+
logger.warning("verify: could not import llm_config — verification disabled")
|
|
245
|
+
return None
|
watson/__init__.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Agent adapters for Watson — pluggable backends."""
|
|
2
|
+
|
|
3
|
+
from .base import AgentAdapter, BrowseResult, InvestigationResult, SearchResult, TerminalResult, VisionResult
|
|
4
|
+
from .hermes import HermesCLIAdapter
|
|
5
|
+
from .hermes_mcp import HermesMCPAdapter
|
|
6
|
+
|
|
7
|
+
# Legacy alias — kept for backward compatibility
|
|
8
|
+
HermesAdapter = HermesCLIAdapter
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"AgentAdapter",
|
|
12
|
+
"HermesAdapter", # Legacy alias
|
|
13
|
+
"HermesCLIAdapter", # CLI subprocess backend
|
|
14
|
+
"HermesMCPAdapter", # MCP protocol backend
|
|
15
|
+
"SearchResult",
|
|
16
|
+
"BrowseResult",
|
|
17
|
+
"VisionResult",
|
|
18
|
+
"TerminalResult",
|
|
19
|
+
"InvestigationResult",
|
|
20
|
+
]
|
watson/agents/base.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Agent adapter — abstract interface for pluggable agent backends.
|
|
3
|
+
Watson is agent-agnostic: Hermes, OpenClaw, OpenHuman, or direct LLM.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from abc import ABC, abstractmethod
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from typing import Optional
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class SearchResult:
|
|
15
|
+
title: str
|
|
16
|
+
url: str
|
|
17
|
+
snippet: str = ""
|
|
18
|
+
source: str = ""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class BrowseResult:
|
|
23
|
+
url: str
|
|
24
|
+
content: str
|
|
25
|
+
title: str = ""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class VisionResult:
|
|
30
|
+
description: str
|
|
31
|
+
objects: list[str] = field(default_factory=list)
|
|
32
|
+
text: str = ""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class TerminalResult:
|
|
37
|
+
output: str
|
|
38
|
+
exit_code: int = 0
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class InvestigationResult:
|
|
43
|
+
"""Structured result from an investigation angle."""
|
|
44
|
+
angle: str
|
|
45
|
+
findings: list[dict] = field(default_factory=list)
|
|
46
|
+
sources: list[str] = field(default_factory=list)
|
|
47
|
+
confidence: float = 0.0
|
|
48
|
+
raw: str = ""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class AgentAdapter(ABC):
|
|
52
|
+
"""Pluggable agent backend for Watson.
|
|
53
|
+
|
|
54
|
+
Watson's engine calls these abstract methods. Each adapter
|
|
55
|
+
implements them for its specific agent. Watson's methodology,
|
|
56
|
+
dispatch logic, graph, and reporting are independent of this layer.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
name: str = "base"
|
|
60
|
+
description: str = ""
|
|
61
|
+
|
|
62
|
+
@abstractmethod
|
|
63
|
+
async def search(self, query: str, num_results: int = 10) -> list[SearchResult]:
|
|
64
|
+
"""Web search."""
|
|
65
|
+
...
|
|
66
|
+
|
|
67
|
+
@abstractmethod
|
|
68
|
+
async def browse(self, url: str) -> BrowseResult:
|
|
69
|
+
"""Navigate to and extract content from a URL."""
|
|
70
|
+
...
|
|
71
|
+
|
|
72
|
+
@abstractmethod
|
|
73
|
+
async def vision(self, image_path: str, question: str = "Describe this image in detail.") -> VisionResult:
|
|
74
|
+
"""Analyze an image."""
|
|
75
|
+
...
|
|
76
|
+
|
|
77
|
+
@abstractmethod
|
|
78
|
+
async def terminal(self, command: str, timeout: int = 30) -> TerminalResult:
|
|
79
|
+
"""Execute a shell command."""
|
|
80
|
+
...
|
|
81
|
+
|
|
82
|
+
async def investigate_angle(self, angle: str, query: str) -> InvestigationResult:
|
|
83
|
+
"""Run a single investigation angle. Default: search + browse top results.
|
|
84
|
+
|
|
85
|
+
Adapters can override this for richer investigation (multi-tool dispatch).
|
|
86
|
+
"""
|
|
87
|
+
results = await self.search(query, num_results=5)
|
|
88
|
+
sources = [r.url for r in results if r.url]
|
|
89
|
+
findings = [
|
|
90
|
+
{"title": r.title, "url": r.url, "snippet": r.snippet}
|
|
91
|
+
for r in results
|
|
92
|
+
]
|
|
93
|
+
|
|
94
|
+
return InvestigationResult(
|
|
95
|
+
angle=angle,
|
|
96
|
+
findings=findings,
|
|
97
|
+
sources=sources,
|
|
98
|
+
confidence=0.5,
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
async def health_check(self) -> bool:
|
|
102
|
+
"""Check if the agent backend is reachable."""
|
|
103
|
+
return True
|
watson/agents/direct.py
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Direct LLM adapter — any OpenAI-compatible API.
|
|
3
|
+
Uses DuckDuckGo via duckduckgo_search library for real web search.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import re
|
|
12
|
+
|
|
13
|
+
import aiohttp
|
|
14
|
+
|
|
15
|
+
from .base import (
|
|
16
|
+
AgentAdapter,
|
|
17
|
+
BrowseResult,
|
|
18
|
+
InvestigationResult,
|
|
19
|
+
SearchResult,
|
|
20
|
+
TerminalResult,
|
|
21
|
+
VisionResult,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
DEFAULT_API_BASE = "https://api.openai.com/v1"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class DirectAdapter(AgentAdapter):
|
|
28
|
+
"""LLM-agnostic adapter — any OpenAI-compatible API + DuckDuckGo search."""
|
|
29
|
+
|
|
30
|
+
name = "direct"
|
|
31
|
+
description = "Any OpenAI-compatible API + DuckDuckGo search"
|
|
32
|
+
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
api_key: str = "",
|
|
36
|
+
model: str = "gpt-4o",
|
|
37
|
+
api_base: str | None = None,
|
|
38
|
+
):
|
|
39
|
+
self.api_key = api_key or os.environ.get("WATSON_API_KEY", "")
|
|
40
|
+
self.model = model or os.environ.get("WATSON_MODEL", "gpt-4o")
|
|
41
|
+
self.api_base = (
|
|
42
|
+
api_base
|
|
43
|
+
or os.environ.get("WATSON_API_BASE")
|
|
44
|
+
or DEFAULT_API_BASE
|
|
45
|
+
)
|
|
46
|
+
self._ddgs = None
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def ddgs(self):
|
|
50
|
+
"""Lazy-load DuckDuckGo search client."""
|
|
51
|
+
if self._ddgs is None:
|
|
52
|
+
from ddgs import DDGS
|
|
53
|
+
self._ddgs = DDGS()
|
|
54
|
+
return self._ddgs
|
|
55
|
+
|
|
56
|
+
async def _call_llm(
|
|
57
|
+
self, prompt: str, system: str = "", max_tokens: int = 4000
|
|
58
|
+
) -> str:
|
|
59
|
+
"""Call the LLM API."""
|
|
60
|
+
messages = []
|
|
61
|
+
if system:
|
|
62
|
+
messages.append({"role": "system", "content": system})
|
|
63
|
+
messages.append({"role": "user", "content": prompt})
|
|
64
|
+
|
|
65
|
+
async with aiohttp.ClientSession() as session:
|
|
66
|
+
async with session.post(
|
|
67
|
+
f"{self.api_base}/chat/completions",
|
|
68
|
+
json={
|
|
69
|
+
"model": self.model,
|
|
70
|
+
"messages": messages,
|
|
71
|
+
"temperature": 0.4,
|
|
72
|
+
"max_tokens": max_tokens,
|
|
73
|
+
},
|
|
74
|
+
headers={
|
|
75
|
+
"Content-Type": "application/json",
|
|
76
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
77
|
+
},
|
|
78
|
+
timeout=aiohttp.ClientTimeout(total=60),
|
|
79
|
+
) as resp:
|
|
80
|
+
if resp.status != 200:
|
|
81
|
+
text = await resp.text()
|
|
82
|
+
raise RuntimeError(f"LLM API error ({resp.status}): {text}")
|
|
83
|
+
data = await resp.json()
|
|
84
|
+
return data["choices"][0]["message"]["content"]
|
|
85
|
+
|
|
86
|
+
async def _ddg_search(self, query: str, num_results: int = 10) -> list[SearchResult]:
|
|
87
|
+
"""Real web search via DuckDuckGo."""
|
|
88
|
+
results: list[SearchResult] = []
|
|
89
|
+
try:
|
|
90
|
+
loop = asyncio.get_running_loop()
|
|
91
|
+
raw_results = await loop.run_in_executor(
|
|
92
|
+
None,
|
|
93
|
+
lambda: list(self.ddgs.text(query, max_results=num_results))
|
|
94
|
+
)
|
|
95
|
+
for r in raw_results:
|
|
96
|
+
results.append(SearchResult(
|
|
97
|
+
title=r.get("title", ""),
|
|
98
|
+
url=r.get("href", r.get("url", "")),
|
|
99
|
+
snippet=r.get("body", r.get("snippet", "")),
|
|
100
|
+
source="duckduckgo",
|
|
101
|
+
))
|
|
102
|
+
except Exception:
|
|
103
|
+
pass
|
|
104
|
+
return results
|
|
105
|
+
|
|
106
|
+
async def search(self, query: str, num_results: int = 10) -> list[SearchResult]:
|
|
107
|
+
"""Web search via DuckDuckGo only. No LLM fallback — only real data."""
|
|
108
|
+
return await self._ddg_search(query, num_results)
|
|
109
|
+
|
|
110
|
+
async def browse(self, url: str) -> BrowseResult:
|
|
111
|
+
"""Fetch URL content via HTTP."""
|
|
112
|
+
try:
|
|
113
|
+
async with aiohttp.ClientSession() as session:
|
|
114
|
+
async with session.get(
|
|
115
|
+
url,
|
|
116
|
+
headers={"User-Agent": "Mozilla/5.0 (compatible; Watson-OSINT/1.0)"},
|
|
117
|
+
timeout=aiohttp.ClientTimeout(total=15),
|
|
118
|
+
) as resp:
|
|
119
|
+
text = await resp.text()
|
|
120
|
+
# Strip HTML tags for plain text
|
|
121
|
+
clean = re.sub(r"<[^>]+>", " ", text)
|
|
122
|
+
clean = re.sub(r"\s+", " ", clean)
|
|
123
|
+
return BrowseResult(
|
|
124
|
+
url=url,
|
|
125
|
+
content=clean[:5000],
|
|
126
|
+
title=url,
|
|
127
|
+
)
|
|
128
|
+
except Exception:
|
|
129
|
+
return BrowseResult(url=url, content=f"[Could not fetch {url}]", title=url)
|
|
130
|
+
|
|
131
|
+
async def vision(self, image_path: str, question: str = "Describe this image in detail.") -> VisionResult:
|
|
132
|
+
"""LLM cannot analyze images."""
|
|
133
|
+
return VisionResult(
|
|
134
|
+
description=f"[Direct adapter cannot analyze images. Use Hermes for vision.]",
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
async def terminal(self, command: str, timeout: int = 30) -> TerminalResult:
|
|
138
|
+
"""Execute commands via subprocess (available in Direct mode too)."""
|
|
139
|
+
try:
|
|
140
|
+
proc = await asyncio.create_subprocess_shell(
|
|
141
|
+
command,
|
|
142
|
+
stdout=asyncio.subprocess.PIPE,
|
|
143
|
+
stderr=asyncio.subprocess.PIPE,
|
|
144
|
+
)
|
|
145
|
+
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout)
|
|
146
|
+
output = stdout.decode("utf-8", errors="replace")
|
|
147
|
+
if stderr:
|
|
148
|
+
output += "\n" + stderr.decode("utf-8", errors="replace")
|
|
149
|
+
return TerminalResult(
|
|
150
|
+
output=output.strip() or "(no output)",
|
|
151
|
+
exit_code=proc.returncode or 0,
|
|
152
|
+
)
|
|
153
|
+
except asyncio.TimeoutError:
|
|
154
|
+
return TerminalResult(output="Command timed out", exit_code=1)
|
|
155
|
+
except Exception as e:
|
|
156
|
+
return TerminalResult(output=str(e), exit_code=1)
|
|
157
|
+
|
|
158
|
+
async def investigate_angle(self, angle: str, query: str) -> InvestigationResult:
|
|
159
|
+
"""Analyze real search results for investigation findings.
|
|
160
|
+
|
|
161
|
+
Always performs DDG web search (no API key needed).
|
|
162
|
+
With LLM API key: enriches results with AI analysis.
|
|
163
|
+
Without: returns raw DDG results directly — never returns empty.
|
|
164
|
+
"""
|
|
165
|
+
# Get real search results (no API key needed)
|
|
166
|
+
search_results = await self._ddg_search(query, num_results=5)
|
|
167
|
+
if not search_results:
|
|
168
|
+
return InvestigationResult(
|
|
169
|
+
angle=angle, confidence=0.0,
|
|
170
|
+
raw="No search results available for this angle."
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
# Without LLM, return raw DDG results as findings
|
|
174
|
+
if not self.api_key:
|
|
175
|
+
raw = "\n".join(
|
|
176
|
+
f"• {r.title}\n {r.snippet[:200]}\n {r.url}"
|
|
177
|
+
for r in search_results
|
|
178
|
+
)
|
|
179
|
+
return InvestigationResult(
|
|
180
|
+
angle=angle,
|
|
181
|
+
raw=raw,
|
|
182
|
+
findings=[
|
|
183
|
+
{"title": r.title, "snippet": r.snippet, "url": r.url}
|
|
184
|
+
for r in search_results
|
|
185
|
+
],
|
|
186
|
+
sources=[r.url for r in search_results if r.url],
|
|
187
|
+
confidence=0.4,
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
# With LLM: enrich with AI analysis
|
|
191
|
+
context = "\n".join(
|
|
192
|
+
f"- {r.title}: {r.snippet} ({r.url})"
|
|
193
|
+
for r in search_results
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
try:
|
|
197
|
+
response = await self._call_llm(
|
|
198
|
+
prompt=(
|
|
199
|
+
f"ANALYZE these search results for an OSINT investigation.\n\n"
|
|
200
|
+
f"Angle: {angle}\n"
|
|
201
|
+
f"Query: {query}\n\n"
|
|
202
|
+
f"SEARCH RESULTS:\n{context}\n\n"
|
|
203
|
+
f"Extract the key facts from these results. Return:\n"
|
|
204
|
+
f"1. A 1-sentence summary of what was found\n"
|
|
205
|
+
f"2. 3-5 specific findings, each with the source URL\n"
|
|
206
|
+
f"3. Names, dates, and key facts mentioned\n"
|
|
207
|
+
f"4. What's missing or needs deeper investigation\n\n"
|
|
208
|
+
f"Be concise. Use ONLY information from the search results above."
|
|
209
|
+
),
|
|
210
|
+
system=(
|
|
211
|
+
"You analyze search results for OSINT investigations. "
|
|
212
|
+
"Extract facts from provided results only. Do not invent or plan — analyze."
|
|
213
|
+
),
|
|
214
|
+
max_tokens=2000,
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
urls = [r.url for r in search_results if r.url]
|
|
218
|
+
return InvestigationResult(
|
|
219
|
+
angle=angle,
|
|
220
|
+
raw=response,
|
|
221
|
+
sources=urls,
|
|
222
|
+
confidence=0.5 if len(response) > 200 else 0.3,
|
|
223
|
+
)
|
|
224
|
+
except Exception as e:
|
|
225
|
+
return InvestigationResult(angle=angle, confidence=0.0, raw=str(e))
|