Livrare LOT 1 - Didi

This commit is contained in:
Dezvoltari Evotech 2026-06-25 14:13:25 -07:00
commit 5380c3fc63
990 changed files with 133308 additions and 0 deletions

View file

@ -0,0 +1,15 @@
.venv/
__pycache__/
*.pyc
.pytest_cache/
.ruff_cache/
.mypy_cache/
tests/
benchmark/
deploy/
.env
.env.*
*.md
!README.md
.git/
.gitignore

View file

@ -0,0 +1,146 @@
# =============================================================================
# Web Module — Environment Configuration
# =============================================================================
# Copy this file to .env and fill in the values marked with CHANGE_ME.
# All variables use the WEB_ prefix.
# =============================================================================
# -----------------------------------------------------------------------------
# REQUIRED — Search (SearXNG metasearch)
# -----------------------------------------------------------------------------
# SearXNG base URL (internal Docker hostname or LAN IP).
# If you already have a SearXNG running elsewhere, point to it here.
WEB_SEARXNG_BASE_URL=http://didiAI-web-searxng:8080
# -----------------------------------------------------------------------------
# REQUIRED — External URL for OpenAPI spec (Swagger / catalog)
# -----------------------------------------------------------------------------
# The publicly reachable URL where this web-api is exposed.
WEB_EXTERNAL_URL=http://localhost:51100
# -----------------------------------------------------------------------------
# REQUIRED — Remote LLM inference (runs on the GPU machine)
# -----------------------------------------------------------------------------
# URL of your llm-inference router (Qwen3.5-35B vLLM or similar).
# Used by the free tier for context detection + evidence extraction.
WEB_LLM_BASE_URL=CHANGE_ME_http://your-gpu-host:14011
# Text model name served by the LLM endpoint
WEB_TEXT_MODEL=qwen3.5
# Vision model — usually same endpoint + model (Qwen3.5 is multimodal)
WEB_VISION_BASE_URL=CHANGE_ME_http://your-gpu-host:14011
WEB_VISION_MODEL=qwen3.5
# Optional API key if your LLM endpoint enforces auth
# WEB_LLM_API_KEY=
# -----------------------------------------------------------------------------
# OPTIONAL — Paid search providers (used by premium tier)
# -----------------------------------------------------------------------------
# Leave blank if you don't have a subscription. If all are blank,
# premium tier returns no search results.
# WEB_SERPAPI_API_KEY=CHANGE_ME_or_leave_blank
# WEB_TAVILY_API_KEY=CHANGE_ME_or_leave_blank
# WEB_BRAVE_API_KEY=CHANGE_ME_or_leave_blank
# WEB_LINKUP_API_KEY=CHANGE_ME_or_leave_blank
# WEB_EXA_API_KEY=CHANGE_ME_or_leave_blank
# -----------------------------------------------------------------------------
# OPTIONAL — OpenRouter (premium tier LLM)
# -----------------------------------------------------------------------------
# When set, premium tier routes all context + evidence LLM calls to
# OpenRouter instead of your local Qwen. Model is runtime-configurable
# via the dashboard.
# WEB_OPENROUTER_API_KEY=CHANGE_ME_sk-or-v1-...
WEB_OPENROUTER_MODEL=google/gemini-3.1-flash-lite-preview
# -----------------------------------------------------------------------------
# OPTIONAL — Dashboard event sink
# -----------------------------------------------------------------------------
# When set, web-api forwards every request event (tier, duration, cost,
# results) to the dashboard and polls /api/config every 30s for runtime
# overrides. Leave blank to run without the dashboard.
WEB_DASHBOARD_URL=http://didiAI-dashboard:51300
# WEB_DASHBOARD_TOKEN= # optional if dashboard ingest requires auth
# -----------------------------------------------------------------------------
# OPTIONAL — External LLM fallback (used when local is down)
# -----------------------------------------------------------------------------
# WEB_OPENAI_API_KEY=
# WEB_ANTHROPIC_API_KEY=
# -----------------------------------------------------------------------------
# OPTIONAL — Server settings
# -----------------------------------------------------------------------------
# WEB_HOST=0.0.0.0
# WEB_PORT=51100
# -----------------------------------------------------------------------------
# OPTIONAL — Search defaults
# -----------------------------------------------------------------------------
# WEB_SEARCH_DEFAULT_MAX_RESULTS=10
# WEB_SEARCH_DEFAULT_LANGUAGE=en
# WEB_SEARCH_DEFAULT_COUNTRY=US
# -----------------------------------------------------------------------------
# OPTIONAL — Fetch & Browse tuning
# -----------------------------------------------------------------------------
# WEB_FETCH_TIMEOUT=30.0
# WEB_FETCH_MIN_TEXT_LENGTH=200
# WEB_BROWSE_TIMEOUT=30000
# WEB_BROWSE_VIEWPORT_WIDTH=1280
# WEB_BROWSE_VIEWPORT_HEIGHT=720
# WEB_BROWSE_BLOCK_RESOURCES=true
# WEB_BROWSE_EXTRA_WAIT_MS=500
# -----------------------------------------------------------------------------
# OPTIONAL — Vision / Evidence tuning
# -----------------------------------------------------------------------------
# WEB_VISION_MAX_TOKENS=2000
# WEB_VISION_SCREENSHOT_QUALITY=80
# WEB_EVIDENCE_MAX_ITEMS=30
# WEB_EVIDENCE_MAX_SNIPPET_LENGTH=500
# WEB_EVIDENCE_DEDUPE_THRESHOLD=0.9
# -----------------------------------------------------------------------------
# OPTIONAL — Rate limiting & concurrency
# -----------------------------------------------------------------------------
# WEB_RATE_LIMIT_RPS=10.0
# WEB_RATE_LIMIT_BURST=20
# WEB_MAX_CONCURRENT_REQUESTS=10
# -----------------------------------------------------------------------------
# OPTIONAL — Timeouts & retries
# -----------------------------------------------------------------------------
# WEB_REQUEST_TIMEOUT=30.0
# WEB_CONNECT_TIMEOUT=10.0
# WEB_MAX_RETRIES=3
# -----------------------------------------------------------------------------
# OPTIONAL — Logging
# -----------------------------------------------------------------------------
# WEB_LOG_LEVEL=INFO
# WEB_LOG_JSON=false
# -----------------------------------------------------------------------------
# OPTIONAL — Authentication (comma-separated Bearer tokens)
# -----------------------------------------------------------------------------
# If set, all /v1/* endpoints require Authorization: Bearer <token>.
# Leave blank to run open (fine for VPN-internal deployments).
# WEB_API_TOKENS=token1,token2

View file

@ -0,0 +1,478 @@
# Web API Documentation
## Base URL
```
{BASE_URL}
```
- **Local development:** `http://localhost:51100`
- **Docker (internal):** `http://web-api:51100`
- **Production:** Use your configured hostname
## Authentication
Authentication is optional. If `WEB_API_TOKENS` is set, Bearer token authentication is required.
```
Authorization: Bearer <token>
```
If authentication is disabled (default), no Authorization header is needed.
## Endpoints
### Gather (Main Endpoint)
Execute the full evidence-gathering pipeline: search → fetch → evidence.
```
POST /v1/gather
```
#### Request Body
```json
{
"claim": "The claim to verify",
"search_queries": ["optional custom queries"],
"max_search_results": 20,
"site_allowlist": ["reuters.com"],
"site_blocklist": ["spam-site.com"],
"fetch_method": "auto",
"auto_fallback": true,
"extract_snippets": true,
"max_evidence_items": 15,
"dedupe": true,
"timeout_seconds": 60.0
}
```
| Field | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `claim` | string | Yes | - | Claim to gather evidence for (10-1000 chars) |
| `search_queries` | array[string] | No | null | Custom search queries (auto-generated if not provided) |
| `max_search_results` | integer | No | 20 | Max search results (5-50) |
| `site_allowlist` | array[string] | No | null | Only search these domains |
| `site_blocklist` | array[string] | No | null | Exclude these domains |
| `fetch_method` | string | No | "auto" | "auto", "http", "browse", "vision" |
| `auto_fallback` | boolean | No | true | Escalate on fetch failure |
| `extract_snippets` | boolean | No | true | Use LLM for snippet extraction |
| `max_evidence_items` | integer | No | 15 | Max items in final pack (1-50) |
| `dedupe` | boolean | No | true | Deduplicate evidence |
| `parallel_fetches` | integer | No | 5 | Concurrent fetch operations (1-10) |
| `timeout_seconds` | number | No | 60.0 | Total pipeline timeout (10-300) |
#### Response
```json
{
"request_id": "uuid",
"claim": "The claim",
"evidence": [
{
"url": "https://example.com/article",
"title": "Article Title",
"publisher": "example.com",
"snippet": "Relevant excerpt...",
"relevance_score": 0.8,
"credibility_score": null,
"published_at": "2024-01-15",
"retrieved_at": "2025-01-20T10:00:00Z",
"full_text_hash": "sha256...",
"provenance": {
"extraction_method": "http",
"fallback_chain": []
}
}
],
"evidence_stats": {
"input_items": 10,
"after_dedup": 8,
"output_items": 8,
"duplicates_removed": 2,
"tokens_used": 1500
},
"stages": [
{"stage": "search", "success": true, "items_processed": 10, "items_failed": 0, "duration_ms": 500.0},
{"stage": "fetch", "success": true, "items_processed": 8, "items_failed": 2, "duration_ms": 5000.0},
{"stage": "evidence", "success": true, "items_processed": 8, "items_failed": 0, "duration_ms": 3000.0}
],
"total_urls_found": 10,
"total_pages_fetched": 8,
"total_evidence_items": 8,
"execution_time_ms": 8500.0
}
```
#### Example
```bash
curl -X POST http://localhost:51100/v1/gather \
-H "Content-Type: application/json" \
-d '{
"claim": "Romania had the highest economic growth in the EU in 2024",
"max_search_results": 5,
"extract_snippets": true,
"max_evidence_items": 5
}'
```
---
### Search
Execute web search queries.
```
POST /v1/search
```
#### Request Body
```json
{
"queries": ["string"],
"max_results": 10,
"site_allowlist": ["string"],
"site_blocklist": ["string"],
"language": "en",
"country": "US",
"freshness": "month",
"safe_search": "moderate"
}
```
| Field | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `queries` | array[string] | Yes | - | Search queries (1-10) |
| `max_results` | integer | No | 10 | Results per query (1-100) |
| `site_allowlist` | array[string] | No | null | Only include these domains |
| `site_blocklist` | array[string] | No | null | Exclude these domains |
| `language` | string | No | "en" | Search language (ISO 639-1) |
| `country` | string | No | "US" | Search country (ISO 3166-1) |
| `freshness` | string | No | null | Filter by age: day, week, month, year |
| `safe_search` | string | No | "moderate" | off, moderate, strict |
#### Response
```json
{
"request_id": "uuid",
"results": [
{
"query": "string",
"url": "string",
"title": "string",
"snippet": "string",
"rank": 1,
"site": "string",
"published_at": "string|null"
}
],
"total_results": 10,
"execution_time_ms": 150.5,
"queries_processed": 1
}
```
#### Example
```bash
curl -X POST http://localhost:51100/v1/search \
-H "Content-Type: application/json" \
-d '{
"queries": ["climate change effects", "renewable energy"],
"max_results": 5,
"site_allowlist": ["reuters.com", "bbc.com"],
"freshness": "month"
}'
```
---
### Image Search
Search for images using SearXNG image search.
```
POST /v1/image-search
```
#### Request Body
```json
{
"queries": ["solar eclipse"],
"max_results": 50,
"language": "en",
"country": "US",
"safe_search": "strict",
"spellcheck": true
}
```
| Field | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `queries` | array[string] | Yes | - | Search queries (1-10) |
| `max_results` | integer | No | 50 | Results per query (1-200) |
| `language` | string | No | "en" | Search language (ISO 639-1) |
| `country` | string | No | "US" | Search country (ISO 3166-1) |
| `safe_search` | string | No | "strict" | off or strict |
| `spellcheck` | boolean | No | true | Enable spellcheck |
#### Response
```json
{
"request_id": "uuid",
"results": [
{
"query": "solar eclipse",
"image_url": "https://example.com/eclipse.jpg",
"thumbnail_url": "https://example.com/eclipse_thumb.jpg",
"source_url": "https://example.com/article",
"title": "Solar Eclipse Photo",
"description": "A total solar eclipse captured in 2024",
"width": 1920,
"height": 1080,
"publisher": "example.com",
"rank": 1
}
],
"total_results": 50,
"execution_time_ms": 200.5,
"queries_processed": 1
}
```
#### Example
```bash
curl -X POST http://localhost:51100/v1/image-search \
-H "Content-Type: application/json" \
-d '{
"queries": ["solar eclipse", "northern lights"],
"max_results": 5,
"safe_search": "strict"
}'
```
---
### Fetch
Fetch and extract content from URLs with automatic fallback (HTTP → Playwright → Vision LLM).
```
POST /v1/fetch
```
#### Request Body
```json
{
"urls": ["https://example.com/article"],
"extract_text": true,
"include_html": false,
"extract_metadata": true,
"auto_fallback": true,
"method": "auto",
"timeout_seconds": 30.0,
"min_text_length": 200,
"parallel_fetches": 5
}
```
| Field | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `urls` | array[string] | Yes | - | URLs to fetch (1-50) |
| `extract_text` | boolean | No | true | Extract main text content |
| `include_html` | boolean | No | false | Include raw HTML |
| `extract_metadata` | boolean | No | true | Extract metadata |
| `auto_fallback` | boolean | No | true | Auto-escalate on failure |
| `method` | string | No | "auto" | "auto", "http", "browse", "vision" |
| `timeout_seconds` | number | No | 30.0 | Timeout per URL (1-120) |
| `min_text_length` | integer | No | 200 | Min text before fallback |
| `parallel_fetches` | integer | No | 5 | Concurrent fetches (1-20) |
#### Response
```json
{
"request_id": "uuid",
"pages": [
{
"url": "https://example.com/article",
"title": "Article Title",
"text": "Extracted text content...",
"text_hash": "sha256...",
"extraction_method": "http",
"fallback_chain": [],
"retrieved_at": "2025-01-20T10:00:00Z",
"extraction_time_ms": 500.0,
"status_code": 200,
"content_type": "text/html"
}
],
"total_fetched": 1,
"total_failed": 0,
"execution_time_ms": 600.0,
"failed_urls": []
}
```
#### Example
```bash
curl -X POST http://localhost:51100/v1/fetch \
-H "Content-Type: application/json" \
-d '{
"urls": ["https://en.wikipedia.org/wiki/Climate_change"],
"method": "auto",
"auto_fallback": true
}'
```
---
### Info
Get component information for service catalog.
```
GET /v1/info
```
#### Response
Returns complete metadata about this service including resource information, available functions, and their schemas. Used by the catalog-api for service discovery.
#### Example
```bash
curl http://localhost:51100/v1/info
```
---
### Health Check
Check service health and provider status.
```
GET /health
```
#### Response
```json
{
"status": "healthy",
"providers": [
{
"name": "searxng",
"healthy": true,
"message": null
}
]
}
```
| Status | Description |
|--------|-------------|
| `healthy` | All providers operational |
| `degraded` | Some providers unavailable |
| `unhealthy` | All providers down |
---
### Readiness Probe
Check if service is ready to accept requests.
```
GET /ready
```
#### Response
```json
{
"ready": true
}
```
---
## Error Responses
### Error Format
```json
{
"detail": {
"error": "error_code",
"message": "Human readable message",
"provider": "searxng"
}
}
```
### HTTP Status Codes
| Code | Error | Description |
|------|-------|-------------|
| 400 | `validation_error` | Invalid request body |
| 401 | `authentication_required` | Missing or invalid auth token |
| 422 | `validation_error` | Request validation failed |
| 429 | `rate_limit_exceeded` | Too many requests |
| 502 | `provider_error` | Search provider error |
| 503 | `service_unavailable` | Concurrency limit reached |
| 504 | `timeout` | Request timeout |
---
## Request Headers
| Header | Required | Description |
|--------|----------|-------------|
| `Content-Type` | Yes | Must be `application/json` |
| `Authorization` | Conditional | Bearer token (if auth enabled) |
| `X-Request-ID` | No | Custom request ID for tracing |
---
## Rate Limiting
The API implements token bucket rate limiting:
- **Default:** 10 requests/second with burst of 20
- **429 Response:** Includes `Retry-After` header
**Note:** Rate limiting is per-process. In multi-replica deployments, use external rate limiting.
---
## SDK Example (Python)
```python
import httpx
async def search(query: str, base_url: str = "http://localhost:51100"):
async with httpx.AsyncClient() as client:
response = await client.post(
f"{base_url}/v1/search",
json={
"queries": [query],
"max_results": 10,
},
)
response.raise_for_status()
return response.json()
# Usage
results = await search("climate change")
for r in results["results"]:
print(f"{r['title']}: {r['url']}")
```

View file

@ -0,0 +1,193 @@
# Web-API ↔ didi-brain integration
Web-api folosește didi-brain ca **layer de cache cu 2 fețe**:
1. **Cache read** (ambele tiers — free și premium) — înainte să cheme SearXNG
sau paid providers, web-api întreabă brain. Dacă brain are evidence
relevante cached, le servește direct; clientul primește răspuns în ~1-2s
în loc de 10-30s.
2. **Cache write** (doar tier premium, quality-gated) — după o cerere premium
care a trecut pragurile de calitate, web-api pompează evidence în brain
fire-and-forget. Brain face extraction async. Viitoarele cereri pe claim-uri
similare vor hit-ui cache-ul.
Ideea de bază: **free users primesc quality-ul plătit de premium users**, dar
doar premium plătește pentru popularea cache-ului.
---
## Flow-ul per request
```
POST /v1/gather { claim, tier }
┌─────────────────────────────────────────┐
│ 1. Cache READ (brain /v1/gather) │
│ (ambele tiers, dacă brain configured│
│ și brain_cache_read_enabled=true) │
└────────┬────────────────────────────────┘
├─ brain HIT cu quality OK
│ → returnează direct răspunsul (skip orchestrator)
└─ brain MISS sau quality slab
┌────────────────────────────────┐
│ 2. Orchestrator normal │
│ free → SearXNG + Qwen │
│ premium → Paid + OpenRouter│
└────────┬───────────────────────┘
┌────────────────────────────────┐
│ 3. Cache WRITE (doar premium) │
│ dacă quality_ok_for_cache │
│ fire-and-forget ingest │
│ (nu blochează response) │
└────────────────────────────────┘
Return to client
```
---
## Componente
### `src/web/brain/client.py``BrainClient`
Client HTTP minimal pentru brain:
- `gather(claim, max_evidence, run_nli, ...)` — cache read, returnează JSON sau None pe eroare
- `ingest(payload)` — cache write, fire-and-forget via sink de obicei
Timeout-uri separate (gather e rapid, ingest poate fi mai lent).
### `src/web/brain/sink.py``BrainIngestSink`
Queue bounded (1000 items max) processat de un single worker task.
Overflow → drop oldest. Eșecuri → log DEBUG, swallowed. Zero impact
pe latența web-api.
### `src/web/brain/quality.py` — Quality gates
- `quality_ok_for_cache(response)` — decide dacă un gather result e worth-caching
(3+ evidence, cel puțin 1 sursă credibilă dacă sunt scored, toate stages OK,
execution > 2s)
- `brain_hit_acceptable(brain_raw)` — decide dacă un brain HIT e suficient
de bun (HIT = da; PARTIAL = da doar dacă 3+ items cu relevance ≥ 0.7)
### `src/web/brain/adapter.py` — Schema conversion
- `brain_response_to_web(brain_raw, request_id)` — convertește response-ul
brain în `GatherResponse` web (datetime → ISO string, provenance dict,
`brain_meta` flatten în provenance per evidence item)
- `web_response_to_ingest_payload(response, claim)` — convertește
`GatherResponse` în `IngestRequest` pentru brain (ISO dates, safe defaults
pentru field-urile required brain-side: title/retrieved_at/score defaults)
---
## Configurare
În `deploy/.env`:
```bash
WEB_BRAIN_URL=http://didibrain-api:8090
# Feature toggles (default true dacă brain_url setat)
WEB_BRAIN_CACHE_READ_ENABLED=true
WEB_BRAIN_INGEST_ENABLED=true
# Quality thresholds pentru ingest
WEB_BRAIN_INGEST_MIN_EVIDENCE=3
WEB_BRAIN_INGEST_MIN_CREDIBILITY=0.7
WEB_BRAIN_INGEST_MIN_EXECUTION_MS=2000
# Acceptance thresholds pentru PARTIAL cache hit
WEB_BRAIN_HIT_MIN_EVIDENCE=3
WEB_BRAIN_HIT_MIN_RELEVANCE=0.7
# Timeouts
WEB_BRAIN_GATHER_TIMEOUT=8.0
WEB_BRAIN_INGEST_TIMEOUT=15.0
```
Toate sunt optional. Dacă `WEB_BRAIN_URL` e gol, integrarea e dezactivată
complet (fallback la orchestrator normal pentru tot).
---
## Networking Docker
Web-api trăiește pe rețeaua `deploy_default` (cu dashboard, video, audio,
searxng). Brain trăiește pe rețeaua proprie `didibrain`.
Web-api e atașat la **ambele** rețele în `deploy/docker-compose.yml`:
```yaml
networks:
deploy_default:
external: true
didibrain:
external: true
services:
web-api:
networks:
- deploy_default
- didibrain
```
Asta îi permite să rezolve `didibrain-api` prin DNS-ul Docker.
---
## Fail-open
Brain down ≠ web-api down. Toate apelurile spre brain sunt wrapped cu
try/except generos:
- Gather cache read eșuează → continuă la orchestrator normal
- Ingest eșuează → log și drop event (queue-ul va reumple)
Clientul nu vede niciodată o eroare datorită brain.
---
## Cum monitorizezi HIT rate
Log-urile web-api emit `Brain cache HIT [tier=X, N items]: <claim>` pe fiecare
hit. Pentru producție, integrarea cu dashboard face count-uri în tabela
`request_history` (câmp `raw_response.stages` conține `retrieval`/`rerank` când
e din brain vs `search`/`fetch` când e direct).
Query exemplu (post-deploy):
```sql
-- HIT rate per tier pe ultimele 24h
SELECT
tier,
COUNT(*) FILTER (
WHERE raw_response::jsonb -> 'stages' @> '[{"stage":"retrieval"}]'
) AS brain_hits,
COUNT(*) AS total_gathers,
ROUND(100.0 * COUNT(*) FILTER (
WHERE raw_response::jsonb -> 'stages' @> '[{"stage":"retrieval"}]'
) / NULLIF(COUNT(*), 0), 1) AS hit_rate_pct
FROM request_history
WHERE endpoint = '/v1/gather'
AND created_at > now() - interval '24 hours'
GROUP BY tier;
```
---
## Ce NU face integrarea
- Nu scrie la brain pe tier `free` (doar citește) — tip premium gold standard
- Nu face fallback la premium când brain MISS pe free (menține tier isolation)
- Nu retry — un singur request la brain, orice eșec → fallback silent
Pentru detaliile contractului verification_cache (backend ↔ brain),
vezi `modules/didi_brain/CONTRACT_VERIFICATION_CACHE.md`.

View file

@ -0,0 +1,156 @@
# Web Module
Web search service for DIDI claim verification. Routes between free (SearXNG meta-search) and premium providers (Brave, Tavily, SerpAPI, Linkup, Exa) based on the `X-Search-Tier` header. Provides a unified gather pipeline (search -> fetch -> evidence extraction) used by agent-v3 as the fallback path when the brain knowledge base returns no evidence for a claim.
- **Stack:** Python 3.10+, FastAPI, Pydantic v2, httpx (HTTP/2), Uvicorn
- **URL:** `http://10.11.10.12:51100` (Dev) / `http://10.11.10.13:51100` (Prod-style reference per agent-v3 default)
- **Container:** `didiAI-web-api`
- **SearXNG cluster:** 3 replicas (`didiAI-web-searxng-1/2/3`) behind nginx LB (`didiAI-web-searxng`, port 55100)
- **SearXNG cache:** 3 Valkey/Redis replicas (`didiAI-web-searxng-redis-1/2/3`)
- **Anonymity proxy:** `didiAI-web-tor` (Tor SOCKS5 shared by all SearXNG instances)
## Ce face
The module powers the fact-checking pipeline:
- **Free search** via SearXNG metasearch (3 round-robin instances, each with a dedicated Valkey for isolated cache/state). Local Qwen LLM is used for context detection + evidence snippet extraction.
- **Premium search** via paid APIs — Brave, Tavily, SerpAPI, Linkup, Exa — selected by `PaidSearchClient` rotation (read keys from `WEB_*_API_KEY` envs). Uses OpenRouter (configurable model) for LLM steps.
- **URL fetching** with readability-lxml extraction; auto-fallback chain `HTTP -> Playwright (browse) -> Vision LLM screenshot OCR` for JS-heavy or protected pages. PDF URLs are skipped.
- **Tor proxy** for sensitive/anonymized SearXNG queries.
- **Brain cache** integration: both tiers READ from the brain cache on `/v1/gather`; only premium WRITES quality results back via the `BrainIngestSink` (fire-and-forget). Free users effectively get the paid knowledge base for free.
- **Dashboard event sink:** every request emits a structured event (tier, provider, duration, status, results_count, raw response snapshot for `/v1/gather`) to the configured dashboard.
- **Runtime config:** `RuntimeConfigClient` polls the dashboard for tier overrides (e.g. `web.tier.free.max_search_results`).
## API endpoints
All `/v1/*` routes are protected by Bearer auth (`verify_bearer_token`) and concurrency-limited. Tier-aware routes read the `X-Search-Tier: free | premium` header (default `free`).
| Method | Path | Body schema | Description |
|--------|------|-------------|-------------|
| `POST` | `/v1/gather` | `GatherRequest` | **Main endpoint** — full pipeline (cache check -> search -> fetch -> evidence) returning `GatherResponse`. Tier-aware (free/premium orchestrators). Premium populates the brain cache when quality threshold is met. |
| `POST` | `/v1/search` | `SearchRequest` | Multi-query web search. Tier-aware (`searxng` for free, `paid-rotation` for premium). |
| `POST` | `/v1/image-search` | `ImageSearchRequest` | Image search (tier-aware, same routing as text search). |
| `POST` | `/v1/fetch` | `FetchRequest` | Fetch URL list with readability extraction. |
| `GET` | `/v1/info` | — | Service catalog metadata (resource + functions, JSON schemas) for the catalog-api. |
| `GET` | `/health` | — | Liveness + provider health (`searxng` reachability). |
| `GET` | `/ready` | — | Readiness probe (verifies `search_client_free` + `orchestrator_free` are wired). |
Errors map to standard HTTP codes via `make_error_detail()`: 429 rate-limit, 502 connection/provider, 504 timeout, 500 generic.
## How didi-backend uses it
- **agent-v3** calls this service via `M17_WEB_API_URL` (default `http://10.11.10.13:51100` per agent-v3 `docker-compose.yml`).
- The brain client in agent-v3 first queries the DIDI brain knowledge base; on a MISS (or low-quality hit), agent-v3 falls through to `POST /v1/gather` here.
- Agent-v3 sets the `X-Search-Tier` header to choose providers: `free` for normal sessions, `premium` for paid/priority queries. Premium runs are what populate the shared brain cache for everyone.
## Structura fisiere
```
src/web/
├── __init__.py
├── cli.py # `web` CLI entry point
├── config.py # WebSettings (pydantic-settings, WEB_* prefix) + SettingsCache
├── exceptions.py # WebError hierarchy (Provider/RateLimit/Timeout/Connection/Search)
├── logging.py # Structured logging + request_id contextvar
├── orchestrator.py # Orchestrator: search -> fetch -> evidence pack pipeline
├── runtime_config.py # RuntimeConfigClient (polls dashboard /api/config)
├── validation.py # Schema validators
├── api/
│ ├── app.py # FastAPI factory, lifespan (clients + free/premium orchestrators)
│ ├── dependencies.py # Bearer auth, concurrency limiter, tier resolver
│ ├── middleware.py # CombinedMiddleware (request_id + rate limit)
│ └── routes/
│ ├── gather.py # POST /v1/gather (main pipeline + brain cache read/write)
│ ├── search.py # POST /v1/search
│ ├── image_search.py # POST /v1/image-search
│ ├── fetch.py # POST /v1/fetch
│ ├── health.py # GET /health, /ready
│ └── info.py # GET /v1/info (catalog metadata)
├── schemas/ # Pydantic request/response models
│ ├── common.py # PageContent, ProviderHealth, error helpers
│ ├── search.py # SearchRequest/Response
│ ├── image_search.py # ImageSearchRequest/Response
│ ├── fetch.py # FetchRequest/Response
│ ├── browse.py # BrowseRequest/Response
│ ├── vision.py # VisionExtractRequest/Response
│ ├── evidence.py # EvidencePackRequest/Response
│ ├── context.py # Context detection schemas
│ └── gather.py # GatherRequest/Response (unified)
├── search/ # Provider implementations
│ ├── protocol.py # SearchProvider Protocol
│ ├── multi.py # MultiSearchClient (default, backwards-compat)
│ ├── paid.py # PaidSearchClient (rotation across paid providers)
│ ├── brave.py # Brave Search API
│ ├── tavily.py # Tavily API
│ ├── serpapi.py # SerpAPI
│ ├── linkup.py # Linkup API
│ └── exa.py # Exa API
├── metasearch/
│ └── client.py # SearXNGClient (free tier)
├── fetch/ # HTTP + readability extraction
├── browse/ # Playwright browser automation
├── vision/ # Screenshot + Vision LLM extraction
├── evidence/ # EvidencePacker (dedupe, snippets, scoring)
├── llm/ # LLMProviderChain (local -> OpenRouter -> OpenAI -> Anthropic)
├── context/ # Claim context detection helpers
├── events/
│ └── sink.py # DashboardEventSink (fire-and-forget telemetry)
└── brain/
├── client.py # BrainClient (gather + ingest HTTP client)
├── adapter.py # brain <-> web schema converters
├── quality.py # quality_ok_for_cache, brain_hit_acceptable
└── sink.py # BrainIngestSink (premium-only cache writer)
```
## SearXNG cluster
Located at `deploy/metasearch/`:
- 3 SearXNG instances (`docker.io/searxng/searxng:latest`) round-robin behind an nginx LB (`searxng-lb` -> port 55100).
- Each SearXNG instance has its own dedicated Valkey 8 cache (`searxng-redis-data-{1,2,3}`) for isolated state.
- Shared Tor SOCKS5 proxy (`dperson/torproxy`) for queries needing anonymity.
- Per-instance config under `deploy/metasearch/searxng-{1,2,3}/`.
- Caddyfile + reset script (`searxng-reset.sh`) included for ops.
- Article extraction in the API uses readability-lxml; JS-heavy pages fall through to Playwright (chromium) and finally to a vision-LLM screenshot pass.
## Configuration
All envs use the `WEB_` prefix (loaded via pydantic-settings). Highlights:
| Variable | Purpose |
|----------|---------|
| `WEB_SEARXNG_BASE_URL` | SearXNG LB endpoint (default `http://didiAI-web-searxng:8080`) — REQUIRED |
| `WEB_LLM_BASE_URL` / `WEB_VISION_BASE_URL` | Local LLM router (Qwen) for free tier |
| `WEB_TEXT_MODEL` / `WEB_VISION_MODEL` | Model names served by the LLM endpoint |
| `WEB_LLM_API_KEY` | Optional auth for the LLM endpoint |
| `WEB_BRAVE_API_KEY` / `WEB_TAVILY_API_KEY` / `WEB_SERPAPI_API_KEY` / `WEB_LINKUP_API_KEY` / `WEB_EXA_API_KEY` | Premium-tier provider keys (any subset) |
| `WEB_OPENROUTER_API_KEY` / `WEB_OPENROUTER_MODEL` | Premium-tier LLM provider |
| `WEB_OPENAI_API_KEY` / `WEB_ANTHROPIC_API_KEY` | Optional fallback LLM providers |
| `WEB_DASHBOARD_URL` / `WEB_DASHBOARD_TOKEN` | Dashboard event sink + runtime config polling |
| `WEB_EXTERNAL_URL` | Public URL advertised in OpenAPI/catalog |
| `WEB_HOST` / `WEB_PORT` | Bind (defaults `0.0.0.0:51100`) |
| `WEB_RATE_LIMIT_RPS` / `WEB_RATE_LIMIT_BURST` / `WEB_MAX_CONCURRENT_REQUESTS` | Throttling |
| `WEB_API_TOKENS` | Comma-separated bearer tokens for `/v1/*`; blank = open (VPN deploys) |
| `WEB_FETCH_*` / `WEB_BROWSE_*` / `WEB_VISION_*` / `WEB_EVIDENCE_*` | Tuning knobs (timeouts, viewport, dedupe threshold, snippet length) |
Full reference: `.env.example`.
## Deployment
- **API:** `cd deploy/ && docker compose --profile api up -d` (builds `didiai-web-api` from `deploy/Dockerfile`, joins networks `didi-network` + `didibrain`, exposes `51100:51100`).
- **SearXNG cluster:** `cd deploy/metasearch/ && docker compose up -d` (LB + 3x SearXNG + 3x Valkey + Tor).
- **Healthcheck:** `curl http://localhost:51100/health`.
- Compose files: `deploy/docker-compose.yml`, `deploy/metasearch/docker-compose.yaml`.
## Related docs
- Module README: `README.md` (quick-start, gather request schema, fallback chain diagram).
- API reference: `API.md`.
- Brain cache integration: `BRAIN_INTEGRATION.md`.
- AI platform CLAUDE.md (parent module conventions).
- Backend integration: agent-v3 brain client (`backend/services/orchestration-layer/agent-v3`) falls through to `POST /v1/gather` here when the brain returns a MISS or low-quality hit.

View file

@ -0,0 +1,317 @@
# Web Module
Unified web module for search, fetch, browse, vision, and evidence gathering. Provides REST APIs for web content extraction with automatic fallback between methods.
## What It Does
This module is designed for **fact-checking pipelines**. Given a claim, it:
1. **Searches** the web for relevant sources (SearXNG metasearch)
2. **Fetches** page content using the best method available
3. **Extracts** relevant evidence snippets using LLM
4. Returns structured evidence for verification
## Prerequisites
**Required:**
- All global prerequisites (see main [README.md](../../README.md))
- SearXNG instance (deploy with `cd deploy/metasearch && docker compose up -d`)
- LLM Inference server (llm-inference module at port 14011)
**Optional:**
- OpenAI API key (for vision/LLM fallback)
- Anthropic API key (for vision/LLM fallback)
## Features
| Component | Description |
|-----------|-------------|
| **Search** | SearXNG metasearch client with site filtering, freshness, language |
| **Fetch** | HTTP content extraction with readability-lxml |
| **Browse** | Playwright-based JavaScript rendering for dynamic pages |
| **Vision** | Screenshot + Vision LLM for complex/protected pages |
| **Evidence** | Deduplicate + LLM snippet extraction + relevance scoring |
| **Orchestrator** | Auto-fallback chain: HTTP → Playwright → Vision LLM |
### Smart Features
- **PDF Filtering**: Automatically skips direct PDF URLs (can't extract text)
- **Thinking Mode Handling**: Strips `<think>` tags from reasoning models (Qwen3)
- **Auto-Fallback**: Escalates to more powerful methods when content is poor
- **Deduplication**: Removes duplicate content based on text similarity
## Installation
```bash
cd modules/web
# Install base dependencies
uv sync
# Install with all extras (fetch, browse, vision)
uv sync --all-extras
# Install Playwright browsers
uv run playwright install chromium
# Install with dev dependencies
uv sync --extra dev --all-extras
```
## Quick Start
### Docker Deployment (Recommended)
```bash
cd deploy/
# Configure environment
cat > .env << 'EOF'
WEB_SEARXNG_BASE_URL=http://localhost:55100
WEB_LLM_BASE_URL=http://didiAI-llm-api:14011
WEB_LLM_API_KEY=your-llm-api-key
WEB_VISION_MODEL=qwen-vl
WEB_TEXT_MODEL=qwen3-235b
WEB_LOG_LEVEL=INFO
EOF
# Start the server
docker compose --profile api up -d
# Check health
curl http://localhost:51100/health
```
### Example: Fact-Check a Claim
```bash
curl -X POST http://localhost:51100/v1/gather \
-H "Content-Type: application/json" \
-d '{
"claim": "Romania had the highest economic growth in the EU in 2024",
"search_queries": ["Romania GDP growth 2024 EU"],
"max_search_results": 5,
"extract_snippets": true,
"max_evidence_items": 5
}'
```
**Response:**
```json
{
"claim": "Romania had the highest economic growth in the EU in 2024",
"evidence": [
{
"url": "https://en.wikipedia.org/wiki/Economy_of_Romania",
"title": "Economy of Romania - Wikipedia",
"snippet": "Romania's nominal GDP reached approximately $423 billion in 2024, reflecting real growth of 0.9% that year...",
"relevance_score": 0.6
}
],
"stages": [
{"stage": "search", "success": true, "items_processed": 5},
{"stage": "fetch", "success": true, "items_processed": 4},
{"stage": "evidence", "success": true, "items_processed": 3}
],
"execution_time_ms": 40643.68
}
```
## API Endpoints
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/v1/gather` | POST | **Main endpoint** - unified pipeline |
| `/v1/search` | POST | Execute web search only |
| `/v1/image-search` | POST | Search for images |
| `/v1/fetch` | POST | Fetch URLs (HTTP + readability) |
| `/health` | GET | Health check |
| `/ready` | GET | Readiness probe |
### Gather Request Schema
```json
{
"claim": "The claim to verify",
"search_queries": ["optional", "custom", "queries"],
"max_search_results": 20,
"site_allowlist": ["reuters.com", "bbc.com"],
"site_blocklist": ["spam-site.com"],
"fetch_method": "auto",
"auto_fallback": true,
"extract_snippets": true,
"max_evidence_items": 15,
"dedupe": true
}
```
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `claim` | string | required | The claim to gather evidence for |
| `search_queries` | array | null | Custom search queries (auto-generated if not provided) |
| `max_search_results` | int | 20 | Max search results (5-50) |
| `site_allowlist` | array | null | Only search these domains |
| `site_blocklist` | array | null | Exclude these domains |
| `fetch_method` | string | "auto" | "auto", "http", "browse", "vision" |
| `auto_fallback` | bool | true | Escalate on fetch failure |
| `extract_snippets` | bool | true | Use LLM for snippet extraction |
| `max_evidence_items` | int | 15 | Max items in final pack |
| `dedupe` | bool | true | Deduplicate evidence |
## Configuration
Configure via environment variables (prefix: `WEB_`):
| Variable | Default | Description |
|----------|---------|-------------|
| `WEB_SEARXNG_BASE_URL` | - | **Required** SearXNG instance URL |
| `WEB_LLM_BASE_URL` | - | **Required** LLM inference server URL |
| `WEB_LLM_API_KEY` | - | LLM API authentication token |
| `WEB_VISION_MODEL` | `qwen-vl` | Vision model for screenshots |
| `WEB_TEXT_MODEL` | `qwen3-235b` | Text model for snippets |
| `WEB_PORT` | `51100` | API server port |
| `WEB_OPENAI_API_KEY` | - | OpenAI fallback (optional) |
| `WEB_ANTHROPIC_API_KEY` | - | Anthropic fallback (optional) |
See `.env.example` for full configuration options.
## Auto-Fallback Logic
The module uses intelligent fallback to get the best content:
```
┌─────────────────────────────────────────────────────────┐
│ URL Input │
└─────────────────┬───────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ 1. PDF Check - Skip .pdf URLs (can't extract text) │
└─────────────────┬───────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ 2. HTTP Fetch (fastest, cheapest) │
│ - Uses readability-lxml for content extraction │
│ - Detects JS-heavy pages and insufficient content │
└─────────────────┬───────────────────────────────────────┘
▼ if text < min_length or JS detected
┌─────────────────────────────────────────────────────────┐
│ 3. Playwright Browse (renders JavaScript) │
│ - Waits for networkidle │
│ - Extracts rendered content │
└─────────────────┬───────────────────────────────────────┘
▼ if content still poor quality
┌─────────────────────────────────────────────────────────┐
│ 4. Vision LLM (screenshot → extract) │
│ - Takes full-page screenshot │
│ - Uses vision model to extract text │
└─────────────────────────────────────────────────────────┘
```
Enable with `fetch_method: "auto"` and `auto_fallback: true`.
## Architecture
```
src/web/
├── __init__.py # Package exports
├── config.py # Unified WebSettings
├── exceptions.py # Custom exceptions
├── logging.py # Structured logging
├── orchestrator.py # Auto-fallback pipeline orchestration
├── cli.py # CLI entry point
├── schemas/ # All request/response schemas
│ ├── common.py # PageContent, FailedUrl, PageImage, shared types
│ ├── search.py # SearchRequest/Response
│ ├── fetch.py # FetchRequest/Response
│ ├── browse.py # BrowseRequest/Response
│ ├── vision.py # VisionExtractRequest/Response
│ ├── evidence.py # EvidencePackRequest/Response
│ └── gather.py # GatherRequest/Response (unified)
├── llm/ # LLM provider abstraction
│ ├── __init__.py
│ └── provider.py # LLMProviderChain (local→OpenAI→Anthropic)
├── search/ # Search providers
│ ├── __init__.py
│ ├── protocol.py # SearchProvider Protocol
│ └── searxng.py # SearXNGClient
├── fetch/ # HTTP + readability extraction
│ └── client.py # FetchClient
├── browse/ # Playwright browser automation
│ └── client.py # BrowseClient
├── vision/ # Screenshot + Vision LLM
│ └── client.py # VisionClient (local + OpenAI + Anthropic)
├── evidence/ # Evidence processing
│ └── packer.py # EvidencePacker (dedupe, snippets, scoring)
└── api/
├── app.py # FastAPI app factory
├── dependencies.py # DI (auth, rate limiter)
├── middleware.py # RequestId, RateLimit
└── routes/
├── search.py # POST /v1/search
├── fetch.py # POST /v1/fetch
├── gather.py # POST /v1/gather (main endpoint)
└── health.py # /health, /ready
```
## Deployment
```bash
cd deploy/
# Configure environment (REQUIRED)
# Edit .env with your API keys
# Start server
docker compose --profile api up -d
# View logs
docker compose --profile api logs -f
# Stop server
docker compose --profile api down
```
### Port Allocation
| Port | Service |
|------|---------|
| 51100 | Web API |
### Network
The container joins the `deploy_default` network to communicate with:
- `didiAI-llm-api:14011` - LLM inference server
## Development
```bash
# Install dev dependencies
uv sync --extra dev --all-extras
# Run tests
uv run pytest
# Run tests with coverage
uv run pytest --cov=src/web --cov-report=term-missing
# Lint and format
uv run ruff check .
uv run ruff format .
```
## Dependencies on Other Modules
| Module | Purpose | Required |
|--------|---------|----------|
| llm-inference | LLM for snippet extraction & relevance scoring | Yes (for extract_snippets) |
## License
MIT

View file

@ -0,0 +1,405 @@
# Web API Benchmark Results
**Date:** 2026-02-08
**Base URL:** http://localhost:51100
**Iterations:** 3 (+ 1 warmup)
## Summary
| Endpoint | Scenarios | Avg Latency | Status |
|----------|-----------|-------------|--------|
| Health | 1 | ~10ms | OK |
| Search (`/v1/search`) | 8 | 1181ms | OK |
| Fetch (`/v1/fetch`) | 7 | 939ms | OK |
| Image Search (`/v1/image-search`) | 2 | 1469ms | 3 errors |
| Gather (`/v1/gather`) | 9 | 6165ms | OK |
---
## Health
### `health`
| Metric | Min | Mean | Median | P95 | Max |
|--------|-----|------|--------|-----|-----|
| curl_ttfb_s | 0.01 | 0.01 | 0.01 | 0.01 | 0.01 |
| curl_total_s | 0.01 | 0.01 | 0.01 | 0.01 | 0.01 |
---
## Search (`/v1/search`)
### `search-single`
**Config:** 1 query, max_results=10
| Metric | Min | Mean | Median | P95 | Max |
|--------|-----|------|--------|-----|-----|
| total_ms | 574 | 661 | 650 | 749 | 760 |
| curl_ttfb_s | 0.58 | 0.67 | 0.66 | 0.75 | 0.76 |
| curl_total_s | 0.58 | 0.67 | 0.66 | 0.75 | 0.76 |
**Results:** 10 items returned
### `search-multi-query`
**Config:** 5 queries, max_results=10
| Metric | Min | Mean | Median | P95 | Max |
|--------|-----|------|--------|-----|-----|
| total_ms | 1775 | 1936 | 1972 | 2053 | 2062 |
| curl_ttfb_s | 1.78 | 1.94 | 1.98 | 2.06 | 2.07 |
| curl_total_s | 1.78 | 1.94 | 1.98 | 2.06 | 2.07 |
**Results:** 50 items returned
### `search-max-queries`
**Config:** 10 queries, max_results=5
| Metric | Min | Mean | Median | P95 | Max |
|--------|-----|------|--------|-----|-----|
| total_ms | 2776 | 2945 | 3012 | 3045 | 3049 |
| curl_ttfb_s | 2.78 | 2.95 | 3.02 | 3.05 | 3.05 |
| curl_total_s | 2.78 | 2.95 | 3.02 | 3.05 | 3.05 |
**Results:** 50 items returned
### `search-large-results`
**Config:** 2 queries, max_results=50
| Metric | Min | Mean | Median | P95 | Max |
|--------|-----|------|--------|-----|-----|
| total_ms | 930 | 1087 | 1105 | 1213 | 1225 |
| curl_ttfb_s | 0.94 | 1.09 | 1.11 | 1.22 | 1.23 |
| curl_total_s | 0.94 | 1.09 | 1.11 | 1.22 | 1.23 |
**Results:** 37 items returned
### `search-freshness-day`
**Config:** 1 query, max_results=10, freshness=day
| Metric | Min | Mean | Median | P95 | Max |
|--------|-----|------|--------|-----|-----|
| total_ms | 662 | 690 | 704 | 705 | 706 |
| curl_ttfb_s | 0.67 | 0.70 | 0.71 | 0.71 | 0.71 |
| curl_total_s | 0.67 | 0.70 | 0.71 | 0.71 | 0.71 |
**Results:** 10 items returned
### `search-freshness-week`
**Config:** 1 query, max_results=10, freshness=week
| Metric | Min | Mean | Median | P95 | Max |
|--------|-----|------|--------|-----|-----|
| total_ms | 573 | 623 | 614 | 675 | 681 |
| curl_ttfb_s | 0.58 | 0.63 | 0.62 | 0.68 | 0.69 |
| curl_total_s | 0.58 | 0.63 | 0.62 | 0.68 | 0.69 |
**Results:** 10 items returned
### `search-site-filter`
**Config:** 1 query, max_results=20, site_allowlist=[wikipedia.org, python.org, realpython.com]
| Metric | Min | Mean | Median | P95 | Max |
|--------|-----|------|--------|-----|-----|
| total_ms | 586 | 701 | 612 | 876 | 905 |
| curl_ttfb_s | 0.59 | 0.71 | 0.62 | 0.88 | 0.91 |
| curl_total_s | 0.59 | 0.71 | 0.62 | 0.88 | 0.91 |
**Results:** 9 items returned
### `search-non-english`
**Config:** 1 query (French), max_results=10, language=fr, country=FR
| Metric | Min | Mean | Median | P95 | Max |
|--------|-----|------|--------|-----|-----|
| total_ms | 570 | 805 | 779 | 1036 | 1065 |
| curl_ttfb_s | 0.58 | 0.81 | 0.78 | 1.04 | 1.07 |
| curl_total_s | 0.58 | 0.81 | 0.79 | 1.04 | 1.07 |
**Results:** 10 items returned
---
## Fetch (`/v1/fetch`)
### `fetch-single`
**Config:** 1 URL
| Metric | Min | Mean | Median | P95 | Max |
|--------|-----|------|--------|-----|-----|
| total_ms | 138 | 142 | 144 | 145 | 146 |
| curl_ttfb_s | 0.14 | 0.15 | 0.15 | 0.15 | 0.15 |
| curl_total_s | 0.14 | 0.15 | 0.15 | 0.15 | 0.15 |
**Results:** 1 item returned
### `fetch-multi`
**Config:** 5 URLs, parallel_fetches=5
| Metric | Min | Mean | Median | P95 | Max |
|--------|-----|------|--------|-----|-----|
| total_ms | 481 | 512 | 496 | 552 | 558 |
| curl_ttfb_s | 0.49 | 0.52 | 0.50 | 0.56 | 0.57 |
| curl_total_s | 0.49 | 0.52 | 0.51 | 0.56 | 0.57 |
**Results:** 5 items returned
### `fetch-large`
**Config:** 10 URLs, parallel_fetches=10
| Metric | Min | Mean | Median | P95 | Max |
|--------|-----|------|--------|-----|-----|
| total_ms | 3342 | 3521 | 3552 | 3658 | 3670 |
| curl_ttfb_s | 3.36 | 3.54 | 3.57 | 3.68 | 3.69 |
| curl_total_s | 3.36 | 3.54 | 3.57 | 3.68 | 3.69 |
**Results:** 9 items (1 failed — reuters.com timeout)
### `fetch-serial`
**Config:** 5 URLs, parallel_fetches=1
| Metric | Min | Mean | Median | P95 | Max |
|--------|-----|------|--------|-----|-----|
| total_ms | 791 | 895 | 894 | 990 | 1001 |
| curl_ttfb_s | 0.80 | 0.90 | 0.90 | 1.00 | 1.01 |
| curl_total_s | 0.80 | 0.90 | 0.90 | 1.00 | 1.01 |
**Results:** 5 items returned
### `fetch-no-text`
**Config:** 3 URLs, extract_text=false, include_html=true
| Metric | Min | Mean | Median | P95 | Max |
|--------|-----|------|--------|-----|-----|
| total_ms | 492 | 501 | 502 | 508 | 509 |
| curl_ttfb_s | 0.52 | 0.53 | 0.53 | 0.53 | 0.53 |
| curl_total_s | 0.52 | 0.53 | 0.53 | 0.54 | 0.54 |
**Results:** 3 items returned
### `fetch-no-fallback`
**Config:** 3 URLs, auto_fallback=false, method=http
| Metric | Min | Mean | Median | P95 | Max |
|--------|-----|------|--------|-----|-----|
| total_ms | 497 | 501 | 501 | 505 | 505 |
| curl_ttfb_s | 0.51 | 0.51 | 0.51 | 0.51 | 0.51 |
| curl_total_s | 0.51 | 0.51 | 0.51 | 0.51 | 0.52 |
**Results:** 3 items returned
### `fetch-short-timeout`
**Config:** 3 URLs, timeout_seconds=5
| Metric | Min | Mean | Median | P95 | Max |
|--------|-----|------|--------|-----|-----|
| total_ms | 485 | 498 | 496 | 511 | 512 |
| curl_ttfb_s | 0.49 | 0.51 | 0.50 | 0.52 | 0.52 |
| curl_total_s | 0.50 | 0.51 | 0.51 | 0.52 | 0.52 |
**Results:** 3 items returned
---
## Image Search (`/v1/image-search`)
### `image-search-small`
**Config:** 1 query, max_results=5
| Metric | Min | Mean | Median | P95 | Max |
|--------|-----|------|--------|-----|-----|
| total_ms | 1365 | 1469 | 1487 | 1549 | 1555 |
| curl_ttfb_s | 1.37 | 1.47 | 1.49 | 1.55 | 1.56 |
| curl_total_s | 1.37 | 1.47 | 1.49 | 1.55 | 1.56 |
**Results:** 5 items returned
### `image-search-large`
**Config:** 2 queries, max_results=100
**Status:** 3/3 FAILED
**Error:** `internal_error: An unexpected error occurred`
> **Note:** Large image search result counts (100+) trigger internal errors. Needs investigation.
---
## Gather (`/v1/gather`)
### `gather-minimal`
**Config:** max_search_results=5, max_evidence_items=3
| Metric | Min | Mean | Median | P95 | Max |
|--------|-----|------|--------|-----|-----|
| total_ms | 2499 | 2843 | 2641 | 3315 | 3390 |
| search_ms | 855 | 1172 | 1008 | 1588 | 1653 |
| fetch_ms | 1537 | 1575 | 1553 | 1626 | 1635 |
| evidence_ms | 84 | 84 | 84 | 85 | 85 |
**Results:** 3 evidence items
### `gather-default`
**Config:** max_search_results=10, max_evidence_items=8
| Metric | Min | Mean | Median | P95 | Max |
|--------|-----|------|--------|-----|-----|
| total_ms | 4604 | 6402 | 5055 | 9099 | 9548 |
| search_ms | 901 | 1493 | 1120 | 2324 | 2458 |
| fetch_ms | 3183 | 4516 | 3762 | 6319 | 6603 |
| evidence_ms | 206 | 208 | 208 | 211 | 211 |
**Results:** 8 evidence items
### `gather-snippets`
**Config:** max_search_results=5, max_evidence_items=5, extract_snippets=true
| Metric | Min | Mean | Median | P95 | Max |
|--------|-----|------|--------|-----|-----|
| total_ms | 8000 | 8074 | 8087 | 8130 | 8134 |
| search_ms | 595 | 908 | 1024 | 1098 | 1106 |
| fetch_ms | 1742 | 1764 | 1773 | 1777 | 1777 |
| evidence_ms | 5107 | 5370 | 5355 | 5619 | 5648 |
**Results:** 5 evidence items
> **Note:** LLM snippet extraction adds ~5s to evidence processing.
### `gather-high-parallel`
**Config:** max_search_results=20, max_evidence_items=10, parallel_fetches=10
| Metric | Min | Mean | Median | P95 | Max |
|--------|-----|------|--------|-----|-----|
| total_ms | 3043 | 3704 | 3663 | 4333 | 4407 |
| search_ms | 797 | 1227 | 1389 | 1485 | 1496 |
| fetch_ms | 2223 | 2357 | 2260 | 2555 | 2588 |
**Results:** 0 evidence items (no matching content)
### `gather-serial-fetch`
**Config:** max_search_results=10, max_evidence_items=5, parallel_fetches=1
| Metric | Min | Mean | Median | P95 | Max |
|--------|-----|------|--------|-----|-----|
| total_ms | 6100 | 9994 | 10926 | 12754 | 12957 |
| search_ms | 775 | 1478 | 982 | 2509 | 2678 |
| fetch_ms | 4209 | 5771 | 5171 | 7656 | 7932 |
**Results:** 0 evidence items
> **Note:** Serial fetching is 2-3x slower than parallel.
### `gather-no-fallback`
**Config:** max_search_results=10, max_evidence_items=5, fetch_method=http, auto_fallback=false
| Metric | Min | Mean | Median | P95 | Max |
|--------|-----|------|--------|-----|-----|
| total_ms | 2475 | 3091 | 3339 | 3446 | 3458 |
| search_ms | 460 | 1055 | 895 | 1718 | 1809 |
| fetch_ms | 1061 | 1467 | 1593 | 1732 | 1747 |
| evidence_ms | 30 | 79 | 37 | 158 | 171 |
**Results:** 5 evidence items
### `gather-site-restricted`
**Config:** max_search_results=15, max_evidence_items=8, site_allowlist=[wikipedia.org, bbc.com, reuters.com]
| Metric | Min | Mean | Median | P95 | Max |
|--------|-----|------|--------|-----|-----|
| total_ms | 5941 | 6925 | 6357 | 8266 | 8478 |
| search_ms | 3411 | 4141 | 3767 | 5098 | 5246 |
| fetch_ms | 2338 | 2530 | 2372 | 2828 | 2879 |
| evidence_ms | 184 | 206 | 209 | 223 | 224 |
**Results:** 8 evidence items
> **Note:** Site filtering increases search latency (SearXNG site: prefix).
### `gather-max-evidence`
**Config:** max_search_results=30, max_evidence_items=25
| Metric | Min | Mean | Median | P95 | Max |
|--------|-----|------|--------|-----|-----|
| total_ms | 5780 | 6238 | 6123 | 6743 | 6812 |
| search_ms | 875 | 1005 | 921 | 1191 | 1221 |
| fetch_ms | 4796 | 5199 | 4885 | 5812 | 5916 |
**Results:** 0 evidence items
### `gather-large`
**Config:** max_search_results=20, max_evidence_items=15
| Metric | Min | Mean | Median | P95 | Max |
|--------|-----|------|--------|-----|-----|
| total_ms | 6053 | 8215 | 8421 | 9996 | 10171 |
| search_ms | 792 | 987 | 1047 | 1115 | 1122 |
| fetch_ms | 4603 | 6648 | 6789 | 8376 | 8552 |
| evidence_ms | 134 | 167 | 148 | 213 | 220 |
**Results:** 15 evidence items
---
## Key Findings
### Performance Rankings
**Fastest scenarios:**
1. `fetch-single` — 142ms
2. `fetch-short-timeout` — 498ms
3. `fetch-no-text` — 501ms
4. `fetch-no-fallback` — 501ms
5. `fetch-multi` — 512ms
**Slowest scenarios:**
1. `gather-serial-fetch` — 9994ms
2. `gather-large` — 8215ms
3. `gather-snippets` — 8074ms
4. `gather-site-restricted` — 6925ms
5. `gather-default` — 6402ms
### Issues Detected
- `image-search-large`: Internal error when max_results=100 — needs investigation
### Observations
| Finding | Impact |
|---------|--------|
| **Search scaling** | ~300ms per additional query (linear) |
| **Fetch parallelism** | parallel_fetches=5-10 is 2x faster than serial |
| **LLM snippets** | extract_snippets=true adds ~5s to evidence stage |
| **Site filtering** | site_allowlist adds 2-3s to search (SearXNG overhead) |
| **Image search limits** | max_results > ~50 causes internal errors |
### Recommendations
1. **Use parallel fetches** — Default of 5 is good, 10 for large workloads
2. **Avoid snippets for speed** — Only enable when LLM extraction is needed
3. **Site filters are expensive** — Use sparingly, prefer post-fetch filtering
4. **Image search cap** — Keep max_results ≤ 50 until bug is fixed

View file

@ -0,0 +1,967 @@
#!/usr/bin/env python3
"""Benchmark runner for the web API.
Usage:
uv run python bench.py # basic run (default scenario)
uv run python bench.py -n 5 # 5 iterations with stats
uv run python bench.py --all -n 3 # all scenarios
uv run python bench.py --group search -n 3 # all search scenarios
uv run python bench.py -c 3 -n 5 # concurrent load test
uv run python bench.py -o out.json --tag "before-refactor" # save with label
"""
from __future__ import annotations
import argparse
import json
import math
import subprocess
import sys
import tempfile
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from dataclasses import asdict, dataclass, field
from datetime import datetime, timezone
# ---------------------------------------------------------------------------
# Benchmark URLs (reliable, fast, publicly available)
# ---------------------------------------------------------------------------
BENCH_URLS = [
"https://www.python.org/",
"https://en.wikipedia.org/wiki/Python_(programming_language)",
"https://docs.python.org/3/tutorial/index.html",
"https://httpbin.org/html",
"https://example.com",
"https://www.reuters.com/",
"https://en.wikipedia.org/wiki/Machine_learning",
"https://en.wikipedia.org/wiki/Artificial_intelligence",
"https://www.bbc.com/news",
"https://docs.python.org/3/library/asyncio.html",
]
# ---------------------------------------------------------------------------
# Scenarios
# ---------------------------------------------------------------------------
SCENARIOS: dict[str, dict] = {
# -------------------------------------------------------------------------
# Health baseline
# -------------------------------------------------------------------------
"health": {
"endpoint": "/health",
"method": "GET",
"body": None,
},
# -------------------------------------------------------------------------
# Search endpoint variations
# -------------------------------------------------------------------------
"search-single": {
"endpoint": "/v1/search",
"method": "POST",
"body": {
"queries": ["Python programming language popularity"],
"max_results": 10,
},
},
"search-multi-query": {
"endpoint": "/v1/search",
"method": "POST",
"body": {
"queries": [
"Python popularity 2025",
"JavaScript frameworks comparison",
"Rust programming adoption",
"machine learning trends",
"cloud computing market",
],
"max_results": 10,
},
},
"search-max-queries": {
"endpoint": "/v1/search",
"method": "POST",
"body": {
"queries": [
"Python web frameworks",
"JavaScript runtime performance",
"Rust memory safety",
"Go concurrency patterns",
"TypeScript adoption rate",
"Kotlin multiplatform",
"Swift server side",
"C++ modern standards",
"Java virtual threads",
"Ruby on Rails 2025",
],
"max_results": 5,
},
},
"search-large-results": {
"endpoint": "/v1/search",
"method": "POST",
"body": {
"queries": ["artificial intelligence news", "climate change research"],
"max_results": 50,
},
},
"search-freshness-day": {
"endpoint": "/v1/search",
"method": "POST",
"body": {
"queries": ["breaking news today"],
"max_results": 10,
"freshness": "day",
},
},
"search-freshness-week": {
"endpoint": "/v1/search",
"method": "POST",
"body": {
"queries": ["technology announcements"],
"max_results": 10,
"freshness": "week",
},
},
"search-site-filter": {
"endpoint": "/v1/search",
"method": "POST",
"body": {
"queries": ["Python programming"],
"max_results": 20,
"site_allowlist": ["wikipedia.org", "python.org", "realpython.com"],
},
},
"search-non-english": {
"endpoint": "/v1/search",
"method": "POST",
"body": {
"queries": ["intelligence artificielle actualités"],
"max_results": 10,
"language": "fr",
"country": "FR",
},
},
# -------------------------------------------------------------------------
# Fetch endpoint variations
# -------------------------------------------------------------------------
"fetch-single": {
"endpoint": "/v1/fetch",
"method": "POST",
"body": {
"urls": [BENCH_URLS[0]],
"timeout_seconds": 30,
},
},
"fetch-multi": {
"endpoint": "/v1/fetch",
"method": "POST",
"body": {
"urls": BENCH_URLS[:5],
"parallel_fetches": 5,
"timeout_seconds": 30,
},
},
"fetch-large": {
"endpoint": "/v1/fetch",
"method": "POST",
"body": {
"urls": BENCH_URLS,
"parallel_fetches": 10,
"timeout_seconds": 45,
},
},
"fetch-serial": {
"endpoint": "/v1/fetch",
"method": "POST",
"body": {
"urls": BENCH_URLS[:5],
"parallel_fetches": 1,
"timeout_seconds": 60,
},
},
"fetch-no-text": {
"endpoint": "/v1/fetch",
"method": "POST",
"body": {
"urls": BENCH_URLS[:3],
"extract_text": False,
"include_html": True,
"timeout_seconds": 30,
},
},
"fetch-no-fallback": {
"endpoint": "/v1/fetch",
"method": "POST",
"body": {
"urls": BENCH_URLS[:3],
"auto_fallback": False,
"method": "http",
"timeout_seconds": 30,
},
},
"fetch-short-timeout": {
"endpoint": "/v1/fetch",
"method": "POST",
"body": {
"urls": BENCH_URLS[:3],
"timeout_seconds": 5,
},
},
# -------------------------------------------------------------------------
# Image search endpoint variations
# -------------------------------------------------------------------------
"image-search-small": {
"endpoint": "/v1/image-search",
"method": "POST",
"body": {
"queries": ["cute cats"],
"max_results": 5,
},
},
"image-search-large": {
"endpoint": "/v1/image-search",
"method": "POST",
"body": {
"queries": ["nature photography", "city skyline"],
"max_results": 100,
},
},
# -------------------------------------------------------------------------
# Gather endpoint variations (unified pipeline)
# -------------------------------------------------------------------------
"gather-minimal": {
"endpoint": "/v1/gather",
"method": "POST",
"body": {
"claim": "Python is the most popular programming language in 2025",
"search_queries": ["Python popularity 2025"],
"max_search_results": 5,
"max_evidence_items": 3,
"extract_snippets": False,
"timeout_seconds": 60,
},
},
"gather-default": {
"endpoint": "/v1/gather",
"method": "POST",
"body": {
"claim": "Python is the most popular programming language in 2025",
"search_queries": [
"Python popularity 2025",
"TIOBE index programming languages",
],
"max_search_results": 10,
"max_evidence_items": 8,
"extract_snippets": False,
"timeout_seconds": 90,
},
},
"gather-snippets": {
"endpoint": "/v1/gather",
"method": "POST",
"body": {
"claim": "Python is the most popular programming language in 2025",
"search_queries": ["Python popularity 2025"],
"max_search_results": 5,
"max_evidence_items": 5,
"extract_snippets": True,
"timeout_seconds": 120,
},
},
"gather-high-parallel": {
"endpoint": "/v1/gather",
"method": "POST",
"body": {
"claim": "Artificial intelligence is transforming healthcare",
"search_queries": [
"AI healthcare applications",
"machine learning medical diagnosis",
],
"max_search_results": 20,
"max_evidence_items": 10,
"parallel_fetches": 10,
"extract_snippets": False,
"timeout_seconds": 90,
},
},
"gather-serial-fetch": {
"endpoint": "/v1/gather",
"method": "POST",
"body": {
"claim": "Electric vehicles are becoming mainstream",
"search_queries": ["electric vehicle adoption 2025"],
"max_search_results": 10,
"max_evidence_items": 5,
"parallel_fetches": 1,
"extract_snippets": False,
"timeout_seconds": 120,
},
},
"gather-no-fallback": {
"endpoint": "/v1/gather",
"method": "POST",
"body": {
"claim": "Renewable energy costs are declining",
"search_queries": ["renewable energy cost trends"],
"max_search_results": 10,
"max_evidence_items": 5,
"fetch_method": "http",
"auto_fallback": False,
"extract_snippets": False,
"timeout_seconds": 60,
},
},
"gather-site-restricted": {
"endpoint": "/v1/gather",
"method": "POST",
"body": {
"claim": "Python is widely used in data science",
"search_queries": ["Python data science"],
"max_search_results": 15,
"max_evidence_items": 8,
"site_allowlist": ["wikipedia.org", "bbc.com", "reuters.com"],
"extract_snippets": False,
"timeout_seconds": 90,
},
},
"gather-max-evidence": {
"endpoint": "/v1/gather",
"method": "POST",
"body": {
"claim": "Climate change is accelerating",
"search_queries": [
"climate change scientific evidence",
"global warming data 2025",
],
"max_search_results": 30,
"max_evidence_items": 25,
"extract_snippets": False,
"timeout_seconds": 180,
},
},
# Legacy aliases for backward compatibility
"default": {
"endpoint": "/v1/gather",
"method": "POST",
"body": {
"claim": "Python is the most popular programming language in 2025",
"search_queries": [
"Python popularity 2025",
"TIOBE index programming languages",
],
"max_search_results": 5,
"max_evidence_items": 5,
"extract_snippets": True,
"timeout_seconds": 90,
},
},
"gather-large": {
"endpoint": "/v1/gather",
"method": "POST",
"body": {
"claim": "Python is the most popular programming language in 2025",
"search_queries": [
"Python popularity 2025",
"TIOBE index programming languages",
],
"max_search_results": 20,
"max_evidence_items": 15,
"extract_snippets": False,
"timeout_seconds": 120,
},
},
"search-only": {
"endpoint": "/v1/search",
"method": "POST",
"body": {
"queries": ["Python popularity 2025", "TIOBE index 2025"],
"max_results": 10,
},
},
"image-search": {
"endpoint": "/v1/image-search",
"method": "POST",
"body": {
"queries": [
"Python programming language",
"machine learning visualization",
],
"max_results": 10,
},
},
"fetch-only": {
"endpoint": "/v1/fetch",
"method": "POST",
"body": {
"urls": BENCH_URLS[:3],
"extract_text": True,
"timeout_seconds": 30,
},
},
}
# ---------------------------------------------------------------------------
# Scenario Groups
# ---------------------------------------------------------------------------
SCENARIO_GROUPS: dict[str, list[str]] = {
"search": [
"search-single",
"search-multi-query",
"search-max-queries",
"search-large-results",
"search-freshness-day",
"search-freshness-week",
"search-site-filter",
"search-non-english",
],
"fetch": [
"fetch-single",
"fetch-multi",
"fetch-large",
"fetch-serial",
"fetch-no-text",
"fetch-no-fallback",
"fetch-short-timeout",
],
"image": [
"image-search-small",
"image-search-large",
],
"gather": [
"gather-minimal",
"gather-default",
"gather-snippets",
"gather-high-parallel",
"gather-serial-fetch",
"gather-no-fallback",
"gather-site-restricted",
"gather-max-evidence",
],
"quick": [
"health",
"search-single",
"fetch-single",
"gather-minimal",
],
}
# curl -w format string for transport-level timing
CURL_WRITE_OUT = json.dumps(
{
"status_code": "%{http_code}",
"time_total": "%{time_total}",
"time_connect": "%{time_connect}",
"time_starttransfer": "%{time_starttransfer}",
"size_download": "%{size_download}",
}
)
# ---------------------------------------------------------------------------
# RunResult
# ---------------------------------------------------------------------------
@dataclass
class RunResult:
scenario: str
iteration: int
status_code: int = 0
curl_time_total: float = 0.0
curl_time_connect: float = 0.0
curl_time_starttransfer: float = 0.0
api_time_ms: float | None = None
stages: dict[str, float] = field(default_factory=dict)
evidence_items: int | None = None
failed_items: int | None = None
queries_processed: int | None = None
tokens_used: int | None = None
error: str | None = None
# ---------------------------------------------------------------------------
# Runner
# ---------------------------------------------------------------------------
def run_single(base_url: str, scenario_name: str, timeout: int) -> RunResult:
"""Execute a single benchmark request via curl."""
scenario = SCENARIOS[scenario_name]
url = f"{base_url}{scenario['endpoint']}"
result = RunResult(scenario=scenario_name, iteration=0)
with tempfile.NamedTemporaryFile(suffix=".json", delete=True) as body_file:
cmd = [
"curl",
"-s",
"--max-time",
str(timeout),
"-w",
CURL_WRITE_OUT,
"-o",
body_file.name,
]
if scenario["method"] == "POST" and scenario["body"] is not None:
cmd += [
"-X",
"POST",
"-H",
"Content-Type: application/json",
"-d",
json.dumps(scenario["body"]),
]
cmd.append(url)
try:
proc = subprocess.run(
cmd, capture_output=True, text=True, timeout=timeout + 10
)
except subprocess.TimeoutExpired:
result.error = "subprocess timeout"
return result
except Exception as exc:
result.error = str(exc)
return result
# Parse curl write-out (appended to stdout)
try:
curl_stats = json.loads(proc.stdout)
result.status_code = int(curl_stats["status_code"])
result.curl_time_total = float(curl_stats["time_total"])
result.curl_time_connect = float(curl_stats["time_connect"])
result.curl_time_starttransfer = float(curl_stats["time_starttransfer"])
except (json.JSONDecodeError, KeyError, ValueError):
result.error = f"failed to parse curl output: {proc.stdout[:200]}"
return result
if result.status_code == 0:
stderr_snippet = proc.stderr[:200] if proc.stderr else "no stderr"
result.error = f"curl failed (code 0): {stderr_snippet}"
return result
# Parse response body
try:
with open(body_file.name) as f:
body = json.load(f)
except (json.JSONDecodeError, OSError):
body = None
if body and isinstance(body, dict):
result.api_time_ms = body.get("execution_time_ms")
# Gather-specific fields (stages, evidence stats)
for stage in body.get("stages", []):
if isinstance(stage, dict) and "stage" in stage:
result.stages[stage["stage"]] = stage.get("duration_ms", 0.0)
stats = body.get("evidence_stats")
if isinstance(stats, dict):
result.evidence_items = stats.get("output_items")
result.tokens_used = stats.get("tokens_used")
# Search / image-search specific
if "total_results" in body:
result.evidence_items = body["total_results"]
if "queries_processed" in body:
result.queries_processed = body["queries_processed"]
# Fetch-specific
if "total_fetched" in body:
result.evidence_items = body["total_fetched"]
if "total_failed" in body:
result.failed_items = body["total_failed"]
# Error in response body
if result.status_code >= 400:
detail = body.get("detail", body.get("error", ""))
if detail:
result.error = str(detail)[:200]
return result
# ---------------------------------------------------------------------------
# Stats
# ---------------------------------------------------------------------------
def _percentile(sorted_vals: list[float], p: float) -> float:
"""Compute the p-th percentile from a sorted list."""
if not sorted_vals:
return 0.0
k = (len(sorted_vals) - 1) * (p / 100.0)
f = math.floor(k)
c = math.ceil(k)
if f == c:
return sorted_vals[int(k)]
return sorted_vals[f] * (c - k) + sorted_vals[c] * (k - f)
def compute_stats(values: list[float]) -> dict[str, float]:
"""Compute min/mean/median/p95/max for a list of values."""
if not values:
return {"min": 0, "mean": 0, "median": 0, "p95": 0, "max": 0}
s = sorted(values)
return {
"min": round(s[0], 2),
"mean": round(sum(s) / len(s), 2),
"median": round(_percentile(s, 50), 2),
"p95": round(_percentile(s, 95), 2),
"max": round(s[-1], 2),
}
def compute_all_stats(results: list[RunResult]) -> dict[str, dict[str, float]]:
"""Compute stats across all metric dimensions."""
out: dict[str, dict[str, float]] = {}
api_times = [r.api_time_ms for r in results if r.api_time_ms is not None]
if api_times:
out["total_ms"] = compute_stats(api_times)
# Collect all stage names
stage_names: set[str] = set()
for r in results:
stage_names.update(r.stages.keys())
for name in sorted(stage_names):
vals = [r.stages[name] for r in results if name in r.stages]
if vals:
out[f"{name}_ms"] = compute_stats(vals)
ttfb = [r.curl_time_starttransfer for r in results if r.curl_time_starttransfer > 0]
if ttfb:
out["curl_ttfb_s"] = compute_stats(ttfb)
totals = [r.curl_time_total for r in results if r.curl_time_total > 0]
if totals:
out["curl_total_s"] = compute_stats(totals)
return out
# ---------------------------------------------------------------------------
# Reporter
# ---------------------------------------------------------------------------
def _fmt_stages(r: RunResult) -> str:
if not r.stages:
return ""
parts = [f"{k}:{v:.0f}" for k, v in r.stages.items()]
return f" [{' '.join(parts)}]"
def _fmt_items(r: RunResult) -> str:
parts = []
if r.evidence_items is not None:
parts.append(f"{r.evidence_items} items")
if r.failed_items is not None and r.failed_items > 0:
parts.append(f"{r.failed_items} failed")
if r.queries_processed is not None:
parts.append(f"{r.queries_processed} queries")
return f" ({', '.join(parts)})" if parts else ""
def print_run(r: RunResult, quiet: bool) -> None:
"""Print a single run result line."""
if quiet:
return
if r.error:
print(f" #{r.iteration} ERROR: {r.error}")
return
time_str = (
f"{r.api_time_ms:.0f}ms"
if r.api_time_ms is not None
else f"{r.curl_time_total:.2f}s"
)
print(f" #{r.iteration} {time_str}{_fmt_stages(r)}{_fmt_items(r)}")
def print_stats_table(stats: dict[str, dict[str, float]]) -> None:
"""Print the statistics summary table."""
if not stats:
return
header = (
f" {'Metric':<16} {'min':>8} {'mean':>8} {'median':>8} {'p95':>8} {'max':>8}"
)
print()
print(header)
print(f" {'' * 56}")
for metric, vals in stats.items():
fmt = ".0f" if metric.endswith("_ms") else ".2f"
print(
f" {metric:<16}"
f" {vals['min']:>8{fmt}}"
f" {vals['mean']:>8{fmt}}"
f" {vals['median']:>8{fmt}}"
f" {vals['p95']:>8{fmt}}"
f" {vals['max']:>8{fmt}}"
)
print()
def print_docker_logs(container: str) -> None:
"""Print recent docker logs."""
print(f"=== Recent logs ({container}) ===")
try:
proc = subprocess.run(
["docker", "logs", container, "--tail", "10"],
capture_output=True,
text=True,
timeout=5,
)
output = proc.stdout or proc.stderr or "(no output)"
print(output.rstrip())
except (subprocess.TimeoutExpired, FileNotFoundError):
print(" (docker logs unavailable)")
print()
# ---------------------------------------------------------------------------
# Main execution
# ---------------------------------------------------------------------------
def run_scenario(
base_url: str,
scenario_name: str,
iterations: int,
warmup: int,
concurrency: int,
timeout: int,
quiet: bool,
) -> list[RunResult]:
"""Run a scenario for the given number of iterations."""
scenario = SCENARIOS[scenario_name]
method = scenario["method"]
endpoint = scenario["endpoint"]
print(f"=== {scenario_name} ({method} {endpoint}) ===")
# Warmup
if warmup > 0:
sys.stdout.write(" warmup...")
sys.stdout.flush()
t0 = time.monotonic()
for _ in range(warmup):
run_single(base_url, scenario_name, timeout)
elapsed = time.monotonic() - t0
print(f"done ({elapsed:.1f}s)")
results: list[RunResult] = []
if concurrency <= 1:
# Sequential mode
for i in range(1, iterations + 1):
r = run_single(base_url, scenario_name, timeout)
r.iteration = i
results.append(r)
print_run(r, quiet)
else:
# Concurrent mode
for i in range(1, iterations + 1):
batch: list[RunResult] = []
with ThreadPoolExecutor(max_workers=concurrency) as pool:
futures = {
pool.submit(run_single, base_url, scenario_name, timeout): c
for c in range(concurrency)
}
for fut in as_completed(futures):
r = fut.result()
r.iteration = i
batch.append(r)
results.extend(batch)
if not quiet:
ok = sum(1 for r in batch if r.error is None)
times = [r.curl_time_total for r in batch if r.error is None]
avg = sum(times) / len(times) if times else 0
print(
f" #{i} {concurrency} reqs {ok}/{concurrency} ok avg {avg:.2f}s"
)
# Stats
successful = [r for r in results if r.error is None]
if len(successful) >= 2:
stats = compute_all_stats(successful)
print_stats_table(stats)
elif successful:
print()
else:
print(" No successful runs.\n")
# Concurrency throughput summary
if concurrency > 1 and successful:
total_time = sum(r.curl_time_total for r in successful)
wall_time = total_time / concurrency
rps = len(successful) / wall_time if wall_time > 0 else 0
print(
f" throughput: ~{rps:.1f} req/s ({len(successful)} reqs, {concurrency} concurrent)"
)
print()
return results
def build_json_output(
base_url: str,
all_results: dict[str, list[RunResult]],
tag: str | None = None,
) -> dict:
"""Build the JSON output structure."""
scenarios_out = {}
for name, results in all_results.items():
successful = [r for r in results if r.error is None]
stats = compute_all_stats(successful) if len(successful) >= 2 else {}
scenarios_out[name] = {
"config": SCENARIOS[name],
"runs": [asdict(r) for r in results],
"stats": stats,
}
output: dict = {
"timestamp": datetime.now(timezone.utc).isoformat(),
"base_url": base_url,
"scenarios": scenarios_out,
}
if tag:
output["tag"] = tag
return output
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="web API benchmark runner",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=(
f"Available scenarios: {', '.join(SCENARIOS)}\n"
f"Available groups: {', '.join(SCENARIO_GROUPS)}"
),
)
parser.add_argument(
"--base-url",
default="http://localhost:51100",
help="API base URL (default: http://localhost:51100)",
)
parser.add_argument(
"-n",
"--iterations",
type=int,
default=3,
help="runs per scenario (default: 3)",
)
parser.add_argument(
"--warmup",
type=int,
default=1,
help="warmup runs excluded from stats (default: 1)",
)
parser.add_argument(
"-c",
"--concurrency",
type=int,
default=1,
help="parallel requests per iteration (default: 1)",
)
parser.add_argument(
"-s",
"--scenario",
default=None,
choices=list(SCENARIOS.keys()),
help="single scenario to run",
)
parser.add_argument(
"--group",
default=None,
choices=list(SCENARIO_GROUPS.keys()),
help="run all scenarios in a group",
)
parser.add_argument(
"--all",
action="store_true",
help="run all scenarios",
)
parser.add_argument(
"--timeout",
type=int,
default=120,
help="curl --max-time in seconds (default: 120)",
)
parser.add_argument(
"-o",
"--output",
default=None,
help="write JSON results to file",
)
parser.add_argument(
"--tag",
default=None,
help="label for this benchmark run (saved in JSON output)",
)
parser.add_argument(
"--docker-logs",
default="didiAI-web-api",
help="container name for log tail (empty to skip, default: didiAI-web-api)",
)
parser.add_argument(
"-q",
"--quiet",
action="store_true",
help="suppress per-run output",
)
return parser.parse_args(argv)
def main(argv: list[str] | None = None) -> None:
args = parse_args(argv)
# Determine which scenarios to run
if args.all:
# All scenarios except legacy aliases
legacy = {"default", "search-only", "image-search", "fetch-only"}
scenario_names = [s for s in SCENARIOS if s not in legacy]
elif args.group:
scenario_names = SCENARIO_GROUPS[args.group]
elif args.scenario:
scenario_names = [args.scenario]
else:
scenario_names = ["gather-default"]
print("bench.py — web API benchmark")
print(f"base_url: {args.base_url}")
if args.tag:
print(f"tag: {args.tag}")
print(f"scenarios: {', '.join(scenario_names)}")
warmup_note = f" (+ {args.warmup} warmup)" if args.warmup else ""
conc_note = f", concurrency {args.concurrency}" if args.concurrency > 1 else ""
print(f"iterations: {args.iterations}{warmup_note}{conc_note}")
print()
all_results: dict[str, list[RunResult]] = {}
for name in scenario_names:
results = run_scenario(
base_url=args.base_url,
scenario_name=name,
iterations=args.iterations,
warmup=args.warmup,
concurrency=args.concurrency,
timeout=args.timeout,
quiet=args.quiet,
)
all_results[name] = results
# Docker logs
if args.docker_logs:
print_docker_logs(args.docker_logs)
# JSON output
if args.output:
output = build_json_output(args.base_url, all_results, args.tag)
with open(args.output, "w") as f:
json.dump(output, f, indent=2)
print(f"Results written to {args.output}")
if __name__ == "__main__":
main()

View file

@ -0,0 +1,71 @@
# Web API Server
# Multi-stage build for smaller final image
FROM python:3.11.12-slim AS builder
# Install uv
COPY --from=ghcr.io/astral-sh/uv:0.10 /uv /usr/local/bin/uv
WORKDIR /app
# Layer 1: Install dependencies only (cached unless pyproject.toml changes)
COPY pyproject.toml README.md ./
RUN uv sync --no-dev --all-extras --no-install-project
# Layer 2: Install Playwright browsers (cached with dependencies)
RUN uv run playwright install chromium --with-deps
# Layer 3: Copy source and install project (fast — deps already cached)
COPY src/ ./src/
RUN uv sync --no-dev --all-extras
# Production image
FROM python:3.11.12-slim
WORKDIR /app
# Install Playwright dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
libnss3 libnspr4 libdbus-1-3 libatk1.0-0 libatk-bridge2.0-0 \
libcups2 libdrm2 libxkbcommon0 libatspi2.0-0 libxcomposite1 \
libxdamage1 libxfixes3 libxrandr2 libgbm1 libasound2 \
libcairo2 libpango-1.0-0 libpangocairo-1.0-0 libgdk-pixbuf-2.0-0 \
libgtk-3-0 libx11-6 libx11-xcb1 libxcb1 libxext6 libxrender1 \
fonts-liberation fonts-noto-color-emoji libopus0 libharfbuzz0b \
libglib2.0-0 libxshmfence1 \
&& rm -rf /var/lib/apt/lists/*
# Create non-root user
RUN groupadd -r webuser && useradd -r -g webuser -d /home/webuser -m webuser
# Copy virtual environment from builder
COPY --from=builder /app/.venv /app/.venv
# Copy Playwright browsers to non-root user's home
COPY --from=builder /root/.cache/ms-playwright /home/webuser/.cache/ms-playwright
# Copy source code
COPY src/ ./src/
# Set ownership
RUN chown -R webuser:webuser /app /home/webuser
# Set environment variables
ENV PATH="/app/.venv/bin:$PATH"
ENV PYTHONUNBUFFERED=1
ENV WEB_HOST=0.0.0.0
ENV WEB_PORT=51100
ENV PLAYWRIGHT_BROWSERS_PATH=/home/webuser/.cache/ms-playwright
# Switch to non-root user
USER webuser
# Health check
HEALTHCHECK --interval=30s --timeout=10s --retries=3 \
CMD python -c "import urllib.request; import os; urllib.request.urlopen(f'http://localhost:{os.environ.get(\"WEB_PORT\", 51100)}/health')" || exit 1
# Expose port
EXPOSE 51100
# Run the server
CMD ["python", "-m", "web.cli"]

View file

@ -0,0 +1,105 @@
#!/usr/bin/env bash
#
# Docker Compose Startup Script for Web Module
#
# Usage: ./deploy/deploy.sh [OPTIONS]
#
# Options:
# --profile <api> Docker compose profile (default: api)
# --detach, -d Run in detached mode
# --down Stop and remove containers
# --logs Show logs
# --help, -h Show this help message
#
# Required: Set environment variables in .env file or export them before running.
# See .env.example for the full list of required variables.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# Load .env file if it exists
ENV_FILE=""
if [[ -f "$SCRIPT_DIR/.env" ]]; then
ENV_FILE="$SCRIPT_DIR/.env"
elif [[ -f "$SCRIPT_DIR/../.env" ]]; then
ENV_FILE="$SCRIPT_DIR/../.env"
fi
if [[ -n "$ENV_FILE" ]]; then
echo "Loading environment from: $ENV_FILE"
set -a
source "$ENV_FILE"
set +a
fi
PROFILE="api"
DETACH=""
ACTION="up"
show_help() {
sed -n '2,15p' "$0" | sed 's/^# //' | sed 's/^#//'
exit 0
}
check_required_var() {
local var_name="$1"
if [[ -z "${!var_name:-}" ]]; then
echo "ERROR: Required environment variable $var_name is not set"
echo "Set it in .env file or export it before running this script"
exit 1
fi
}
# Parse arguments
while [[ $# -gt 0 ]]; do
case $1 in
--profile)
PROFILE="$2"
shift 2
;;
--detach|-d)
DETACH="-d"
shift
;;
--down)
ACTION="down"
shift
;;
--logs)
ACTION="logs"
shift
;;
--help|-h)
show_help
;;
*)
echo "Unknown option: $1"
echo "Use --help for usage information"
exit 1
;;
esac
done
# Check required variables
check_required_var "WEB_LLM_BASE_URL"
check_required_var "WEB_EXTERNAL_URL"
check_required_var "WEB_SEARXNG_BASE_URL"
cd "$SCRIPT_DIR"
case $ACTION in
up)
echo "Starting Web module with profile: $PROFILE"
echo ""
# shellcheck disable=SC2086
exec docker compose --profile "$PROFILE" up $DETACH
;;
down)
echo "Stopping Web module containers..."
exec docker compose --profile "$PROFILE" down
;;
logs)
exec docker compose --profile "$PROFILE" logs -f
;;
esac

View file

@ -0,0 +1,53 @@
# Web Module - Docker Compose Configuration
#
# Port Allocation (Dev API Gateway: 51100):
# 51100 - Web API (fact-checking service)
#
# Naming Convention: didiAI-{module}-{service}
#
# Profiles:
# api - Web API server
networks:
didi-network:
external: true # single shared network for all DIDI + AI platform stacks
services:
# ==========================================================================
# Web API Server
# ==========================================================================
web-api:
container_name: didiAI-web-api
image: didiai-web-api
build:
context: ..
dockerfile: deploy/Dockerfile
ports:
- "51100:51100"
shm_size: '2gb'
networks:
- didi-network
env_file:
- .env
environment:
# Container-internal overrides (always set regardless of .env)
- WEB_HOST=0.0.0.0
- WEB_PORT=51100
# Tier-3 search fallback (cloak stealth-browser scraping service).
# Default OFF; flip to true once cloak service is verified healthy.
- WEB_CLOAK_ENABLED=${WEB_CLOAK_ENABLED:-false}
- WEB_CLOAK_URL=${WEB_CLOAK_URL:-http://didiAI-cloak:8770}
- WEB_CLOAK_FALLBACK_THRESHOLD=${WEB_CLOAK_FALLBACK_THRESHOLD:-5}
- WEB_CLOAK_TIMEOUT_SEC=${WEB_CLOAK_TIMEOUT_SEC:-20}
# OTel — traces to Jaeger via OTel Collector
- OTEL_EXPORTER_OTLP_ENDPOINT=${OTEL_EXPORTER_OTLP_ENDPOINT:-http://didi-otel-collector:4317}
- OTEL_SERVICE_NAME=didiAI-web-api
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:51100/health')"]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
restart: unless-stopped
profiles:
- api

View file

@ -0,0 +1,91 @@
{
admin off
log {
output stderr
format filter {
# Preserves first 8 bits from IPv4 and 32 bits from IPv6
request>remote_ip ip_mask 8 32
request>client_ip ip_mask 8 32
# Remove identificable information
request>remote_port delete
request>headers delete
request>uri query {
delete url
delete h
delete q
}
}
}
servers {
client_ip_headers X-Forwarded-For X-Real-IP
# Allow the following IP to passthrough the "X-Forwarded-*" headers to SearXNG
# https://caddyserver.com/docs/caddyfile/options#trusted-proxies
trusted_proxies static private_ranges
trusted_proxies_strict
}
}
{$SEARXNG_HOSTNAME}
tls {$SEARXNG_TLS}
encode zstd gzip
@api {
path /config
path /healthz
path /stats/errors
path /stats/checker
}
@static {
path /static/*
}
@imageproxy {
path /image_proxy
}
header {
# CSP (https://content-security-policy.com)
Content-Security-Policy "upgrade-insecure-requests; default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; form-action 'self' https:; font-src 'self'; frame-ancestors 'self'; base-uri 'self'; connect-src 'self'; img-src * data:; frame-src https:;"
# Disable browser features
Permissions-Policy "accelerometer=(),camera=(),geolocation=(),gyroscope=(),magnetometer=(),microphone=(),payment=(),usb=()"
# Only allow same-origin requests
Referrer-Policy "same-origin"
# Prevent MIME type sniffing from the declared Content-Type
X-Content-Type-Options "nosniff"
# Comment header to allow indexing by search engines
X-Robots-Tag "noindex, nofollow, noarchive, nositelinkssearchbox, nosnippet, notranslate, noimageindex"
# enable HSTS
# WARNING: Once this value is set, the site must continue to support HTTPS until the expiry time is reached.
# Strict-Transport-Security max-age=15768000;
# Remove "Server" header
-Server
}
header @api {
Access-Control-Allow-Methods "GET, OPTIONS"
Access-Control-Allow-Origin "*"
}
route {
# Cache policy
header Cache-Control "no-cache"
header @static Cache-Control "public, max-age=30, stale-while-revalidate=60"
header @imageproxy Cache-Control "public, max-age=3600"
}
# SearXNG
reverse_proxy localhost:8080

View file

@ -0,0 +1,661 @@
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU Affero General Public License is a free, copyleft license for
software and other kinds of works, specifically designed to ensure
cooperation with the community in the case of network server software.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
our General Public Licenses are intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
Developers that use our General Public Licenses protect your rights
with two steps: (1) assert copyright on the software, and (2) offer
you this License which gives you legal permission to copy, distribute
and/or modify the software.
A secondary benefit of defending all users' freedom is that
improvements made in alternate versions of the program, if they
receive widespread use, become available for other developers to
incorporate. Many developers of free software are heartened and
encouraged by the resulting cooperation. However, in the case of
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.
The GNU Affero General Public License is designed specifically to
ensure that, in such cases, the modified source code becomes available
to the community. It requires the operator of a network server to
provide the source code of the modified version running there to the
users of that server. Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.
An older license, called the Affero General Public License and
published by Affero, was designed to accomplish similar goals. This is
a different license, not a version of the Affero GPL, but Affero has
released a new version of the Affero GPL which permits relicensing under
this license.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU Affero General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Remote Network Interaction; Use with the GNU General Public License.
Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a computer network (if your version
supports such interaction) an opportunity to receive the Corresponding
Source of your version by providing access to the Corresponding Source
from a network server at no charge, through some standard or customary
means of facilitating copying of software. This Corresponding Source
shall include the Corresponding Source for any work covered by version 3
of the GNU General Public License that is incorporated pursuant to the
following paragraph.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the work with which it is combined will remain governed by version
3 of the GNU General Public License.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU Affero General Public License from time to time. Such new versions
will be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU Affero General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU Affero General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU Affero General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If your software can interact with users remotely through a computer
network, you should also make sure that it provides a way for users to
get its source. For example, if your program is a web application, its
interface could display a "Source" link that leads users to an archive
of the code. There are many ways you could offer source, and different
solutions will be better for different programs; see section 13 for the
specific requirements.
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU AGPL, see
<https://www.gnu.org/licenses/>.

View file

@ -0,0 +1,119 @@
# searxng-docker
Create a new SearXNG instance in five minutes using Docker
## What is included?
| Name | Description | Docker image | Dockerfile |
|-----------------------------------------------|----------------------------------------------------------------|------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| [Caddy](https://github.com/caddyserver/caddy) | Reverse proxy (create a LetsEncrypt certificate automatically) | [docker.io/library/caddy:2-alpine](https://hub.docker.com/_/caddy) | [Dockerfile](https://github.com/caddyserver/caddy-docker/blob/master/Dockerfile.tmpl) |
| [SearXNG](https://github.com/searxng/searxng) | SearXNG by itself | [docker.io/searxng/searxng:latest](https://hub.docker.com/r/searxng/searxng) | [builder.dockerfile](https://github.com/searxng/searxng/blob/master/container/builder.dockerfile) [dist.dockerfile](https://github.com/searxng/searxng/blob/master/container/dist.dockerfile) |
| [Valkey](https://github.com/valkey-io/valkey) | In-memory database | [docker.io/valkey/valkey:8-alpine](https://hub.docker.com/r/valkey/valkey) | [Dockerfile](https://github.com/valkey-io/valkey-container/blob/mainline/Dockerfile.template) |
## How to use it
There are two ways to host SearXNG. The first one doesn't require any prior knowledge about self-hosting and thus is
recommended for beginners. It includes caddy as a reverse proxy and automatically deals with the TLS certificates for
you. The second one is recommended for more advanced users that already have their own reverse proxy (e.g. Nginx,
HAProxy, ...) and probably some other services running on their machine. The first few steps are the same for both
installation methods however.
1. [Install docker](https://docs.docker.com/install/)
2. Get searxng-docker
```shell
cd /usr/local
git clone https://github.com/searxng/searxng-docker.git
cd searxng-docker
```
3. Edit the [.env](https://github.com/searxng/searxng-docker/blob/master/.env) file to set the hostname and an email
4. Generate the secret key `sed -i "s|ultrasecretkey|$(openssl rand -hex 32)|g" searxng/settings.yml`
On a Mac: `sed -i '' "s|ultrasecretkey|$(openssl rand -hex 32)|g" searxng/settings.yml`
5. Edit [searxng/settings.yml](https://github.com/searxng/searxng-docker/blob/master/searxng/settings.yml) according to
your needs
> [!NOTE]
> Windows users can use the following powershell script to generate the secret key:
> ```powershell
> $randomBytes = New-Object byte[] 32
> (New-Object Security.Cryptography.RNGCryptoServiceProvider).GetBytes($randomBytes)
> $secretKey = -join ($randomBytes | ForEach-Object { "{0:x2}" -f $_ })
> (Get-Content searxng/settings.yml) -replace 'ultrasecretkey', $secretKey | Set-Content searxng/settings.yml
> ```
### Method 1: With Caddy included (recommended for beginners)
6. Run SearXNG in the background: `docker compose up -d`
### Method 2: Bring your own reverse proxy (experienced users)
6. Remove the caddy related parts in `docker-compose.yaml` such as the caddy service and its volumes.
7. Point your reverse proxy to the port set for the `searxng` service in `docker-compose.yml` (8080 by default).
8. Generate and configure the required TLS certificates with the reverse proxy of your choice.
9. Run SearXNG in the background: `docker compose up -d`
> [!NOTE]
> You can change the port `searxng` listens on inside the docker container (e.g. if you want to operate in `host`
> network mode) with the `BIND_ADDRESS` environment variable (defaults to `[::]:8080`). The environment variable can be
> set directly inside `docker-compose.yaml`.
## Troubleshooting - How to access the logs
To access the logs from all the containers use: `docker compose logs -f`.
To access the logs of one specific container:
- Caddy: `docker compose logs -f caddy`
- SearXNG: `docker compose logs -f searxng`
- Valkey: `docker compose logs -f redis`
### Start SearXNG with systemd
You can skip this step if you don't use systemd.
1. Copy the service template file:
```sh
cp searxng-docker.service.template searxng-docker.service
```
2. Edit the content of ```WorkingDirectory``` in the ```searxng-docker.service``` file (only if the installation path is
different from ```/usr/local/searxng-docker```)
3. Enable the service:
```sh
systemctl enable $(pwd)/searxng-docker.service
```
4. Start the service:
```sh
systemctl start searxng-docker.service
```
**Note:** Ensure the service file path matches your installation directory before enabling it.
## Multi Architecture Docker images
Supported architecture:
- amd64
- arm64
- arm/v7
## How to update ?
To update the SearXNG stack:
```sh
git pull
docker compose pull
docker compose up -d
```
Or the old way (with the old docker-compose version):
```sh
git pull
docker-compose pull
docker-compose up -d
```

View file

@ -0,0 +1,201 @@
# Metasearch Module - Docker Compose Configuration
#
# Architecture:
# nginx (load balancer) -> 3x SearXNG instances + dedicated Redis each
# Round-robin distributes requests to avoid CAPTCHA/rate-limits
#
# Port Allocation (x55xx = Backend Services / Search):
# 55100 - nginx load balancer (entry point)
#
# Naming Convention: didiAI-{module}-{service}
#
# Network:
# Uses deploy_default network (shared with other modules)
networks:
didi-network:
external: true # single shared network for all DIDI + AI platform stacks
services:
# ==========================================================================
# Nginx Load Balancer (entry point)
# ==========================================================================
searxng-lb:
container_name: didiAI-web-searxng
image: nginx:1.27-alpine
ports:
- "${SEARXNG_PORT:-55100}:8080"
networks:
- didi-network
volumes:
- ./nginx-lb.conf:/etc/nginx/nginx.conf:ro
depends_on:
searxng-1:
condition: service_healthy
searxng-2:
condition: service_healthy
searxng-3:
condition: service_healthy
healthcheck:
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:8080/healthz"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
restart: unless-stopped
# ==========================================================================
# SearXNG Instance 1
# ==========================================================================
searxng-1:
container_name: didiAI-web-searxng-1
image: docker.io/searxng/searxng:latest
networks:
- didi-network
volumes:
- ./searxng-1:/etc/searxng:rw
- searxng-data-1:/var/cache/searxng:rw
environment:
- SEARXNG_BASE_URL=http://localhost:55100
depends_on:
redis-1:
condition: service_healthy
tor:
condition: service_healthy
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://localhost:8080/healthz"]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
restart: unless-stopped
# ==========================================================================
# SearXNG Instance 2
# ==========================================================================
searxng-2:
container_name: didiAI-web-searxng-2
image: docker.io/searxng/searxng:latest
networks:
- didi-network
volumes:
- ./searxng-2:/etc/searxng:rw
- searxng-data-2:/var/cache/searxng:rw
environment:
- SEARXNG_BASE_URL=http://localhost:55100
depends_on:
redis-2:
condition: service_healthy
tor:
condition: service_healthy
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://localhost:8080/healthz"]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
restart: unless-stopped
# ==========================================================================
# SearXNG Instance 3
# ==========================================================================
searxng-3:
container_name: didiAI-web-searxng-3
image: docker.io/searxng/searxng:latest
networks:
- didi-network
volumes:
- ./searxng-3:/etc/searxng:rw
- searxng-data-3:/var/cache/searxng:rw
environment:
- SEARXNG_BASE_URL=http://localhost:55100
depends_on:
redis-3:
condition: service_healthy
tor:
condition: service_healthy
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://localhost:8080/healthz"]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
restart: unless-stopped
# ==========================================================================
# Tor SOCKS5 proxy (shared by all SearXNG instances)
# ==========================================================================
tor:
container_name: didiAI-web-tor
image: dperson/torproxy:latest
networks:
- didi-network
environment:
- TOR_MaxCircuitDirtiness=300
- TOR_NewCircuitPeriod=60
healthcheck:
test: ["CMD", "curl", "-sf", "--socks5-hostname", "127.0.0.1:9050", "https://check.torproject.org/api/ip"]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
restart: unless-stopped
# ==========================================================================
# Redis instances (one per SearXNG for isolated cache/state)
# ==========================================================================
redis-1:
container_name: didiAI-web-searxng-redis-1
image: docker.io/valkey/valkey:8-alpine
command: valkey-server --save 30 1 --loglevel warning
networks:
- didi-network
volumes:
- searxng-redis-data-1:/data
healthcheck:
test: ["CMD", "valkey-cli", "ping"]
interval: 10s
timeout: 5s
retries: 3
start_period: 10s
restart: unless-stopped
redis-2:
container_name: didiAI-web-searxng-redis-2
image: docker.io/valkey/valkey:8-alpine
command: valkey-server --save 30 1 --loglevel warning
networks:
- didi-network
volumes:
- searxng-redis-data-2:/data
healthcheck:
test: ["CMD", "valkey-cli", "ping"]
interval: 10s
timeout: 5s
retries: 3
start_period: 10s
restart: unless-stopped
redis-3:
container_name: didiAI-web-searxng-redis-3
image: docker.io/valkey/valkey:8-alpine
command: valkey-server --save 30 1 --loglevel warning
networks:
- didi-network
volumes:
- searxng-redis-data-3:/data
healthcheck:
test: ["CMD", "valkey-cli", "ping"]
interval: 10s
timeout: 5s
retries: 3
start_period: 10s
restart: unless-stopped
volumes:
searxng-data-1:
searxng-data-2:
searxng-data-3:
searxng-redis-data-1:
searxng-redis-data-2:
searxng-redis-data-3:

View file

@ -0,0 +1,43 @@
import http from 'k6/http';
import { check, sleep } from 'k6';
import { Rate, Trend } from 'k6/metrics';
// Custom metrics
const errorRate = new Rate('errors');
const searchDuration = new Trend('search_duration');
// Simple baseline test - 1 user, 2 minutes
export const options = {
vus: 1, // 1 virtual user
duration: '2m', // 2 minutes
thresholds: {
http_req_duration: ['p(95)<3000'], // 95% under 3s
errors: ['rate<0.05'], // Error rate < 5%
},
};
const queries = [
'python tutorial',
'machine learning',
'linux commands',
'docker compose',
];
export default function () {
const query = queries[Math.floor(Math.random() * queries.length)];
const url = `http://localhost:8080/search?q=${encodeURIComponent(query)}&format=html`;
const res = http.get(url);
searchDuration.add(res.timings.duration);
errorRate.add(res.status !== 200);
check(res, {
'status is 200': (r) => r.status === 200,
'response has results': (r) => r.body.includes('result'),
'response time < 3s': (r) => r.timings.duration < 3000,
});
sleep(2); // 2 seconds between requests
}

View file

@ -0,0 +1,144 @@
import http from 'k6/http';
import { check, sleep } from 'k6';
import { Rate, Trend } from 'k6/metrics';
// Custom metrics
const errorRate = new Rate('errors');
const searchDuration = new Trend('search_duration');
// Test configuration - 50 users, similar to previous test
export const options = {
stages: [
{ duration: '2m', target: 10 }, // Ramp up to 10 users
{ duration: '5m', target: 10 }, // Stay at 10 users
{ duration: '2m', target: 50 }, // Ramp up to 50 users
{ duration: '5m', target: 50 }, // Stay at 50 users
{ duration: '2m', target: 0 }, // Ramp down
],
thresholds: {
http_req_duration: ['p(95)<10000'], // 95% under 10s (longer for complex queries)
errors: ['rate<0.1'],
},
};
// =============================================================================
// REAL FACT-CHECKING CLAIMS - All TRUE and VERIFIABLE
// These simulate what users would search on a disinformation verification platform
// =============================================================================
const factCheckClaims = [
// --- US POLITICS ---
"Joe Biden signed Infrastructure Investment and Jobs Act 2021",
"Donald Trump impeached twice by House of Representatives",
"Barack Obama born in Hawaii August 4 1961",
"January 6 2021 Capitol building attack Washington DC",
"Supreme Court overturned Roe v Wade June 2022",
"Biden student loan forgiveness plan blocked by Supreme Court",
"Trump classified documents Mar-a-Lago FBI search 2022",
"Ketanji Brown Jackson first Black woman Supreme Court",
// --- EUROPEAN POLITICS ---
"Russia invaded Ukraine February 24 2022",
"UK Brexit officially completed January 31 2020",
"Emmanuel Macron reelected French president 2022",
"Olaf Scholz became German Chancellor December 2021",
"Boris Johnson resigned as UK Prime Minister July 2022",
"Sweden Finland applied NATO membership 2022",
"Italy Giorgia Meloni first female prime minister 2022",
"European Union sanctions Russia Ukraine invasion",
// --- COVID-19 FACTS ---
"COVID-19 pandemic declared by WHO March 11 2020",
"Pfizer BioNTech vaccine FDA emergency authorization December 2020",
"COVID-19 originated Wuhan China December 2019",
"WHO declared COVID-19 emergency over May 2023",
"mRNA vaccines developed Moderna Pfizer coronavirus",
"COVID-19 global deaths exceeded 6 million 2022",
// --- CLIMATE & SCIENCE ---
"Paris Agreement climate change signed 2015",
"IPCC report 1.5 degrees celsius warming target",
"NASA confirms 2023 hottest year on record",
"Arctic sea ice minimum record low 2012",
"UN climate summit COP28 Dubai 2023",
"Greenhouse gas emissions carbon dioxide methane",
// --- ELECTIONS & DEMOCRACY ---
"2020 US presidential election Biden 306 electoral votes",
"Brazil Lula da Silva elected president October 2022",
"India Narendra Modi BJP won 2024 general election",
"Mexico Claudia Sheinbaum elected first female president 2024",
"Argentina Javier Milei elected president November 2023",
"Poland Donald Tusk returned as prime minister 2023",
// --- INTERNATIONAL EVENTS ---
"Hamas attack Israel October 7 2023",
"Nord Stream pipeline explosions September 2022",
"Queen Elizabeth II died September 8 2022",
"Evan Gershkovich Wall Street Journal detained Russia",
"Wagner Group rebellion Prigozhin June 2023",
"China spy balloon shot down US February 2023",
// --- ECONOMIC FACTS ---
"US Federal Reserve raised interest rates 2022 2023",
"Silicon Valley Bank collapsed March 2023",
"US debt ceiling crisis June 2023",
"Inflation rate US peaked 9.1 percent June 2022",
"Twitter acquired by Elon Musk October 2022 renamed X",
"OpenAI released ChatGPT November 2022",
// --- MISINFORMATION TOPICS (TRUE FACTS TO COUNTER) ---
"vaccines do not cause autism scientific consensus",
"earth is approximately 4.5 billion years old",
"climate change caused by human activity scientific consensus",
"2020 US election no widespread voter fraud evidence",
"COVID-19 lab leak theory investigated WHO",
"5G technology does not spread coronavirus",
// --- POLITICAL QUOTES (VERIFIABLE) ---
"Putin called Ukraine invasion special military operation",
"Trump said inject disinfectant COVID briefing April 2020",
"Biden said democracy is on the ballot 2024",
"Zelensky addressed US Congress December 2022",
"Xi Jinping third term Chinese president 2023",
// --- LEGAL & INVESTIGATIONS ---
"Trump indicted Manhattan DA March 2023",
"Trump indicted classified documents June 2023",
"Trump indicted January 6 investigation August 2023",
"Hunter Biden convicted gun charges June 2024",
"Alex Jones ordered pay Sandy Hook families damages",
"Sam Bankman-Fried FTX fraud conviction 2023",
// --- TECHNOLOGY & AI ---
"artificial intelligence ChatGPT GPT-4 release 2023",
"European Union AI Act regulation passed 2024",
"Google Bard AI chatbot released 2023",
"Elon Musk founded xAI company 2023",
"deepfake technology election misinformation concerns",
"social media algorithms spread misinformation studies",
];
export default function () {
// Random claim from list
const claim = factCheckClaims[Math.floor(Math.random() * factCheckClaims.length)];
// Make search request
const url = `http://localhost:8080/search?q=${encodeURIComponent(claim)}&format=html`;
const res = http.get(url);
// Record metrics
searchDuration.add(res.timings.duration);
errorRate.add(res.status !== 200);
// Validate response
check(res, {
'status is 200': (r) => r.status === 200,
'response has results': (r) => r.body.includes('result'),
'response time < 10s': (r) => r.timings.duration < 10000,
});
// Think time (user reads results, 2-5 seconds)
sleep(Math.random() * 3 + 2);
}

View file

@ -0,0 +1,60 @@
import http from 'k6/http';
import { check, sleep } from 'k6';
import { Rate, Trend } from 'k6/metrics';
// Custom metrics
const errorRate = new Rate('errors');
const searchDuration = new Trend('search_duration');
// Test configuration
export const options = {
// Test stages (ramp up, steady, ramp down)
stages: [
{ duration: '2m', target: 10 }, // Ramp up to 10 users
{ duration: '5m', target: 10 }, // Stay at 10 users
{ duration: '2m', target: 50 }, // Ramp up to 50 users
{ duration: '5m', target: 50 }, // Stay at 50 users
{ duration: '2m', target: 0 }, // Ramp down
],
// Pass/fail thresholds
thresholds: {
http_req_duration: ['p(95)<5000'], // 95% under 5s
errors: ['rate<0.1'], // Error rate < 10%
},
};
// Sample search queries (vary these!)
const queries = [
'python tutorial',
'machine learning',
'linux commands',
'javascript async await',
'docker compose',
'rust programming',
'kubernetes deployment',
'postgresql optimization',
];
export default function () {
// Random query from list
const query = queries[Math.floor(Math.random() * queries.length)];
// Make search request
const url = `http://localhost:8080/search?q=${encodeURIComponent(query)}&format=html`;
const res = http.get(url);
// Record custom metrics
searchDuration.add(res.timings.duration);
errorRate.add(res.status !== 200);
// Validate response
check(res, {
'status is 200': (r) => r.status === 200,
'response has results': (r) => r.body.includes('result'),
'response time < 5s': (r) => r.timings.duration < 5000,
});
// Think time (simulates real user)
sleep(Math.random() * 3 + 1); // 1-4 seconds between requests
}

View file

@ -0,0 +1,69 @@
import http from 'k6/http';
import { check, sleep } from 'k6';
import { Rate, Trend, Counter } from 'k6/metrics';
const errorRate = new Rate('errors');
const searchDuration = new Trend('search_duration');
const requestCount = new Counter('total_requests');
// =============================================================================
// STRESS TEST - Push to 200 users, find breaking point
// Duration: ~13 minutes
// =============================================================================
export const options = {
stages: [
{ duration: '1m', target: 50 }, // Warm up to 50 users
{ duration: '2m', target: 50 }, // Hold - baseline
{ duration: '1m', target: 100 }, // Double to 100 users
{ duration: '2m', target: 100 }, // Hold - watch for degradation
{ duration: '1m', target: 150 }, // Push to 150 users
{ duration: '2m', target: 150 }, // Hold - likely slowing down
{ duration: '1m', target: 200 }, // Push to 200 users
{ duration: '2m', target: 200 }, // Hold - stress zone
{ duration: '1m', target: 0 }, // Ramp down - watch recovery
],
thresholds: {
errors: ['rate<0.5'], // Allow up to 50% errors
http_req_duration: ['p(50)<30000'], // Median under 30s (very lenient)
},
};
// Fact-checking claims for realistic testing
const factCheckClaims = [
"Joe Biden signed Infrastructure Investment and Jobs Act 2021",
"Russia invaded Ukraine February 24 2022",
"COVID-19 pandemic declared by WHO March 11 2020",
"Supreme Court overturned Roe v Wade June 2022",
"Twitter acquired by Elon Musk October 2022",
"Hamas attack Israel October 7 2023",
"Trump indicted classified documents June 2023",
"ChatGPT released by OpenAI November 2022",
"Queen Elizabeth II died September 8 2022",
"2020 US election Biden 306 electoral votes",
"Brexit UK left European Union January 2020",
"Zelensky addressed US Congress December 2022",
"Silicon Valley Bank collapsed March 2023",
"Wagner Group rebellion Prigozhin June 2023",
"OpenAI Sam Altman fired rehired November 2023",
];
export default function () {
const claim = factCheckClaims[Math.floor(Math.random() * factCheckClaims.length)];
const url = `http://localhost:8080/search?q=${encodeURIComponent(claim)}&format=html`;
const res = http.get(url, { timeout: '60s' }); // Longer timeout for stress
searchDuration.add(res.timings.duration);
errorRate.add(res.status !== 200);
requestCount.add(1);
check(res, {
'status is 200': (r) => r.status === 200,
'response has results': (r) => r.body && r.body.includes('result'),
'no timeout': (r) => r.timings.duration < 60000,
});
// Minimal sleep = maximum pressure (0.5-1.5 seconds)
sleep(Math.random() + 0.5);
}

View file

@ -0,0 +1,29 @@
worker_processes auto;
events {
worker_connections 256;
}
http {
# Round-robin upstream across 3 SearXNG instances
upstream searxng {
server didiAI-web-searxng-1:8080;
server didiAI-web-searxng-2:8080;
server didiAI-web-searxng-3:8080;
}
server {
listen 8080;
location / {
proxy_pass http://searxng;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_connect_timeout 10s;
proxy_read_timeout 30s;
proxy_next_upstream error timeout http_502 http_503;
proxy_next_upstream_tries 3;
}
}
}

View file

@ -0,0 +1,7 @@
# This configuration file updates the default configuration file
# See https://github.com/searxng/searxng/blob/master/searx/limiter.toml
[botdetection.ip_limit]
# activate advanced bot protection
# enable this when running the instance for a public usage on the internet
link_token = false

View file

@ -0,0 +1,140 @@
# SearXNG Configuration for didiAI Web Module
# https://docs.searxng.org/admin/settings/settings.html
use_default_settings: true
general:
instance_name: "didiAI-searxng"
enable_metrics: false
debug: false
server:
# base_url is defined in the SEARXNG_BASE_URL environment variable
secret_key: "instance1-2d596738d2092ebd"
bind_address: "0.0.0.0:8080"
limiter: false
image_proxy: true
search:
formats:
- html
- json
default_lang: "auto"
safe_search: 1
autocomplete: ""
suspended_times:
SearxEngineAccessDenied: 5
SearxEngineCaptcha: 10
SearxEngineTooManyRequests: 5
cf_SearxEngineAccessDenied: 5
cf_SearxEngineCaptcha: 10
cf_SearxEngineTooManyRequests: 5
outgoing:
request_timeout: 10.0
max_request_timeout: 15.0
useragent_suffix: ""
pool_connections: 50
pool_maxsize: 8
proxies:
all://:
- http://smart-ouowetdhvcnw:ctAFve4tpQkTyLtB@proxy.smartproxy.net:3120
redis:
url: redis://didiAI-web-searxng-redis-1:6379/0
# =============================================================================
# Engine Configuration
# =============================================================================
# Primary engines (general web) + specialized engines for comprehensive coverage
#
# Weight affects result ranking (higher = more prominent)
# Timeout is per-engine maximum wait time
engines:
# ===========================================================================
# Primary - General Web Search
# ===========================================================================
- name: google
disabled: false
timeout: 6.0
weight: 1.2
- name: bing
disabled: false
timeout: 4.0
weight: 1.0
- name: duckduckgo
disabled: false
timeout: 4.0
weight: 1.0
- name: brave
disabled: false
timeout: 4.0
weight: 1.2
api_key: BSAuTXcB4TopHanUatjFxNuOixFYV3j
- name: qwant
disabled: false
timeout: 4.0
weight: 0.8
# ===========================================================================
# Secondary - Specialized Sources
# ===========================================================================
- name: wikipedia
disabled: false
timeout: 3.0
weight: 0.9
- name: google news
disabled: false
timeout: 4.0
weight: 0.8
- name: google scholar
disabled: false
timeout: 5.0
weight: 0.8
- name: reddit
disabled: false
timeout: 4.0
weight: 0.6
- name: archive.org
disabled: false
timeout: 5.0
weight: 0.5
# ===========================================================================
# Disabled - Not needed for fact-checking
# ===========================================================================
- name: tineye
disabled: true
- name: youtube
disabled: true
- name: dailymotion
disabled: true
- name: vimeo
disabled: true
- name: soundcloud
disabled: true
- name: bandcamp
disabled: true
- name: piratebay
disabled: true
- name: 1337x
disabled: true
- name: nyaa
disabled: true

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,7 @@
# This configuration file updates the default configuration file
# See https://github.com/searxng/searxng/blob/master/searx/limiter.toml
[botdetection.ip_limit]
# activate advanced bot protection
# enable this when running the instance for a public usage on the internet
link_token = false

View file

@ -0,0 +1,140 @@
# SearXNG Configuration for didiAI Web Module
# https://docs.searxng.org/admin/settings/settings.html
use_default_settings: true
general:
instance_name: "didiAI-searxng"
enable_metrics: false
debug: false
server:
# base_url is defined in the SEARXNG_BASE_URL environment variable
secret_key: "instance2-2d596738d2092ebd"
bind_address: "0.0.0.0:8080"
limiter: false
image_proxy: true
search:
formats:
- html
- json
default_lang: "auto"
safe_search: 1
autocomplete: ""
suspended_times:
SearxEngineAccessDenied: 5
SearxEngineCaptcha: 10
SearxEngineTooManyRequests: 5
cf_SearxEngineAccessDenied: 5
cf_SearxEngineCaptcha: 10
cf_SearxEngineTooManyRequests: 5
outgoing:
request_timeout: 10.0
max_request_timeout: 15.0
useragent_suffix: ""
pool_connections: 50
pool_maxsize: 8
proxies:
all://:
- http://smart-ouowetdhvcnw:ctAFve4tpQkTyLtB@proxy.smartproxy.net:3120
redis:
url: redis://didiAI-web-searxng-redis-2:6379/0
# =============================================================================
# Engine Configuration
# =============================================================================
# Primary engines (general web) + specialized engines for comprehensive coverage
#
# Weight affects result ranking (higher = more prominent)
# Timeout is per-engine maximum wait time
engines:
# ===========================================================================
# Primary - General Web Search
# ===========================================================================
- name: google
disabled: false
timeout: 6.0
weight: 1.2
- name: bing
disabled: false
timeout: 4.0
weight: 1.0
- name: duckduckgo
disabled: false
timeout: 4.0
weight: 1.0
- name: brave
disabled: false
timeout: 4.0
weight: 1.2
api_key: BSAuTXcB4TopHanUatjFxNuOixFYV3j
- name: qwant
disabled: false
timeout: 4.0
weight: 0.8
# ===========================================================================
# Secondary - Specialized Sources
# ===========================================================================
- name: wikipedia
disabled: false
timeout: 3.0
weight: 0.9
- name: google news
disabled: false
timeout: 4.0
weight: 0.8
- name: google scholar
disabled: false
timeout: 5.0
weight: 0.8
- name: reddit
disabled: false
timeout: 4.0
weight: 0.6
- name: archive.org
disabled: false
timeout: 5.0
weight: 0.5
# ===========================================================================
# Disabled - Not needed for fact-checking
# ===========================================================================
- name: tineye
disabled: true
- name: youtube
disabled: true
- name: dailymotion
disabled: true
- name: vimeo
disabled: true
- name: soundcloud
disabled: true
- name: bandcamp
disabled: true
- name: piratebay
disabled: true
- name: 1337x
disabled: true
- name: nyaa
disabled: true

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,7 @@
# This configuration file updates the default configuration file
# See https://github.com/searxng/searxng/blob/master/searx/limiter.toml
[botdetection.ip_limit]
# activate advanced bot protection
# enable this when running the instance for a public usage on the internet
link_token = false

View file

@ -0,0 +1,140 @@
# SearXNG Configuration for didiAI Web Module
# https://docs.searxng.org/admin/settings/settings.html
use_default_settings: true
general:
instance_name: "didiAI-searxng"
enable_metrics: false
debug: false
server:
# base_url is defined in the SEARXNG_BASE_URL environment variable
secret_key: "instance3-2d596738d2092ebd"
bind_address: "0.0.0.0:8080"
limiter: false
image_proxy: true
search:
formats:
- html
- json
default_lang: "auto"
safe_search: 1
autocomplete: ""
suspended_times:
SearxEngineAccessDenied: 5
SearxEngineCaptcha: 10
SearxEngineTooManyRequests: 5
cf_SearxEngineAccessDenied: 5
cf_SearxEngineCaptcha: 10
cf_SearxEngineTooManyRequests: 5
outgoing:
request_timeout: 10.0
max_request_timeout: 15.0
useragent_suffix: ""
pool_connections: 50
pool_maxsize: 8
proxies:
all://:
- http://smart-ouowetdhvcnw:ctAFve4tpQkTyLtB@proxy.smartproxy.net:3120
redis:
url: redis://didiAI-web-searxng-redis-3:6379/0
# =============================================================================
# Engine Configuration
# =============================================================================
# Primary engines (general web) + specialized engines for comprehensive coverage
#
# Weight affects result ranking (higher = more prominent)
# Timeout is per-engine maximum wait time
engines:
# ===========================================================================
# Primary - General Web Search
# ===========================================================================
- name: google
disabled: false
timeout: 6.0
weight: 1.2
- name: bing
disabled: false
timeout: 4.0
weight: 1.0
- name: duckduckgo
disabled: false
timeout: 4.0
weight: 1.0
- name: brave
disabled: false
timeout: 4.0
weight: 1.2
api_key: BSAuTXcB4TopHanUatjFxNuOixFYV3j
- name: qwant
disabled: false
timeout: 4.0
weight: 0.8
# ===========================================================================
# Secondary - Specialized Sources
# ===========================================================================
- name: wikipedia
disabled: false
timeout: 3.0
weight: 0.9
- name: google news
disabled: false
timeout: 4.0
weight: 0.8
- name: google scholar
disabled: false
timeout: 5.0
weight: 0.8
- name: reddit
disabled: false
timeout: 4.0
weight: 0.6
- name: archive.org
disabled: false
timeout: 5.0
weight: 0.5
# ===========================================================================
# Disabled - Not needed for fact-checking
# ===========================================================================
- name: tineye
disabled: true
- name: youtube
disabled: true
- name: dailymotion
disabled: true
- name: vimeo
disabled: true
- name: soundcloud
disabled: true
- name: bandcamp
disabled: true
- name: piratebay
disabled: true
- name: 1337x
disabled: true
- name: nyaa
disabled: true

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,162 @@
#!/usr/bin/env bash
#
# SearXNG Reset Script (3-instance cluster)
# ==========================================
# Clears CAPTCHA/rate-limit state and restarts SearXNG instances.
#
# Usage:
# ./searxng-reset.sh # Full reset (flush all + restart all)
# ./searxng-reset.sh --status # Show engine error counts
# ./searxng-reset.sh --check # Quick health check
# ./searxng-reset.sh --one 2 # Reset only instance 2
#
set -euo pipefail
INSTANCES=(1 2 3)
LB_CONTAINER="didiAI-web-searxng"
SEARXNG_URL="http://localhost:55100"
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
check_health() {
echo "=== Health Check ==="
echo ""
# LB
local lb_status
lb_status=$(curl -s --max-time 5 "${SEARXNG_URL}/healthz" 2>/dev/null) || true
if [[ "$lb_status" == "OK" ]]; then
echo -e "Load balancer (55100): ${GREEN}UP${NC}"
else
echo -e "Load balancer (55100): ${RED}DOWN${NC}"
fi
# Individual instances
for i in "${INSTANCES[@]}"; do
local container="didiAI-web-searxng-${i}"
local status
status=$(docker inspect --format '{{.State.Health.Status}}' "$container" 2>/dev/null) || status="not found"
if [[ "$status" == "healthy" ]]; then
echo -e " Instance ${i}: ${GREEN}${status}${NC}"
else
echo -e " Instance ${i}: ${RED}${status}${NC}"
fi
done
# Test search
echo ""
local results
results=$(curl -s --max-time 10 "${SEARXNG_URL}/search?q=test&format=json" 2>/dev/null \
| python3 -c "import sys,json; d=json.load(sys.stdin); print(len(d.get('results',[])))" 2>/dev/null) || results=0
if [[ "$results" -gt 0 ]]; then
echo -e "Search test: ${GREEN}OK ($results results)${NC}"
else
echo -e "Search test: ${RED}FAILED (0 results)${NC}"
fi
}
show_status() {
echo "=== Engine Errors (all instances) ==="
echo ""
for i in "${INSTANCES[@]}"; do
local container="didiAI-web-searxng-${i}"
echo -e "${YELLOW}Instance ${i} ($container):${NC}"
local captcha_count
captcha_count=$(docker logs "$container" 2>&1 | grep -ci 'CAPTCHA' 2>/dev/null) || captcha_count=0
local toomany_count
toomany_count=$(docker logs "$container" 2>&1 | grep -ci 'TooManyRequests' 2>/dev/null) || toomany_count=0
if [[ $captcha_count -gt 0 || $toomany_count -gt 0 ]]; then
echo " CAPTCHA: $captcha_count | TooManyRequests: $toomany_count"
docker logs "$container" 2>&1 \
| grep -iE 'CAPTCHA|TooMany' \
| grep -oP 'engines\.\K[^:]+' \
| sort | uniq -c | sort -rn \
| sed 's/^/ /' || true
else
echo -e " ${GREEN}Clean (no errors)${NC}"
fi
echo ""
done
}
reset_instance() {
local i=$1
local container="didiAI-web-searxng-${i}"
local redis="didiAI-web-searxng-redis-${i}"
echo -n " [${i}] Flush Redis... "
if docker exec "$redis" valkey-cli FLUSHALL > /dev/null 2>&1; then
echo -e "${GREEN}OK${NC}"
else
echo -e "${YELLOW}WARN${NC}"
fi
echo -n " [${i}] Restart SearXNG... "
docker restart "$container" > /dev/null 2>&1
echo -e "${GREEN}OK${NC}"
}
do_reset() {
local targets=("${@}")
if [[ ${#targets[@]} -eq 0 ]]; then
targets=("${INSTANCES[@]}")
fi
echo "=== SearXNG Reset ==="
echo "Resetting instances: ${targets[*]}"
echo ""
for i in "${targets[@]}"; do
reset_instance "$i"
done
# Wait for health
echo ""
echo -n "Waiting for startup... "
local attempts=0
while [[ $attempts -lt 15 ]]; do
sleep 2
local healthy=0
for i in "${targets[@]}"; do
local status
status=$(docker inspect --format '{{.State.Health.Status}}' "didiAI-web-searxng-${i}" 2>/dev/null) || status="none"
[[ "$status" == "healthy" ]] && healthy=$((healthy + 1))
done
if [[ $healthy -eq ${#targets[@]} ]]; then
echo -e "${GREEN}All ${healthy} instances UP${NC}"
echo ""
check_health
return 0
fi
attempts=$((attempts + 1))
done
echo -e "${YELLOW}Some instances still starting (check with --check)${NC}"
}
# Parse arguments
case "${1:-}" in
--status|-s)
show_status
;;
--check|-c)
check_health
;;
--one|-o)
[[ -z "${2:-}" ]] && echo "Usage: $0 --one <1|2|3>" && exit 1
do_reset "$2"
;;
--help|-h)
sed -n '2,12p' "$0" | sed 's/^# //' | sed 's/^#//'
;;
*)
do_reset
;;
esac

View file

@ -0,0 +1,7 @@
# This configuration file updates the default configuration file
# See https://github.com/searxng/searxng/blob/master/searx/limiter.toml
[botdetection.ip_limit]
# activate advanced bot protection
# enable this when running the instance for a public usage on the internet
link_token = false

View file

@ -0,0 +1,139 @@
# SearXNG Configuration for didiAI Web Module
# https://docs.searxng.org/admin/settings/settings.html
use_default_settings: true
general:
instance_name: "didiAI-searxng"
enable_metrics: false
debug: false
server:
# base_url is defined in the SEARXNG_BASE_URL environment variable
secret_key: "2d596738d2092ebd"
bind_address: "0.0.0.0:8080"
limiter: false
image_proxy: true
search:
formats:
- html
- json
default_lang: "auto"
safe_search: 1
autocomplete: ""
suspended_times:
SearxEngineAccessDenied: 5
SearxEngineCaptcha: 10
SearxEngineTooManyRequests: 5
cf_SearxEngineAccessDenied: 5
cf_SearxEngineCaptcha: 10
cf_SearxEngineTooManyRequests: 5
outgoing:
request_timeout: 10.0
max_request_timeout: 15.0
useragent_suffix: "didiAI-metasearch"
pool_connections: 100
pool_maxsize: 20
proxies:
all://:
- http://smart-ouowetdhvcnw:ctAFve4tpQkTyLtB@proxy.smartproxy.net:3120
redis:
url: redis://didiAI-web-searxng-redis:6379/0
# =============================================================================
# Engine Configuration
# =============================================================================
# Primary engines (general web) + specialized engines for comprehensive coverage
#
# Weight affects result ranking (higher = more prominent)
# Timeout is per-engine maximum wait time
engines:
# ===========================================================================
# Primary - General Web Search
# ===========================================================================
- name: google
disabled: false
timeout: 6.0
weight: 1.2
- name: bing
disabled: true
timeout: 4.0
weight: 1.0
- name: duckduckgo
disabled: false
timeout: 4.0
weight: 1.0
- name: brave
disabled: false
timeout: 4.0
weight: 1.0
- name: qwant
disabled: false
timeout: 4.0
weight: 0.8
# ===========================================================================
# Secondary - Specialized Sources
# ===========================================================================
- name: wikipedia
disabled: false
timeout: 3.0
weight: 0.9
- name: google news
disabled: false
timeout: 4.0
weight: 0.8
- name: google scholar
disabled: false
timeout: 5.0
weight: 0.8
- name: reddit
disabled: false
timeout: 4.0
weight: 0.6
- name: archive.org
disabled: false
timeout: 5.0
weight: 0.5
# ===========================================================================
# Disabled - Not needed for fact-checking
# ===========================================================================
- name: tineye
disabled: true
- name: youtube
disabled: true
- name: dailymotion
disabled: true
- name: vimeo
disabled: true
- name: soundcloud
disabled: true
- name: bandcamp
disabled: true
- name: piratebay
disabled: true
- name: 1337x
disabled: true
- name: nyaa
disabled: true

View file

@ -0,0 +1,51 @@
upstream webapi {
server didiAI-web-api:51100;
}
server {
listen 80;
server_name _;
# Request body limit
client_max_body_size 1m;
# Timeouts
proxy_connect_timeout 60s;
proxy_send_timeout 120s;
proxy_read_timeout 120s;
# Gzip compression
gzip on;
gzip_types application/json text/plain;
gzip_min_length 256;
gzip_vary on;
# Proxy buffering
proxy_buffering on;
proxy_buffer_size 4k;
proxy_buffers 8 8k;
# Proxy settings
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Request-ID $request_id;
# Health check endpoint
location /health {
proxy_pass http://webapi/health;
add_header Cache-Control "no-cache";
}
location /ready {
proxy_pass http://webapi/ready;
add_header Cache-Control "no-cache";
}
# API endpoints
location / {
proxy_pass http://webapi;
}
}

View file

@ -0,0 +1,63 @@
# Web API Gather Results - NER & Fact-Checking Research
Generated: 2026-01-30
---
## Query 1: MultiNERD Entity Types
**Claim:** `MultiNERD entity types list NER categories person organization location date time money percentage fact-checking`
**Execution Time:** 5203ms
### URLs Accessed
- [Named-entity recognition - Wikipedia](https://en.wikipedia.org/wiki/Named-entity_recognition)
- [Gold standard, multi-genre dataset for named entity recognition and linking | Scientific Data](https://www.nature.com/articles/s41597-025-05274-4)
- [Named Entity Recognition (NER): Ultimate Guide | Encord](https://encord.com/blog/named-entity-recognition/)
- [A Comprehensive Guide to Named Entity Recognition](https://www.turing.com/kb/a-comprehensive-guide-to-named-entity-recognition)
- [Entity categories recognized by Named Entity Recognition in Azure](https://learn.microsoft.com/en-us/azure/ai-services/language-service/named-entity-recognition/concepts/named-entity-categories)
---
## Query 2: Fact-Checking Methodology
**Claim:** `fact-checking entity extraction named entity recognition claim verification ClaimBuster Full Fact methodology 2024`
**Execution Time:** 9698ms
### URLs Accessed
- [arxiv.org PDF 1809.08193](https://arxiv.org/pdf/1809.08193)
- [Claim Extraction for Fact-Checking: Data, Models, and Automated Metrics](https://arxiv.org/html/2502.04955v1)
- [Facilitating automated fact-checking: a machine learning based weighted ensemble technique for claim detection](https://link.springer.com/article/10.1007/s42452-024-06444-6)
- [Document-level Claim Extraction and Decontextualisation for Fact-Checking](https://arxiv.org/html/2406.03239v2)
- [An Entity-based Claim Extraction Pipeline for Real-world Biomedical Fact-checking](https://arxiv.org/abs/2304.05268)
---
## Query 3: NER Query Generation for Fact-Checking
**Claim:** `named entity recognition query generation fact-checking entity types person organization location date event claim verification search query templates`
**Execution Time:** 3485ms
### URLs Accessed
- [Tools for Named Entity Recognition | CLARIN ERIC](https://www.clarin.eu/resource-families/tools-named-entity-recognition)
- [Named-entity recognition - Wikipedia](https://en.wikipedia.org/wiki/Named-entity_recognition)
- [Named Entity Recognition - GeeksforGeeks](https://www.geeksforgeeks.org/nlp/named-entity-recognition/)
- [Named Entity Recognition | Yext](https://www.yext.com/platform/features/named-entity-recognition)
- [Named Entity Recognition on Search Engine Queries with Python - Stack Overflow](https://stackoverflow.com/questions/78151241/named-entity-recognition-on-search-engine-queries-with-python)
---
## Summary
| Query | Evidence URLs | Execution Time |
|-------|---------------|----------------|
| 1 - MultiNERD Entity Types | 5 | 5.2s |
| 2 - Fact-Checking Methodology | 5 | 9.7s |
| 3 - NER Query Generation | 5 | 3.5s |
**Total URLs:** 15

View file

@ -0,0 +1,67 @@
[project]
name = "web"
version = "0.1.0"
description = "Web module - search, fetch, browse, vision, evidence for ml-projects"
readme = "README.md"
requires-python = ">=3.10"
dependencies = [
"fastapi>=0.115.0,<1.0",
"uvicorn[standard]>=0.32.0",
"pydantic>=2.0,<3.0",
"pydantic-settings>=2.0,<3.0",
"httpx[http2]>=0.27.0,<1.0",
"prometheus-fastapi-instrumentator>=7.0.0",
"opentelemetry-instrumentation-fastapi>=0.50b0",
"opentelemetry-instrumentation-httpx>=0.50b0",
"opentelemetry-exporter-otlp-proto-grpc>=1.30.0",
]
[project.optional-dependencies]
dev = [
"pytest>=8.0",
"pytest-cov>=4.0",
"pytest-asyncio>=0.24.0",
"ruff>=0.8",
"respx>=0.21.0",
]
browse = [
"playwright>=1.40.0",
"readability-lxml>=0.8.0",
"lxml>=5.0.0",
]
vision = [
"openai>=1.0.0",
"anthropic>=0.25.0",
]
fetch = [
"readability-lxml>=0.8.0",
"beautifulsoup4>=4.12.0",
"lxml>=5.0.0",
]
all = [
"playwright>=1.40.0",
"openai>=1.0.0",
"anthropic>=0.25.0",
"readability-lxml>=0.8.0",
"beautifulsoup4>=4.12.0",
"lxml>=5.0.0",
]
[project.scripts]
web = "web.cli:main"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["src/web"]
[tool.ruff]
extend = "../../ruff.toml"
[tool.pytest.ini_options]
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"
testpaths = ["tests"]
addopts = "-v --tb=short"

View file

@ -0,0 +1,32 @@
"""Web module - search, fetch, browse, vision, evidence."""
__version__ = "0.1.0"
from web.browse.client import BrowseClient
from web.evidence.packer import EvidencePacker
from web.fetch.client import FetchClient
from web.orchestrator import Orchestrator
from web.schemas.image_search import (
ImageSearchRequest,
ImageSearchResponse,
ImageSearchResult,
)
from web.schemas.search import SearchRequest, SearchResponse, SearchResult
from web.search.protocol import SearchProvider
from web.vision.client import VisionClient
__all__ = [
"BrowseClient",
"EvidencePacker",
"FetchClient",
"ImageSearchRequest",
"ImageSearchResponse",
"ImageSearchResult",
"Orchestrator",
"SearchProvider",
"SearchRequest",
"SearchResponse",
"SearchResult",
"VisionClient",
"__version__",
]

View file

@ -0,0 +1 @@
"""Web Search API package."""

View file

@ -0,0 +1,172 @@
"""FastAPI application factory."""
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
from fastapi import FastAPI
from web.api.dependencies import init_concurrency_limiter
from web.api.middleware import CombinedMiddleware
from web.api.routes import fetch, gather, health, image_search, info, search
from web.brain.client import BrainClient
from web.brain.sink import BrainIngestSink
from web.config import SettingsCache
from web.events.sink import DashboardEventSink
from web.fetch.client import FetchClient
from web.logging import configure_logging, get_logger
from web.metasearch.client import SearXNGClient
from web.orchestrator import Orchestrator
from web.runtime_config import RuntimeConfigClient
from web.search.multi import MultiSearchClient
from web.search.paid import PaidSearchClient
logger = get_logger("app")
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
"""Application lifespan manager.
Initializes clients, settings, logging, and concurrency limiter
on startup, and performs cleanup on shutdown.
"""
settings = SettingsCache.get()
configure_logging(settings.log_level, settings.log_json)
logger.info("Starting Web API")
logger.info("Port: %d", settings.port)
init_concurrency_limiter(settings.max_concurrent_requests)
app.state.settings = settings
# Runtime config client — fetched from dashboard
app.state.runtime_config = RuntimeConfigClient(
dashboard_url=settings.dashboard_url,
)
await app.state.runtime_config.start()
# Shared fetch client (used by both tiers)
app.state.fetch_client = FetchClient(settings)
# FREE tier: SearXNG only + local Qwen LLM
app.state.search_client_free = SearXNGClient(settings)
app.state.orchestrator_free = Orchestrator(
settings,
search_client=app.state.search_client_free,
fetch_client=app.state.fetch_client,
llm_provider="local",
runtime_config=app.state.runtime_config,
)
# PREMIUM tier: paid engines only + OpenRouter LLM
app.state.search_client_premium = PaidSearchClient(
settings, runtime_config=app.state.runtime_config
)
app.state.orchestrator_premium = Orchestrator(
settings,
search_client=app.state.search_client_premium,
fetch_client=app.state.fetch_client,
llm_provider="openrouter",
runtime_config=app.state.runtime_config,
)
# Default search client (backwards compat — used by /v1/search, /v1/image-search)
app.state.search_client = MultiSearchClient(settings)
# Dashboard event sink (fire-and-forget, optional)
app.state.event_sink = DashboardEventSink(
dashboard_url=settings.dashboard_url,
token=settings.dashboard_token,
)
await app.state.event_sink.start()
# Brain cache client + ingest sink (premium tier only; fire-and-forget)
app.state.brain_client = BrainClient(
base_url=settings.brain_url,
gather_timeout=settings.brain_gather_timeout,
ingest_timeout=settings.brain_ingest_timeout,
)
await app.state.brain_client.start()
app.state.brain_sink = BrainIngestSink(app.state.brain_client)
await app.state.brain_sink.start()
logger.info(
"Web API started successfully (free + premium tiers, brain=%s)",
"on" if app.state.brain_client.enabled else "off",
)
yield
logger.info("Shutting down Web API")
await app.state.brain_sink.stop()
await app.state.brain_client.close()
await app.state.event_sink.stop()
await app.state.runtime_config.stop()
await app.state.search_client.close()
await app.state.search_client_free.close()
await app.state.search_client_premium.close()
await app.state.fetch_client.close()
await app.state.orchestrator_free.close()
await app.state.orchestrator_premium.close()
def create_app() -> FastAPI:
"""Create and configure the FastAPI application.
Returns:
FastAPI: Configured FastAPI application.
"""
settings = SettingsCache.get()
app = FastAPI(
title="Web API",
description="Web module - search, fetch, browse, vision, evidence",
version="0.1.0",
lifespan=lifespan,
servers=[{"url": settings.external_url, "description": "Web API"}],
)
app.add_middleware(
CombinedMiddleware,
rate=settings.rate_limit_rps,
burst=settings.rate_limit_burst,
exclude_paths=["/health", "/ready"],
)
# Prometheus /metrics + OTel tracing (no-op if deps missing or OTEL endpoint unset)
try:
from prometheus_fastapi_instrumentator import Instrumentator # type: ignore
Instrumentator(should_group_status_codes=True).instrument(app).expose(
app, endpoint="/metrics", include_in_schema=False
)
except ImportError:
pass
import os as _os
_otel_ep = _os.environ.get("OTEL_EXPORTER_OTLP_ENDPOINT")
if _otel_ep:
try:
from opentelemetry import trace as _trace # type: ignore
from opentelemetry.sdk.resources import Resource as _R # type: ignore
from opentelemetry.sdk.trace import TracerProvider as _TP # type: ignore
from opentelemetry.sdk.trace.export import BatchSpanProcessor as _BSP # type: ignore
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter as _Exp # type: ignore
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor as _FInst # type: ignore
from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor as _HXInst # type: ignore
_provider = _TP(resource=_R.create({"service.name": _os.environ.get("OTEL_SERVICE_NAME", "didiAI-web-api")}))
_provider.add_span_processor(_BSP(_Exp(endpoint=_otel_ep, insecure=True)))
_trace.set_tracer_provider(_provider)
_FInst.instrument_app(app)
_HXInst().instrument()
print(f"[otel] didiAI-web-api instrumented → {_otel_ep}")
except ImportError as _e:
print(f"[otel] skip: {_e}")
app.include_router(health.router, tags=["Health"])
app.include_router(search.router, prefix="/v1", tags=["Search"])
app.include_router(image_search.router, prefix="/v1", tags=["Image Search"])
app.include_router(fetch.router, prefix="/v1", tags=["Fetch"])
app.include_router(gather.router, prefix="/v1", tags=["Gather"])
app.include_router(info.router, tags=["Catalog"])
return app

View file

@ -0,0 +1,177 @@
"""FastAPI dependencies for Web module."""
import asyncio
import hmac
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from fastapi import Header, HTTPException, Request
from web.config import WebSettings
from web.logging import get_logger
from web.search.protocol import SearchProvider
logger = get_logger("dependencies")
def get_search_tier(
request: Request,
x_search_tier: str | None = Header(default=None, alias="X-Search-Tier"),
) -> str:
"""Extract search tier from X-Search-Tier header.
Returns:
"free" or "premium". Defaults to "free".
"""
if x_search_tier and x_search_tier.lower() == "premium":
return "premium"
return "free"
def get_search_client(request: Request) -> SearchProvider:
"""Get the Search client from application state (default/legacy)."""
return request.app.state.search_client
def get_search_client_for_tier(
request: Request,
tier: str,
) -> SearchProvider:
"""Get tier-specific search client.
Args:
request: FastAPI request.
tier: "free" or "premium".
Returns:
SearchProvider for the given tier.
"""
if tier == "premium":
return request.app.state.search_client_premium
return request.app.state.search_client_free
def get_settings(request: Request) -> WebSettings:
"""Get settings from application state."""
return request.app.state.settings
def verify_bearer_token(
request: Request,
authorization: str | None = Header(default=None, alias="Authorization"),
) -> str | None:
"""Verify Bearer token authentication.
If authentication is disabled (no tokens configured), returns None.
If authentication is enabled, validates the token and returns it.
"""
settings = request.app.state.settings
if not settings.auth_enabled:
return None
if not authorization:
raise HTTPException(
status_code=401,
detail={
"error": "Authentication required",
"message": "Missing Authorization header",
},
headers={"WWW-Authenticate": "Bearer"},
)
parts = authorization.split()
if len(parts) != 2 or parts[0].lower() != "bearer":
raise HTTPException(
status_code=401,
detail={
"error": "Authentication required",
"message": "Invalid Authorization header format. Use: Bearer <token>",
},
headers={"WWW-Authenticate": "Bearer"},
)
token = parts[1]
is_valid = any(
hmac.compare_digest(token.encode(), valid_token.encode())
for valid_token in settings.api_tokens
)
if not is_valid:
raise HTTPException(
status_code=401,
detail={
"error": "Authentication failed",
"message": "Invalid API token",
},
headers={"WWW-Authenticate": "Bearer"},
)
return token
class ConcurrencyLimiter:
"""Limits concurrent searches to prevent resource exhaustion."""
def __init__(self, max_concurrent: int) -> None:
"""Initialize concurrency limiter."""
self.max_concurrent = max_concurrent
self._semaphore = asyncio.Semaphore(max_concurrent)
self._current = 0
@property
def current_count(self) -> int:
"""Get current number of active requests."""
return self._current
@property
def available(self) -> int:
"""Get number of available slots."""
return self.max_concurrent - self._current
@asynccontextmanager
async def acquire(self, blocking: bool = False) -> AsyncIterator[None]:
"""Acquire a search slot."""
if not blocking and self._semaphore.locked():
logger.warning(
"Concurrency limit reached: %d/%d active",
self._current,
self.max_concurrent,
)
raise HTTPException(
status_code=503,
detail={
"error": "Service temporarily unavailable",
"reason": "Too many concurrent requests",
"max_concurrent": self.max_concurrent,
"retry_after": 5,
},
headers={"Retry-After": "5"},
)
await self._semaphore.acquire()
self._current += 1
try:
yield
finally:
self._current -= 1
self._semaphore.release()
_concurrency_limiter: ConcurrencyLimiter | None = None
def init_concurrency_limiter(max_concurrent: int) -> ConcurrencyLimiter:
"""Initialize the global concurrency limiter."""
global _concurrency_limiter
_concurrency_limiter = ConcurrencyLimiter(max_concurrent)
logger.info("Concurrency limiter initialized: max_concurrent=%d", max_concurrent)
return _concurrency_limiter
def get_concurrency_limiter() -> ConcurrencyLimiter:
"""Get the global concurrency limiter."""
if _concurrency_limiter is None:
raise RuntimeError("Concurrency limiter not initialized")
return _concurrency_limiter

View file

@ -0,0 +1,164 @@
"""Pure ASGI middleware for request handling and rate limiting."""
import asyncio
import math
import re
import time
import uuid
from typing import Any
from starlette.types import ASGIApp, Receive, Scope, Send
from web.logging import get_logger, set_request_id
logger = get_logger("middleware")
_REQUEST_ID_RE = re.compile(r"^[a-zA-Z0-9\-_.]{1,128}$")
class TokenBucket:
"""Token bucket for rate limiting (async-safe)."""
def __init__(self, rate: float, burst: int) -> None:
"""Initialize token bucket.
Args:
rate: Tokens added per second.
burst: Maximum tokens (bucket capacity).
"""
self.rate = rate
self.burst = burst
self.tokens = float(burst)
self.last_update = time.monotonic()
self._lock = asyncio.Lock()
async def acquire(self) -> tuple[bool, float]:
"""Try to acquire a token (async-safe).
Returns:
tuple: (success, retry_after_seconds)
"""
async with self._lock:
now = time.monotonic()
elapsed = now - self.last_update
self.last_update = now
self.tokens = min(self.burst, self.tokens + elapsed * self.rate)
if self.tokens >= 1:
self.tokens -= 1
return True, 0.0
tokens_needed = 1 - self.tokens
return False, tokens_needed / self.rate
class CombinedMiddleware:
"""Combined request-id + rate-limiting pure ASGI middleware.
WARNING: Rate limiting is per-process. In multi-replica deployments,
each replica maintains its own independent rate limit.
TODO: For multi-replica deployments, replace TokenBucket with a
Redis-based rate limiter (e.g., sliding window counter in Redis)
to enforce global rate limits across all replicas. Add a
``rate_limit_backend`` config option (``memory`` | ``redis``)
to WebSettings to select the backend at startup.
"""
def __init__(
self,
app: ASGIApp,
rate: float = 10.0,
burst: int = 20,
exclude_paths: list[str] | None = None,
) -> None:
"""Initialize combined middleware.
Args:
app: The ASGI application.
rate: Tokens added per second for rate limiting.
burst: Maximum tokens (bucket capacity).
exclude_paths: Paths to exclude from rate limiting.
"""
self.app = app
self.bucket = TokenBucket(rate, burst)
self.exclude_paths = set(exclude_paths or ["/health", "/ready"])
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
"""Process ASGI request."""
if scope["type"] != "http":
await self.app(scope, receive, send)
return
# Extract/generate request ID from headers
headers_list: list[tuple[bytes, bytes]] = scope.get("headers", [])
request_id = ""
for name, value in headers_list:
if name == b"x-request-id":
request_id = value.decode()
break
if not request_id or not _REQUEST_ID_RE.match(request_id):
request_id = str(uuid.uuid4())
set_request_id(request_id)
path = scope.get("path", "")
# Rate limiting (skip excluded paths)
if path not in self.exclude_paths:
allowed, retry_after = await self.bucket.acquire()
if not allowed:
logger.warning(
"Rate limit exceeded for %s, retry_after=%.2f",
path,
retry_after,
)
await self._send_429(send, request_id, retry_after)
set_request_id(None)
return
# Inject response header
async def send_with_request_id(message: dict[str, Any]) -> None:
if message["type"] == "http.response.start":
headers = list(message.get("headers", []))
headers.append((b"x-request-id", request_id.encode()))
message["headers"] = headers
await send(message)
try:
await self.app(scope, receive, send_with_request_id)
finally:
set_request_id(None)
async def _send_429(self, send: Send, request_id: str, retry_after: float) -> None:
"""Send a 429 Too Many Requests response."""
import json
from web.schemas.common import make_error_detail
body = json.dumps(
make_error_detail(
"rate_limit",
"Too many requests",
request_id=request_id,
retry_after=retry_after,
)
).encode()
await send(
{
"type": "http.response.start",
"status": 429,
"headers": [
(b"content-type", b"application/json"),
(b"retry-after", str(math.ceil(retry_after)).encode()),
(b"x-request-id", request_id.encode()),
],
}
)
await send(
{
"type": "http.response.body",
"body": body,
}
)

View file

@ -0,0 +1 @@
"""API routes package."""

View file

@ -0,0 +1,88 @@
"""Fetch endpoint."""
from fastapi import APIRouter, Depends, HTTPException, Request
from web.api.dependencies import (
get_concurrency_limiter,
verify_bearer_token,
)
from web.exceptions import (
WebConnectionError,
WebError,
WebTimeoutError,
)
from web.fetch.client import FetchClient
from web.logging import get_logger, get_request_id
from web.schemas.common import make_error_detail
from web.schemas.fetch import FetchRequest, FetchResponse
logger = get_logger("routes.fetch")
router = APIRouter(dependencies=[Depends(verify_bearer_token)])
def get_fetch_client(request: Request) -> FetchClient:
"""Get the fetch client from application state."""
return request.app.state.fetch_client
@router.post("/fetch", response_model=FetchResponse)
async def fetch(
request: Request,
fetch_request: FetchRequest,
client: FetchClient = Depends(get_fetch_client),
) -> FetchResponse:
"""Fetch content from URLs.
Args:
request: FastAPI request object.
fetch_request: Fetch parameters.
client: Fetch client.
Returns:
FetchResponse: Fetched content.
Raises:
HTTPException: On fetch errors.
"""
limiter = get_concurrency_limiter()
request_id = get_request_id()
async with limiter.acquire():
try:
result = await client.fetch(fetch_request, request_id=request_id)
return result
except WebTimeoutError as e:
logger.error("Fetch timeout: %s", e)
raise HTTPException(
status_code=504,
detail=make_error_detail("timeout", str(e), request_id=request_id),
) from None
except WebConnectionError as e:
logger.error("Connection error: %s", e)
raise HTTPException(
status_code=502,
detail=make_error_detail(
"connection_error", str(e), request_id=request_id
),
) from None
except WebError as e:
logger.error("Fetch error: %s", e)
raise HTTPException(
status_code=500,
detail=make_error_detail("fetch_error", str(e), request_id=request_id),
) from None
except Exception as e:
logger.exception("Unexpected error during fetch: %s", e)
raise HTTPException(
status_code=500,
detail=make_error_detail(
"internal_error",
f"Unexpected error: {type(e).__name__}: {e}",
request_id=request_id,
),
) from e

View file

@ -0,0 +1,265 @@
"""Gather endpoint - unified pipeline."""
import time
from fastapi import APIRouter, Depends, HTTPException, Request
from web.api.dependencies import (
get_concurrency_limiter,
get_search_tier,
verify_bearer_token,
)
from web.brain.adapter import brain_response_to_web, web_response_to_ingest_payload
from web.brain.quality import brain_hit_acceptable, quality_ok_for_cache
from web.exceptions import WebError
from web.logging import get_logger, get_request_id
from web.orchestrator import Orchestrator
from web.schemas.common import make_error_detail
from web.schemas.gather import GatherRequest, GatherResponse
logger = get_logger("routes.gather")
router = APIRouter(dependencies=[Depends(verify_bearer_token)])
def get_orchestrator_for_tier(request: Request, tier: str) -> Orchestrator:
"""Get tier-specific orchestrator from application state."""
if tier == "premium":
return request.app.state.orchestrator_premium
return request.app.state.orchestrator_free
def _provider_for_tier(tier: str) -> str:
return "paid-rotation" if tier == "premium" else "searxng"
_MAX_RAW_RESPONSE_BYTES = 200_000
def _trim_response_for_storage(response: GatherResponse) -> dict:
"""Build a persistable snapshot of the GatherResponse.
Drops full_text on evidence items beyond the first few to keep
row size reasonable. The trimmed snapshot is still enough to
promote a gather into the archive.
"""
data = response.model_dump(mode="json")
# Keep full_text on the first 3 items only
evidence = data.get("evidence") or []
for i, item in enumerate(evidence):
if i >= 3 and item.get("full_text"):
item["full_text"] = item["full_text"][:500] + ""
# Truncate search_results snippets
for sr in data.get("search_results") or []:
if sr.get("snippet") and len(sr["snippet"]) > 400:
sr["snippet"] = sr["snippet"][:400] + ""
return data
def _emit(
request: Request,
request_id: str,
tier: str,
start: float,
status_code: int,
response: GatherResponse | None = None,
error: str | None = None,
claim: str | None = None,
) -> None:
sink = getattr(request.app.state, "event_sink", None)
if sink is None or not sink.enabled:
return
duration_ms = int((time.perf_counter() - start) * 1000)
event: dict = {
"request_id": request_id,
"tier": tier,
"endpoint": "/v1/gather",
"provider": _provider_for_tier(tier),
"claim": claim,
"duration_ms": duration_ms,
"status_code": status_code,
"error": error,
}
if response is not None:
event["results_count"] = response.total_urls_found
event["evidence_count"] = response.total_evidence_items
event["stages"] = [s.model_dump() for s in response.stages]
# Persist the full response for archive promotion — trimmed to avoid
# blowing up the history table. Drop entirely if over the cap.
try:
snapshot = _trim_response_for_storage(response)
import json
serialized = json.dumps(snapshot, default=str)
if len(serialized.encode()) <= _MAX_RAW_RESPONSE_BYTES:
event["raw_response"] = snapshot
except Exception:
pass
sink.emit(event)
@router.post("/gather", response_model=GatherResponse)
async def gather(
request: Request,
gather_request: GatherRequest,
tier: str = Depends(get_search_tier),
) -> GatherResponse:
"""Execute the full gather pipeline: search -> fetch -> evidence.
Tier selection via X-Search-Tier header:
- "free" (default): SearXNG + local Qwen LLM
- "premium": Paid search engines + OpenRouter LLM
Args:
request: FastAPI request object.
gather_request: Gather parameters.
tier: Search tier from X-Search-Tier header.
Returns:
GatherResponse: Gathered evidence.
Raises:
HTTPException: On pipeline errors.
"""
orchestrator = get_orchestrator_for_tier(request, tier)
limiter = get_concurrency_limiter()
request_id = get_request_id()
start = time.perf_counter()
# Apply runtime config override for max_search_results if backend
# didn't explicitly pass a value (comparing against the default is
# fragile but workable: if tier override is set, apply it).
runtime_cfg = getattr(request.app.state, "runtime_config", None)
if runtime_cfg is not None:
override_key = f"web.tier.{tier}.max_search_results"
override_val = runtime_cfg.get_int(override_key, 0)
if override_val and gather_request.max_search_results == 10:
gather_request = gather_request.model_copy(
update={"max_search_results": override_val}
)
logger.info(
"Gather request [tier=%s max_results=%d]: %.100s",
tier,
gather_request.max_search_results,
gather_request.claim,
)
settings = request.app.state.settings
brain_client = getattr(request.app.state, "brain_client", None)
brain_sink = getattr(request.app.state, "brain_sink", None)
# --- Cache read: both tiers benefit from the premium-quality cache.
# Free users get the paid-for knowledge base for free; only premium
# populates it (see cache-write below).
if (
brain_client is not None
and brain_client.enabled
and settings.brain_cache_read_enabled
):
brain_raw = await brain_client.gather(
claim=gather_request.claim,
max_evidence=max(gather_request.max_evidence_items, 5),
run_nli=False,
include_full_text=gather_request.include_full_text,
)
if brain_hit_acceptable(
brain_raw,
min_evidence=settings.brain_hit_min_evidence,
min_relevance=settings.brain_hit_min_relevance,
):
cache_status = (
(brain_raw or {}).get("brain_meta") or {}
).get("cache_status", "?")
logger.info(
"Brain cache %s [tier=%s, %s items]: %.80s",
cache_status,
tier,
len((brain_raw or {}).get("evidence") or []),
gather_request.claim,
)
web_result = brain_response_to_web(brain_raw or {}, request_id)
_emit(
request,
request_id,
tier,
start,
status_code=200,
response=web_result,
claim=gather_request.claim,
)
return web_result
async with limiter.acquire():
try:
result = await orchestrator.gather(gather_request, request_id=request_id)
# --- Cache write: only for successful premium runs meeting quality bar
if (
tier == "premium"
and brain_sink is not None
and brain_sink.enabled
and settings.brain_ingest_enabled
and quality_ok_for_cache(
result,
min_evidence=settings.brain_ingest_min_evidence,
min_credibility=settings.brain_ingest_min_credibility,
min_execution_ms=settings.brain_ingest_min_execution_ms,
)
):
payload = web_response_to_ingest_payload(
result, claim=gather_request.claim
)
brain_sink.emit(payload)
_emit(
request,
request_id,
tier,
start,
status_code=200,
response=result,
claim=gather_request.claim,
)
return result
except WebError as e:
logger.error("Gather error: %s", e)
_emit(
request,
request_id,
tier,
start,
status_code=500,
error=str(e),
claim=gather_request.claim,
)
raise HTTPException(
status_code=500,
detail=make_error_detail("gather_error", str(e), request_id=request_id),
) from None
except Exception as e:
logger.exception("Unexpected error during gather: %s", e)
_emit(
request,
request_id,
tier,
start,
status_code=500,
error=f"{type(e).__name__}: {e}",
claim=gather_request.claim,
)
raise HTTPException(
status_code=500,
detail=make_error_detail(
"internal_error",
f"Unexpected error: {type(e).__name__}: {e}",
request_id=request_id,
),
) from e

View file

@ -0,0 +1,57 @@
"""Health check endpoints."""
from fastapi import APIRouter, Depends, Request
from web.api.dependencies import get_search_client
from web.schemas.common import HealthResponse, ProviderHealth, ReadinessResponse
from web.search.protocol import SearchProvider
router = APIRouter()
@router.get("/health", response_model=HealthResponse)
async def health_check(
client: SearchProvider = Depends(get_search_client),
) -> HealthResponse:
"""Check the health of the service and its providers.
Returns:
HealthResponse: Health status of the service.
"""
search_healthy = await client.health_check()
# Determine provider name from client attribute or default
provider_name = getattr(client, "provider_name", "searxng")
providers = [
ProviderHealth(
name=provider_name,
healthy=search_healthy,
message=None if search_healthy else f"{provider_name} search unavailable",
)
]
all_healthy = all(p.healthy for p in providers)
any_healthy = any(p.healthy for p in providers)
if all_healthy:
status = "healthy"
elif any_healthy:
status = "degraded"
else:
status = "unhealthy"
return HealthResponse(status=status, providers=providers)
@router.get("/ready", response_model=ReadinessResponse)
async def readiness_check(request: Request) -> ReadinessResponse:
"""Check if the service is ready to accept requests.
Returns:
ReadinessResponse: Readiness status.
"""
ready = hasattr(request.app.state, "search_client_free") and hasattr(
request.app.state, "orchestrator_free"
)
return ReadinessResponse(ready=ready)

View file

@ -0,0 +1,115 @@
"""Image search endpoint."""
from fastapi import APIRouter, Depends, HTTPException, Request
from web.api.dependencies import (
get_concurrency_limiter,
get_search_client_for_tier,
get_search_tier,
verify_bearer_token,
)
from web.exceptions import (
ProviderError,
RateLimitError,
SearchError,
WebConnectionError,
WebTimeoutError,
)
from web.logging import get_logger, get_request_id
from web.schemas.common import make_error_detail
from web.schemas.image_search import ImageSearchRequest, ImageSearchResponse
logger = get_logger("routes.image_search")
router = APIRouter(dependencies=[Depends(verify_bearer_token)])
@router.post("/image-search", response_model=ImageSearchResponse)
async def image_search(
request: Request,
search_request: ImageSearchRequest,
tier: str = Depends(get_search_tier),
) -> ImageSearchResponse:
"""Execute image search queries.
Args:
request: FastAPI request object.
search_request: Image search parameters.
tier: Search tier from X-Search-Tier header.
Returns:
ImageSearchResponse: Image search results.
Raises:
HTTPException: On search errors.
"""
client = get_search_client_for_tier(request, tier)
limiter = get_concurrency_limiter()
request_id = get_request_id()
async with limiter.acquire():
try:
result = await client.image_search(search_request, request_id=request_id)
return result
except RateLimitError as e:
logger.warning("Rate limit exceeded: %s", e)
raise HTTPException(
status_code=429,
detail=make_error_detail(
"rate_limit_exceeded",
str(e),
request_id=request_id,
retry_after=e.retry_after,
),
headers={"Retry-After": str(int(e.retry_after or 60))},
) from None
except WebTimeoutError as e:
logger.error("Image search timeout: %s", e)
raise HTTPException(
status_code=504,
detail=make_error_detail("timeout", str(e), request_id=request_id),
) from None
except WebConnectionError as e:
logger.error("Connection error: %s", e)
raise HTTPException(
status_code=502,
detail=make_error_detail(
"connection_error",
str(e),
request_id=request_id,
provider=e.provider,
),
) from None
except ProviderError as e:
logger.error("Provider error: %s", e)
raise HTTPException(
status_code=502,
detail=make_error_detail(
"provider_error",
str(e),
request_id=request_id,
provider=e.provider,
),
) from None
except SearchError as e:
logger.error("Image search error: %s", e)
raise HTTPException(
status_code=500,
detail=make_error_detail("search_error", str(e), request_id=request_id),
) from None
except Exception as e:
logger.exception("Unexpected error during image search: %s", e)
raise HTTPException(
status_code=500,
detail=make_error_detail(
"internal_error",
f"Unexpected error: {type(e).__name__}: {e}",
request_id=request_id,
),
) from e

View file

@ -0,0 +1,169 @@
"""Component information endpoint for service catalog."""
from fastapi import APIRouter
from web.config import get_settings
from web.schemas.fetch import FetchRequest, FetchResponse
from web.schemas.gather import GatherRequest, GatherResponse
from web.schemas.image_search import ImageSearchRequest, ImageSearchResponse
from web.schemas.search import SearchRequest, SearchResponse
router = APIRouter()
@router.get("/v1/info")
def get_component_info():
"""Get component information for service catalog.
Returns complete metadata about this service including:
- Resource information (component metadata)
- Available functions (API endpoints)
This endpoint is used by the catalog-api to aggregate service information
and by backend systems to populate the catalog database.
Returns:
dict: Component information matching catalog.resources and catalog.functions schemas.
"""
settings = get_settings()
# Build resource information (maps to catalog.resources)
resource = {
"name": "Web Fact-checking Service",
"slug": "web-factcheck",
"resource_type": "api_service",
"provider": "internal",
"base_url": f"http://web-api:{settings.port}",
"configuration": {
"version": "0.1.0",
"port": settings.port,
"external_url": f"http://localhost:{settings.port}",
"llm_base_url": settings.llm_base_url,
"vision_model": settings.vision_model,
"text_model": settings.text_model,
},
"authentication": {
"type": "bearer" if settings.api_tokens else "none",
"required": bool(settings.api_tokens),
"env_var": "WEB_API_TOKENS",
},
"headers": {
"Content-Type": "application/json",
"Accept": "application/json",
},
"rate_limits": {
"requests_per_second": settings.rate_limit_rps,
"burst": settings.rate_limit_burst,
"concurrent": settings.max_concurrent_requests,
},
"cost_tracking": {
"enabled": False,
},
"tags": ["web", "fact-checking", "search", "evidence", "searxng"],
"is_active": True,
"metadata": {
"category": "web",
"gpu_required": False,
"status": "healthy",
"brave_api_required": False,
"llm_integration": bool(settings.llm_base_url),
},
}
# No models for web module (it's an orchestrator)
models = []
# Define available functions (maps to catalog.functions)
functions = [
{
"name": "Fact-checking Evidence Gathering",
"slug": "web-gather-evidence",
"category": "fact-checking",
"description": (
"Complete fact-checking pipeline: search web via SearXNG metasearch, "
"fetch URLs with fallback, extract evidence snippets using LLM, "
"deduplicate and rank by relevance."
),
"input_schema": GatherRequest.model_json_schema(),
"output_schema": GatherResponse.model_json_schema(),
"implementation": {
"method": "POST",
"path": "/v1/gather",
"content_type": "application/json",
"timeout": 120,
},
"endpoint": f"http://localhost:{settings.port}/v1/gather",
"tags": ["fact-checking", "evidence", "web-search"],
"is_active": True,
"metadata": {
"average_latency_s": 40,
"requires_brave_api": False,
"requires_llm": True,
},
},
{
"name": "Web Search",
"slug": "web-search",
"category": "search",
"description": "Search the web using SearXNG metasearch engine",
"input_schema": SearchRequest.model_json_schema(),
"output_schema": SearchResponse.model_json_schema(),
"implementation": {
"method": "POST",
"path": "/v1/search",
"content_type": "application/json",
"timeout": 10,
},
"endpoint": f"http://localhost:{settings.port}/v1/search",
"tags": ["search", "searxng"],
"is_active": True,
"metadata": {},
},
{
"name": "Image Search",
"slug": "web-image-search",
"category": "search",
"description": "Search for images using SearXNG metasearch engine",
"input_schema": ImageSearchRequest.model_json_schema(),
"output_schema": ImageSearchResponse.model_json_schema(),
"implementation": {
"method": "POST",
"path": "/v1/image-search",
"content_type": "application/json",
"timeout": 10,
},
"endpoint": f"http://localhost:{settings.port}/v1/image-search",
"tags": ["search", "images", "searxng"],
"is_active": True,
"metadata": {},
},
{
"name": "Fetch URLs",
"slug": "web-fetch",
"category": "fetch",
"description": (
"Fetch and extract content from URLs with automatic fallback "
"(HTTP \u2192 Playwright \u2192 Vision LLM)"
),
"input_schema": FetchRequest.model_json_schema(),
"output_schema": FetchResponse.model_json_schema(),
"implementation": {
"method": "POST",
"path": "/v1/fetch",
"content_type": "application/json",
"timeout": 60,
},
"endpoint": f"http://localhost:{settings.port}/v1/fetch",
"tags": ["fetch", "scraping"],
"is_active": True,
"metadata": {
"supports_fallback": True,
},
},
]
return {
"resource": resource,
"models": models,
"functions": functions,
}

View file

@ -0,0 +1,181 @@
"""Search endpoint."""
import time
from fastapi import APIRouter, Depends, HTTPException, Request
from web.api.dependencies import (
get_concurrency_limiter,
get_search_client_for_tier,
get_search_tier,
verify_bearer_token,
)
from web.exceptions import (
ProviderError,
RateLimitError,
SearchError,
WebConnectionError,
WebTimeoutError,
)
from web.logging import get_logger, get_request_id
from web.schemas.common import make_error_detail
from web.schemas.search import SearchRequest, SearchResponse
logger = get_logger("routes.search")
router = APIRouter(dependencies=[Depends(verify_bearer_token)])
def _provider_for_tier(tier: str) -> str:
return "paid-rotation" if tier == "premium" else "searxng"
def _emit(
request: Request,
request_id: str,
tier: str,
start: float,
status_code: int,
response: SearchResponse | None = None,
error: str | None = None,
query: str | None = None,
) -> None:
sink = getattr(request.app.state, "event_sink", None)
if sink is None or not sink.enabled:
return
duration_ms = int((time.perf_counter() - start) * 1000)
event = {
"request_id": request_id,
"tier": tier,
"endpoint": "/v1/search",
"provider": _provider_for_tier(tier),
"query": query,
"duration_ms": duration_ms,
"status_code": status_code,
"error": error,
}
if response is not None:
event["results_count"] = response.total_results
sink.emit(event)
@router.post("/search", response_model=SearchResponse)
async def search(
request: Request,
search_request: SearchRequest,
tier: str = Depends(get_search_tier),
) -> SearchResponse:
"""Execute web search queries.
Tier selection via X-Search-Tier header:
- "free" (default): SearXNG only
- "premium": Paid search engines only
Args:
request: FastAPI request object.
search_request: Search parameters.
tier: Search tier from X-Search-Tier header.
Returns:
SearchResponse: Search results.
Raises:
HTTPException: On search errors.
"""
client = get_search_client_for_tier(request, tier)
limiter = get_concurrency_limiter()
request_id = get_request_id()
start = time.perf_counter()
query_summary = " | ".join(search_request.queries)[:500]
logger.info("Search request [tier=%s]", tier)
async with limiter.acquire():
try:
result = await client.search(search_request, request_id=request_id)
_emit(
request,
request_id,
tier,
start,
status_code=200,
response=result,
query=query_summary,
)
return result
except RateLimitError as e:
_emit(request, request_id, tier, start, 429, error=str(e), query=query_summary)
logger.warning("Rate limit exceeded: %s", e)
raise HTTPException(
status_code=429,
detail=make_error_detail(
"rate_limit_exceeded",
str(e),
request_id=request_id,
retry_after=e.retry_after,
),
headers={"Retry-After": str(int(e.retry_after or 60))},
) from None
except WebTimeoutError as e:
_emit(request, request_id, tier, start, 504, error=str(e), query=query_summary)
logger.error("Search timeout: %s", e)
raise HTTPException(
status_code=504,
detail=make_error_detail("timeout", str(e), request_id=request_id),
) from None
except WebConnectionError as e:
_emit(request, request_id, tier, start, 502, error=str(e), query=query_summary)
logger.error("Connection error: %s", e)
raise HTTPException(
status_code=502,
detail=make_error_detail(
"connection_error",
str(e),
request_id=request_id,
provider=e.provider,
),
) from None
except ProviderError as e:
_emit(request, request_id, tier, start, 502, error=str(e), query=query_summary)
logger.error("Provider error: %s", e)
raise HTTPException(
status_code=502,
detail=make_error_detail(
"provider_error",
str(e),
request_id=request_id,
provider=e.provider,
),
) from None
except SearchError as e:
_emit(request, request_id, tier, start, 500, error=str(e), query=query_summary)
logger.error("Search error: %s", e)
raise HTTPException(
status_code=500,
detail=make_error_detail("search_error", str(e), request_id=request_id),
) from None
except Exception as e:
_emit(
request,
request_id,
tier,
start,
500,
error=f"{type(e).__name__}: {e}",
query=query_summary,
)
logger.exception("Unexpected error during search: %s", e)
raise HTTPException(
status_code=500,
detail=make_error_detail(
"internal_error",
f"Unexpected error: {type(e).__name__}: {e}",
request_id=request_id,
),
) from e

View file

@ -0,0 +1,14 @@
"""Brain integration — premium results → brain cache + cache-read."""
from web.brain.adapter import brain_response_to_web
from web.brain.client import BrainClient
from web.brain.quality import quality_ok_for_cache, brain_hit_acceptable
from web.brain.sink import BrainIngestSink
__all__ = [
"BrainClient",
"BrainIngestSink",
"brain_response_to_web",
"quality_ok_for_cache",
"brain_hit_acceptable",
]

View file

@ -0,0 +1,211 @@
"""Map brain-api responses to web-api GatherResponse shape.
Brain and web use slightly different schemas (datetime vs ISO string,
nested Provenance with brain_meta vs plain dict, etc). This adapter
normalizes brain responses so web clients see a consistent shape.
"""
from __future__ import annotations
import hashlib
from typing import Any
from web.schemas.context import EntitySet, SearchContext
from web.schemas.evidence import EvidenceItem, EvidenceStats
from web.schemas.gather import GatherResponse, GatherStageResult
from web.schemas.search import SearchResult
def _iso(value: Any) -> str | None:
"""Normalize a datetime-like value to ISO string (brain emits datetime)."""
if value is None:
return None
if isinstance(value, str):
return value
# datetime has isoformat(); pydantic will usually have already serialized
try:
return value.isoformat() # type: ignore[no-any-return]
except Exception:
return str(value)
def _hash_if_missing(text: str | None, existing: str | None) -> str:
"""Compute a sha256 hex digest when brain didn't supply one."""
if existing:
return existing
if not text:
return ""
return hashlib.sha256(text.encode("utf-8", errors="ignore")).hexdigest()
def _map_evidence(item: dict[str, Any]) -> EvidenceItem:
provenance_src = item.get("provenance") or {}
extraction_method = (
provenance_src.get("extraction_method")
if isinstance(provenance_src, dict)
else None
) or "brain"
provenance = {
"extraction_method": extraction_method,
"fallback_chain": (
provenance_src.get("fallback_chain")
if isinstance(provenance_src, dict)
else None
) or [],
"source": "brain",
}
# Preserve brain's NLI metadata inside provenance (non-breaking addition)
if isinstance(provenance_src, dict) and provenance_src.get("brain_meta"):
provenance["brain_meta"] = provenance_src["brain_meta"]
return EvidenceItem(
url=item.get("url") or "",
canonical_url=item.get("canonical_url"),
title=item.get("title") or None,
publisher=item.get("publisher") or None,
published_at=_iso(item.get("published_at")),
retrieved_at=_iso(item.get("retrieved_at")) or "",
snippet=item.get("snippet"),
summary=item.get("summary"),
full_text=item.get("full_text"),
full_text_hash=_hash_if_missing(
item.get("full_text"), item.get("full_text_hash")
),
provenance=provenance,
relevance_score=item.get("relevance_score"),
credibility_score=item.get("credibility_score"),
)
def _map_search_result(item: dict[str, Any]) -> SearchResult:
return SearchResult(
query=item.get("query") or "",
url=item.get("url") or "",
title=item.get("title") or "",
snippet=item.get("snippet") or "",
rank=item.get("rank") or 1,
site=item.get("site") or "",
published_at=_iso(item.get("published_at")),
)
def _map_stage(stage: dict[str, Any]) -> GatherStageResult:
return GatherStageResult(
stage=stage.get("stage") or "unknown",
success=bool(stage.get("success", False)),
items_processed=stage.get("items_processed") or 0,
items_failed=stage.get("items_failed") or 0,
duration_ms=float(stage.get("duration_ms") or 0.0),
error=stage.get("error"),
)
def _map_context(ctx: dict[str, Any] | None) -> SearchContext | None:
if not ctx:
return None
entities = ctx.get("entities") or {}
return SearchContext(
primary_country=ctx.get("primary_country"),
secondary_countries=ctx.get("secondary_countries") or [],
entities=EntitySet(
persons=entities.get("persons") or [],
institutions=entities.get("institutions") or [],
locations=entities.get("locations") or [],
),
detected_language=ctx.get("detected_language") or "en",
search_queries=ctx.get("search_queries") or [],
)
def _map_stats(stats: dict[str, Any] | None) -> EvidenceStats:
s = stats or {}
return EvidenceStats(
input_items=s.get("input_items") or 0,
after_dedup=s.get("after_dedup") or 0,
output_items=s.get("output_items") or 0,
duplicates_removed=s.get("duplicates_removed") or 0,
tokens_used=s.get("tokens_used") or 0,
)
def brain_response_to_web(
brain: dict[str, Any],
request_id: str,
) -> GatherResponse:
"""Convert a brain /v1/gather JSON response into a web GatherResponse."""
evidence_items = [_map_evidence(e) for e in (brain.get("evidence") or [])]
search_results = [_map_search_result(s) for s in (brain.get("search_results") or [])]
stages = [_map_stage(s) for s in (brain.get("stages") or [])]
return GatherResponse(
request_id=request_id,
claim=brain.get("claim") or "",
evidence=evidence_items,
evidence_stats=_map_stats(brain.get("evidence_stats")),
search_context=_map_context(brain.get("search_context")),
search_results=search_results,
stages=stages,
total_urls_found=brain.get("total_urls_found") or len(search_results),
total_pages_fetched=brain.get("total_pages_fetched") or len(evidence_items),
total_evidence_items=brain.get("total_evidence_items") or len(evidence_items),
execution_time_ms=float(brain.get("execution_time_ms") or 0.0),
)
def web_response_to_ingest_payload(
response: GatherResponse,
claim: str,
default_tags: list[str] | None = None,
) -> dict[str, Any]:
"""Build a brain /v1/ingest payload from a web GatherResponse.
Brain's IngestRequest schema accepts EvidenceItem with the same field
names as web; only datetime fields get interpreted by pydantic on the
brain side, so sending ISO strings is fine.
"""
evidence_payload: list[dict[str, Any]] = []
for ev in response.evidence:
provenance = ev.provenance if isinstance(ev.provenance, dict) else {}
# Inherit extraction method; stamp source as web premium so we can
# audit/regret later if premium-sourced results turn out to be noisy.
provenance = {
**provenance,
"source": provenance.get("source") or "web-premium",
}
# Brain requires title/retrieved_at non-null and uses float defaults
# for relevance/credibility. Map None → safe defaults so ingest validates.
evidence_payload.append(
{
"url": ev.url,
"canonical_url": ev.canonical_url,
"title": ev.title or "",
"publisher": ev.publisher or "",
"published_at": ev.published_at,
"retrieved_at": ev.retrieved_at,
"snippet": ev.snippet,
"summary": ev.summary,
"full_text": ev.full_text,
"full_text_hash": ev.full_text_hash or "",
"provenance": provenance,
"relevance_score": (
ev.relevance_score if ev.relevance_score is not None else 0.0
),
"credibility_score": (
ev.credibility_score if ev.credibility_score is not None else 0.5
),
}
)
tags = list(default_tags or [])
ctx = response.search_context
if ctx and ctx.primary_country and ctx.primary_country.lower() != "global":
tags.append(f"Country/{ctx.primary_country}")
if ctx and ctx.detected_language:
tags.append(f"Language/{ctx.detected_language.upper()}")
return {
"claim": claim,
"evidence": evidence_payload,
"default_tags": tags,
"run_extraction": True,
}

View file

@ -0,0 +1,103 @@
"""Async HTTP client for didi-brain (cache read + ingest write)."""
from typing import Any
import httpx
from web.logging import get_logger
logger = get_logger("brain.client")
class BrainClient:
"""Minimal client that talks to brain_api /v1/gather and /v1/ingest."""
def __init__(
self,
base_url: str | None,
gather_timeout: float = 8.0,
ingest_timeout: float = 15.0,
) -> None:
self.base_url = base_url.rstrip("/") if base_url else None
self._client: httpx.AsyncClient | None = None
self._gather_timeout = gather_timeout
self._ingest_timeout = ingest_timeout
self._enabled = bool(base_url)
@property
def enabled(self) -> bool:
return self._enabled
async def start(self) -> None:
if not self._enabled:
logger.info("BrainClient disabled (no brain URL)")
return
self._client = httpx.AsyncClient(
timeout=httpx.Timeout(
connect=2.0,
read=max(self._gather_timeout, self._ingest_timeout),
write=5.0,
pool=5.0,
),
limits=httpx.Limits(max_connections=10, max_keepalive_connections=3),
)
logger.info("BrainClient started → %s", self.base_url)
async def close(self) -> None:
if self._client is not None and not self._client.is_closed:
await self._client.aclose()
self._client = None
async def gather(
self,
claim: str,
max_evidence: int = 15,
run_nli: bool = False,
include_full_text: bool = True,
) -> dict[str, Any] | None:
"""Ask brain for cached evidence. Returns None if brain unavailable."""
if not self._enabled or self._client is None or self.base_url is None:
return None
try:
resp = await self._client.post(
f"{self.base_url}/v1/gather",
json={
"claim": claim,
"max_evidence": max_evidence,
"run_nli": run_nli,
"include_full_text": include_full_text,
"summarize": False,
"score_relevance": True,
},
timeout=self._gather_timeout,
)
if resp.status_code != 200:
logger.debug("Brain gather non-200: %s", resp.status_code)
return None
return resp.json()
except Exception as e:
logger.debug("Brain gather failed (%s): %s", type(e).__name__, e)
return None
async def ingest(self, payload: dict[str, Any]) -> bool:
"""Push evidence to brain. Returns True on success, False otherwise."""
if not self._enabled or self._client is None or self.base_url is None:
return False
try:
resp = await self._client.post(
f"{self.base_url}/v1/ingest",
json=payload,
timeout=self._ingest_timeout,
)
resp.raise_for_status()
body = resp.json()
logger.info(
"Brain ingest OK: accepted=%d skipped_dup=%d errors=%d",
body.get("accepted", 0),
body.get("skipped_duplicate", 0),
body.get("errors", 0),
)
return True
except Exception as e:
logger.warning("Brain ingest failed (%s): %s", type(e).__name__, e)
return False

View file

@ -0,0 +1,70 @@
"""Quality gates for brain cache read/write decisions."""
from typing import Any
from web.schemas.gather import GatherResponse
def quality_ok_for_cache(
response: GatherResponse,
min_evidence: int = 3,
min_credibility: float = 0.7,
min_execution_ms: float = 2000.0,
require_all_stages_ok: bool = True,
) -> bool:
"""Decide whether a web gather response is worth caching into brain.
Applied only to premium results (caller enforces tier). Conservative by
default we prefer undercaching to polluting the brain with junk.
"""
if response.total_evidence_items < min_evidence:
return False
# Credibility check: if any item has a score, at least one must clear the bar.
# If no item was scored (scoring disabled), we trust premium by default.
scored = [e for e in response.evidence if e.credibility_score is not None]
if scored and not any(
(e.credibility_score or 0.0) >= min_credibility for e in scored
):
return False
if require_all_stages_ok and any(not s.success for s in response.stages):
return False
if response.execution_time_ms < min_execution_ms:
return False
return True
def brain_hit_acceptable(
brain_response: dict[str, Any] | None,
min_evidence: int = 3,
min_relevance: float = 0.7,
) -> bool:
"""Decide whether a brain gather response is good enough to serve.
HIT = always acceptable (brain already has strong confidence).
PARTIAL = require min_evidence items with relevance min_relevance.
MISS = never acceptable.
"""
if not brain_response:
return False
meta = brain_response.get("brain_meta") or {}
cache_status = meta.get("cache_status", "MISS")
if cache_status == "MISS":
return False
evidence = brain_response.get("evidence") or []
if cache_status == "HIT":
return len(evidence) >= 1 # HIT with any evidence is fine
# PARTIAL — require some strong items
strong = [
e
for e in evidence
if (e.get("relevance_score") or 0.0) >= min_relevance
]
return len(strong) >= min_evidence

View file

@ -0,0 +1,69 @@
"""Fire-and-forget async sink that pushes premium web results to brain.
Mirror of DashboardEventSink: bounded queue, single worker, drop-oldest
on overflow, failures swallowed so brain outages don't affect web latency.
"""
import asyncio
import contextlib
from typing import Any
from web.brain.client import BrainClient
from web.logging import get_logger
logger = get_logger("brain.sink")
class BrainIngestSink:
def __init__(
self,
client: BrainClient,
queue_size: int = 1000,
) -> None:
self._client = client
self._queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue(maxsize=queue_size)
self._worker_task: asyncio.Task[None] | None = None
@property
def enabled(self) -> bool:
return self._client.enabled
async def start(self) -> None:
if not self._client.enabled:
logger.info("BrainIngestSink disabled (no brain URL)")
return
self._worker_task = asyncio.create_task(self._worker())
logger.info("BrainIngestSink started")
async def stop(self) -> None:
if self._worker_task is not None:
self._worker_task.cancel()
with contextlib.suppress(asyncio.CancelledError, Exception):
await self._worker_task
self._worker_task = None
def emit(self, payload: dict[str, Any]) -> None:
"""Enqueue a brain /v1/ingest payload. Never raises."""
if not self.enabled:
return
try:
self._queue.put_nowait(payload)
except asyncio.QueueFull:
try:
_ = self._queue.get_nowait()
self._queue.put_nowait(payload)
except Exception:
pass
async def _worker(self) -> None:
while True:
try:
payload = await self._queue.get()
except asyncio.CancelledError:
raise
try:
await self._client.ingest(payload)
except Exception as e:
logger.debug("Brain ingest dropped (%s): %s", type(e).__name__, e)
finally:
self._queue.task_done()

View file

@ -0,0 +1,5 @@
"""Browse submodule - Playwright browser automation."""
from web.browse.client import BrowseClient
__all__ = ["BrowseClient"]

View file

@ -0,0 +1,701 @@
"""Playwright-based browser client for JavaScript-heavy pages."""
import asyncio
import hashlib
import time
from datetime import datetime, timezone
from typing import Any
from web.config import WebSettings
from web.exceptions import (
WebError,
WebTimeoutError,
)
from web.logging import get_logger
from web.schemas.browse import BrowsePageResult, BrowseRequest, BrowseResponse
from web.schemas.common import FailedUrl, PageContent
logger = get_logger("browse.client")
# ---------------------------------------------------------------------------
# Stealth: realistic Chrome UA and headers to avoid bot detection
# ---------------------------------------------------------------------------
_CHROME_UA = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/124.0.0.0 Safari/537.36"
)
_STEALTH_HEADERS = {
"Accept": (
"text/html,application/xhtml+xml,application/xml;"
"q=0.9,image/avif,image/webp,*/*;q=0.8"
),
"Accept-Language": "en-US,en;q=0.9",
"Accept-Encoding": "gzip, deflate, br",
"Sec-CH-UA": '"Chromium";v="124", "Google Chrome";v="124", "Not-A.Brand";v="99"',
"Sec-CH-UA-Mobile": "?0",
"Sec-CH-UA-Platform": '"Windows"',
"Upgrade-Insecure-Requests": "1",
}
# Hide navigator.webdriver from page JS (survives navigations within context)
_STEALTH_INIT_SCRIPT = (
"Object.defineProperty(navigator, 'webdriver', {get: () => undefined})"
)
# ---------------------------------------------------------------------------
# Resource types to block in Playwright (images, fonts, media, stylesheets)
# ---------------------------------------------------------------------------
_BLOCK_RESOURCE_TYPES = frozenset({"image", "media", "font", "stylesheet"})
# ---------------------------------------------------------------------------
# CMP-aware consent dismissal (runs in browser JS with 2s self-timeout)
# ---------------------------------------------------------------------------
_DISMISS_CONSENT_JS = """
() => Promise.race([
(async () => {
// Tier 1: Call major CMP platform APIs directly
if (window.OneTrust && window.OneTrust.AllowAll) {
window.OneTrust.AllowAll();
return 'onetrust-api';
}
if (window.Cookiebot && window.Cookiebot.submitCustomConsent) {
window.Cookiebot.submitCustomConsent(true, true, true);
return 'cookiebot-api';
}
if (typeof window.__tcfapi === 'function') {
window.__tcfapi('setConsent', 2, () => {}, {purpose: {consents: {}}});
}
if (window.CookieControl && window.CookieControl.acceptAll) {
window.CookieControl.acceptAll();
return 'cookiecontrol-api';
}
// Tier 2: DOM selector fallback (major CMPs + generic)
const SELECTORS = [
'#onetrust-accept-btn-handler',
'#CybotCookiebotDialogBodyLevelButtonLevelOptinAllowAll',
'.trustarc-agree-btn',
'.qc-cmp2-summary-buttons button:last-child',
'[data-testid="uc-accept-all-button"]',
'[data-cookiebanner="accept_button"]',
'[data-testid="cookie-policy-manage-dialog-accept-button"]',
'button[id*="cookie-accept"]',
'button[id*="cookieAccept"]',
'button[id*="accept"]',
'button[class*="accept"]',
'[aria-label*="Accept all" i]',
'[aria-label*="Agree" i]',
'button#tiktok-accept-row',
'[data-testid="xMigrationBottomBar"] button',
];
// Multilingual text patterns
const TEXT_PATTERNS = [
/^accept all$/i, /^accept cookies$/i, /^i accept$/i,
/^agree$/i, /^agree all$/i, /^got it$/i, /^ok$/i,
/^allow all$/i, /^allow cookies$/i, /^i agree$/i,
/^accept all cookies$/i,
/^akzeptieren$/i,
/^tout accepter$/i,
/^aceptar todo$/i,
/^accetta tutto$/i,
/^accept\\u0103 tot$/i,
/^accept\\u0103$/i,
/^de acord$/i,
];
for (const sel of SELECTORS) {
const el = document.querySelector(sel);
if (el && el.offsetParent !== null) { el.click(); return 'selector:' + sel; }
}
for (const btn of document.querySelectorAll('button, [role="button"]')) {
if (btn.offsetParent !== null && TEXT_PATTERNS.some(p => p.test(btn.innerText.trim()))) {
btn.click(); return 'text:' + btn.innerText.trim();
}
}
// Tier 3: generic dialog close buttons
const closeBtn = document.querySelector(
'div[role="dialog"] button[aria-label="Close"],' +
'div[role="dialog"] button[aria-label="Dismiss"],' +
'div[role="dialog"] div[aria-label="Close"],' +
'div[role="dialog"] div[aria-label="Închide"]'
);
if (closeBtn && closeBtn.offsetParent !== null) {
closeBtn.click(); return 'dialog-close';
}
return null;
})(),
new Promise(r => setTimeout(() => r(null), 2000))
])
"""
# ---------------------------------------------------------------------------
# Auth-wall / paywall detection (runs in browser JS with 5s self-timeout)
# ---------------------------------------------------------------------------
_DETECT_AUTH_WALL_JS = """
() => Promise.race([
Promise.resolve({
hasPasswordField: !!document.querySelector('input[type="password"]'),
hasPaywall: !!(document.querySelector(
'[class*="paywall"],[id*="paywall"],[class*="subscribe"],[class*="premium"]'
)),
hasCFChallenge: !!(
document.querySelector('script[src*="challenge-platform"]') ||
document.querySelector('iframe[src*="turnstile"]')
),
currentUrl: window.location.href,
}),
new Promise(r => setTimeout(() => r({__timeout: true}), 5000))
])
"""
# ---------------------------------------------------------------------------
# Content extraction via readability (Python-side) with JS metadata fallback
# ---------------------------------------------------------------------------
_EXTRACT_METADATA_JS = """
() => Promise.race([
Promise.resolve({
canonical: (() => {
const l = document.querySelector('link[rel="canonical"]');
return l ? l.href : null;
})(),
publishedAt: (() => {
const sels = [
'time[datetime]', '[itemprop="datePublished"]',
'meta[property="article:published_time"]', '.date', '.published'
];
for (const s of sels) {
const el = document.querySelector(s);
if (el) return el.getAttribute('datetime') || el.getAttribute('content') || el.innerText;
}
return null;
})(),
}),
new Promise(r => setTimeout(() => r({canonical: null, publishedAt: null}), 3000))
])
"""
# Fallback JS text extraction with browser-side timeout
_EXTRACT_TEXT_JS = """
() => Promise.race([
Promise.resolve((() => {
const main = document.querySelector('main, article, [role="main"], .content, #content');
return (main || document.body).innerText;
})()),
new Promise(r => setTimeout(() => r(''), 5000))
])
"""
# Optional imports
try:
from playwright.async_api import (
Browser,
Page,
Route,
TimeoutError as PlaywrightTimeout,
async_playwright,
)
HAS_PLAYWRIGHT = True
except ImportError:
HAS_PLAYWRIGHT = False
logger.warning("playwright not installed, browse functionality unavailable")
# Optional readability import (used for content extraction)
try:
from readability import Document as ReadabilityDocument
HAS_READABILITY = True
except ImportError:
HAS_READABILITY = False
try:
from lxml.html import fromstring as lxml_parse
HAS_LXML = True
except ImportError:
HAS_LXML = False
# Global lock for readability-lxml parsing (lxml is not thread-safe,
# concurrent parsing causes "double free or corruption" crashes)
_readability_lock = asyncio.Lock()
async def _block_resources(route: Route) -> None:
"""Abort image/media/font/stylesheet requests to speed up browsing."""
if route.request.resource_type in _BLOCK_RESOURCE_TYPES:
await route.abort()
else:
await route.continue_()
class BrowseClient:
"""Client for browser-based content extraction using Playwright."""
def __init__(
self,
settings: WebSettings,
browser: Browser | None = None,
) -> None:
"""Initialize the client.
Args:
settings: Application settings.
browser: Optional shared browser instance. If provided, the client
will not launch its own browser and will not close this one.
"""
if not HAS_PLAYWRIGHT:
raise ImportError(
"playwright is required for BrowseClient. "
"Install with: uv add playwright && uv run playwright install chromium"
)
self.settings = settings
self._playwright = None
self._browser: Browser | None = browser
self._owns_browser = browser is None
self._browser_lock = asyncio.Lock()
async def _get_browser(self) -> Browser:
"""Get or create the browser instance."""
async with self._browser_lock:
if self._browser is None or not self._browser.is_connected():
self._playwright = await async_playwright().start()
self._browser = await self._playwright.chromium.launch(
headless=True,
args=[
"--disable-gpu",
"--disable-dev-shm-usage",
"--no-sandbox",
"--disable-setuid-sandbox",
],
)
return self._browser
@property
def shared_browser(self) -> Browser | None:
"""Get browser instance for sharing (e.g., with VisionClient)."""
if self._browser and self._browser.is_connected():
return self._browser
return None
async def __aenter__(self) -> "BrowseClient":
return self
async def __aexit__(self, *exc: object) -> None:
await self.close()
async def close(self) -> None:
"""Close the browser (only if this client owns it)."""
if self._owns_browser:
if self._browser is not None:
await self._browser.close()
self._browser = None
if self._playwright is not None:
await self._playwright.stop()
self._playwright = None
async def browse(
self,
request: BrowseRequest,
request_id: str | None = None,
) -> BrowseResponse:
"""Browse URLs and extract content.
Args:
request: Browse request with URLs and parameters.
request_id: Optional request ID for tracing.
Returns:
BrowseResponse: Browsed page content.
"""
start_time = time.perf_counter()
pages: list[BrowsePageResult] = []
failed: list[FailedUrl] = []
browser = await self._get_browser()
# Process URLs with concurrency limit
semaphore = asyncio.Semaphore(request.parallel_browses)
async def browse_with_semaphore(url: str) -> BrowsePageResult | None:
async with semaphore:
try:
return await self._browse_single(browser, url, request)
except Exception as e:
logger.error("Failed to browse %s: %s", url, e)
failed.append(FailedUrl(url=url, error=str(e)))
return None
# Browse all URLs concurrently
tasks = [browse_with_semaphore(url) for url in request.urls]
results = await asyncio.gather(*tasks)
for result in results:
if result is not None:
pages.append(result)
execution_time_ms = (time.perf_counter() - start_time) * 1000
return BrowseResponse(
request_id=request_id or "-",
pages=pages,
total_browsed=len(pages),
total_failed=len(failed),
failed_urls=failed,
execution_time_ms=round(execution_time_ms, 2),
)
async def _browse_single(
self,
browser: Browser,
url: str,
request: BrowseRequest,
) -> BrowsePageResult:
"""Browse a single URL with retry on transient timeout.
Args:
browser: Playwright browser instance.
url: URL to browse.
request: Browse request with parameters.
Returns:
BrowsePageResult: Browsed page content.
"""
last_exc: Exception | None = None
max_attempts = max(1, self.settings.max_retries)
for attempt in range(max_attempts):
try:
return await self._attempt_browse(browser, url, request)
except WebTimeoutError as e:
last_exc = e
if attempt < max_attempts - 1:
wait = min(
self.settings.retry_min_wait * (2**attempt),
self.settings.retry_max_wait,
)
logger.warning(
"Browse timeout for %s (attempt %d/%d), retrying in %.1fs",
url,
attempt + 1,
max_attempts,
wait,
)
await asyncio.sleep(wait)
except WebError:
raise # Non-transient — no retry
raise last_exc # type: ignore[misc]
async def _attempt_browse(
self,
browser: Browser,
url: str,
request: BrowseRequest,
) -> BrowsePageResult:
"""Execute a single browse attempt.
Args:
browser: Playwright browser instance.
url: URL to browse.
request: Browse request with parameters.
Returns:
BrowsePageResult: Browsed page content.
"""
start_time = time.perf_counter()
warnings: list[str] = []
context = await browser.new_context(
viewport={
"width": self.settings.browse_viewport_width,
"height": self.settings.browse_viewport_height,
},
user_agent=_CHROME_UA,
extra_http_headers=_STEALTH_HEADERS,
)
# Hide navigator.webdriver
await context.add_init_script(_STEALTH_INIT_SCRIPT)
# Block heavy resources to speed up browsing
if self.settings.browse_block_resources:
await context.route("**/*", _block_resources)
page = await context.new_page()
# Auth-wall redirect listener
redirect_to_auth: list[str] = []
_login_path_patterns = (
"/login", "/signin", "/sign-in", "/auth", "/subscribe",
)
def _on_response(resp: Any) -> None:
if (
hasattr(resp, "status")
and resp.status in (301, 302, 303, 307, 308)
and any(p in resp.url for p in _login_path_patterns)
):
redirect_to_auth.append(resp.url)
page.on("response", _on_response)
try:
# Navigate
try:
response = await page.goto(
url,
wait_until=request.wait_until,
timeout=request.timeout_ms,
)
except PlaywrightTimeout as e:
raise WebTimeoutError(
f"Timeout loading {url}",
timeout=request.timeout_ms / 1000,
) from e
# Check HTTP status from page.goto() response
if response is None or not response.ok:
status = response.status if response else 0
raise WebError(f"HTTP {status} browsing {url}")
# Auth redirect detected
if redirect_to_auth:
warnings.append(
f"Redirect to auth wall observed: {redirect_to_auth[0]}"
)
# Wait for selector if requested
if request.wait_for_selector:
try:
await page.wait_for_selector(
request.wait_for_selector,
timeout=5000,
)
except PlaywrightTimeout:
logger.warning(
"Selector %s not found on %s",
request.wait_for_selector,
url,
)
# Dismiss consent banners (CMP-aware, browser-side 2s timeout)
await self._dismiss_consent_banners(page)
# Additional wait for dynamic content (SPA rendering)
if request.extra_wait_ms > 0:
await asyncio.sleep(request.extra_wait_ms / 1000)
# Detect auth-wall / paywall (browser-side 5s timeout)
auth_warning = await self._detect_auth_wall(page, url)
if auth_warning:
warnings.append(auth_warning)
# Extract content (readability + JS metadata)
extracted = await self._extract_content(page, request)
warnings.extend(extracted.get("warnings", []))
# Take screenshot if requested
screenshot_base64 = None
if request.screenshot:
screenshot_bytes = await page.screenshot(
type="jpeg",
quality=self.settings.vision_screenshot_quality,
full_page=request.full_page_screenshot,
)
import base64
screenshot_base64 = base64.b64encode(screenshot_bytes).decode()
extraction_time_ms = (time.perf_counter() - start_time) * 1000
retrieved_at = datetime.now(timezone.utc).isoformat()
return BrowsePageResult(
url=url,
final_url=page.url,
canonical_url=extracted.get("canonical_url"),
title=extracted.get("title"),
text=extracted["text"],
text_hash=hashlib.sha256(extracted["text"].encode()).hexdigest(),
html=extracted.get("html") if request.include_html else None,
extraction_method="browse",
fallback_chain=[],
published_at=extracted.get("published_at"),
retrieved_at=retrieved_at,
extraction_time_ms=round(extraction_time_ms, 2),
warnings=warnings,
screenshot_base64=screenshot_base64,
viewport_width=self.settings.browse_viewport_width,
viewport_height=self.settings.browse_viewport_height,
)
finally:
await context.close()
async def _dismiss_consent_banners(self, page: Page) -> None:
"""Dismiss cookie/consent banners using CMP APIs + DOM selectors.
Uses browser-side Promise.race with 2s timeout so it never blocks
longer than that regardless of page state.
Args:
page: Playwright page instance.
"""
try:
method = await page.evaluate(_DISMISS_CONSENT_JS)
if method:
logger.debug("Dismissed consent banner via: %s", method)
await asyncio.sleep(0.3)
except Exception as e:
logger.debug("Consent dismissal failed (non-fatal): %s", e)
async def _detect_auth_wall(self, page: Page, original_url: str) -> str | None:
"""Detect login walls, paywalls, and Cloudflare challenges.
Uses browser-side Promise.race with 5s timeout.
Args:
page: Playwright page instance.
original_url: The URL we intended to visit.
Returns:
Warning string if auth wall detected, None otherwise.
"""
try:
signals = await page.evaluate(_DETECT_AUTH_WALL_JS)
if isinstance(signals, dict) and signals.get("__timeout"):
logger.warning("Auth-wall detection timed out for %s", original_url)
return None
final_url = signals.get("currentUrl", "") if isinstance(signals, dict) else ""
login_keywords = (
"/login", "/signin", "/sign-in", "/auth/",
"/account/login", "/subscribe",
)
if any(kw in final_url for kw in login_keywords):
return f"Redirected to auth wall: {final_url}"
if isinstance(signals, dict):
if signals.get("hasCFChallenge"):
return "Cloudflare challenge page detected"
if signals.get("hasPasswordField"):
return "Login form detected on page"
if signals.get("hasPaywall"):
return "Paywall detected on page"
except Exception as e:
logger.debug("Auth-wall detection failed (non-fatal): %s", e)
return None
async def _extract_content(
self,
page: Page,
request: BrowseRequest,
) -> dict[str, Any]:
"""Extract content using Python readability on rendered HTML.
Falls back to JS innerText if readability is not available or fails.
Metadata extracted via a single JS evaluate with browser-side timeout.
Args:
page: Playwright page instance.
request: Browse request with parameters.
Returns:
dict: Extracted content.
"""
result: dict[str, Any] = {
"text": "",
"title": None,
"canonical_url": None,
"published_at": None,
"html": None,
"warnings": [],
}
# Title from Playwright API (governed by page default timeout)
result["title"] = await page.title()
# Get full rendered HTML
html = await page.content()
if request.include_html:
result["html"] = html
# Primary: Python readability-lxml on rendered HTML
# Lock required: lxml is not thread-safe, concurrent parsing crashes
if HAS_READABILITY and html:
try:
async with _readability_lock:
doc = ReadabilityDocument(html)
result["title"] = result["title"] or doc.title()
summary_html = doc.summary()
if HAS_LXML:
tree = lxml_parse(summary_html)
result["text"] = tree.text_content().strip()
else:
import re
text = re.sub(r"<[^>]+>", " ", summary_html)
result["text"] = re.sub(r"\s+", " ", text).strip()
except Exception as e:
logger.warning(
"Readability extraction failed for %s: %s", page.url, e
)
# Fallback: JS body text (browser-side 5s timeout)
if not result["text"]:
try:
text = await page.evaluate(_EXTRACT_TEXT_JS)
result["text"] = text.strip() if text else ""
except Exception as e:
logger.warning("JS text extraction failed: %s", e)
# Metadata: single JS evaluate, browser-side 3s timeout
try:
meta = await page.evaluate(_EXTRACT_METADATA_JS)
if isinstance(meta, dict):
result["canonical_url"] = meta.get("canonical")
result["published_at"] = meta.get("publishedAt")
except Exception as e:
logger.warning("Metadata extraction failed: %s", e)
return result
async def browse_to_page_content(
self,
url: str,
request: BrowseRequest | None = None,
) -> PageContent:
"""Convenience method to browse a single URL and return PageContent.
Args:
url: URL to browse.
request: Optional browse request parameters.
Returns:
PageContent: Extracted page content.
"""
if request is None:
request = BrowseRequest(urls=[url])
else:
request = BrowseRequest(
urls=[url],
wait_until=request.wait_until,
timeout_ms=request.timeout_ms,
screenshot=request.screenshot,
)
response = await self.browse(request)
if not response.pages:
raise WebError(f"Failed to browse {url}")
return response.pages[0].to_page_content()

View file

@ -0,0 +1,123 @@
"""CLI entry point for the Web server."""
import argparse
import signal
import sys
from typing import Any
import uvicorn
from web.config import SettingsCache
from web.logging import configure_logging, get_logger
class GracefulShutdown:
"""Handles graceful shutdown on signals."""
def __init__(self) -> None:
"""Initialize shutdown handler."""
self._server: uvicorn.Server | None = None
self._shutdown_requested = False
self._logger = get_logger("cli.shutdown")
def register_signals(self) -> None:
"""Register signal handlers for graceful shutdown."""
signal.signal(signal.SIGTERM, self._signal_handler)
signal.signal(signal.SIGINT, self._signal_handler)
def _signal_handler(self, signum: int, frame: Any) -> None:
"""Handle shutdown signals."""
if self._shutdown_requested:
self._logger.warning("Forced shutdown requested")
sys.exit(1)
self._shutdown_requested = True
signal_name = signal.Signals(signum).name
self._logger.info("Received %s, initiating graceful shutdown...", signal_name)
if self._server:
self._server.should_exit = True
def set_server(self, server: uvicorn.Server) -> None:
"""Set the uvicorn server for shutdown control."""
self._server = server
def main() -> None:
"""Run the Web server with graceful shutdown support."""
parser = argparse.ArgumentParser(
description="Web API Server",
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
)
parser.add_argument(
"--host",
type=str,
default=None,
help="Host to bind to (default: from WEB_HOST env or 0.0.0.0)",
)
parser.add_argument(
"--port",
type=int,
default=None,
help="Port to bind to (default: from WEB_PORT env or 8110)",
)
parser.add_argument(
"--workers",
type=int,
default=1,
help="Number of worker processes",
)
parser.add_argument(
"--reload",
action="store_true",
help="Enable auto-reload for development",
)
parser.add_argument(
"--graceful-timeout",
type=int,
default=30,
help="Timeout in seconds for graceful shutdown",
)
args = parser.parse_args()
settings = SettingsCache.get()
configure_logging(settings.log_level, settings.log_json)
logger = get_logger("cli")
host = args.host or settings.host
port = args.port or settings.port
logger.info("Starting Web API Server on %s:%d", host, port)
if args.reload:
logger.info("Development mode - auto-reload enabled")
uvicorn.run(
"web.api.app:create_app",
factory=True,
host=host,
port=port,
workers=1,
reload=True,
)
else:
shutdown_handler = GracefulShutdown()
shutdown_handler.register_signals()
config = uvicorn.Config(
"web.api.app:create_app",
factory=True,
host=host,
port=port,
workers=args.workers,
timeout_graceful_shutdown=args.graceful_timeout,
)
server = uvicorn.Server(config)
shutdown_handler.set_server(server)
server.run()
if __name__ == "__main__":
main()

View file

@ -0,0 +1,562 @@
"""Unified configuration for Web module."""
import threading
from typing import Literal
from pydantic import Field, field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
class WebSettings(BaseSettings):
"""Unified Web module configuration.
All settings can be configured via environment variables with the WEB_ prefix.
Example:
WEB_SEARXNG_BASE_URL=http://localhost:55100
WEB_LLM_BASE_URL=http://localhost:14011
WEB_PORT=51100
"""
model_config = SettingsConfigDict(
env_prefix="WEB_",
env_file=".env",
env_file_encoding="utf-8",
extra="forbid",
env_ignore_empty=True,
)
# ==========================================================================
# REQUIRED - Search (SearXNG) - Primary
# ==========================================================================
searxng_base_url: str = Field(
description="SearXNG base URL (REQUIRED). E.g., http://localhost:55100",
)
# ==========================================================================
# OPTIONAL - Additional Search Providers
# ==========================================================================
serpapi_api_key: str | None = Field(
default=None,
description="SerpAPI key for Google results (optional, runs parallel to SearXNG)",
)
tavily_api_key: str | None = Field(
default=None,
description="Tavily API key for AI-optimized search (optional, runs parallel to SearXNG)",
)
exa_api_key: str | None = Field(
default=None,
description="Exa API key for neural search (optional, runs parallel to SearXNG)",
)
linkup_api_key: str | None = Field(
default=None,
description="LinkUp API key for web content search (optional, runs parallel to SearXNG)",
)
brave_api_key: str | None = Field(
default=None,
description="Brave Search API key (optional, premium tier)",
)
# ==========================================================================
# OPTIONAL - Tier 3 fallback: Cloak stealth-browser scraping service
# ==========================================================================
# Invoked only when SearXNG + paid rotation return fewer results than the
# threshold. Scrapes Google/Bing/DDG SERPs through CloakBrowser. Service
# lives in ai_platform/modules/cloak.
cloak_enabled: bool = Field(
default=False,
description="Enable cloak tier-3 fallback. Default OFF.",
)
cloak_url: str = Field(
default="http://didiAI-cloak:8770",
description="Cloak service base URL inside the cluster.",
)
cloak_fallback_threshold: int = Field(
default=5,
description="Invoke cloak when dedup'd primary results are below this count.",
)
cloak_timeout_sec: int = Field(
default=20,
description="Hard cap on the cloak call. Errors return empty (non-fatal).",
)
cloak_auth_token: str | None = Field(
default=None,
description="Optional bearer token if cloak service is configured with auth.",
)
# ==========================================================================
# REQUIRED - LLM Integration (for vision + evidence)
# ==========================================================================
llm_base_url: str = Field(
description="LLM Inference API base URL (for text model). REQUIRED.",
)
llm_api_key: str | None = Field(
default=None,
description="LLM API key (if auth enabled)",
)
vision_base_url: str | None = Field(
default=None,
description="Vision model base URL (if different from llm_base_url)",
)
# Vision model
vision_model: str = Field(
default="qwen-vl",
description="Default vision model for screenshot extraction",
)
# Text model (for evidence snippets)
text_model: str = Field(
default="qwen3-235b",
description="Default text model for snippet extraction",
)
# ==========================================================================
# OPTIONAL - External LLM Fallback
# ==========================================================================
openai_api_key: str | None = Field(
default=None,
description="OpenAI API key for vision fallback",
)
anthropic_api_key: str | None = Field(
default=None,
description="Anthropic API key for vision fallback",
)
openrouter_api_key: str | None = Field(
default=None,
description="OpenRouter API key for premium tier LLM",
)
openrouter_base_url: str = Field(
default="https://openrouter.ai/api/v1",
description="OpenRouter API base URL",
)
openrouter_model: str = Field(
default="google/gemini-2.5-flash-preview",
description="OpenRouter model for premium tier",
)
# ==========================================================================
# API Server Settings
# ==========================================================================
host: str = Field(
default="0.0.0.0",
description="Host to bind the server to",
)
port: int = Field(
default=51100,
description="Port to bind the server to (Dev API Gateway: 51100)",
)
external_url: str = Field(
description="External URL for OpenAPI spec (e.g., http://10.11.10.42:51100). REQUIRED.",
)
# ==========================================================================
# Search Defaults
# ==========================================================================
search_default_max_results: int = Field(
default=10,
ge=1,
le=100,
description="Default search results per query",
)
search_default_language: str = Field(
default="en",
description="Default search language",
)
search_default_country: str = Field(
default="US",
description="Default search country",
)
# ==========================================================================
# Fetch Settings
# ==========================================================================
fetch_timeout: float = Field(
default=30.0,
ge=1.0,
description="HTTP fetch timeout in seconds",
)
fetch_min_text_length: int = Field(
default=200,
ge=0,
description="Minimum text length before triggering fallback",
)
fetch_user_agent: str = Field(
default="Mozilla/5.0 (compatible; WebBot/1.0)",
description="User agent for HTTP requests",
)
# ==========================================================================
# Browse (Playwright) Settings
# ==========================================================================
browse_timeout: int = Field(
default=30000,
ge=5000,
le=120000,
description="Playwright page load timeout in milliseconds",
)
browse_viewport_width: int = Field(default=1280, ge=320, le=1920)
browse_viewport_height: int = Field(default=720, ge=240, le=1080)
browse_block_resources: bool = Field(
default=True,
description="Block images/media/fonts/stylesheets in Playwright to speed up browsing",
)
browse_extra_wait_ms: int = Field(
default=500,
ge=0,
le=5000,
description="Extra ms to wait after page load for JS-rendered content (SPAs)",
)
# ==========================================================================
# Vision Settings
# ==========================================================================
vision_max_tokens: int = Field(
default=2000,
ge=100,
le=8000,
description="Max tokens for vision extraction",
)
vision_screenshot_quality: int = Field(
default=80,
ge=10,
le=100,
description="Screenshot JPEG quality",
)
vision_max_concurrent: int = Field(
default=3,
ge=1,
le=10,
description="Max concurrent vision extractions",
)
# ==========================================================================
# Evidence Settings
# ==========================================================================
evidence_max_items: int = Field(
default=30,
ge=1,
le=100,
description="Max items in evidence pack",
)
evidence_max_snippet_length: int = Field(
default=500,
ge=100,
le=2000,
description="Max characters per snippet",
)
evidence_dedupe_threshold: float = Field(
default=0.9,
ge=0.0,
le=1.0,
description="Similarity threshold for deduplication",
)
evidence_max_concurrent: int = Field(
default=5,
ge=1,
le=20,
description="Max concurrent evidence item builds",
)
summary_max_length: int = Field(
default=800,
ge=200,
le=2000,
description="Max chars for LLM-generated summary",
)
llm_max_context_chars: int = Field(
default=8000,
ge=1000,
le=32000,
description="Max chars of page text sent to LLM per snippet extraction",
)
llm_batch_context_chars: int = Field(
default=3000,
ge=500,
le=16000,
description="Max chars per page in batched LLM calls",
)
# ==========================================================================
# Gather Pipeline Settings
# ==========================================================================
gather_max_vision_urls: int = Field(
default=5,
ge=1,
le=20,
description="Max URLs to send to vision fallback in gather pipeline",
)
gather_max_search_queries: int = Field(
default=3,
ge=1,
le=10,
description="Max search queries per gather request",
)
# ==========================================================================
# Context Detection (Stage 0)
# ==========================================================================
context_detection_timeout: float = Field(
default=10.0,
ge=1.0,
le=30.0,
description="Timeout for Stage 0 context detection in seconds",
)
context_detection_model: str = Field(
default="qwen3.5",
description="Model to use for context detection (should be fast)",
)
# ==========================================================================
# External LLM Model Names
# ==========================================================================
openai_model: str = Field(
default="gpt-4o",
description="OpenAI model for vision/evidence fallback",
)
anthropic_model: str = Field(
default="claude-3-5-sonnet-20241022",
description="Anthropic model for vision/evidence fallback",
)
# ==========================================================================
# Timeouts and Retries
# ==========================================================================
request_timeout: float = Field(
default=30.0,
ge=1.0,
description="Default request timeout in seconds",
)
connect_timeout: float = Field(
default=5.0,
ge=1.0,
description="Connection timeout in seconds",
)
max_retries: int = Field(
default=3,
ge=0,
description="Maximum retries for transient failures",
)
retry_min_wait: float = Field(
default=1.0,
ge=0.1,
description="Minimum wait between retries",
)
retry_max_wait: float = Field(
default=10.0,
ge=1.0,
description="Maximum wait between retries",
)
# ==========================================================================
# HTTP Connection Pool Sizing
# ==========================================================================
searxng_rate_min_interval: float = Field(
default=1.0,
ge=0.1,
le=10.0,
description="Minimum seconds between SearXNG requests to avoid upstream rate limits",
)
search_pool_max_connections: int = Field(
default=20,
ge=1,
description="Max HTTP connections for search client (talks to one API)",
)
search_pool_max_keepalive: int = Field(
default=5,
ge=0,
description="Max keepalive connections for search client",
)
fetch_pool_max_connections: int = Field(
default=50,
ge=1,
description="Max HTTP connections for fetch client (talks to many websites)",
)
fetch_pool_max_keepalive: int = Field(
default=10,
ge=0,
description="Max keepalive connections for fetch client",
)
llm_pool_max_connections: int = Field(
default=10,
ge=1,
description="Max HTTP connections for LLM clients (vision + evidence packer)",
)
llm_pool_max_keepalive: int = Field(
default=3,
ge=0,
description="Max keepalive connections for LLM clients",
)
# ==========================================================================
# Observability
# ==========================================================================
log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = Field(
default="INFO",
description="Logging level",
)
log_json: bool = Field(
default=False,
description="Output logs in JSON format",
)
# ==========================================================================
# Rate Limiting and Concurrency
# ==========================================================================
rate_limit_rps: float = Field(
default=10.0,
ge=0.1,
description="Rate limit: requests per second",
)
rate_limit_burst: int = Field(
default=20,
ge=1,
description="Rate limit: burst capacity",
)
max_concurrent_requests: int = Field(
default=10,
ge=1,
description="Maximum concurrent requests",
)
# ==========================================================================
# Authentication
# ==========================================================================
api_tokens: frozenset[str] | None = Field(
default=None,
description="API tokens for Bearer auth (comma-separated)",
)
# ==========================================================================
# Dashboard event sink (optional)
# ==========================================================================
dashboard_url: str | None = Field(
default=None,
description="Dashboard base URL for event ingestion (e.g., http://didiAI-dashboard:51300)",
)
dashboard_token: str | None = Field(
default=None,
description="Bearer token for dashboard /api/ingest/event",
)
# ==========================================================================
# Brain cache integration (premium tier only)
# ==========================================================================
brain_url: str | None = Field(
default=None,
description="didi-brain base URL (e.g., http://didibrain-api:8090). Unset disables brain integration.",
)
brain_cache_read_enabled: bool = Field(
default=True,
description="If brain_url is set, try brain cache before calling premium providers.",
)
brain_ingest_enabled: bool = Field(
default=True,
description="If brain_url is set, push successful premium gathers back to brain.",
)
brain_ingest_min_evidence: int = Field(
default=3,
ge=1,
description="Minimum evidence items for a premium gather to qualify for ingest.",
)
brain_ingest_min_credibility: float = Field(
default=0.7,
ge=0.0,
le=1.0,
description="At least one evidence item must have credibility ≥ this to qualify for ingest.",
)
brain_ingest_min_execution_ms: float = Field(
default=2000.0,
ge=0.0,
description="Skip ingest if gather completed faster than this (likely a nothing-found fast path).",
)
brain_hit_min_evidence: int = Field(
default=3,
ge=1,
description="Minimum evidence items a PARTIAL brain hit must contain to be served instead of calling premium.",
)
brain_hit_min_relevance: float = Field(
default=0.7,
ge=0.0,
le=1.0,
description="For PARTIAL brain hits, this many items must exceed this relevance score.",
)
brain_gather_timeout: float = Field(
default=8.0,
ge=1.0,
description="Timeout for brain cache-read calls.",
)
brain_ingest_timeout: float = Field(
default=15.0,
ge=1.0,
description="Timeout for brain ingest calls.",
)
@field_validator("api_tokens", mode="before")
@classmethod
def parse_api_tokens(cls, v: str | list[str] | None) -> frozenset[str] | None:
"""Parse comma-separated tokens into a frozenset."""
if v is None or v == "":
return None
if isinstance(v, str):
tokens = [t.strip() for t in v.split(",") if t.strip()]
return frozenset(tokens) if tokens else None
return frozenset(v) if v else None
@property
def auth_enabled(self) -> bool:
"""Check if authentication is enabled."""
return bool(self.api_tokens)
class SettingsCache:
"""Thread-safe settings cache."""
_instance: WebSettings | None = None
_lock: threading.Lock = threading.Lock()
@classmethod
def get(cls) -> WebSettings:
"""Get or create the settings instance."""
with cls._lock:
if cls._instance is None:
cls._instance = WebSettings()
return cls._instance
@classmethod
def clear(cls) -> None:
"""Clear the cached settings instance."""
with cls._lock:
cls._instance = None
@classmethod
def set(cls, settings: WebSettings) -> None:
"""Set a specific settings instance."""
with cls._lock:
cls._instance = settings
def get_settings() -> WebSettings:
"""Get cached settings instance."""
return SettingsCache.get()

View file

@ -0,0 +1,251 @@
"""Stage 0: Context detection from claim text using local LLM.
Analyzes the claim before search to extract entities, detect
the primary country, and generate optimized search queries.
"""
import json
import re
import time
import httpx
from web.config import WebSettings
from web.llm.provider import LLMProviderChain
from web.logging import get_logger
from web.schemas.context import EntitySet, SearchContext
logger = get_logger("context.detector")
class ContextDetector:
"""Detects search context (country, entities, language) from claim text.
Uses the local LLM to analyze the claim before search begins.
Falls back to empty context on any failure so the pipeline
continues with default (current) behavior.
"""
_LLM_PROBE_CACHE_SECS = 60.0
def __init__(self, settings: WebSettings, llm_provider: str = "local") -> None:
"""Initialize the detector.
Args:
settings: Application settings.
llm_provider: LLM provider to use ("local" or "openrouter").
"""
self.settings = settings
self.llm_provider = llm_provider
self._http_client: httpx.AsyncClient | None = None
self._llm_available: bool | None = None
self._llm_check_time: float = 0.0
self._llm_chain = LLMProviderChain(
settings, self._get_http_client, domain="context"
)
async def _get_http_client(self) -> httpx.AsyncClient:
"""Get or create the HTTP client with tight timeouts."""
if self._http_client is None or self._http_client.is_closed:
self._http_client = httpx.AsyncClient(
timeout=httpx.Timeout(
connect=2.0,
read=15.0,
write=5.0,
pool=15.0,
),
limits=httpx.Limits(
max_connections=self.settings.llm_pool_max_connections,
max_keepalive_connections=self.settings.llm_pool_max_keepalive,
),
)
return self._http_client
async def _is_llm_available(self) -> bool:
"""Check if the local LLM is reachable (cached with negative TTL).
Returns:
True if the LLM responded within the timeout window.
"""
now = time.perf_counter()
if self._llm_available is not None:
if self._llm_available:
return True
if now - self._llm_check_time < self._LLM_PROBE_CACHE_SECS:
return False
try:
headers: dict[str, str] = {}
if self.settings.llm_api_key:
headers["Authorization"] = f"Bearer {self.settings.llm_api_key}"
async with httpx.AsyncClient(
timeout=httpx.Timeout(connect=2.0, read=5.0, write=5.0, pool=5.0),
) as probe:
resp = await probe.get(
f"{self.settings.llm_base_url}/v1/models",
headers=headers,
)
resp.raise_for_status()
self._llm_available = True
except Exception:
self._llm_available = False
self._llm_check_time = now
logger.info(
"Local LLM at %s unreachable — skipping context detection",
self.settings.llm_base_url,
)
return self._llm_available
async def detect(self, claim: str) -> SearchContext:
"""Analyze claim text and return search context.
Args:
claim: The claim text to analyze.
Returns:
SearchContext with detected country, entities, language.
Returns empty SearchContext on any failure.
"""
if not await self._is_llm_available():
return SearchContext()
try:
return await self._detect_with_llm(claim)
except Exception as e:
logger.warning("Context detection failed, using defaults: %s", e)
return SearchContext()
async def _detect_with_llm(self, claim: str) -> SearchContext:
"""Call LLM for context extraction.
Args:
claim: The claim text.
Returns:
Parsed SearchContext.
"""
prompt = _build_prompt(claim)
messages = [{"role": "user", "content": prompt}]
text, _tokens, _provider, _model = await self._llm_chain.call_chat(
messages=messages,
provider=self.llm_provider,
max_tokens=2000,
temperature=0.1,
model=self.settings.context_detection_model,
)
text = _strip_thinking_tags(text)
return _parse_response(text)
async def close(self) -> None:
"""Close resources."""
if self._http_client is not None and not self._http_client.is_closed:
await self._http_client.aclose()
self._http_client = None
def _build_prompt(claim: str) -> str:
"""Build the LLM prompt for context extraction.
Args:
claim: The claim text.
Returns:
Formatted prompt string.
"""
return (
"Analyze this claim and extract structured context. "
"Respond ONLY with a JSON object, no other text.\n\n"
f"CLAIM: {claim}\n\n"
"Return this exact JSON structure:\n"
"{\n"
' "primary_country": "<ISO 3166-1 alpha-2 code or null>",\n'
' "secondary_countries": ["<codes>"],\n'
' "entities": {\n'
' "persons": ["<full names>"],\n'
' "institutions": ["<organizations, parties, companies>"],\n'
' "locations": ["<cities, regions, countries>"]\n'
" },\n"
' "detected_language": "<ISO 639-1 code>",\n'
' "search_queries": ["<2-3 optimized fact-check queries>"]\n'
"}\n\n"
"Rules:\n"
"- primary_country: the country this claim is PRIMARILY ABOUT based on its content, NOT the language it is written in\n"
"- The language of the text must NEVER influence primary_country\n"
"- detected_language: language the claim is written in\n"
"- search_queries: 2-3 queries that would help verify or debunk this claim. "
"ALWAYS write search queries in ENGLISH regardless of the claim language.\n"
"- Output ONLY valid JSON. No markdown fences. No explanation.\n"
"- Do NOT use <think> tags."
)
def _parse_response(text: str) -> SearchContext:
"""Parse LLM JSON response into SearchContext.
Args:
text: Raw LLM output (should be JSON).
Returns:
Parsed SearchContext.
Raises:
json.JSONDecodeError: If JSON parsing fails.
KeyError: If required fields are missing.
"""
text = text.strip()
# Handle markdown code fences
if text.startswith("```"):
text = re.sub(r"```(?:json)?\n?", "", text)
text = text.rstrip("`").strip()
# Try to find JSON object in response
start = text.find("{")
end = text.rfind("}")
if start != -1 and end != -1 and end > start:
text = text[start : end + 1]
data = json.loads(text)
entities_data = data.get("entities", {})
entities = EntitySet(
persons=entities_data.get("persons", []),
institutions=entities_data.get("institutions", []),
locations=entities_data.get("locations", []),
)
primary = data.get("primary_country")
if isinstance(primary, str):
primary = primary.upper() if primary else None
secondary = data.get("secondary_countries", [])
if isinstance(secondary, list):
secondary = [c.upper() for c in secondary if isinstance(c, str) and c]
return SearchContext(
primary_country=primary,
secondary_countries=secondary,
entities=entities,
detected_language=data.get("detected_language", "en"),
search_queries=data.get("search_queries", []),
)
def _strip_thinking_tags(text: str) -> str:
"""Strip <think>...</think> tags from LLM output.
Qwen3 models emit reasoning in <think> blocks by default.
Args:
text: Raw LLM output.
Returns:
Text with thinking tags removed.
"""
cleaned = re.sub(r"<think>.*?</think>", "", text, flags=re.DOTALL | re.IGNORECASE)
cleaned = re.sub(r"<think>.*$", "", cleaned, flags=re.DOTALL | re.IGNORECASE)
return cleaned.strip()

View file

@ -0,0 +1,92 @@
"""Country-specific source mappings for search prioritization.
Used by the multi-round search strategy to prioritize local and
fact-check sources based on the detected country.
"""
from dataclasses import dataclass, field
@dataclass(frozen=True)
class CountrySources:
"""Source domains for a specific country."""
official: list[str] = field(default_factory=list)
media: list[str] = field(default_factory=list)
COUNTRY_SOURCES: dict[str, CountrySources] = {
"RO": CountrySources(
official=[
"gov.ro",
"cdep.ro",
"senat.ro",
"presidency.ro",
"mae.ro",
"edu.ro",
"ms.ro",
"insse.ro",
],
media=[
"digi24.ro",
"hotnews.ro",
"g4media.ro",
"mediafax.ro",
"agerpres.ro",
"adevarul.ro",
"romania-insider.com",
"stiripesurse.ro",
],
),
"US": CountrySources(
official=[
"whitehouse.gov",
"congress.gov",
"senate.gov",
"house.gov",
"state.gov",
"cdc.gov",
"nih.gov",
"fbi.gov",
],
media=[
"nytimes.com",
"washingtonpost.com",
"apnews.com",
"reuters.com",
"npr.org",
"pbs.org",
"politico.com",
"thehill.com",
],
),
}
FACTCHECK_SOURCES: list[str] = [
"reuters.com",
"snopes.com",
"politifact.com",
"factcheck.org",
"fullfact.org",
"apnews.com",
"bbc.com",
"afp.com",
"leadstories.com",
"veridica.ro",
"factual.ro",
]
def get_country_allowlist(country_code: str) -> list[str]:
"""Get combined official + media domains for a country.
Args:
country_code: ISO 3166-1 alpha-2 code (uppercase).
Returns:
List of domain strings, or empty list if country unknown.
"""
sources = COUNTRY_SOURCES.get(country_code.upper())
if sources is None:
return []
return sources.official + sources.media

View file

@ -0,0 +1 @@
"""Dashboard event sink — sends request metadata to the dashboard module."""

View file

@ -0,0 +1,112 @@
"""Async event sink that forwards request events to the dashboard module.
Fire-and-forget dashboard outages must never affect web-api latency
or availability. Events are dropped silently on failure.
"""
import asyncio
import contextlib
from typing import Any
import httpx
from web.logging import get_logger
logger = get_logger("events.sink")
class DashboardEventSink:
"""POSTs request events to the dashboard ingest endpoint, fire-and-forget.
The sink runs all writes through a bounded queue processed by a single
worker task. Overflow drops oldest. Failures are logged but swallowed.
"""
def __init__(
self,
dashboard_url: str | None,
token: str | None = None,
queue_size: int = 1000,
request_timeout: float = 5.0,
) -> None:
self.dashboard_url = dashboard_url.rstrip("/") if dashboard_url else None
self.token = token
self._queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue(maxsize=queue_size)
self._client: httpx.AsyncClient | None = None
self._worker_task: asyncio.Task[None] | None = None
self._request_timeout = request_timeout
self._enabled = bool(dashboard_url)
@property
def enabled(self) -> bool:
return self._enabled
async def start(self) -> None:
"""Start the background worker."""
if not self._enabled:
logger.info("DashboardEventSink disabled (no dashboard URL)")
return
self._client = httpx.AsyncClient(
timeout=httpx.Timeout(
connect=2.0, read=self._request_timeout, write=2.0, pool=5.0
),
limits=httpx.Limits(max_connections=5, max_keepalive_connections=2),
)
self._worker_task = asyncio.create_task(self._worker())
logger.info("DashboardEventSink started → %s", self.dashboard_url)
async def stop(self) -> None:
"""Stop worker and drain remaining events (best-effort)."""
if self._worker_task is not None:
self._worker_task.cancel()
with contextlib.suppress(asyncio.CancelledError, Exception):
await self._worker_task
self._worker_task = None
if self._client is not None and not self._client.is_closed:
await self._client.aclose()
self._client = None
def emit(self, event: dict[str, Any]) -> None:
"""Enqueue an event for async send. Never raises.
Args:
event: Event dict matching IngestEvent schema.
"""
if not self._enabled:
return
try:
self._queue.put_nowait(event)
except asyncio.QueueFull:
# Drop the oldest event to make room for the new one
try:
_ = self._queue.get_nowait()
self._queue.put_nowait(event)
except Exception:
pass
async def _worker(self) -> None:
"""Background loop that drains the queue and POSTs events."""
while True:
try:
event = await self._queue.get()
except asyncio.CancelledError:
raise
try:
await self._send(event)
except Exception as e:
logger.debug("Event dropped (%s): %s", type(e).__name__, e)
finally:
self._queue.task_done()
async def _send(self, event: dict[str, Any]) -> None:
if self._client is None or self.dashboard_url is None:
return
headers = {"Content-Type": "application/json"}
if self.token:
headers["Authorization"] = f"Bearer {self.token}"
resp = await self._client.post(
f"{self.dashboard_url}/api/ingest/event",
json=event,
headers=headers,
)
resp.raise_for_status()

View file

@ -0,0 +1,5 @@
"""Evidence submodule - Evidence pack builder with deduplication."""
from web.evidence.packer import EvidencePacker
__all__ = ["EvidencePacker"]

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,89 @@
"""Custom exceptions for Web module."""
class WebError(Exception):
"""Base exception for Web module errors."""
class AuthenticationError(WebError):
"""Raised when authentication fails."""
def __init__(self, message: str = "Authentication required") -> None:
super().__init__(message)
class ProviderError(WebError):
"""Raised when a search provider returns an error."""
def __init__(self, provider: str, message: str) -> None:
self.provider = provider
super().__init__(f"{provider}: {message}")
class ProviderNotAvailableError(WebError):
"""Raised when a search provider is not available."""
def __init__(self, provider: str, reason: str | None = None) -> None:
self.provider = provider
self.reason = reason
message = f"Provider '{provider}' is not available"
if reason:
message += f": {reason}"
super().__init__(message)
class SearchError(WebError):
"""Raised when a search request fails."""
def __init__(
self,
query: str,
reason: str,
provider: str | None = None,
) -> None:
self.query = query
self.reason = reason
self.provider = provider
message = f"Search failed for '{query}': {reason}"
if provider:
message += f" (provider: {provider})"
super().__init__(message)
class RateLimitError(WebError):
"""Raised when rate limit is exceeded."""
def __init__(
self,
message: str,
retry_after: float | None = None,
) -> None:
self.retry_after = retry_after
full_message = message
if retry_after:
full_message += f" (retry after {retry_after}s)"
super().__init__(full_message)
class WebTimeoutError(WebError):
"""Raised when a request times out."""
def __init__(
self,
message: str,
timeout: float | None = None,
) -> None:
self.timeout = timeout
super().__init__(message)
class WebConnectionError(WebError):
"""Raised when connection to provider fails."""
def __init__(self, provider: str, reason: str | None = None) -> None:
self.provider = provider
self.reason = reason
message = f"Connection to provider '{provider}' failed"
if reason:
message += f": {reason}"
super().__init__(message)

View file

@ -0,0 +1,5 @@
"""Fetch submodule - HTTP fetch with text extraction."""
from web.fetch.client import FetchClient
__all__ = ["FetchClient"]

View file

@ -0,0 +1,460 @@
"""HTTP fetch client with readability extraction."""
import asyncio
import hashlib
import re
import threading
import time
from datetime import datetime, timezone
from typing import Any
import httpx
from web.config import WebSettings
from web.exceptions import (
WebConnectionError,
WebError,
WebTimeoutError,
)
from web.logging import get_logger
from web.schemas.common import FailedUrl, PageContent
from web.schemas.fetch import FetchPageResult, FetchRequest, FetchResponse
from web.validation import validate_urls_async
logger = get_logger("fetch.client")
# Optional imports for content extraction
try:
from readability import Document
HAS_READABILITY = True
except ImportError:
HAS_READABILITY = False
logger.warning("readability-lxml not installed, text extraction will be limited")
try:
from bs4 import BeautifulSoup
HAS_BS4 = True
except ImportError:
HAS_BS4 = False
logger.warning("beautifulsoup4 not installed, HTML parsing will be limited")
# Pre-compiled regex for noscript JS detection
_NOSCRIPT_JS_RE = re.compile(
r"<noscript[^>]*>([^<]*javascript[^<]*)</noscript>",
re.IGNORECASE | re.DOTALL,
)
# Thread lock for readability-lxml/lxml parsing (not thread-safe,
# concurrent parsing causes "double free or corruption" crashes)
_lxml_lock = threading.Lock()
class FetchClient:
"""Client for HTTP content fetching with readability extraction."""
MAX_PAGE_SIZE = 5 * 1024 * 1024 # 5MB
def __init__(self, settings: WebSettings) -> None:
"""Initialize the client.
Args:
settings: Application settings.
"""
self.settings = settings
self._client: httpx.AsyncClient | None = None
async def _get_client(self) -> httpx.AsyncClient:
"""Get or create the HTTP client."""
if self._client is None or self._client.is_closed:
self._client = httpx.AsyncClient(
http2=True,
timeout=httpx.Timeout(
connect=self.settings.connect_timeout,
read=self.settings.fetch_timeout,
write=self.settings.fetch_timeout,
pool=self.settings.fetch_timeout,
),
limits=httpx.Limits(
max_connections=self.settings.fetch_pool_max_connections,
max_keepalive_connections=self.settings.fetch_pool_max_keepalive,
),
headers={
"User-Agent": self.settings.fetch_user_agent,
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Language": "en-US,en;q=0.5",
"Accept-Encoding": "gzip, deflate",
},
follow_redirects=True,
max_redirects=5,
)
return self._client
async def __aenter__(self) -> "FetchClient":
return self
async def __aexit__(self, *exc: object) -> None:
await self.close()
async def close(self) -> None:
"""Close the HTTP client."""
if self._client is not None and not self._client.is_closed:
await self._client.aclose()
self._client = None
async def fetch(
self,
request: FetchRequest,
request_id: str | None = None,
) -> FetchResponse:
"""Fetch content from URLs.
Args:
request: Fetch request with URLs and parameters.
request_id: Optional request ID for tracing.
Returns:
FetchResponse: Fetched page content.
"""
start_time = time.perf_counter()
pages: list[FetchPageResult] = []
failed: list[FailedUrl] = []
# Async SSRF DNS check before fetching (skipped when orchestrator already validated)
if not request.skip_validation:
request.urls, dns_failed = await validate_urls_async(request.urls)
failed.extend(dns_failed)
# Process URLs with concurrency limit
semaphore = asyncio.Semaphore(request.parallel_fetches)
async def fetch_with_semaphore(url: str) -> FetchPageResult | None:
async with semaphore:
try:
return await self._fetch_single(url, request)
except Exception as e:
logger.error("Failed to fetch %s: %s", url, e)
failed.append(FailedUrl(url=url, error=str(e)))
return None
# Fetch all URLs concurrently
tasks = [fetch_with_semaphore(url) for url in request.urls]
results = await asyncio.gather(*tasks)
for result in results:
if result is not None:
pages.append(result)
execution_time_ms = (time.perf_counter() - start_time) * 1000
return FetchResponse(
request_id=request_id or "-",
pages=pages,
total_fetched=len(pages),
total_failed=len(failed),
failed_urls=failed,
execution_time_ms=round(execution_time_ms, 2),
)
async def _fetch_single(
self,
url: str,
request: FetchRequest,
) -> FetchPageResult:
"""Fetch content from a single URL.
Args:
url: URL to fetch.
request: Fetch request with parameters.
Returns:
FetchPageResult: Fetched page content.
Raises:
WebError: If fetch fails.
"""
start_time = time.perf_counter()
client = await self._get_client()
try:
async with client.stream("GET", url) as response:
response.raise_for_status()
status_code = response.status_code
content_type = response.headers.get("content-type", "")
chunks: list[str] = []
total = 0
async for chunk in response.aiter_text(chunk_size=8192):
chunks.append(chunk)
total += len(chunk)
if total > self.MAX_PAGE_SIZE:
break
html = "".join(chunks)
except httpx.TimeoutException as e:
raise WebTimeoutError(
f"Timeout fetching {url}",
timeout=self.settings.fetch_timeout,
) from e
except httpx.ConnectError as e:
raise WebConnectionError(url, str(e)) from e
except httpx.HTTPStatusError as e:
raise WebError(f"HTTP {e.response.status_code} for {url}") from e
except Exception as e:
raise WebError(f"Failed to fetch {url}: {e}") from e
# Extract content + JS detection (run in thread to avoid blocking event loop)
loop = asyncio.get_running_loop()
extracted = await loop.run_in_executor(
None, self._extract_and_analyze, html, url, request
)
extraction_time_ms = (time.perf_counter() - start_time) * 1000
retrieved_at = datetime.now(timezone.utc).isoformat()
js_detected = extracted.pop("js_detected", False)
needs_fallback = (
request.auto_fallback and len(extracted["text"]) < request.min_text_length
)
if js_detected and request.auto_fallback:
needs_fallback = True
warnings = []
if needs_fallback:
warnings.append(
f"Content may be incomplete (length: {len(extracted['text'])})"
)
if js_detected:
warnings.append("JavaScript-heavy page detected")
return FetchPageResult(
url=url,
canonical_url=extracted.get("canonical_url"),
title=extracted.get("title"),
text=extracted["text"],
text_hash=hashlib.sha256(extracted["text"].encode()).hexdigest(),
html=html if request.include_html else None,
extraction_method="http",
fallback_chain=[],
published_at=extracted.get("published_at"),
retrieved_at=retrieved_at,
extraction_time_ms=round(extraction_time_ms, 2),
warnings=warnings,
needs_fallback=needs_fallback,
status_code=status_code,
content_type=content_type,
)
def _extract_content(
self,
html: str,
url: str,
request: FetchRequest,
) -> dict[str, Any]:
"""Extract readable content from HTML.
Args:
html: Raw HTML content.
url: Source URL (for relative link resolution).
request: Fetch request with parameters.
Returns:
dict: Extracted content with title, text, etc.
"""
result: dict[str, Any] = {
"text": "",
"title": None,
"canonical_url": None,
"published_at": None,
}
# Try readability first (best quality)
# Lock required: lxml is not thread-safe, concurrent run_in_executor
# calls cause "double free or corruption" crashes
if HAS_READABILITY:
try:
with _lxml_lock:
doc = Document(html)
result["title"] = doc.title()
# Get summary (cleaned HTML)
summary_html = doc.summary()
# Use lxml directly (readability already depends on it)
from lxml.html import fromstring as lxml_parse
tree = lxml_parse(summary_html)
result["text"] = tree.text_content().strip()
except Exception as e:
logger.warning("Readability extraction failed for %s: %s", url, e)
# Fallback to BS4 if readability returned nothing or too little.
# Use a higher threshold (500) than fetch_min_text_length to catch cases
# where readability extracts only a fragment of the article.
_bs4_threshold = max(self.settings.fetch_min_text_length, 500)
if len(result["text"]) < _bs4_threshold and HAS_BS4:
try:
soup = BeautifulSoup(html, "lxml")
# Remove script, style, nav, footer, etc.
for tag in soup(
["script", "style", "nav", "footer", "header", "aside"]
):
tag.decompose()
# Extract title
if soup.title:
result["title"] = soup.title.get_text(strip=True)
# Try to find main content - keep the longer extraction
main = soup.find("main") or soup.find("article") or soup.find("body")
if main:
bs4_text = main.get_text(separator="\n", strip=True)
if len(bs4_text) > len(result["text"]):
result["text"] = bs4_text
# Extract canonical URL
canonical = soup.find("link", rel="canonical")
if canonical and canonical.get("href"):
result["canonical_url"] = canonical["href"]
# Try to find publication date
for selector in ["time", "[datetime]", ".date", ".published"]:
elem = soup.select_one(selector)
if elem:
dt = elem.get("datetime") or elem.get_text(strip=True)
if dt:
result["published_at"] = dt
break
except Exception as e:
logger.warning("BS4 extraction failed for %s: %s", url, e)
# Last resort: basic regex extraction
if not result["text"]:
# Remove script and style tags
text = re.sub(
r"<(script|style)[^>]*>.*?</\1>",
"",
html,
flags=re.DOTALL | re.IGNORECASE,
)
# Remove HTML tags
text = re.sub(r"<[^>]+>", " ", text)
# Clean up whitespace
text = re.sub(r"\s+", " ", text).strip()
result["text"] = text
# Try to extract title
title_match = re.search(r"<title[^>]*>([^<]+)</title>", html, re.IGNORECASE)
if title_match:
result["title"] = title_match.group(1).strip()
return result
def _extract_and_analyze(
self,
html: str,
url: str,
request: FetchRequest,
) -> dict[str, Any]:
"""Extract content and detect JS requirement in a single executor call.
Args:
html: Raw HTML content.
url: Source URL.
request: Fetch request with parameters.
Returns:
dict: Extracted content with an added 'js_detected' key.
"""
result = self._extract_content(html, url, request)
result["js_detected"] = self._detect_javascript_required(html, result["text"])
return result
def _detect_javascript_required(self, html: str, extracted_text: str) -> bool:
"""Detect if page requires JavaScript for content.
Only flags JS-required when extracted text is also short, to avoid
false positives on SSR frameworks (Next.js, Nuxt/Vue SSR) that
render full HTML server-side.
Args:
html: Raw HTML content.
extracted_text: Text extracted from page.
Returns:
bool: True if JavaScript is likely required.
"""
# Check for "Please enable JavaScript" messages (always a strong signal)
js_required_phrases = [
"enable javascript",
"javascript required",
"javascript is required",
"please turn on javascript",
"this app requires javascript",
]
text_lower = extracted_text.lower()
for phrase in js_required_phrases:
if phrase in text_lower:
return True
# Check for noscript warning
html_lower = html.lower()
if (
"<noscript>" in html_lower
and "javascript" in html_lower
and _NOSCRIPT_JS_RE.search(html)
):
return True
# SPA indicators only matter when extracted text is short
# (SSR frameworks like Next.js and Nuxt render full HTML)
if len(extracted_text) >= self.settings.fetch_min_text_length:
return False
spa_indicators = [
"react-root",
"ng-app",
"ng-view",
"ember-view",
"data-reactroot",
]
return any(indicator in html_lower for indicator in spa_indicators)
async def fetch_to_page_content(
self,
url: str,
request: FetchRequest | None = None,
) -> PageContent:
"""Convenience method to fetch a single URL and return PageContent.
Args:
url: URL to fetch.
request: Optional fetch request parameters.
Returns:
PageContent: Extracted page content.
"""
if request is None:
request = FetchRequest(urls=[url])
else:
request = FetchRequest(
urls=[url],
timeout_seconds=request.timeout_seconds,
min_text_length=request.min_text_length,
auto_fallback=request.auto_fallback,
include_html=request.include_html,
)
response = await self.fetch(request)
if not response.pages:
raise WebError(f"Failed to fetch {url}")
return response.pages[0].to_page_content()

View file

@ -0,0 +1,5 @@
"""LLM provider utilities."""
from web.llm.provider import LLMProviderChain
__all__ = ["LLMProviderChain"]

View file

@ -0,0 +1,362 @@
"""Shared LLM provider chain with auto-fallback."""
from typing import Any
import httpx
from web.config import WebSettings
from web.exceptions import ProviderError
from web.logging import get_logger
logger = get_logger("llm.provider")
class LLMProviderChain:
"""Routes LLM calls through local -> OpenAI -> Anthropic fallback.
Consolidates the duplicated provider selection logic from VisionClient
and EvidencePacker into a single reusable class.
"""
def __init__(
self,
settings: WebSettings,
get_http_client: Any,
domain: str = "llm",
local_base_url: str | None = None,
runtime_config: object | None = None,
) -> None:
"""Initialize the provider chain.
Args:
settings: Application settings.
get_http_client: Async callable that returns an httpx.AsyncClient.
domain: Label for error messages (e.g. "vision", "text_llm").
local_base_url: Override base URL for local LLM calls.
Defaults to settings.llm_base_url.
runtime_config: Optional RuntimeConfigClient for model overrides.
"""
self.settings = settings
self._get_http_client = get_http_client
self._domain = domain
self._local_base_url = local_base_url
self._runtime_config = runtime_config
async def call_chat(
self,
messages: list[dict[str, Any]],
provider: str,
max_tokens: int,
temperature: float = 0.1,
model: str | None = None,
) -> tuple[str, int, str, str]:
"""Send a chat completion request with provider fallback.
Args:
messages: Chat messages in OpenAI format.
provider: Provider selection ("auto", "local", "openai", "anthropic").
max_tokens: Max response tokens.
temperature: Sampling temperature.
model: Model override for local provider (defaults from settings).
Returns:
Tuple of (response_text, tokens_used, provider_used, model_used).
Raises:
ProviderError: If all providers fail or none are available.
"""
if provider == "auto":
providers = ["local", "openai", "anthropic"]
elif provider == "openrouter":
providers = ["openrouter"]
else:
providers = [provider]
last_error = None
for p in providers:
try:
if p == "local":
return await self._call_local(
messages, max_tokens, temperature, model
)
elif p == "openrouter":
if not self.settings.openrouter_api_key:
continue
return await self._call_openrouter(
messages, max_tokens, temperature
)
elif p == "openai":
if not self.settings.openai_api_key:
continue
return await self._call_openai(messages, max_tokens, temperature)
elif p == "anthropic":
if not self.settings.anthropic_api_key:
continue
return await self._call_anthropic(messages, max_tokens, temperature)
except Exception as e:
logger.warning("Provider %s failed: %s", p, e)
last_error = e
continue
if last_error:
raise ProviderError(self._domain, str(last_error))
raise ProviderError(self._domain, f"No {self._domain} provider available")
async def _call_local(
self,
messages: list[dict[str, Any]],
max_tokens: int,
temperature: float,
model: str | None,
) -> tuple[str, int, str, str]:
"""Call local vLLM-compatible endpoint.
Args:
messages: Chat messages.
max_tokens: Max response tokens.
temperature: Sampling temperature.
model: Model override.
Returns:
Tuple of (text, tokens, "local", model_name).
"""
client = await self._get_http_client()
model_name = model or self.settings.text_model
headers: dict[str, str] = {"Content-Type": "application/json"}
if self.settings.llm_api_key:
headers["Authorization"] = f"Bearer {self.settings.llm_api_key}"
base_url = self._local_base_url or self.settings.llm_base_url
try:
payload: dict[str, Any] = {
"model": model_name,
"messages": messages,
"max_tokens": max_tokens,
"temperature": temperature,
"chat_template_kwargs": {"enable_thinking": False},
}
response = await client.post(
f"{base_url}/v1/chat/completions",
headers=headers,
json=payload,
)
response.raise_for_status()
data = response.json()
text = data["choices"][0]["message"]["content"]
tokens = data.get("usage", {}).get("total_tokens", 0)
return text, tokens, "local", model_name
except httpx.HTTPStatusError as e:
raise ProviderError("local", f"HTTP {e.response.status_code}") from e
except Exception as e:
raise ProviderError("local", str(e)) from e
async def _call_openrouter(
self,
messages: list[dict[str, Any]],
max_tokens: int,
temperature: float,
) -> tuple[str, int, str, str]:
"""Call OpenRouter chat completions API (OpenAI-compatible).
Args:
messages: Chat messages.
max_tokens: Max response tokens.
temperature: Sampling temperature.
Returns:
Tuple of (text, tokens, "openrouter", model_name).
"""
client = await self._get_http_client()
# Prefer runtime override, fall back to env-based setting
model_name = self.settings.openrouter_model
if self._runtime_config is not None:
override = self._runtime_config.get_str(
"web.openrouter.model", default=model_name
)
if override:
model_name = override
try:
response = await client.post(
f"{self.settings.openrouter_base_url}/chat/completions",
headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {self.settings.openrouter_api_key}",
},
json={
"model": model_name,
"messages": messages,
"max_tokens": max_tokens,
"temperature": temperature,
},
)
response.raise_for_status()
data = response.json()
text = data["choices"][0]["message"]["content"]
tokens = data.get("usage", {}).get("total_tokens", 0)
return text, tokens, "openrouter", model_name
except httpx.HTTPStatusError as e:
raise ProviderError("openrouter", f"HTTP {e.response.status_code}") from e
except Exception as e:
raise ProviderError("openrouter", str(e)) from e
async def _call_openai(
self,
messages: list[dict[str, Any]],
max_tokens: int,
temperature: float,
) -> tuple[str, int, str, str]:
"""Call OpenAI chat completions API.
Args:
messages: Chat messages.
max_tokens: Max response tokens.
temperature: Sampling temperature.
Returns:
Tuple of (text, tokens, "openai", model_name).
"""
client = await self._get_http_client()
model_name = self.settings.openai_model
try:
response = await client.post(
"https://api.openai.com/v1/chat/completions",
headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {self.settings.openai_api_key}",
},
json={
"model": model_name,
"messages": messages,
"max_tokens": max_tokens,
"temperature": temperature,
},
)
response.raise_for_status()
data = response.json()
text = data["choices"][0]["message"]["content"]
tokens = data.get("usage", {}).get("total_tokens", 0)
return text, tokens, "openai", model_name
except httpx.HTTPStatusError as e:
raise ProviderError("openai", f"HTTP {e.response.status_code}") from e
except Exception as e:
raise ProviderError("openai", str(e)) from e
async def _call_anthropic(
self,
messages: list[dict[str, Any]],
max_tokens: int,
temperature: float,
) -> tuple[str, int, str, str]:
"""Call Anthropic messages API.
Converts OpenAI-format messages to Anthropic format.
Args:
messages: Chat messages in OpenAI format.
max_tokens: Max response tokens.
temperature: Sampling temperature.
Returns:
Tuple of (text, tokens, "anthropic", model_name).
"""
client = await self._get_http_client()
model_name = self.settings.anthropic_model
# Convert OpenAI message format to Anthropic format
anthropic_messages = _convert_to_anthropic_messages(messages)
try:
response = await client.post(
"https://api.anthropic.com/v1/messages",
headers={
"Content-Type": "application/json",
"x-api-key": self.settings.anthropic_api_key,
"anthropic-version": "2023-06-01",
},
json={
"model": model_name,
"messages": anthropic_messages,
"max_tokens": max_tokens,
"temperature": temperature,
},
)
response.raise_for_status()
data = response.json()
text = data["content"][0]["text"]
tokens = data.get("usage", {}).get("input_tokens", 0) + data.get(
"usage", {}
).get("output_tokens", 0)
return text, tokens, "anthropic", model_name
except httpx.HTTPStatusError as e:
raise ProviderError("anthropic", f"HTTP {e.response.status_code}") from e
except Exception as e:
raise ProviderError("anthropic", str(e)) from e
def _convert_to_anthropic_messages(
messages: list[dict[str, Any]],
) -> list[dict[str, Any]]:
"""Convert OpenAI-format messages to Anthropic format.
Handles both text-only and multimodal (image) messages.
Args:
messages: Messages in OpenAI chat format.
Returns:
Messages in Anthropic format.
"""
result = []
for msg in messages:
content = msg.get("content")
if isinstance(content, str):
result.append({"role": msg["role"], "content": content})
elif isinstance(content, list):
anthropic_content = []
for part in content:
if part.get("type") == "text":
anthropic_content.append({"type": "text", "text": part["text"]})
elif part.get("type") == "image_url":
url = part["image_url"]["url"]
if url.startswith("data:"):
# Parse data URI: data:image/jpeg;base64,<data>
header, data = url.split(",", 1)
media_type = header.split(":")[1].split(";")[0]
anthropic_content.append(
{
"type": "image",
"source": {
"type": "base64",
"media_type": media_type,
"data": data,
},
}
)
else:
anthropic_content.append(
{
"type": "image",
"source": {"type": "url", "url": url},
}
)
result.append({"role": msg["role"], "content": anthropic_content})
else:
result.append(msg)
return result

View file

@ -0,0 +1,102 @@
"""Structured logging with request_id context propagation.
This module provides:
- ContextVar-based request_id tracking
- Structured JSON logging option
- Request ID injection into all log records
"""
import json
import logging
import sys
from contextvars import ContextVar
from typing import Any
# Context variable for request ID propagation
request_id_ctx: ContextVar[str | None] = ContextVar("request_id", default=None)
class RequestIdFilter(logging.Filter):
"""Logging filter that adds request_id to log records."""
def filter(self, record: logging.LogRecord) -> bool:
"""Add request_id to the log record."""
record.request_id = request_id_ctx.get() or "-"
return True
class JsonFormatter(logging.Formatter):
"""JSON log formatter for structured logging."""
def format(self, record: logging.LogRecord) -> str:
"""Format log record as JSON."""
log_data: dict[str, Any] = {
"timestamp": self.formatTime(record, self.datefmt),
"level": record.levelname,
"logger": record.name,
"message": record.getMessage(),
"request_id": getattr(record, "request_id", "-"),
}
if record.exc_info:
log_data["exception"] = self.formatException(record.exc_info)
if hasattr(record, "extra_data"):
log_data.update(record.extra_data)
return json.dumps(log_data)
def configure_logging(level: str = "INFO", json_format: bool = False) -> None:
"""Configure logging for the application.
Args:
level: Log level (DEBUG, INFO, WARNING, ERROR, CRITICAL).
json_format: If True, use JSON formatting; otherwise use text.
"""
root_logger = logging.getLogger("web")
root_logger.setLevel(getattr(logging, level.upper(), logging.INFO))
for handler in root_logger.handlers[:]:
root_logger.removeHandler(handler)
handler = logging.StreamHandler(sys.stdout)
handler.setLevel(logging.DEBUG)
handler.addFilter(RequestIdFilter())
if json_format:
handler.setFormatter(JsonFormatter())
else:
handler.setFormatter(
logging.Formatter(
"%(asctime)s [%(levelname)s] [%(request_id)s] %(name)s: %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
)
root_logger.addHandler(handler)
root_logger.propagate = False
def get_logger(name: str) -> logging.Logger:
"""Get a logger with the web prefix.
Args:
name: Logger name (will be prefixed with 'web.').
Returns:
logging.Logger: Configured logger instance.
"""
if name.startswith("web."):
return logging.getLogger(name)
return logging.getLogger(f"web.{name}")
def set_request_id(request_id: str | None) -> None:
"""Set the request ID for the current context."""
request_id_ctx.set(request_id)
def get_request_id() -> str | None:
"""Get the request ID for the current context."""
return request_id_ctx.get()

View file

@ -0,0 +1,5 @@
"""Metasearch module - SearXNG client for web search."""
from web.metasearch.client import SearXNGClient
__all__ = ["SearXNGClient"]

View file

@ -0,0 +1,537 @@
"""SearXNG metasearch client."""
import asyncio
import random
import time
from typing import Any
from urllib.parse import urlparse
import httpx
from web.config import WebSettings
from web.exceptions import (
ProviderError,
SearchError,
WebConnectionError,
WebTimeoutError,
)
from web.logging import get_logger
from web.schemas.image_search import (
ImageSearchRequest,
ImageSearchResponse,
ImageSearchResult,
)
from web.schemas.search import SearchRequest, SearchResponse, SearchResult
logger = get_logger("metasearch")
class SearXNGClient:
"""Client for SearXNG metasearch engine.
SearXNG aggregates results from multiple search engines (Google, Bing,
DuckDuckGo, etc.) and returns unified results via JSON API.
"""
provider_name: str = "searxng"
def __init__(self, settings: WebSettings) -> None:
"""Initialize the client.
Args:
settings: Application settings.
"""
self.settings = settings
self._client: httpx.AsyncClient | None = None
self._rate_lock = asyncio.Lock()
self._last_request_time: float = 0.0
self._min_interval: float = settings.searxng_rate_min_interval
async def _get_client(self) -> httpx.AsyncClient:
"""Get or create the HTTP client."""
if self._client is None or self._client.is_closed:
self._client = httpx.AsyncClient(
timeout=httpx.Timeout(
connect=self.settings.connect_timeout,
read=self.settings.request_timeout,
write=self.settings.request_timeout,
pool=self.settings.request_timeout,
),
limits=httpx.Limits(
max_connections=self.settings.search_pool_max_connections,
max_keepalive_connections=self.settings.search_pool_max_keepalive,
),
headers={
"Accept": "application/json",
"Accept-Encoding": "gzip",
},
)
return self._client
async def __aenter__(self) -> "SearXNGClient":
return self
async def __aexit__(self, *exc: object) -> None:
await self.close()
async def close(self) -> None:
"""Close the HTTP client."""
if self._client is not None and not self._client.is_closed:
await self._client.aclose()
self._client = None
async def health_check(self) -> bool:
"""Check if SearXNG is reachable.
Returns:
bool: True if healthy, False otherwise.
"""
try:
client = await self._get_client()
response = await client.get(
f"{self.settings.searxng_base_url}/healthz",
timeout=5.0,
)
return response.status_code == 200
except Exception as e:
logger.warning("SearXNG health check failed: %s", e)
return False
async def search(
self,
request: SearchRequest,
request_id: str | None = None,
) -> SearchResponse:
"""Execute search queries via SearXNG.
Args:
request: Search request with queries and parameters.
request_id: Optional request ID for tracing.
Returns:
SearchResponse: Search results.
Raises:
SearchError: If search fails.
WebTimeoutError: If request times out.
WebConnectionError: If connection fails.
"""
start_time = time.perf_counter()
# Run all queries in parallel
tasks = [self._search_single(query, request) for query in request.queries]
results_or_errors = await asyncio.gather(*tasks, return_exceptions=True)
all_results: list[SearchResult] = []
first_error: Exception | None = None
for i, result in enumerate(results_or_errors):
if isinstance(result, Exception):
logger.error(
"SearXNG search failed for query '%s': %s",
request.queries[i],
result,
)
if first_error is None:
first_error = result
else:
all_results.extend(result)
# If ALL queries failed, raise the first error
if not all_results and first_error is not None:
raise first_error
execution_time_ms = (time.perf_counter() - start_time) * 1000
return SearchResponse(
request_id=request_id or "-",
results=all_results,
total_results=len(all_results),
execution_time_ms=round(execution_time_ms, 2),
queries_processed=len(request.queries),
)
async def _search_single(
self,
query: str,
request: SearchRequest,
) -> list[SearchResult]:
"""Execute a single search query.
Enforces a minimum interval between requests to SearXNG,
preventing upstream engines from rate-limiting our IP.
Args:
query: The search query.
request: Search request with parameters.
Returns:
list[SearchResult]: Search results for this query.
"""
async with self._rate_lock:
now = time.perf_counter()
wait = self._min_interval - (now - self._last_request_time)
if wait > 0:
self._last_request_time = now + wait
else:
self._last_request_time = now
wait = 0
if wait > 0:
await asyncio.sleep(wait)
# Build queries (may split site allowlist into chunks)
effective_queries = self._build_queries(query, request)
# Base parameters (shared across chunked queries)
language = request.language if request.language not in ("auto", "") else "all"
base_params: dict[str, Any] = {
"format": "json",
"language": language,
}
if request.country and request.country.lower() not in ("auto", ""):
base_params["country"] = request.country
safesearch_map = {
"off": 0,
"moderate": 1,
"strict": 2,
}
base_params["safesearch"] = safesearch_map.get(request.safe_search, 1)
if request.freshness:
base_params["time_range"] = request.freshness
# Execute all chunked queries and merge results
all_results: list[SearchResult] = []
seen_urls: set[str] = set()
for eq in effective_queries:
params = {**base_params, "q": eq}
response_data = await self._execute_request(params)
for result in self._parse_results(query, response_data, request):
if result.url not in seen_urls:
seen_urls.add(result.url)
all_results.append(result)
return all_results
# Max site: filters per query to avoid bot detection by search engines.
# Google/Brave flag queries with 10+ site: operators as automated traffic.
_max_sites_per_query: int = 5
def _build_queries(self, query: str, request: SearchRequest) -> list[str]:
"""Build one or more queries with site filters.
Long site allowlists are split into chunks to produce shorter,
more natural-looking queries that avoid bot detection.
Args:
query: Original search query.
request: Search request with site filters.
Returns:
list[str]: One or more query strings.
"""
blocklist_part = ""
if request.site_blocklist:
blocklist_part = " ".join(f"-site:{s}" for s in request.site_blocklist)
if not request.site_allowlist:
parts = [query]
if blocklist_part:
parts.append(blocklist_part)
return [" ".join(parts)]
# Split site allowlist into chunks
sites = list(request.site_allowlist)
queries: list[str] = []
for i in range(0, len(sites), self._max_sites_per_query):
chunk = sites[i : i + self._max_sites_per_query]
site_filter = " OR ".join(f"site:{s}" for s in chunk)
parts = [query, f"({site_filter})"]
if blocklist_part:
parts.append(blocklist_part)
queries.append(" ".join(parts))
return queries
async def _execute_request(
self,
params: dict[str, Any],
) -> dict[str, Any]:
"""Execute HTTP request with retries.
Args:
params: Query parameters.
Returns:
dict: Response JSON data.
Raises:
SearchError: If request fails after retries.
WebTimeoutError: If request times out.
WebConnectionError: If connection fails.
"""
client = await self._get_client()
url = f"{self.settings.searxng_base_url}/search"
last_error: Exception | None = None
for attempt in range(self.settings.max_retries + 1):
try:
response = await client.get(url, params=params)
if response.status_code == 200:
return response.json()
if response.status_code == 429:
raise ProviderError("searxng", "Rate limit exceeded")
if response.status_code >= 500:
raise ProviderError(
"searxng", f"Server error: HTTP {response.status_code}"
)
# Other errors
error_msg = f"HTTP {response.status_code}"
try:
error_data = response.json()
if "error" in error_data:
error_msg = error_data["error"]
except Exception:
pass
raise ProviderError("searxng", error_msg)
except httpx.TimeoutException as e:
last_error = WebTimeoutError(
f"Request timed out after {self.settings.request_timeout}s",
timeout=self.settings.request_timeout,
)
logger.warning(
"SearXNG request timeout (attempt %d/%d): %s",
attempt + 1,
self.settings.max_retries + 1,
e,
)
except httpx.ConnectError as e:
last_error = WebConnectionError("searxng", str(e))
logger.warning(
"SearXNG connection error (attempt %d/%d): %s",
attempt + 1,
self.settings.max_retries + 1,
e,
)
except ProviderError as e:
if "Server error" in str(e):
last_error = e
logger.warning(
"SearXNG server error (attempt %d/%d): %s",
attempt + 1,
self.settings.max_retries + 1,
e,
)
else:
raise
except Exception as e:
last_error = SearchError(
params.get("q", "unknown"),
str(e),
provider="searxng",
)
logger.warning(
"SearXNG request failed (attempt %d/%d): %s",
attempt + 1,
self.settings.max_retries + 1,
e,
)
# Wait before retry (exponential backoff with jitter)
if attempt < self.settings.max_retries:
wait_time = min(
self.settings.retry_min_wait * (2**attempt),
self.settings.retry_max_wait,
)
wait_time *= 0.5 + random.random() # jitter: 50%-150% of base
await asyncio.sleep(wait_time)
# All retries exhausted
if last_error:
raise last_error
raise SearchError(
params.get("q", "unknown"), "Unknown error", provider="searxng"
)
def _parse_results(
self,
query: str,
response_data: dict[str, Any],
request: SearchRequest,
) -> list[SearchResult]:
"""Parse SearXNG API response.
Args:
query: The original query.
response_data: Raw API response.
request: Search request with filters.
Returns:
list[SearchResult]: Parsed search results.
"""
results: list[SearchResult] = []
searxng_results = response_data.get("results", [])
# Limit to max_results
searxng_results = searxng_results[: request.max_results]
for rank, item in enumerate(searxng_results, start=1):
url = item.get("url", "")
parsed_url = urlparse(url)
site = parsed_url.netloc
# Apply blocklist filter (in case SearXNG didn't fully filter)
if request.site_blocklist and site in request.site_blocklist:
continue
result = SearchResult(
query=query,
url=url,
title=item.get("title") or "",
snippet=item.get("content") or "",
rank=rank,
site=site,
published_at=item.get("publishedDate"),
)
results.append(result)
return results
async def image_search(
self,
request: ImageSearchRequest,
request_id: str | None = None,
) -> ImageSearchResponse:
"""Execute image search queries via SearXNG.
Args:
request: Image search request with queries and parameters.
request_id: Optional request ID for tracing.
Returns:
ImageSearchResponse: Image search results.
Raises:
SearchError: If search fails.
WebTimeoutError: If request times out.
WebConnectionError: If connection fails.
"""
start_time = time.perf_counter()
tasks = [self._image_search_single(query, request) for query in request.queries]
results_or_errors = await asyncio.gather(*tasks, return_exceptions=True)
all_results: list[ImageSearchResult] = []
first_error: Exception | None = None
for i, result in enumerate(results_or_errors):
if isinstance(result, Exception):
logger.error(
"SearXNG image search failed for query '%s': %s",
request.queries[i],
result,
)
if first_error is None:
first_error = result
else:
all_results.extend(result)
if not all_results and first_error is not None:
raise first_error
execution_time_ms = (time.perf_counter() - start_time) * 1000
return ImageSearchResponse(
request_id=request_id or "-",
results=all_results,
total_results=len(all_results),
execution_time_ms=round(execution_time_ms, 2),
queries_processed=len(request.queries),
)
async def _image_search_single(
self,
query: str,
request: ImageSearchRequest,
) -> list[ImageSearchResult]:
"""Execute a single image search query.
Args:
query: The search query.
request: Image search request with parameters.
Returns:
list[ImageSearchResult]: Image search results for this query.
"""
params: dict[str, Any] = {
"q": query,
"format": "json",
"categories": "images",
"language": request.language,
}
# SearXNG uses 'safesearch' with values: 0 (off), 1 (moderate), 2 (strict)
safesearch_map = {
"off": 0,
"strict": 2,
}
params["safesearch"] = safesearch_map.get(request.safe_search, 2)
response_data = await self._execute_request(params)
return self._parse_image_results(query, response_data, request)
def _parse_image_results(
self,
query: str,
response_data: dict[str, Any],
request: ImageSearchRequest,
) -> list[ImageSearchResult]:
"""Parse SearXNG image search response.
Args:
query: The original query.
response_data: Raw API response.
request: Image search request.
Returns:
list[ImageSearchResult]: Parsed image search results.
"""
results: list[ImageSearchResult] = []
searxng_results = response_data.get("results", [])
# Limit to max_results
searxng_results = searxng_results[: request.max_results]
for rank, item in enumerate(searxng_results, start=1):
# Parse resolution string (e.g., "1920x1080" or "1920 x 1080")
width, height = None, None
resolution = item.get("resolution", "")
if resolution:
parts = resolution.replace(" ", "").split("x")
if len(parts) == 2:
width = int(parts[0]) if parts[0].isdigit() else None
height = int(parts[1]) if parts[1].isdigit() else None
result = ImageSearchResult(
query=query,
image_url=item.get("img_src") or item.get("url") or "",
thumbnail_url=item.get("thumbnail_src") or item.get("thumbnail") or "",
source_url=item.get("url") or "",
title=item.get("title") or "",
description=item.get("content") or "",
width=width,
height=height,
publisher=item.get("source") or "",
rank=rank,
)
results.append(result)
return results

View file

@ -0,0 +1,972 @@
"""Orchestrator for unified content gathering with auto-fallback.
Implements the fallback chain: HTTP Fetch Playwright Browse Vision LLM
"""
import asyncio
import hashlib
import time
from dataclasses import dataclass, field
from datetime import datetime, timezone
from urllib.parse import parse_qs, urlparse
from web.browse.client import BrowseClient
from web.config import WebSettings, get_settings
from web.context.sources import (
COUNTRY_SOURCES,
FACTCHECK_SOURCES,
get_country_allowlist,
)
from web.evidence.packer import EvidencePacker
from web.fetch.client import FetchClient
from web.logging import get_logger
from web.schemas.browse import BrowseRequest
from web.schemas.common import PageContent
from web.schemas.context import SearchContext
from web.schemas.evidence import EvidenceItem, EvidencePackRequest, EvidenceStats
from web.schemas.fetch import FetchRequest
from web.schemas.gather import GatherRequest, GatherResponse, GatherStageResult
from web.schemas.search import SearchRequest, SearchResult
from web.search.protocol import SearchProvider
from web.validation import validate_urls_async
from web.vision.client import VisionClient
logger = get_logger("orchestrator")
@dataclass
class _StageData:
"""Internal container for inter-stage results."""
context: SearchContext | None = None
results: list[SearchResult] = field(default_factory=list)
pages: list[PageContent] = field(default_factory=list)
evidence: list[EvidenceItem] = field(default_factory=list)
stats: EvidenceStats | None = None
def _is_pdf_url(url: str) -> bool:
"""Check if URL points directly to a PDF document.
Detects:
- URLs ending with .pdf
- URLs with filename=*.pdf in query string
- URLs with .pdf before query string
Args:
url: URL to check.
Returns:
bool: True if URL is a direct PDF link.
"""
try:
parsed = urlparse(url)
path = parsed.path.lower()
# Check path ends with .pdf
if path.endswith(".pdf"):
return True
# Check query string for filename=*.pdf
if parsed.query:
query_params = parse_qs(parsed.query)
for key, values in query_params.items():
if key.lower() == "filename":
for val in values:
if val.lower().endswith(".pdf"):
return True
return False
except Exception:
return False
def _search_result_to_page(sr: "SearchResult") -> PageContent:
"""Convert a SearchResult into a PageContent using its snippet."""
text = f"{sr.title}\n\n{sr.snippet}" if sr.title else sr.snippet
return PageContent(
url=sr.url,
title=sr.title or None,
text=text,
text_hash=hashlib.sha256(text.encode()).hexdigest(),
extraction_method="snippet",
retrieved_at=datetime.now(timezone.utc).isoformat(),
extraction_time_ms=0.0,
published_at=sr.published_at,
warnings=["Content from search snippet only (full page not accessible)"],
)
class Orchestrator:
"""Orchestrates the full gather pipeline: search → fetch → evidence."""
def __init__(
self,
settings: WebSettings | None = None,
search_client: SearchProvider | None = None,
fetch_client: FetchClient | None = None,
llm_provider: str = "local",
runtime_config: object | None = None,
) -> None:
"""Initialize the orchestrator.
Args:
settings: Application settings.
search_client: Optional shared search client.
fetch_client: Optional shared fetch client.
llm_provider: Default LLM provider for evidence/context/vision.
"local" for free tier (Qwen), "openrouter" for premium tier.
runtime_config: Optional RuntimeConfigClient for live overrides.
"""
self.settings = settings or get_settings()
self.llm_provider = llm_provider
self.runtime_config = runtime_config
# Use shared clients when provided, otherwise lazy-create
self._search_client: SearchProvider | None = search_client
self._fetch_client: FetchClient | None = fetch_client
self._owns_search = search_client is None
self._owns_fetch = fetch_client is None
self._browse_client: BrowseClient | None = None
self._vision_client: VisionClient | None = None
self._evidence_packer: EvidencePacker | None = None
self._context_detector = None
@property
def search_client(self) -> SearchProvider:
"""Get or create search client."""
if self._search_client is None:
from web.metasearch.client import SearXNGClient
self._search_client = SearXNGClient(self.settings)
return self._search_client
@property
def fetch_client(self) -> FetchClient:
"""Get or create fetch client."""
if self._fetch_client is None:
self._fetch_client = FetchClient(self.settings)
return self._fetch_client
@property
def browse_client(self) -> BrowseClient:
"""Get or create browse client."""
if self._browse_client is None:
self._browse_client = BrowseClient(self.settings)
return self._browse_client
@property
def vision_client(self) -> VisionClient:
"""Get or create vision client, sharing browser from browse client."""
if self._vision_client is None:
# Share browser if browse client already has one
browser = None
if self._browse_client:
browser = self._browse_client.shared_browser
self._vision_client = VisionClient(self.settings, browser=browser)
return self._vision_client
@property
def evidence_packer(self) -> EvidencePacker:
"""Get or create evidence packer."""
if self._evidence_packer is None:
self._evidence_packer = EvidencePacker(self.settings)
if self.runtime_config is not None:
# Inject runtime config into the packer's LLM chain
self._evidence_packer._llm_chain._runtime_config = self.runtime_config # type: ignore[attr-defined]
return self._evidence_packer
@property
def context_detector(self):
"""Get or create context detector."""
if self._context_detector is None:
from web.context.detector import ContextDetector
self._context_detector = ContextDetector(
self.settings, llm_provider=self.llm_provider
)
if self.runtime_config is not None:
self._context_detector._llm_chain._runtime_config = ( # type: ignore[attr-defined]
self.runtime_config
)
return self._context_detector
async def close(self) -> None:
"""Close only self-owned clients (not shared ones passed via __init__)."""
if self._search_client and self._owns_search:
await self._search_client.close()
if self._fetch_client and self._owns_fetch:
await self._fetch_client.close()
if self._browse_client:
await self._browse_client.close()
if self._vision_client:
await self._vision_client.close()
if self._evidence_packer:
await self._evidence_packer.close()
if self._context_detector:
await self._context_detector.close()
async def gather(
self,
request: GatherRequest,
request_id: str | None = None,
) -> GatherResponse:
"""Execute the full gather pipeline.
Args:
request: Gather request.
request_id: Optional request ID for tracing.
Returns:
GatherResponse: Gathered evidence.
"""
start_time = time.perf_counter()
try:
return await asyncio.wait_for(
self._run_pipeline(request, request_id, start_time),
timeout=request.timeout_seconds,
)
except asyncio.TimeoutError:
logger.warning(
"Gather pipeline timed out after %.1fs for claim: %.100s",
request.timeout_seconds,
request.claim,
)
execution_time_ms = (time.perf_counter() - start_time) * 1000
return GatherResponse(
request_id=request_id or "-",
claim=request.claim,
evidence=[],
evidence_stats=EvidenceStats(
input_items=0,
after_dedup=0,
output_items=0,
duplicates_removed=0,
tokens_used=0,
),
search_results=[],
stages=[
GatherStageResult(
stage="pipeline",
success=False,
items_processed=0,
items_failed=0,
duration_ms=round(execution_time_ms, 2),
error=f"Pipeline timed out after {request.timeout_seconds}s",
)
],
total_urls_found=0,
total_pages_fetched=0,
total_evidence_items=0,
execution_time_ms=round(execution_time_ms, 2),
)
async def _run_pipeline(
self,
request: GatherRequest,
request_id: str | None,
start_time: float,
) -> GatherResponse:
"""Run the full gather pipeline (called within timeout).
Args:
request: Gather request.
request_id: Optional request ID for tracing.
start_time: Pipeline start time.
Returns:
GatherResponse: Gathered evidence.
"""
stages: list[GatherStageResult] = []
data = _StageData()
# Stage 0: Context Detection (optional)
context: SearchContext | None = None
if request.country:
# Caller provided explicit country — build minimal context
context = SearchContext(primary_country=request.country.upper())
data.context = context
elif request.enable_context_detection:
ctx_stage, context = await self._run_context_stage(request)
stages.append(ctx_stage)
data.context = context
# Stage 1: Search (with multi-round if context available)
search_stage, search_data = await self._run_search_stage(request, context)
stages.append(search_stage)
if search_stage.success:
data.results = search_data
# Stage 2: Fetch/Browse/Vision
if data.results:
urls = list(
dict.fromkeys(r.url for r in data.results[: request.max_search_results])
)
# Pre-validate URLs (SSRF DNS check) at pipeline level so fetch can skip it
urls, dns_failed = await validate_urls_async(urls)
dns_failed_urls = {f.url for f in dns_failed}
fetch_stage, fetch_data = await self._run_fetch_stage(request, urls)
stages.append(fetch_stage)
if fetch_stage.success:
data.pages = fetch_data
# Snippet fallback: if fetch returned few pages, fill from
# search result snippets so evidence stage has something to work with.
# Skip URLs that failed DNS validation (gibberish/fake domains).
fetched_urls = {p.url for p in data.pages}
for sr in data.results[: request.max_search_results]:
if sr.url in dns_failed_urls:
continue
if sr.url not in fetched_urls and sr.snippet:
data.pages.append(
_search_result_to_page(sr)
)
fetched_urls.add(sr.url)
# Stage 3: Evidence Pack
evidence_items: list[EvidenceItem] = []
evidence_stats = EvidenceStats(
input_items=0,
after_dedup=0,
output_items=0,
duplicates_removed=0,
tokens_used=0,
)
if data.pages:
evidence_stage, evidence_data, stats = await self._run_evidence_stage(
request, data.pages
)
stages.append(evidence_stage)
if evidence_stage.success:
evidence_items = evidence_data
evidence_stats = stats
execution_time_ms = (time.perf_counter() - start_time) * 1000
return GatherResponse(
request_id=request_id or "-",
claim=request.claim,
search_context=data.context,
evidence=evidence_items,
evidence_stats=evidence_stats,
search_results=data.results,
stages=stages,
total_urls_found=len(data.results),
total_pages_fetched=len(data.pages),
total_evidence_items=len(evidence_items),
execution_time_ms=round(execution_time_ms, 2),
)
async def _run_context_stage(
self,
request: GatherRequest,
) -> tuple[GatherStageResult, SearchContext | None]:
"""Run Stage 0: Context detection from claim text.
Analyzes the claim using the local LLM to extract entities,
detect country, and generate optimized search queries.
Args:
request: Gather request.
Returns:
Tuple of (stage result, search context or None).
"""
start_time = time.perf_counter()
try:
context = await asyncio.wait_for(
self.context_detector.detect(request.claim),
timeout=self.settings.context_detection_timeout,
)
duration_ms = (time.perf_counter() - start_time) * 1000
logger.info(
"Context detected: country=%s, language=%s, queries=%d (%.0fms)",
context.primary_country,
context.detected_language,
len(context.search_queries),
duration_ms,
)
result = GatherStageResult(
stage="context",
success=True,
items_processed=1,
items_failed=0,
duration_ms=round(duration_ms, 2),
)
return result, context
except asyncio.TimeoutError:
duration_ms = (time.perf_counter() - start_time) * 1000
logger.warning(
"Context detection timed out after %.1fs", duration_ms / 1000
)
result = GatherStageResult(
stage="context",
success=False,
items_processed=0,
items_failed=1,
duration_ms=round(duration_ms, 2),
error="Context detection timed out",
)
return result, None
except Exception as e:
duration_ms = (time.perf_counter() - start_time) * 1000
logger.warning("Context detection failed: %s", e)
result = GatherStageResult(
stage="context",
success=False,
items_processed=0,
items_failed=1,
duration_ms=round(duration_ms, 2),
error=str(e),
)
return result, None
async def _run_search_stage(
self,
request: GatherRequest,
context: SearchContext | None = None,
) -> tuple[GatherStageResult, list[SearchResult]]:
"""Run the search stage with optional multi-round strategy.
When context is provided with a primary_country, runs up to three
search rounds in parallel:
Round 1: Country-specific official + media sources
Round 2: International fact-check sources
Round 3: Normal unrestricted search (current behavior)
Args:
request: Gather request.
context: Optional search context from Stage 0.
Returns:
Tuple of (stage result, deduplicated search results).
"""
start_time = time.perf_counter()
try:
# Determine queries: explicit > context-generated > raw claim
if request.search_queries:
queries = request.search_queries
elif context and context.search_queries:
queries = context.search_queries
else:
queries = [request.claim]
queries = queries[: self.settings.gather_max_search_queries]
# Determine search country for SearXNG regional results.
# If the detected country has no sources in our mapping, fall back
# to "US" so SearXNG returns useful international results instead
# of regional junk (e.g. zhihu.com for country=IR).
detected_country = (
request.country
or (context.primary_country if context else None)
or "US"
)
country = (
detected_country
if detected_country in COUNTRY_SOURCES
else "US"
)
# Determine language for SearXNG.
# Context-generated queries are always in English, so use "en"
# when the queries come from context detection. Only use the
# detected language when the caller provided explicit queries.
language = request.language
if language == "auto":
if request.search_queries:
# Caller-provided queries: use detected language
language = (
context.detected_language
if context and context.detected_language
else "en"
)
else:
# Context-generated queries are in English
language = "en"
# Build search tasks (multi-round when context available)
tasks = []
# Round 1: Country-specific sources (only when the detected
# country actually has sources defined — never use the fallback)
country_sites = (
get_country_allowlist(detected_country)
if context and context.primary_country
else []
)
if country_sites and not request.site_allowlist:
r1_request = SearchRequest(
queries=queries,
max_results=request.max_search_results,
site_allowlist=country_sites,
site_blocklist=request.site_blocklist,
language=language,
country=country,
)
tasks.append(self.search_client.search(r1_request))
# Round 2: Fact-check sources
if context and context.primary_country and not request.site_allowlist:
r2_request = SearchRequest(
queries=queries,
max_results=10,
site_allowlist=FACTCHECK_SOURCES,
site_blocklist=request.site_blocklist,
language=language,
country=country,
)
tasks.append(self.search_client.search(r2_request))
# Round 3: Normal unrestricted search (always runs)
r3_request = SearchRequest(
queries=queries,
max_results=request.max_search_results,
site_allowlist=request.site_allowlist,
site_blocklist=request.site_blocklist,
language=language,
country=country,
)
tasks.append(self.search_client.search(r3_request))
# Run all rounds in parallel
results_or_errors = await asyncio.gather(*tasks, return_exceptions=True)
# Combine and deduplicate by URL (first seen wins)
all_results: list[SearchResult] = []
seen_urls: set[str] = set()
failed_rounds = 0
for result in results_or_errors:
if isinstance(result, Exception):
logger.warning("Search round failed: %s", result)
failed_rounds += 1
continue
for sr in result.results:
if sr.url not in seen_urls:
seen_urls.add(sr.url)
all_results.append(sr)
# ─── Tier 3 fallback: cloak stealth scraping ───────────────────
# Only invoked when:
# 1. CLOAK_ENABLED is true
# 2. Primary tiers (SearXNG + paid rotation) returned thin results
# Failures are non-fatal (we keep whatever we already have).
if (
getattr(self.settings, "cloak_enabled", False)
and len(all_results) < self.settings.cloak_fallback_threshold
):
try:
from web.search.cloak import CloakHTTPClient
if not hasattr(self, "_cloak_client") or self._cloak_client is None:
self._cloak_client = CloakHTTPClient(self.settings)
cloak_req = SearchRequest(
queries=queries,
max_results=10,
site_allowlist=request.site_allowlist,
site_blocklist=request.site_blocklist,
language=language if language != "auto" else None,
country=country,
)
cloak_resp = await self._cloak_client.search(cloak_req)
before = len(all_results)
for sr in cloak_resp.results:
if sr.url not in seen_urls:
seen_urls.add(sr.url)
all_results.append(sr)
logger.info(
"Tier-3 cloak top-up: +%d new results (was %d, now %d) in %dms",
len(all_results) - before, before, len(all_results),
int(cloak_resp.execution_time_ms),
)
except Exception as cloak_err:
logger.warning("Tier-3 cloak fallback failed (non-fatal): %s", cloak_err)
duration_ms = (time.perf_counter() - start_time) * 1000
stage_result = GatherStageResult(
stage="search",
success=len(all_results) > 0,
items_processed=len(all_results),
items_failed=failed_rounds,
duration_ms=round(duration_ms, 2),
)
return stage_result, all_results
except Exception as e:
duration_ms = (time.perf_counter() - start_time) * 1000
logger.error("Search stage failed: %s", e)
result = GatherStageResult(
stage="search",
success=False,
items_processed=0,
items_failed=1,
duration_ms=round(duration_ms, 2),
error=str(e),
)
return result, []
async def _run_fetch_stage(
self,
request: GatherRequest,
urls: list[str],
) -> tuple[GatherStageResult, list[PageContent]]:
"""Run the fetch stage with fallback.
Args:
request: Gather request.
urls: URLs to fetch.
Returns:
Tuple of (stage result, fetched pages).
"""
start_time = time.perf_counter()
pages: list[PageContent] = []
failed_count = 0
# Filter out direct PDF URLs (we can't extract text from PDFs via HTTP)
fetchable_urls = []
skipped_pdfs = []
for url in urls:
if _is_pdf_url(url):
skipped_pdfs.append(url)
logger.info("Skipping PDF URL: %s", url)
else:
fetchable_urls.append(url)
if skipped_pdfs:
logger.info("Skipped %d PDF URLs", len(skipped_pdfs))
# SSRF DNS validation already done at pipeline level (_run_pipeline)
try:
# Determine fetch method
if request.fetch_method == "auto":
# Use auto-fallback logic
pages, failed_count = await self._fetch_with_fallback(
fetchable_urls,
request.auto_fallback,
request.parallel_fetches,
)
elif request.fetch_method == "http":
pages, failed_count = await self._fetch_http_only(
fetchable_urls, request.parallel_fetches
)
elif request.fetch_method == "browse":
pages, failed_count = await self._fetch_browse_only(
fetchable_urls, request.parallel_fetches
)
elif request.fetch_method == "vision":
pages, failed_count = await self._fetch_vision_only(fetchable_urls)
duration_ms = (time.perf_counter() - start_time) * 1000
result = GatherStageResult(
stage="fetch",
success=True,
items_processed=len(pages),
items_failed=failed_count,
duration_ms=round(duration_ms, 2),
)
return result, pages
except Exception as e:
duration_ms = (time.perf_counter() - start_time) * 1000
logger.error("Fetch stage failed: %s", e)
result = GatherStageResult(
stage="fetch",
success=False,
items_processed=0,
items_failed=len(fetchable_urls),
duration_ms=round(duration_ms, 2),
error=str(e),
)
return result, []
async def _fetch_with_fallback(
self,
urls: list[str],
auto_fallback: bool,
parallel_fetches: int,
) -> tuple[list[PageContent], int]:
"""Fetch URLs with auto-fallback: HTTP → Browse → Vision.
Args:
urls: URLs to fetch.
auto_fallback: Whether to auto-fallback.
parallel_fetches: Number of parallel fetches.
Returns:
tuple: (pages, failed_count)
"""
pages: list[PageContent] = []
urls_needing_browse: list[str] = []
urls_needing_vision: list[str] = []
# Step 1: Try HTTP fetch first (skip_validation=True: orchestrator already validated)
fetch_request = FetchRequest(
urls=urls,
auto_fallback=auto_fallback,
parallel_fetches=parallel_fetches,
skip_validation=True,
)
fetch_response = await self.fetch_client.fetch(fetch_request)
for page_result in fetch_response.pages:
if page_result.needs_fallback and auto_fallback:
urls_needing_browse.append(page_result.url)
else:
pages.append(page_result.to_page_content())
# Add failed URLs to browse queue if auto_fallback
if auto_fallback:
for failed in fetch_response.failed_urls:
urls_needing_browse.append(failed.url)
# Step 2: Try Playwright browse for failed/incomplete pages
if urls_needing_browse:
logger.info(
"Browse fallback: %d URLs to retry with Playwright",
len(urls_needing_browse),
)
try:
browse_request = BrowseRequest(
urls=urls_needing_browse,
parallel_browses=min(3, parallel_fetches),
extra_wait_ms=self.settings.browse_extra_wait_ms,
)
browse_response = await self.browse_client.browse(browse_request)
for page_result in browse_response.pages:
# Check if content is still insufficient
if (
len(page_result.text) < self.settings.fetch_min_text_length
and auto_fallback
):
urls_needing_vision.append(page_result.url)
else:
pages.append(
page_result.to_page_content(fallback_chain=["http"])
)
# Add browse failures to vision queue
if auto_fallback:
for failed in browse_response.failed_urls:
urls_needing_vision.append(failed.url)
except ImportError:
# Playwright not installed, add to vision queue
logger.warning("Playwright not available, trying vision")
urls_needing_vision.extend(urls_needing_browse)
except Exception as e:
logger.warning("Browse failed: %s", e)
urls_needing_vision.extend(urls_needing_browse)
# Step 3: Try vision extraction as last resort (parallel, semaphore in VisionClient)
if urls_needing_vision and auto_fallback:
try:
# Vision is expensive, limit to first few
vision_urls = urls_needing_vision[
: self.settings.gather_max_vision_urls
]
tasks = [
self.vision_client.extract_to_page_content(url)
for url in vision_urls
]
results = await asyncio.gather(*tasks, return_exceptions=True)
for i, result in enumerate(results):
if isinstance(result, Exception):
logger.warning(
"Vision extraction failed for %s: %s",
vision_urls[i],
result,
)
else:
pages.append(result)
except ImportError:
logger.warning("Vision not available")
except Exception as e:
logger.warning("Vision stage failed: %s", e)
# Count failures once: URLs that failed HTTP and were never recovered
all_failed = {f.url for f in fetch_response.failed_urls}
all_recovered = {p.url for p in pages}
failed_count = len(all_failed - all_recovered)
return pages, failed_count
async def _fetch_http_only(
self,
urls: list[str],
parallel_fetches: int,
) -> tuple[list[PageContent], int]:
"""Fetch URLs using HTTP only.
Args:
urls: URLs to fetch.
parallel_fetches: Number of parallel fetches.
Returns:
tuple: (pages, failed_count)
"""
fetch_request = FetchRequest(
urls=urls,
auto_fallback=False,
parallel_fetches=parallel_fetches,
skip_validation=True,
)
response = await self.fetch_client.fetch(fetch_request)
pages = [page_result.to_page_content() for page_result in response.pages]
return pages, len(response.failed_urls)
async def _fetch_browse_only(
self,
urls: list[str],
parallel_fetches: int,
) -> tuple[list[PageContent], int]:
"""Fetch URLs using Playwright browse only.
Args:
urls: URLs to fetch.
parallel_fetches: Number of parallel fetches.
Returns:
tuple: (pages, failed_count)
"""
browse_request = BrowseRequest(
urls=urls,
parallel_browses=min(3, parallel_fetches),
)
response = await self.browse_client.browse(browse_request)
pages = [page_result.to_page_content() for page_result in response.pages]
return pages, len(response.failed_urls)
async def _fetch_vision_only(
self,
urls: list[str],
) -> tuple[list[PageContent], int]:
"""Fetch URLs using vision extraction only (parallel).
Args:
urls: URLs to fetch.
Returns:
tuple: (pages, failed_count)
"""
pages: list[PageContent] = []
failed_count = 0
vision_urls = urls[: self.settings.gather_max_vision_urls * 2]
tasks = [self.vision_client.extract_to_page_content(url) for url in vision_urls]
results = await asyncio.gather(*tasks, return_exceptions=True)
for i, result in enumerate(results):
if isinstance(result, Exception):
logger.warning(
"Vision extraction failed for %s: %s", vision_urls[i], result
)
failed_count += 1
else:
pages.append(result)
return pages, failed_count
async def _run_evidence_stage(
self,
request: GatherRequest,
pages: list[PageContent],
) -> tuple[GatherStageResult, list[EvidenceItem], EvidenceStats]:
"""Run the evidence packing stage.
Args:
request: Gather request.
pages: Fetched pages.
Returns:
Tuple of (stage result, evidence items, evidence stats).
"""
start_time = time.perf_counter()
try:
# Derive score_relevance: explicit request value wins,
# otherwise only score when LLM is already in use for snippets
score_rel = request.score_relevance
if score_rel is None:
score_rel = request.extract_snippets
evidence_request = EvidencePackRequest(
pages=pages,
claim=request.claim,
dedupe=request.dedupe,
max_items=request.max_evidence_items,
include_full_text=request.include_full_text,
extract_snippets=request.extract_snippets,
summarize=request.summarize,
score_relevance=score_rel,
llm_provider=self.llm_provider,
)
response = await self.evidence_packer.pack(evidence_request)
duration_ms = (time.perf_counter() - start_time) * 1000
result = GatherStageResult(
stage="evidence",
success=True,
items_processed=response.stats.output_items,
items_failed=0,
duration_ms=round(duration_ms, 2),
)
return result, response.evidence, response.stats
except Exception as e:
duration_ms = (time.perf_counter() - start_time) * 1000
logger.error("Evidence stage failed: %s", e)
empty_stats = EvidenceStats(
input_items=len(pages),
after_dedup=0,
output_items=0,
duplicates_removed=0,
tokens_used=0,
)
result = GatherStageResult(
stage="evidence",
success=False,
items_processed=0,
items_failed=len(pages),
duration_ms=round(duration_ms, 2),
error=str(e),
)
return result, [], empty_stats

View file

@ -0,0 +1,131 @@
"""Runtime config client — fetches config overrides from dashboard.
Polls the dashboard /api/config endpoint periodically and caches values
in memory. All accessors fall back to defaults if the dashboard is
unreachable or the key is missing.
"""
import asyncio
import contextlib
from typing import Any
import httpx
from web.logging import get_logger
logger = get_logger("runtime_config")
# Default values — matches dashboard KNOWN_KEYS for safety
_DEFAULTS: dict[str, Any] = {
"web.providers.serpapi.enabled": True,
"web.providers.tavily.enabled": True,
"web.providers.brave.enabled": True,
"web.providers.linkup.enabled": True,
"web.providers.exa.enabled": True,
"web.premium.strategy": "round-robin",
"web.premium.priority_order": "serpapi,brave,tavily,linkup",
"web.openrouter.model": "google/gemini-3.1-flash-lite-preview",
"web.tier.free.max_search_results": 10,
"web.tier.premium.max_search_results": 10,
}
class RuntimeConfigClient:
"""Polls dashboard /api/config and caches values in-process."""
def __init__(
self,
dashboard_url: str | None,
poll_interval_seconds: int = 30,
request_timeout: float = 5.0,
) -> None:
self.dashboard_url = dashboard_url.rstrip("/") if dashboard_url else None
self.poll_interval = poll_interval_seconds
self._timeout = request_timeout
self._cache: dict[str, Any] = dict(_DEFAULTS)
self._client: httpx.AsyncClient | None = None
self._task: asyncio.Task[None] | None = None
self._enabled = bool(dashboard_url)
@property
def enabled(self) -> bool:
return self._enabled
def get(self, key: str, default: Any = None) -> Any:
"""Return cached config value (or default)."""
if key in self._cache:
return self._cache[key]
if default is not None:
return default
return _DEFAULTS.get(key)
def get_bool(self, key: str, default: bool = True) -> bool:
v = self.get(key, default)
return bool(v)
def get_int(self, key: str, default: int = 0) -> int:
v = self.get(key, default)
try:
return int(v)
except (TypeError, ValueError):
return default
def get_str(self, key: str, default: str = "") -> str:
v = self.get(key, default)
return str(v) if v is not None else default
async def start(self) -> None:
"""Start the background poller."""
if not self._enabled:
logger.info("RuntimeConfigClient disabled (no dashboard URL) — using defaults")
return
self._client = httpx.AsyncClient(
timeout=httpx.Timeout(
connect=2.0, read=self._timeout, write=2.0, pool=5.0
)
)
# Fetch once synchronously so first requests already have overrides
await self._refresh()
self._task = asyncio.create_task(self._loop())
logger.info(
"RuntimeConfigClient started (polling %s every %ds)",
self.dashboard_url,
self.poll_interval,
)
async def stop(self) -> None:
if self._task is not None:
self._task.cancel()
with contextlib.suppress(asyncio.CancelledError, Exception):
await self._task
self._task = None
if self._client is not None and not self._client.is_closed:
await self._client.aclose()
self._client = None
async def _loop(self) -> None:
while True:
try:
await asyncio.sleep(self.poll_interval)
await self._refresh()
except asyncio.CancelledError:
raise
except Exception as e:
logger.debug("Config poll failed: %s", e)
async def _refresh(self) -> None:
if self._client is None or self.dashboard_url is None:
return
try:
resp = await self._client.get(f"{self.dashboard_url}/api/config")
resp.raise_for_status()
data = resp.json()
except Exception as e:
logger.debug("Config refresh failed: %s", e)
return
items = data.get("items", {})
new_cache: dict[str, Any] = dict(_DEFAULTS)
for key, entry in items.items():
new_cache[key] = entry.get("value")
self._cache = new_cache

View file

@ -0,0 +1,64 @@
"""Schemas package - All request/response schemas."""
from web.schemas.browse import BrowsePageResult, BrowseRequest, BrowseResponse
from web.schemas.common import (
ErrorDetail,
ErrorResponse,
HealthResponse,
PageContent,
ProviderHealth,
ReadinessResponse,
)
from web.schemas.evidence import EvidenceItem, EvidencePackRequest, EvidencePackResponse
from web.schemas.fetch import FetchPageResult, FetchRequest, FetchResponse
from web.schemas.gather import GatherRequest, GatherResponse
from web.schemas.image_search import (
ImageSearchRequest,
ImageSearchResponse,
ImageSearchResult,
)
from web.schemas.search import SearchRequest, SearchResponse, SearchResult
from web.schemas.vision import (
ImageContext,
VisionExtractRequest,
VisionExtractResponse,
VisionPageResult,
)
__all__ = [
"BrowsePageResult",
# Browse
"BrowseRequest",
"BrowseResponse",
# Common
"ErrorDetail",
"ErrorResponse",
"EvidenceItem",
# Evidence
"EvidencePackRequest",
"EvidencePackResponse",
"FetchPageResult",
# Fetch
"FetchRequest",
"FetchResponse",
# Gather
"GatherRequest",
"GatherResponse",
"HealthResponse",
"ImageContext",
# Image Search
"ImageSearchRequest",
"ImageSearchResponse",
"ImageSearchResult",
"PageContent",
"ProviderHealth",
"ReadinessResponse",
# Search
"SearchRequest",
"SearchResponse",
"SearchResult",
# Vision
"VisionExtractRequest",
"VisionExtractResponse",
"VisionPageResult",
]

View file

@ -0,0 +1,167 @@
"""Browse schemas - Playwright browser automation."""
from typing import TYPE_CHECKING, Literal
if TYPE_CHECKING:
from web.schemas.common import PageContent
from pydantic import BaseModel, ConfigDict, Field, field_validator
from web.schemas.common import FailedUrl
from web.validation import validate_url
class BrowseRequest(BaseModel):
"""Request schema for browser-based fetch."""
model_config = ConfigDict(extra="forbid")
urls: list[str] = Field(
min_length=1,
max_length=20,
description="URLs to browse (1-20, limited due to browser overhead)",
)
@field_validator("urls")
@classmethod
def validate_urls(cls, v: list[str]) -> list[str]:
"""Validate all URLs for SSRF protection."""
for url in v:
validate_url(url)
return v
# Browser behavior
wait_until: Literal["load", "domcontentloaded", "networkidle"] = Field(
default="load",
description="Wait condition before extraction",
)
timeout_ms: int = Field(
default=30000,
ge=1000,
le=60000,
description="Max wait time for page load",
)
extra_wait_ms: int = Field(
default=0,
ge=0,
le=10000,
description="Additional wait after page load for dynamic content",
)
wait_for_selector: str | None = Field(
default=None,
description="CSS selector to wait for before extraction",
)
# Screenshot options
screenshot: bool = Field(
default=False,
description="Capture screenshot",
)
full_page_screenshot: bool = Field(
default=True,
description="Capture full page (scroll) or viewport only",
)
# Extraction
extract_text: bool = Field(default=True, description="Extract rendered text")
include_html: bool = Field(default=False, description="Include rendered HTML")
# Concurrency
parallel_browses: int = Field(
default=3,
ge=1,
le=5,
description="Number of parallel browser contexts",
)
class BrowsePageResult(BaseModel):
"""Result for a single browsed page."""
model_config = ConfigDict(extra="forbid")
url: str = Field(description="Original URL")
final_url: str | None = Field(default=None, description="Final URL after redirects")
canonical_url: str | None = Field(
default=None, description="Canonical URL if found"
)
title: str | None = Field(default=None, description="Page title")
text: str = Field(description="Extracted text content")
text_hash: str = Field(description="SHA256 hash of text content")
html: str | None = Field(default=None, description="Rendered HTML (if requested)")
extraction_method: Literal["http", "browse", "vision"] = Field(
default="browse",
description="Method used to extract content",
)
fallback_chain: list[str] = Field(
default_factory=list,
description="Methods attempted before success",
)
published_at: str | None = Field(
default=None, description="Publication date if found"
)
retrieved_at: str = Field(description="ISO timestamp of retrieval")
extraction_time_ms: float = Field(description="Time taken to extract")
warnings: list[str] = Field(
default_factory=list,
description="Warnings during extraction",
)
# Browse-specific fields
screenshot_base64: str | None = Field(
default=None, description="Screenshot as base64"
)
viewport_width: int | None = Field(
default=None, description="Browser viewport width"
)
viewport_height: int | None = Field(
default=None, description="Browser viewport height"
)
def to_page_content(self, fallback_chain: list[str] | None = None) -> "PageContent":
"""Convert to PageContent for orchestrator pipeline.
Args:
fallback_chain: Override fallback chain (defaults to self.fallback_chain).
Returns:
PageContent with fields mapped from this result.
"""
from web.schemas.common import PageContent
return PageContent(
url=self.url,
canonical_url=self.canonical_url,
title=self.title,
text=self.text,
text_hash=self.text_hash,
html=self.html,
extraction_method="browse",
fallback_chain=fallback_chain
if fallback_chain is not None
else self.fallback_chain,
published_at=self.published_at,
retrieved_at=self.retrieved_at,
extraction_time_ms=self.extraction_time_ms,
warnings=self.warnings,
)
class BrowseResponse(BaseModel):
"""Response schema for browser-based fetch."""
model_config = ConfigDict(extra="forbid")
request_id: str = Field(description="Request ID for tracing")
pages: list[BrowsePageResult] = Field(description="Browsed pages")
total_browsed: int = Field(description="Pages successfully browsed")
total_failed: int = Field(description="Pages that failed")
execution_time_ms: float = Field(description="Total execution time")
failed_urls: list[FailedUrl] = Field(
default_factory=list,
description="URLs that failed",
)

View file

@ -0,0 +1,123 @@
"""Common schemas shared across all submodules."""
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field
class FailedUrl(BaseModel):
"""A URL that failed during processing."""
model_config = ConfigDict(extra="forbid")
url: str = Field(description="URL that failed")
error: str = Field(description="Error message")
class PageImage(BaseModel):
"""An image found on a page."""
model_config = ConfigDict(extra="forbid")
url: str = Field(description="Image URL")
alt: str | None = Field(default=None, description="Alt text")
def make_error_detail(
code: str,
message: str,
request_id: str | None = None,
**extra: Any,
) -> dict[str, Any]:
"""Build a standardized error detail dict.
Args:
code: Machine-readable error code.
message: Human-readable error message.
request_id: Optional request ID for tracing.
**extra: Additional key-value pairs to include.
Returns:
Standardized error detail dict.
"""
result: dict[str, Any] = {"error": code, "message": message}
if request_id:
result["request_id"] = request_id
result.update(extra)
return result
class PageContent(BaseModel):
"""Extracted content from a web page."""
model_config = ConfigDict(extra="forbid")
url: str = Field(description="Original URL")
canonical_url: str | None = Field(
default=None, description="Canonical URL if found"
)
title: str | None = Field(default=None, description="Page title")
text: str = Field(description="Extracted text content")
text_hash: str = Field(description="SHA256 hash of text content")
html: str | None = Field(default=None, description="Raw HTML (if requested)")
extraction_method: Literal["http", "browse", "vision", "snippet"] = Field(
description="Method used to extract content"
)
fallback_chain: list[str] = Field(
default_factory=list,
description="Methods attempted before success",
)
published_at: str | None = Field(
default=None, description="Publication date if found"
)
retrieved_at: str = Field(description="ISO timestamp of retrieval")
extraction_time_ms: float = Field(description="Time taken to extract")
images: list[PageImage] | None = Field(
default=None,
description="Images with extracted context (from vision)",
)
warnings: list[str] = Field(
default_factory=list,
description="Warnings during extraction",
)
class ProviderHealth(BaseModel):
"""Health status for a provider."""
name: str = Field(description="Provider name")
healthy: bool = Field(description="Whether the provider is healthy")
message: str | None = Field(default=None, description="Status message")
class HealthResponse(BaseModel):
"""Health check response."""
status: Literal["healthy", "degraded", "unhealthy"] = Field(
description="Overall status"
)
providers: list[ProviderHealth] = Field(description="Per-provider health")
class ReadinessResponse(BaseModel):
"""Readiness probe response."""
ready: bool = Field(default=True)
class ErrorDetail(BaseModel):
"""Error detail."""
code: str = Field(description="Error code")
message: str = Field(description="Error message")
details: dict | None = Field(default=None, description="Additional details")
class ErrorResponse(BaseModel):
"""Standard error response."""
request_id: str | None = Field(default=None, description="Request ID")
error: ErrorDetail = Field(description="Error information")

View file

@ -0,0 +1,47 @@
"""Context detection schemas for Stage 0 of the gather pipeline."""
from pydantic import BaseModel, ConfigDict, Field
class EntitySet(BaseModel):
"""Extracted named entities from claim text."""
model_config = ConfigDict(extra="forbid")
persons: list[str] = Field(default_factory=list, description="Person names")
institutions: list[str] = Field(
default_factory=list, description="Organizations, parties, companies"
)
locations: list[str] = Field(
default_factory=list, description="Cities, regions, countries mentioned"
)
class SearchContext(BaseModel):
"""Output of Stage 0: context detected from claim text.
Used by the search stage to prioritize country-specific sources.
"""
model_config = ConfigDict(extra="forbid")
primary_country: str | None = Field(
default=None,
description="ISO 3166-1 alpha-2 code of primary country (e.g. 'RO', 'US')",
)
secondary_countries: list[str] = Field(
default_factory=list,
description="Other countries involved",
)
entities: EntitySet = Field(
default_factory=EntitySet,
description="Extracted named entities",
)
detected_language: str = Field(
default="en",
description="ISO 639-1 language code detected in claim",
)
search_queries: list[str] = Field(
default_factory=list,
description="LLM-generated search queries tailored to the claim",
)

View file

@ -0,0 +1,157 @@
"""Evidence schemas - Evidence pack builder."""
from pydantic import BaseModel, ConfigDict, Field
from web.schemas.common import PageContent
class EvidenceItem(BaseModel):
"""A single evidence item in the pack."""
model_config = ConfigDict(extra="forbid")
url: str = Field(description="Source URL")
canonical_url: str | None = Field(default=None)
title: str | None = Field(default=None, description="Page title")
publisher: str | None = Field(default=None, description="Publisher/domain")
published_at: str | None = Field(default=None, description="Publication date")
retrieved_at: str = Field(description="When retrieved")
# Content
snippet: str | None = Field(
default=None,
description="LLM-extracted relevant snippet (deprecated: use full_text)",
)
summary: str | None = Field(
default=None,
description="LLM-generated summary relevant to the claim",
)
full_text: str | None = Field(
default=None,
description="Full page text",
)
full_text_hash: str = Field(description="Hash of full text for dedup")
# Provenance
provenance: dict = Field(
description="How this evidence was found",
default_factory=dict,
)
# Scoring
relevance_score: float | None = Field(
default=None,
ge=0.0,
le=1.0,
description="Relevance to claim (0-1)",
)
credibility_score: float | None = Field(
default=None,
ge=0.0,
le=1.0,
description="Source credibility (0-1)",
)
class EvidencePackRequest(BaseModel):
"""Request schema for evidence pack building."""
model_config = ConfigDict(extra="forbid")
pages: list[PageContent] = Field(
min_length=1,
description="Pages to pack (from fetch/browse/vision)",
)
# Context for snippet extraction
claim: str = Field(
description="The claim being verified - used for relevance scoring",
)
# Deduplication
dedupe: bool = Field(
default=True,
description="Remove duplicate content",
)
dedupe_threshold: float = Field(
default=0.9,
ge=0.0,
le=1.0,
description="Similarity threshold for dedup (0-1)",
)
# Limits
max_items: int = Field(
default=30,
ge=1,
le=100,
description="Maximum items in pack",
)
max_snippet_length: int = Field(
default=500,
ge=100,
le=2000,
description="Max characters per snippet",
)
# Full text inclusion
include_full_text: bool = Field(
default=True,
description="Include full page text in evidence items",
)
# Snippet extraction
extract_snippets: bool = Field(
default=False,
description="Use LLM to extract relevant snippets (deprecated: use full_text)",
)
# Summarization
summarize: bool = Field(
default=False,
description="Use LLM to summarize page content",
)
max_summary_length: int = Field(
default=800,
ge=200,
le=2000,
description="Max summary length in chars",
)
# Scoring
score_relevance: bool = Field(
default=False,
description="Score relevance to claim (requires LLM)",
)
score_credibility: bool = Field(
default=False,
description="Score source credibility",
)
# LLM settings for snippet extraction
llm_provider: str = Field(
default="auto",
description="LLM provider for snippet extraction",
)
class EvidenceStats(BaseModel):
"""Statistics about evidence pack building."""
input_items: int = Field(description="Items received")
after_dedup: int = Field(description="Items after deduplication")
output_items: int = Field(description="Items in final pack")
duplicates_removed: int = Field(description="Duplicates removed")
tokens_used: int = Field(description="LLM tokens used")
class EvidencePackResponse(BaseModel):
"""Response schema for evidence pack."""
model_config = ConfigDict(extra="forbid")
request_id: str = Field(description="Request ID for tracing")
claim: str = Field(description="Original claim")
evidence: list[EvidenceItem] = Field(description="Evidence items")
stats: EvidenceStats = Field(description="Pack statistics")
execution_time_ms: float = Field(description="Total execution time")

View file

@ -0,0 +1,164 @@
"""Fetch schemas - HTTP fetch with text extraction."""
from typing import TYPE_CHECKING, Literal
if TYPE_CHECKING:
from web.schemas.common import PageContent
from pydantic import BaseModel, ConfigDict, Field, field_validator
from web.schemas.common import FailedUrl
from web.validation import validate_url
class FetchRequest(BaseModel):
"""Request schema for web fetch."""
model_config = ConfigDict(extra="forbid")
urls: list[str] = Field(
min_length=1,
max_length=50,
description="URLs to fetch (1-50)",
)
@field_validator("urls")
@classmethod
def validate_urls(cls, v: list[str]) -> list[str]:
"""Validate all URLs for SSRF protection."""
for url in v:
validate_url(url)
return v
# Extraction options
extract_text: bool = Field(default=True, description="Extract main text content")
include_html: bool = Field(default=False, description="Include raw HTML")
extract_metadata: bool = Field(default=True, description="Extract metadata")
# Auto-fallback behavior
auto_fallback: bool = Field(
default=True,
description="Auto-escalate: HTTP → Browse → Vision on failure",
)
method: Literal["http", "browse", "vision", "auto"] = Field(
default="auto",
description="Extraction method (auto = try HTTP first)",
)
# Timeouts
timeout_seconds: float = Field(
default=30.0,
ge=1.0,
le=120.0,
description="Timeout per URL",
)
# Quality thresholds for auto-fallback
min_text_length: int = Field(
default=200,
ge=0,
description="Minimum text length before fallback",
)
# Concurrency
parallel_fetches: int = Field(
default=5,
ge=1,
le=20,
description="Number of parallel fetches",
)
# Internal flag: skip async SSRF validation when already done by orchestrator
skip_validation: bool = Field(
default=False,
description="Skip async SSRF DNS validation (set by orchestrator after pre-validation)",
exclude=True,
)
class FetchPageResult(BaseModel):
"""Result for a single fetched page."""
model_config = ConfigDict(extra="forbid")
url: str = Field(description="Original URL")
canonical_url: str | None = Field(
default=None, description="Canonical URL if found"
)
title: str | None = Field(default=None, description="Page title")
text: str = Field(description="Extracted text content")
text_hash: str = Field(description="SHA256 hash of text content")
html: str | None = Field(default=None, description="Raw HTML (if requested)")
extraction_method: Literal["http", "browse", "vision"] = Field(
default="http",
description="Method used to extract content",
)
fallback_chain: list[str] = Field(
default_factory=list,
description="Methods attempted before success",
)
published_at: str | None = Field(
default=None, description="Publication date if found"
)
retrieved_at: str = Field(description="ISO timestamp of retrieval")
extraction_time_ms: float = Field(description="Time taken to extract")
warnings: list[str] = Field(
default_factory=list,
description="Warnings during extraction",
)
# HTTP-specific fields
needs_fallback: bool = Field(
default=False,
description="Whether content needs fallback to browse/vision",
)
status_code: int | None = Field(default=None, description="HTTP status code")
content_type: str | None = Field(default=None, description="Content-Type header")
def to_page_content(self, fallback_chain: list[str] | None = None) -> "PageContent":
"""Convert to PageContent for orchestrator pipeline.
Args:
fallback_chain: Override fallback chain (defaults to self.fallback_chain).
Returns:
PageContent with fields mapped from this result.
"""
from web.schemas.common import PageContent
return PageContent(
url=self.url,
canonical_url=self.canonical_url,
title=self.title,
text=self.text,
text_hash=self.text_hash,
html=self.html,
extraction_method="http",
fallback_chain=fallback_chain
if fallback_chain is not None
else self.fallback_chain,
published_at=self.published_at,
retrieved_at=self.retrieved_at,
extraction_time_ms=self.extraction_time_ms,
warnings=self.warnings,
)
class FetchResponse(BaseModel):
"""Response schema for web fetch."""
model_config = ConfigDict(extra="forbid")
request_id: str = Field(description="Request ID for tracing")
pages: list[FetchPageResult] = Field(description="Fetched pages")
total_fetched: int = Field(description="Number of pages successfully fetched")
total_failed: int = Field(description="Number of pages that failed")
execution_time_ms: float = Field(description="Total execution time")
failed_urls: list[FailedUrl] = Field(
default_factory=list,
description="URLs that failed with error details",
)

View file

@ -0,0 +1,161 @@
"""Gather schemas - Unified endpoint that orchestrates everything."""
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field
from web.schemas.context import SearchContext
from web.schemas.evidence import EvidenceItem, EvidenceStats
from web.schemas.search import SearchResult
class GatherRequest(BaseModel):
"""Request schema for unified gather endpoint.
This endpoint orchestrates: search fetch evidence pack
"""
model_config = ConfigDict(extra="forbid")
# What to gather evidence for
claim: str = Field(
min_length=10,
max_length=1000,
description="The claim to gather evidence for",
)
# Search options
search_queries: list[str] | None = Field(
default=None,
description="Custom search queries (auto-generated if not provided)",
)
max_search_results: int = Field(
default=10,
ge=5,
le=50,
description="Max search results to fetch",
)
site_allowlist: list[str] | None = Field(
default=None,
description="Only search these domains",
)
site_blocklist: list[str] | None = Field(
default=None,
description="Exclude these domains",
)
language: str = Field(
default="auto",
description="Search language (ISO 639-1 code, or 'auto' for auto-detect)",
)
# Fetch options
fetch_method: Literal["auto", "http", "browse", "vision"] = Field(
default="auto",
description="How to fetch pages",
)
auto_fallback: bool = Field(
default=True,
description="Auto-escalate on fetch failure",
)
# Evidence options
max_evidence_items: int = Field(
default=15,
ge=1,
le=50,
description="Max items in final evidence pack",
)
include_full_text: bool = Field(
default=True,
description="Include full page text in evidence items",
)
extract_snippets: bool = Field(
default=False,
description="Extract LLM snippets (deprecated: use full_text)",
)
summarize: bool = Field(
default=False,
description="Generate LLM summaries of page content relative to the claim",
)
dedupe: bool = Field(
default=True,
description="Deduplicate evidence",
)
score_relevance: bool | None = Field(
default=None,
description="Score relevance via LLM. Defaults to True when extract_snippets=True.",
)
# Context detection options
enable_context_detection: bool = Field(
default=True,
description="Run Stage 0 context detection before search",
)
country: str | None = Field(
default=None,
description=(
"Override country for search (ISO 3166-1 alpha-2). "
"Skips auto-detection when set."
),
)
# Performance options
parallel_fetches: int = Field(
default=5,
ge=1,
le=10,
description="Concurrent fetch operations",
)
timeout_seconds: float = Field(
default=90.0,
ge=10.0,
le=300.0,
description="Total timeout for gather",
)
class GatherStageResult(BaseModel):
"""Result from a single stage of gathering."""
stage: str = Field(description="Stage name")
success: bool = Field(description="Stage completed successfully")
items_processed: int = Field(description="Items processed")
items_failed: int = Field(description="Items that failed")
duration_ms: float = Field(description="Stage duration")
error: str | None = Field(default=None, description="Error if failed")
class GatherResponse(BaseModel):
"""Response schema for unified gather endpoint."""
model_config = ConfigDict(extra="forbid")
request_id: str = Field(description="Request ID for tracing")
claim: str = Field(description="Original claim")
# Final evidence
evidence: list[EvidenceItem] = Field(description="Gathered evidence")
evidence_stats: EvidenceStats = Field(description="Evidence statistics")
# Context detection output (Stage 0)
search_context: SearchContext | None = Field(
default=None,
description="Detected context from claim (Stage 0 output)",
)
# Search results (intermediate)
search_results: list[SearchResult] = Field(
default_factory=list,
description="Raw search results",
)
# Stage results for debugging
stages: list[GatherStageResult] = Field(
description="Results from each stage",
)
# Overall stats
total_urls_found: int = Field(description="URLs from search")
total_pages_fetched: int = Field(description="Pages successfully fetched")
total_evidence_items: int = Field(description="Final evidence count")
execution_time_ms: float = Field(description="Total execution time")

View file

@ -0,0 +1,81 @@
"""Image search schemas for image search endpoints."""
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field, field_validator
class ImageSearchResult(BaseModel):
"""A single image search result."""
model_config = ConfigDict(extra="forbid")
query: str = Field(description="The query that produced this result")
image_url: str = Field(description="Direct URL to the image")
thumbnail_url: str = Field(default="", description="URL to the thumbnail")
source_url: str = Field(description="URL of the page containing the image")
title: str = Field(description="Title of the image or page")
description: str = Field(default="", description="Description of the image")
width: int | None = Field(default=None, description="Image width in pixels")
height: int | None = Field(default=None, description="Image height in pixels")
publisher: str = Field(default="", description="Publisher or source domain")
rank: int = Field(ge=1, description="Result rank (1-indexed)")
class ImageSearchRequest(BaseModel):
"""Request schema for image search."""
model_config = ConfigDict(extra="forbid")
queries: list[str] = Field(
min_length=1,
max_length=10,
description="List of search queries (1-10 queries)",
)
max_results: int = Field(
default=50,
ge=1,
le=200,
description="Maximum results per query",
)
language: str = Field(
default="en",
description="Search language (ISO 639-1 code)",
)
country: str = Field(
default="US",
description="Search country (ISO 3166-1 alpha-2 code)",
)
safe_search: Literal["off", "strict"] = Field(
default="strict",
description="Safe search filter level (off or strict)",
)
spellcheck: bool = Field(
default=True,
description="Enable spellcheck for queries",
)
@field_validator("queries")
@classmethod
def validate_queries(cls, v: list[str]) -> list[str]:
"""Validate that queries are not empty."""
for i, query in enumerate(v):
if not query or not query.strip():
raise ValueError(f"Query at index {i} cannot be empty")
if len(query) > 500:
raise ValueError(
f"Query at index {i} exceeds maximum length of 500 characters"
)
return [q.strip() for q in v]
class ImageSearchResponse(BaseModel):
"""Response schema for image search."""
model_config = ConfigDict(extra="forbid")
request_id: str = Field(description="Unique request ID for tracing")
results: list[ImageSearchResult] = Field(description="Image search results")
total_results: int = Field(description="Total number of results returned")
execution_time_ms: float = Field(description="Execution time in milliseconds")
queries_processed: int = Field(description="Number of queries processed")

View file

@ -0,0 +1,106 @@
"""Search schemas for web search endpoints."""
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field, field_validator
class SearchResult(BaseModel):
"""A single search result."""
model_config = ConfigDict(extra="forbid")
query: str = Field(description="The query that produced this result")
url: str = Field(description="URL of the result")
title: str = Field(description="Title of the result")
snippet: str = Field(description="Text snippet from the result")
rank: int = Field(ge=1, description="Result rank (1-indexed)")
site: str = Field(description="Domain of the result")
published_at: str | None = Field(
default=None, description="Publication date if available"
)
class SearchRequest(BaseModel):
"""Request schema for web search."""
model_config = ConfigDict(extra="forbid")
queries: list[str] = Field(
min_length=1,
max_length=10,
description="List of search queries (1-10 queries)",
)
max_results: int = Field(
default=10,
ge=1,
le=100,
description="Maximum results per query",
)
site_allowlist: list[str] | None = Field(
default=None,
description="Only include results from these domains",
)
site_blocklist: list[str] | None = Field(
default=None,
description="Exclude results from these domains",
)
language: str = Field(
default="auto",
description="Search language (ISO 639-1 code, or 'auto' for auto-detect)",
)
country: str = Field(
default="US",
description="Search country (ISO 3166-1 alpha-2 code)",
)
freshness: Literal["day", "week", "month", "year"] | None = Field(
default=None,
description="Filter results by freshness",
)
safe_search: Literal["off", "moderate", "strict"] = Field(
default="moderate",
description="Safe search filter level",
)
@field_validator("queries")
@classmethod
def validate_queries(cls, v: list[str]) -> list[str]:
"""Validate that queries are not empty."""
for i, query in enumerate(v):
if not query or not query.strip():
raise ValueError(f"Query at index {i} cannot be empty")
if len(query) > 500:
raise ValueError(
f"Query at index {i} exceeds maximum length of 500 characters"
)
return [q.strip() for q in v]
@field_validator("site_allowlist", "site_blocklist")
@classmethod
def validate_site_list(cls, v: list[str] | None) -> list[str] | None:
"""Validate and clean site lists."""
if v is None:
return None
cleaned = []
for site in v:
site = site.strip().lower()
if site.startswith("http://"):
site = site[7:]
elif site.startswith("https://"):
site = site[8:]
site = site.rstrip("/")
if site:
cleaned.append(site)
return cleaned if cleaned else None
class SearchResponse(BaseModel):
"""Response schema for web search."""
model_config = ConfigDict(extra="forbid")
request_id: str = Field(description="Unique request ID for tracing")
results: list[SearchResult] = Field(description="Search results")
total_results: int = Field(description="Total number of results returned")
execution_time_ms: float = Field(description="Execution time in milliseconds")
queries_processed: int = Field(description="Number of queries processed")

View file

@ -0,0 +1,128 @@
"""Vision schemas - Screenshot + Vision LLM extraction."""
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field, field_validator
from web.schemas.common import FailedUrl
from web.validation import validate_url
class ImageContext(BaseModel):
"""Extracted context from an image."""
model_config = ConfigDict(extra="forbid")
image_url: str | None = Field(default=None, description="Image source URL")
alt_text: str | None = Field(default=None, description="Alt text if available")
description: str = Field(description="LLM-generated description")
extracted_text: str | None = Field(default=None, description="Text found in image")
relevance_score: float | None = Field(
default=None, description="Relevance to context"
)
class VisionExtractRequest(BaseModel):
"""Request schema for vision-based extraction."""
model_config = ConfigDict(extra="forbid")
urls: list[str] = Field(
min_length=1,
max_length=10,
description="URLs to process (1-10, vision is expensive)",
)
@field_validator("urls")
@classmethod
def validate_urls(cls, v: list[str]) -> list[str]:
"""Validate all URLs for SSRF protection."""
for url in v:
validate_url(url)
return v
# What to extract
extract_page_text: bool = Field(
default=True,
description="Extract main text from page screenshot",
)
extract_images: bool = Field(
default=True,
description="Analyze images found on page",
)
# Context for relevance scoring
context_query: str | None = Field(
default=None,
description="Query/claim for relevance scoring",
)
# Vision model settings
model: str | None = Field(
default=None,
description="Vision model to use (default from config)",
)
max_tokens: int = Field(
default=2000,
ge=100,
le=8000,
description="Max tokens for vision response",
)
# Screenshot options
full_page: bool = Field(
default=True,
description="Screenshot full page or viewport",
)
max_screenshots: int = Field(
default=3,
ge=1,
le=10,
description="Max screenshots per page (for long pages)",
)
# LLM provider fallback
llm_provider: Literal["local", "openai", "anthropic", "auto"] = Field(
default="auto",
description="LLM provider (auto = local first, fallback to external)",
)
class VisionPageResult(BaseModel):
"""Vision extraction result for a single page."""
model_config = ConfigDict(extra="forbid")
url: str = Field(description="Page URL")
title: str | None = Field(default=None, description="Page title")
extracted_text: str = Field(description="Text extracted via vision")
images: list[ImageContext] = Field(
default_factory=list,
description="Analyzed images from page",
)
extraction_method: Literal["vision"] = Field(default="vision")
model_used: str = Field(description="Vision model used")
llm_provider: str = Field(description="LLM provider used")
screenshots_processed: int = Field(description="Number of screenshots processed")
tokens_used: int = Field(description="Total tokens used")
extraction_time_ms: float = Field(description="Extraction time")
class VisionExtractResponse(BaseModel):
"""Response schema for vision extraction."""
model_config = ConfigDict(extra="forbid")
request_id: str = Field(description="Request ID for tracing")
pages: list[VisionPageResult] = Field(description="Extracted pages")
total_processed: int = Field(description="Pages processed")
total_failed: int = Field(description="Pages failed")
total_tokens_used: int = Field(description="Total tokens across all pages")
execution_time_ms: float = Field(description="Total execution time")
failed_urls: list[FailedUrl] = Field(
default_factory=list,
description="URLs that failed",
)

View file

@ -0,0 +1,5 @@
"""Search submodule — search provider protocol."""
from web.search.protocol import SearchProvider
__all__ = ["SearchProvider"]

View file

@ -0,0 +1,169 @@
"""Brave Search API client."""
import asyncio
import time
from urllib.parse import urlparse
import httpx
from web.config import WebSettings
from web.logging import get_logger
from web.schemas.image_search import ImageSearchRequest, ImageSearchResponse
from web.schemas.search import SearchRequest, SearchResponse, SearchResult
logger = get_logger("search.brave")
_BRAVE_BASE = "https://api.search.brave.com/res/v1/web/search"
class BraveSearchClient:
"""Search client using Brave Search API."""
provider_name: str = "brave"
def __init__(self, settings: WebSettings) -> None:
self.settings = settings
self._api_key = settings.brave_api_key
self._client: httpx.AsyncClient | None = None
async def _get_client(self) -> httpx.AsyncClient:
if self._client is None or self._client.is_closed:
self._client = httpx.AsyncClient(
timeout=httpx.Timeout(connect=5.0, read=15.0, write=10.0, pool=15.0),
limits=httpx.Limits(max_connections=10, max_keepalive_connections=3),
headers={
"Accept": "application/json",
"Accept-Encoding": "gzip",
"X-Subscription-Token": self._api_key or "",
},
)
return self._client
async def close(self) -> None:
if self._client is not None and not self._client.is_closed:
await self._client.aclose()
self._client = None
async def health_check(self) -> bool:
if not self._api_key:
return False
try:
client = await self._get_client()
resp = await client.get(
_BRAVE_BASE,
params={"q": "test", "count": 1},
timeout=5.0,
)
return resp.status_code == 200
except Exception:
return False
async def search(
self,
request: SearchRequest,
request_id: str | None = None,
) -> SearchResponse:
if not self._api_key:
return SearchResponse(
request_id=request_id or "-",
results=[],
total_results=0,
execution_time_ms=0,
queries_processed=0,
)
start_time = time.perf_counter()
tasks = [self._search_single(q, request) for q in request.queries]
results_or_errors = await asyncio.gather(*tasks, return_exceptions=True)
all_results: list[SearchResult] = []
for i, result in enumerate(results_or_errors):
if isinstance(result, Exception):
logger.warning(
"Brave search failed for '%s': %s", request.queries[i], result
)
else:
all_results.extend(result)
execution_time_ms = (time.perf_counter() - start_time) * 1000
return SearchResponse(
request_id=request_id or "-",
results=all_results,
total_results=len(all_results),
execution_time_ms=round(execution_time_ms, 2),
queries_processed=len(request.queries),
)
async def _search_single(
self, query: str, request: SearchRequest
) -> list[SearchResult]:
client = await self._get_client()
# Build query with site filters
effective_query = query
if request.site_allowlist:
site_filter = " OR ".join(f"site:{s}" for s in request.site_allowlist)
effective_query = f"{query} ({site_filter})"
if request.site_blocklist:
for site in request.site_blocklist:
effective_query += f" -site:{site}"
params = {
"q": effective_query,
"count": min(request.max_results, 20),
}
# Country
if request.country and request.country.lower() not in ("auto", ""):
params["country"] = request.country.lower()
# Safe search
safesearch_map = {"off": "off", "moderate": "moderate", "strict": "strict"}
params["safesearch"] = safesearch_map.get(request.safe_search, "moderate")
# Freshness
if request.freshness:
freshness_map = {
"day": "pd",
"week": "pw",
"month": "pm",
"year": "py",
}
if request.freshness in freshness_map:
params["freshness"] = freshness_map[request.freshness]
resp = await client.get(_BRAVE_BASE, params=params)
resp.raise_for_status()
data = resp.json()
results: list[SearchResult] = []
web_results = data.get("web", {}).get("results", [])
for rank, item in enumerate(web_results, start=1):
url = item.get("url", "")
parsed = urlparse(url)
results.append(
SearchResult(
query=query,
url=url,
title=item.get("title", ""),
snippet=item.get("description", ""),
rank=rank,
site=parsed.netloc,
published_at=item.get("page_age"),
)
)
return results
async def image_search(
self,
request: ImageSearchRequest,
request_id: str | None = None,
) -> ImageSearchResponse:
return ImageSearchResponse(
request_id=request_id or "-",
results=[],
total_results=0,
execution_time_ms=0,
queries_processed=0,
)

View file

@ -0,0 +1,139 @@
"""Cloak HTTP client — invokes the `didiAI-cloak` standalone scraping service.
The `cloak` service (ai_platform/modules/cloak) wraps CloakBrowser (patched
stealth Chromium) behind a FastAPI endpoint. It scrapes Google/Bing/DDG SERPs
and returns parsed organic results. We invoke it as a tier-3 search fallback
in `orchestrator._run_search_stage` when SearXNG + paid rotation return thin
results (often the case on very niche or recent queries).
This client matches the SearchProvider protocol so it could in theory be
slotted into MultiSearchClient but the intended use is tier-3 fallback only,
not part of the per-call rotation.
"""
from __future__ import annotations
import time
import uuid
from typing import Any
from urllib.parse import urlparse
import httpx
from web.config import WebSettings
from web.logging import get_logger
from web.schemas.search import SearchRequest, SearchResponse, SearchResult
logger = get_logger("search.cloak")
class CloakHTTPClient:
"""Thin HTTP wrapper around the cloak scraping service."""
provider_name: str = "cloak"
def __init__(self, settings: WebSettings) -> None:
self.settings = settings
self._url = (settings.cloak_url or "http://didiAI-cloak:8770").rstrip("/")
self._timeout = settings.cloak_timeout_sec or 20
self._token = settings.cloak_auth_token or ""
self._client: httpx.AsyncClient | None = None
async def _get_client(self) -> httpx.AsyncClient:
if self._client is None or self._client.is_closed:
headers = {"Accept": "application/json"}
if self._token:
headers["Authorization"] = f"Bearer {self._token}"
self._client = httpx.AsyncClient(
timeout=httpx.Timeout(connect=5.0, read=self._timeout, write=5.0, pool=self._timeout),
headers=headers,
)
return self._client
async def close(self) -> None:
if self._client and not self._client.is_closed:
await self._client.aclose()
async def health(self) -> bool:
"""Quick readiness probe — returns True if cloak is up & has browsers ready."""
try:
client = await self._get_client()
r = await client.get(f"{self._url}/health", timeout=3.0)
if r.status_code != 200:
return False
j = r.json()
return j.get("status") in ("healthy", "degraded")
except Exception as e:
logger.debug("cloak health check failed: %s", e)
return False
async def search(
self,
request: SearchRequest,
request_id: str | None = None,
) -> SearchResponse:
"""Run a search through cloak service.
Maps web.SearchRequest cloak's POST /v1/search body and converts the
response back to web.SearchResponse so it composes with other providers.
"""
client = await self._get_client()
body: dict[str, Any] = {
"queries": list(request.queries[:5]), # service caps at 5 anyway
"engines": ["google", "bing", "ddg"],
"max_results_per_engine": min(request.max_results or 10, 30),
}
if request.language and request.language != "auto":
body["language"] = request.language
t0 = time.perf_counter()
try:
r = await client.post(f"{self._url}/v1/search", json=body)
r.raise_for_status()
data = r.json()
except Exception as e:
elapsed = (time.perf_counter() - t0) * 1000
logger.warning("cloak search failed: %s", e)
return SearchResponse(
request_id=request_id or str(uuid.uuid4()),
results=[],
total_results=0,
execution_time_ms=elapsed,
queries_processed=len(body["queries"]),
)
# Map cloak results into web.SearchResult shape (extra='forbid' strict)
results: list[SearchResult] = []
rank_counter: dict[str, int] = {}
for r in data.get("results", []):
try:
q = r.get("query", body["queries"][0])
rank_counter[q] = rank_counter.get(q, 0) + 1
url = r["url"]
host = urlparse(url).hostname or ""
results.append(SearchResult(
query=q,
url=url,
title=(r.get("title") or "")[:300],
snippet=(r.get("snippet") or "")[:500],
rank=rank_counter[q],
site=host,
))
except Exception:
continue
elapsed_ms = (time.perf_counter() - t0) * 1000
stats = data.get("stats", [])
blocked = sum(1 for s in stats if s.get("blocked"))
captcha = sum(1 for s in stats if s.get("captcha"))
logger.info(
"cloak[req=%s] q=%d -> %d results in %dms (blocked=%d, captcha=%d)",
request_id or "-", len(body["queries"]), len(results), int(elapsed_ms), blocked, captcha,
)
return SearchResponse(
request_id=request_id or str(uuid.uuid4()),
results=results,
total_results=len(results),
execution_time_ms=elapsed_ms,
queries_processed=len(body["queries"]),
)

View file

@ -0,0 +1,158 @@
"""Exa search client — neural search API."""
import asyncio
import time
from urllib.parse import urlparse
import httpx
from web.config import WebSettings
from web.logging import get_logger
from web.schemas.image_search import ImageSearchRequest, ImageSearchResponse
from web.schemas.search import SearchRequest, SearchResponse, SearchResult
logger = get_logger("search.exa")
_EXA_BASE = "https://api.exa.ai/search"
class ExaClient:
"""Search client using Exa neural search API."""
provider_name: str = "exa"
def __init__(self, settings: WebSettings) -> None:
self.settings = settings
self._api_key = settings.exa_api_key
self._client: httpx.AsyncClient | None = None
async def _get_client(self) -> httpx.AsyncClient:
if self._client is None or self._client.is_closed:
self._client = httpx.AsyncClient(
timeout=httpx.Timeout(connect=5.0, read=15.0, write=10.0, pool=15.0),
limits=httpx.Limits(max_connections=10, max_keepalive_connections=3),
headers={
"x-api-key": self._api_key or "",
"Content-Type": "application/json",
},
)
return self._client
async def close(self) -> None:
if self._client is not None and not self._client.is_closed:
await self._client.aclose()
self._client = None
async def health_check(self) -> bool:
if not self._api_key:
return False
try:
client = await self._get_client()
resp = await client.post(
_EXA_BASE,
json={"query": "test", "type": "auto", "numResults": 1},
timeout=5.0,
)
return resp.status_code == 200
except Exception:
return False
async def search(
self,
request: SearchRequest,
request_id: str | None = None,
) -> SearchResponse:
if not self._api_key:
return SearchResponse(
request_id=request_id or "-",
results=[],
total_results=0,
execution_time_ms=0,
queries_processed=0,
)
start_time = time.perf_counter()
tasks = [self._search_single(q, request) for q in request.queries]
results_or_errors = await asyncio.gather(*tasks, return_exceptions=True)
all_results: list[SearchResult] = []
for i, result in enumerate(results_or_errors):
if isinstance(result, Exception):
logger.warning(
"Exa search failed for '%s': %s", request.queries[i], result
)
else:
all_results.extend(result)
execution_time_ms = (time.perf_counter() - start_time) * 1000
return SearchResponse(
request_id=request_id or "-",
results=all_results,
total_results=len(all_results),
execution_time_ms=round(execution_time_ms, 2),
queries_processed=len(request.queries),
)
async def _search_single(
self, query: str, request: SearchRequest
) -> list[SearchResult]:
client = await self._get_client()
payload = {
"query": query,
"type": "auto",
"numResults": min(request.max_results, 10),
"contents": {
"highlights": {"maxCharacters": 4000},
},
}
# Domain filtering
if request.site_allowlist:
payload["includeDomains"] = list(request.site_allowlist)
if request.site_blocklist:
payload["excludeDomains"] = list(request.site_blocklist)
# Category for news freshness
if request.freshness in ("day", "week"):
payload["category"] = "news"
resp = await client.post(_EXA_BASE, json=payload)
resp.raise_for_status()
data = resp.json()
results: list[SearchResult] = []
for rank, item in enumerate(data.get("results", []), start=1):
url = item.get("url", "")
parsed = urlparse(url)
# Build snippet from highlights or text
highlights = item.get("highlights", [])
snippet = " ".join(highlights) if highlights else item.get("text", "")[:500]
results.append(
SearchResult(
query=query,
url=url,
title=item.get("title", ""),
snippet=snippet,
rank=rank,
site=parsed.netloc,
published_at=item.get("publishedDate"),
)
)
return results
async def image_search(
self,
request: ImageSearchRequest,
request_id: str | None = None,
) -> ImageSearchResponse:
return ImageSearchResponse(
request_id=request_id or "-",
results=[],
total_results=0,
execution_time_ms=0,
queries_processed=0,
)

View file

@ -0,0 +1,150 @@
"""LinkUp search client — web content API."""
import asyncio
import time
from urllib.parse import urlparse
import httpx
from web.config import WebSettings
from web.logging import get_logger
from web.schemas.image_search import ImageSearchRequest, ImageSearchResponse
from web.schemas.search import SearchRequest, SearchResponse, SearchResult
logger = get_logger("search.linkup")
_LINKUP_BASE = "https://api.linkup.so/v1/search"
class LinkUpClient:
"""Search client using LinkUp API."""
provider_name: str = "linkup"
def __init__(self, settings: WebSettings) -> None:
self.settings = settings
self._api_key = settings.linkup_api_key
self._client: httpx.AsyncClient | None = None
async def _get_client(self) -> httpx.AsyncClient:
if self._client is None or self._client.is_closed:
self._client = httpx.AsyncClient(
timeout=httpx.Timeout(connect=5.0, read=15.0, write=10.0, pool=15.0),
limits=httpx.Limits(max_connections=10, max_keepalive_connections=3),
headers={
"Authorization": f"Bearer {self._api_key}",
"Content-Type": "application/json",
},
)
return self._client
async def close(self) -> None:
if self._client is not None and not self._client.is_closed:
await self._client.aclose()
self._client = None
async def health_check(self) -> bool:
if not self._api_key:
return False
try:
client = await self._get_client()
resp = await client.post(
_LINKUP_BASE,
json={"q": "test", "depth": "standard", "outputType": "searchResults"},
timeout=5.0,
)
return resp.status_code == 200
except Exception:
return False
async def search(
self,
request: SearchRequest,
request_id: str | None = None,
) -> SearchResponse:
if not self._api_key:
return SearchResponse(
request_id=request_id or "-",
results=[],
total_results=0,
execution_time_ms=0,
queries_processed=0,
)
start_time = time.perf_counter()
tasks = [self._search_single(q, request) for q in request.queries]
results_or_errors = await asyncio.gather(*tasks, return_exceptions=True)
all_results: list[SearchResult] = []
for i, result in enumerate(results_or_errors):
if isinstance(result, Exception):
logger.warning(
"LinkUp search failed for '%s': %s", request.queries[i], result
)
else:
all_results.extend(result)
execution_time_ms = (time.perf_counter() - start_time) * 1000
return SearchResponse(
request_id=request_id or "-",
results=all_results,
total_results=len(all_results),
execution_time_ms=round(execution_time_ms, 2),
queries_processed=len(request.queries),
)
async def _search_single(
self, query: str, request: SearchRequest
) -> list[SearchResult]:
client = await self._get_client()
# Build query with site filters
effective_query = query
if request.site_allowlist:
site_filter = " OR ".join(f"site:{s}" for s in request.site_allowlist)
effective_query = f"{query} ({site_filter})"
if request.site_blocklist:
for site in request.site_blocklist:
effective_query += f" -site:{site}"
payload = {
"q": effective_query,
"depth": "standard",
"outputType": "searchResults",
"includeImages": False,
}
resp = await client.post(_LINKUP_BASE, json=payload)
resp.raise_for_status()
data = resp.json()
results: list[SearchResult] = []
for rank, item in enumerate(data.get("results", []), start=1):
url = item.get("url", "")
parsed = urlparse(url)
results.append(
SearchResult(
query=query,
url=url,
title=item.get("name", ""),
snippet=item.get("content", ""),
rank=rank,
site=parsed.netloc,
published_at=None,
)
)
return results
async def image_search(
self,
request: ImageSearchRequest,
request_id: str | None = None,
) -> ImageSearchResponse:
return ImageSearchResponse(
request_id=request_id or "-",
results=[],
total_results=0,
execution_time_ms=0,
queries_processed=0,
)

View file

@ -0,0 +1,178 @@
"""Multi-provider search client — SearXNG always + one paid provider rotated."""
import asyncio
import time
from web.config import WebSettings
from web.logging import get_logger
from web.schemas.image_search import ImageSearchRequest, ImageSearchResponse
from web.schemas.search import SearchRequest, SearchResponse, SearchResult
logger = get_logger("search.multi")
class MultiSearchClient:
"""Combines SearXNG (always) with paid providers on round-robin rotation.
Each search() call uses SearXNG + ONE paid provider. The paid provider
rotates on every call to spread API credits evenly.
Satisfies the SearchProvider protocol.
"""
provider_name: str = "multi"
def __init__(self, settings: WebSettings) -> None:
self.settings = settings
# Primary provider — always runs (free / self-hosted)
from web.metasearch.client import SearXNGClient
self._primary = SearXNGClient(settings)
# Paid providers — rotated round-robin, one per search() call
self._paid: list = []
self._paid_index: int = 0
if settings.serpapi_api_key:
from web.search.serpapi import SerpAPIClient
self._paid.append(SerpAPIClient(settings))
logger.info("SerpAPI provider enabled (paid, rotated)")
if settings.tavily_api_key:
from web.search.tavily import TavilyClient
self._paid.append(TavilyClient(settings))
logger.info("Tavily provider enabled (paid, rotated)")
if settings.exa_api_key:
from web.search.exa import ExaClient
self._paid.append(ExaClient(settings))
logger.info("Exa provider enabled (paid, rotated)")
if settings.linkup_api_key:
from web.search.linkup import LinkUpClient
self._paid.append(LinkUpClient(settings))
logger.info("LinkUp provider enabled (paid, rotated)")
if settings.brave_api_key:
from web.search.brave import BraveSearchClient
self._paid.append(BraveSearchClient(settings))
logger.info("Brave Search provider enabled (paid, rotated)")
logger.info(
"MultiSearchClient: primary=searxng, paid=%d providers [%s], rotation=round-robin",
len(self._paid),
", ".join(p.provider_name for p in self._paid),
)
def _next_paid(self) -> object | None:
"""Get next paid provider in round-robin rotation."""
if not self._paid:
return None
provider = self._paid[self._paid_index % len(self._paid)]
self._paid_index += 1
return provider
async def search(
self,
request: SearchRequest,
request_id: str | None = None,
) -> SearchResponse:
"""Run SearXNG + one rotated paid provider in parallel, deduplicate.
Args:
request: Search request.
request_id: Optional request ID.
Returns:
Combined, deduplicated SearchResponse.
"""
start_time = time.perf_counter()
# Always run primary (SearXNG)
tasks = [self._primary.search(request, request_id=request_id)]
providers_used = ["searxng"]
# Add one paid provider (round-robin)
paid = self._next_paid()
if paid is not None:
tasks.append(paid.search(request, request_id=request_id))
providers_used.append(paid.provider_name)
results_or_errors = await asyncio.gather(*tasks, return_exceptions=True)
# Combine and deduplicate by URL (first seen wins)
all_results: list[SearchResult] = []
seen_urls: set[str] = set()
for i, result in enumerate(results_or_errors):
name = providers_used[i]
if isinstance(result, Exception):
logger.warning("Provider %s failed: %s", name, result)
continue
count = 0
for sr in result.results:
if sr.url not in seen_urls:
seen_urls.add(sr.url)
all_results.append(sr)
count += 1
logger.debug(
"Provider %s: %d results (%d new)", name, result.total_results, count
)
execution_time_ms = (time.perf_counter() - start_time) * 1000
logger.info(
"Search [%s]: %d results in %.0fms",
"+".join(providers_used),
len(all_results),
execution_time_ms,
)
return SearchResponse(
request_id=request_id or "-",
results=all_results,
total_results=len(all_results),
execution_time_ms=round(execution_time_ms, 2),
queries_processed=len(request.queries),
)
async def image_search(
self,
request: ImageSearchRequest,
request_id: str | None = None,
) -> ImageSearchResponse:
"""Image search — delegates to SearXNG (primary)."""
try:
return await self._primary.image_search(request, request_id=request_id)
except Exception as e:
logger.warning("Image search failed: %s", e)
return ImageSearchResponse(
request_id=request_id or "-",
results=[],
total_results=0,
execution_time_ms=0,
queries_processed=len(request.queries),
)
async def health_check(self) -> bool:
"""Healthy if primary (SearXNG) is healthy."""
try:
return await self._primary.health_check()
except Exception:
return False
async def close(self) -> None:
await self._primary.close()
for provider in self._paid:
try:
await provider.close()
except Exception as e:
logger.warning("Error closing %s: %s", provider.provider_name, e)

View file

@ -0,0 +1,306 @@
"""Paid-only search client — rotates SerpAPI, Tavily, Exa, LinkUp, Brave.
Supports three rotation strategies, configurable at runtime:
round-robin: one provider per request, rotated
parallel: all enabled providers in parallel, results deduped
priority: try providers in configured order, fallback on empty/error
"""
import asyncio
import time
from typing import TYPE_CHECKING
from web.config import WebSettings
from web.logging import get_logger
from web.schemas.image_search import ImageSearchRequest, ImageSearchResponse
from web.schemas.search import SearchRequest, SearchResponse, SearchResult
if TYPE_CHECKING:
from web.runtime_config import RuntimeConfigClient
logger = get_logger("search.paid")
class PaidSearchClient:
"""Premium-tier search client with runtime-configurable rotation."""
provider_name: str = "paid"
def __init__(
self,
settings: WebSettings,
runtime_config: "RuntimeConfigClient | None" = None,
) -> None:
self.settings = settings
self.runtime_config = runtime_config
self._index: int = 0
# Instantiate every provider that has a key — enable/disable is runtime
self._providers: dict[str, object] = {}
if settings.serpapi_api_key:
from web.search.serpapi import SerpAPIClient
self._providers["serpapi"] = SerpAPIClient(settings)
if settings.tavily_api_key:
from web.search.tavily import TavilyClient
self._providers["tavily"] = TavilyClient(settings)
if settings.exa_api_key:
from web.search.exa import ExaClient
self._providers["exa"] = ExaClient(settings)
if settings.linkup_api_key:
from web.search.linkup import LinkUpClient
self._providers["linkup"] = LinkUpClient(settings)
if settings.brave_api_key:
from web.search.brave import BraveSearchClient
self._providers["brave"] = BraveSearchClient(settings)
logger.info(
"PaidSearchClient: %d providers registered [%s]",
len(self._providers),
", ".join(self._providers.keys()),
)
# ------------------------------------------------------------------
# Runtime config helpers
# ------------------------------------------------------------------
def _enabled_providers(self) -> list[tuple[str, object]]:
"""Return list of (name, client) for providers currently enabled."""
if self.runtime_config is None:
return list(self._providers.items())
out = []
for name, client in self._providers.items():
if self.runtime_config.get_bool(
f"web.providers.{name}.enabled", default=True
):
out.append((name, client))
return out
def _strategy(self) -> str:
if self.runtime_config is None:
return "round-robin"
return self.runtime_config.get_str(
"web.premium.strategy", default="round-robin"
)
def _priority_order(self) -> list[str]:
if self.runtime_config is None:
return ["serpapi", "brave", "tavily", "linkup", "exa"]
raw = self.runtime_config.get_str(
"web.premium.priority_order",
default="serpapi,brave,tavily,linkup",
)
return [p.strip() for p in raw.split(",") if p.strip()]
# ------------------------------------------------------------------
# Search dispatch
# ------------------------------------------------------------------
async def search(
self,
request: SearchRequest,
request_id: str | None = None,
) -> SearchResponse:
enabled = self._enabled_providers()
if not enabled:
logger.warning("PaidSearchClient: no providers enabled")
return self._empty(request, request_id)
strategy = self._strategy()
if strategy == "parallel":
return await self._search_parallel(enabled, request, request_id)
if strategy == "priority":
return await self._search_priority(enabled, request, request_id)
return await self._search_round_robin(enabled, request, request_id)
async def _search_round_robin(
self,
enabled: list[tuple[str, object]],
request: SearchRequest,
request_id: str | None,
) -> SearchResponse:
"""Pick one provider per request, rotate, fallback on empty."""
start_time = time.perf_counter()
start_idx = self._index % len(enabled)
self._index = (self._index + 1) % max(1, len(enabled))
last_error: Exception | None = None
for offset in range(len(enabled)):
idx = (start_idx + offset) % len(enabled)
name, provider = enabled[idx]
try:
result: SearchResponse = await provider.search( # type: ignore[attr-defined]
request, request_id=request_id
)
if result.results:
duration = (time.perf_counter() - start_time) * 1000
logger.info(
"Search [%s/round-robin]: %d results in %.0fms",
name,
len(result.results),
duration,
)
return SearchResponse(
request_id=request_id or "-",
results=result.results,
total_results=result.total_results,
execution_time_ms=round(duration, 2),
queries_processed=result.queries_processed,
)
logger.warning("Provider %s returned 0 results, trying next", name)
except Exception as e:
logger.warning("Provider %s failed: %s", name, e)
last_error = e
if last_error:
logger.error("All paid providers failed. Last: %s", last_error)
return self._empty(request, request_id, start_time)
async def _search_parallel(
self,
enabled: list[tuple[str, object]],
request: SearchRequest,
request_id: str | None,
) -> SearchResponse:
"""Hit all enabled providers at once, dedupe by URL."""
start_time = time.perf_counter()
tasks = [
provider.search(request, request_id=request_id) # type: ignore[attr-defined]
for _, provider in enabled
]
results = await asyncio.gather(*tasks, return_exceptions=True)
seen: set[str] = set()
combined: list[SearchResult] = []
providers_hit: list[str] = []
for (name, _), result in zip(enabled, results, strict=False):
if isinstance(result, BaseException):
logger.warning("Provider %s failed: %s", name, result)
continue
response: SearchResponse = result # type: ignore[assignment]
new_count = 0
for r in response.results:
if r.url not in seen:
seen.add(r.url)
combined.append(r)
new_count += 1
providers_hit.append(f"{name}:{new_count}")
duration = (time.perf_counter() - start_time) * 1000
logger.info(
"Search [parallel: %s]: %d unique results in %.0fms",
", ".join(providers_hit),
len(combined),
duration,
)
return SearchResponse(
request_id=request_id or "-",
results=combined,
total_results=len(combined),
execution_time_ms=round(duration, 2),
queries_processed=len(request.queries),
)
async def _search_priority(
self,
enabled: list[tuple[str, object]],
request: SearchRequest,
request_id: str | None,
) -> SearchResponse:
"""Try providers in configured priority order, fallback on empty/error."""
start_time = time.perf_counter()
enabled_map = dict(enabled)
ordered = [name for name in self._priority_order() if name in enabled_map]
# Append any enabled providers not in the priority list
for name in enabled_map:
if name not in ordered:
ordered.append(name)
last_error: Exception | None = None
for name in ordered:
provider = enabled_map[name]
try:
result: SearchResponse = await provider.search( # type: ignore[attr-defined]
request, request_id=request_id
)
if result.results:
duration = (time.perf_counter() - start_time) * 1000
logger.info(
"Search [%s/priority]: %d results in %.0fms",
name,
len(result.results),
duration,
)
return SearchResponse(
request_id=request_id or "-",
results=result.results,
total_results=result.total_results,
execution_time_ms=round(duration, 2),
queries_processed=result.queries_processed,
)
except Exception as e:
logger.warning("Provider %s failed: %s", name, e)
last_error = e
if last_error:
logger.error("All paid providers failed. Last: %s", last_error)
return self._empty(request, request_id, start_time)
# ------------------------------------------------------------------
# Utilities
# ------------------------------------------------------------------
def _empty(
self,
request: SearchRequest,
request_id: str | None,
start_time: float | None = None,
) -> SearchResponse:
duration = (time.perf_counter() - start_time) * 1000 if start_time else 0.0
return SearchResponse(
request_id=request_id or "-",
results=[],
total_results=0,
execution_time_ms=round(duration, 2),
queries_processed=len(request.queries),
)
async def image_search(
self,
request: ImageSearchRequest,
request_id: str | None = None,
) -> ImageSearchResponse:
return ImageSearchResponse(
request_id=request_id or "-",
results=[],
total_results=0,
execution_time_ms=0,
queries_processed=len(request.queries),
)
async def health_check(self) -> bool:
if not self._providers:
return False
checks = await asyncio.gather(
*[p.health_check() for p in self._providers.values()], # type: ignore[attr-defined]
return_exceptions=True,
)
return any(c is True for c in checks)
async def close(self) -> None:
for name, provider in self._providers.items():
try:
await provider.close() # type: ignore[attr-defined]
except Exception as e:
logger.warning("Error closing %s: %s", name, e)

View file

@ -0,0 +1,23 @@
"""Search provider protocol — contract shared by all search backends."""
from typing import Protocol, runtime_checkable
from web.schemas.image_search import ImageSearchRequest, ImageSearchResponse
from web.schemas.search import SearchRequest, SearchResponse
@runtime_checkable
class SearchProvider(Protocol):
"""Protocol that all search backends must satisfy."""
async def search(
self, request: SearchRequest, *, request_id: str | None = None
) -> SearchResponse: ...
async def image_search(
self, request: ImageSearchRequest, *, request_id: str | None = None
) -> ImageSearchResponse: ...
async def health_check(self) -> bool: ...
async def close(self) -> None: ...

View file

@ -0,0 +1,164 @@
"""SerpAPI search client — wraps Google/Bing via SerpAPI."""
import asyncio
import time
from typing import Any
from urllib.parse import urlparse
import httpx
from web.config import WebSettings
from web.logging import get_logger
from web.schemas.image_search import (
ImageSearchRequest,
ImageSearchResponse,
)
from web.schemas.search import SearchRequest, SearchResponse, SearchResult
logger = get_logger("search.serpapi")
_SERPAPI_BASE = "https://serpapi.com/search"
class SerpAPIClient:
"""Search client using SerpAPI (Google results without CAPTCHA)."""
provider_name: str = "serpapi"
def __init__(self, settings: WebSettings) -> None:
self.settings = settings
self._api_key = settings.serpapi_api_key
self._client: httpx.AsyncClient | None = None
async def _get_client(self) -> httpx.AsyncClient:
if self._client is None or self._client.is_closed:
self._client = httpx.AsyncClient(
timeout=httpx.Timeout(connect=5.0, read=15.0, write=10.0, pool=15.0),
limits=httpx.Limits(max_connections=10, max_keepalive_connections=3),
)
return self._client
async def close(self) -> None:
if self._client is not None and not self._client.is_closed:
await self._client.aclose()
self._client = None
async def health_check(self) -> bool:
if not self._api_key:
return False
try:
client = await self._get_client()
resp = await client.get(
_SERPAPI_BASE,
params={"q": "test", "api_key": self._api_key, "engine": "google", "num": 1},
timeout=5.0,
)
return resp.status_code == 200
except Exception:
return False
async def search(
self,
request: SearchRequest,
request_id: str | None = None,
) -> SearchResponse:
if not self._api_key:
return SearchResponse(
request_id=request_id or "-",
results=[],
total_results=0,
execution_time_ms=0,
queries_processed=0,
)
start_time = time.perf_counter()
tasks = [self._search_single(q, request) for q in request.queries]
results_or_errors = await asyncio.gather(*tasks, return_exceptions=True)
all_results: list[SearchResult] = []
for i, result in enumerate(results_or_errors):
if isinstance(result, Exception):
logger.warning("SerpAPI search failed for '%s': %s", request.queries[i], result)
else:
all_results.extend(result)
execution_time_ms = (time.perf_counter() - start_time) * 1000
return SearchResponse(
request_id=request_id or "-",
results=all_results,
total_results=len(all_results),
execution_time_ms=round(execution_time_ms, 2),
queries_processed=len(request.queries),
)
async def _search_single(
self, query: str, request: SearchRequest
) -> list[SearchResult]:
client = await self._get_client()
# Build query with site filters
effective_query = query
if request.site_allowlist:
site_filter = " OR ".join(f"site:{s}" for s in request.site_allowlist)
effective_query = f"{query} ({site_filter})"
if request.site_blocklist:
for site in request.site_blocklist:
effective_query += f" -site:{site}"
params: dict[str, Any] = {
"q": effective_query,
"api_key": self._api_key,
"engine": "google",
"num": min(request.max_results, 20),
}
# Language / country
lang = request.language if request.language not in ("auto", "") else "en"
params["hl"] = lang
if request.country and request.country.lower() not in ("auto", ""):
params["gl"] = request.country.lower()
# Safe search
if request.safe_search == "strict":
params["safe"] = "active"
# Freshness
freshness_map = {"day": "qdr:d", "week": "qdr:w", "month": "qdr:m", "year": "qdr:y"}
if request.freshness and request.freshness in freshness_map:
params["tbs"] = freshness_map[request.freshness]
resp = await client.get(_SERPAPI_BASE, params=params)
resp.raise_for_status()
data = resp.json()
results: list[SearchResult] = []
for rank, item in enumerate(data.get("organic_results", []), start=1):
url = item.get("link", "")
parsed = urlparse(url)
results.append(
SearchResult(
query=query,
url=url,
title=item.get("title", ""),
snippet=item.get("snippet", ""),
rank=rank,
site=parsed.netloc,
published_at=item.get("date"),
)
)
return results
async def image_search(
self,
request: ImageSearchRequest,
request_id: str | None = None,
) -> ImageSearchResponse:
# Not implemented — SerpAPI image search costs extra
return ImageSearchResponse(
request_id=request_id or "-",
results=[],
total_results=0,
execution_time_ms=0,
queries_processed=0,
)

View file

@ -0,0 +1,156 @@
"""Tavily search client — AI-optimized search API."""
import asyncio
import time
from urllib.parse import urlparse
import httpx
from web.config import WebSettings
from web.logging import get_logger
from web.schemas.image_search import ImageSearchRequest, ImageSearchResponse
from web.schemas.search import SearchRequest, SearchResponse, SearchResult
logger = get_logger("search.tavily")
_TAVILY_BASE = "https://api.tavily.com/search"
class TavilyClient:
"""Search client using Tavily API (optimized for AI/fact-checking)."""
provider_name: str = "tavily"
def __init__(self, settings: WebSettings) -> None:
self.settings = settings
self._api_key = settings.tavily_api_key
self._client: httpx.AsyncClient | None = None
async def _get_client(self) -> httpx.AsyncClient:
if self._client is None or self._client.is_closed:
self._client = httpx.AsyncClient(
timeout=httpx.Timeout(connect=5.0, read=15.0, write=10.0, pool=15.0),
limits=httpx.Limits(max_connections=10, max_keepalive_connections=3),
)
return self._client
async def close(self) -> None:
if self._client is not None and not self._client.is_closed:
await self._client.aclose()
self._client = None
async def health_check(self) -> bool:
if not self._api_key:
return False
try:
client = await self._get_client()
resp = await client.post(
_TAVILY_BASE,
json={"query": "test", "api_key": self._api_key, "max_results": 1},
timeout=5.0,
)
return resp.status_code == 200
except Exception:
return False
async def search(
self,
request: SearchRequest,
request_id: str | None = None,
) -> SearchResponse:
if not self._api_key:
return SearchResponse(
request_id=request_id or "-",
results=[],
total_results=0,
execution_time_ms=0,
queries_processed=0,
)
start_time = time.perf_counter()
tasks = [self._search_single(q, request) for q in request.queries]
results_or_errors = await asyncio.gather(*tasks, return_exceptions=True)
all_results: list[SearchResult] = []
for i, result in enumerate(results_or_errors):
if isinstance(result, Exception):
logger.warning(
"Tavily search failed for '%s': %s", request.queries[i], result
)
else:
all_results.extend(result)
execution_time_ms = (time.perf_counter() - start_time) * 1000
return SearchResponse(
request_id=request_id or "-",
results=all_results,
total_results=len(all_results),
execution_time_ms=round(execution_time_ms, 2),
queries_processed=len(request.queries),
)
async def _search_single(
self, query: str, request: SearchRequest
) -> list[SearchResult]:
client = await self._get_client()
# Build query with site filters
effective_query = query
if request.site_allowlist:
site_filter = " OR ".join(f"site:{s}" for s in request.site_allowlist)
effective_query = f"{query} ({site_filter})"
if request.site_blocklist:
for site in request.site_blocklist:
effective_query += f" -site:{site}"
payload = {
"query": effective_query,
"api_key": self._api_key,
"max_results": min(request.max_results, 20),
"search_depth": "basic",
"include_answer": False,
}
# Freshness mapping
if request.freshness:
freshness_map = {"day": "day", "week": "week", "month": "month", "year": "year"}
if request.freshness in freshness_map:
payload["days"] = {"day": 1, "week": 7, "month": 30, "year": 365}[
request.freshness
]
resp = await client.post(_TAVILY_BASE, json=payload)
resp.raise_for_status()
data = resp.json()
results: list[SearchResult] = []
for rank, item in enumerate(data.get("results", []), start=1):
url = item.get("url", "")
parsed = urlparse(url)
results.append(
SearchResult(
query=query,
url=url,
title=item.get("title", ""),
snippet=item.get("content", ""),
rank=rank,
site=parsed.netloc,
published_at=item.get("published_date"),
)
)
return results
async def image_search(
self,
request: ImageSearchRequest,
request_id: str | None = None,
) -> ImageSearchResponse:
# Tavily doesn't have image search
return ImageSearchResponse(
request_id=request_id or "-",
results=[],
total_results=0,
execution_time_ms=0,
queries_processed=0,
)

View file

@ -0,0 +1,154 @@
"""URL validation utilities for SSRF protection."""
import asyncio
import ipaddress
import socket
from urllib.parse import urlparse
from web.logging import get_logger
from web.schemas.common import FailedUrl
logger = get_logger("validation")
# Allowed URL schemes
_ALLOWED_SCHEMES = {"http", "https"}
# Blocked hostnames
_BLOCKED_HOSTNAMES = {
"localhost",
"localhost.localdomain",
"metadata.google.internal",
"169.254.169.254",
}
def _is_private_ip(hostname: str) -> bool:
"""Check if a hostname resolves to a private/reserved IP.
Args:
hostname: Hostname or IP address string.
Returns:
True if the address is private or reserved.
"""
try:
addr = ipaddress.ip_address(hostname)
return addr.is_private or addr.is_reserved or addr.is_loopback
except ValueError:
# Not a raw IP - will be checked via DNS later
return False
def validate_url(url: str) -> str:
"""Validate a URL for safe fetching (cheap synchronous checks only).
Performs fast SSRF checks: scheme, hostname format, blocklist, raw IP.
Does NOT do DNS resolution (use validate_url_dns for that).
Args:
url: The URL to validate.
Returns:
The validated URL (unchanged).
Raises:
ValueError: If the URL is not safe to fetch.
"""
try:
parsed = urlparse(url)
except Exception as e:
raise ValueError(f"Invalid URL: {e}") from e
# Check scheme
if parsed.scheme not in _ALLOWED_SCHEMES:
raise ValueError(
f"URL scheme '{parsed.scheme}' is not allowed. "
f"Only {_ALLOWED_SCHEMES} are permitted."
)
# Check hostname exists
hostname = parsed.hostname
if not hostname:
raise ValueError("URL must include a hostname.")
hostname_lower = hostname.lower()
# Check blocked hostnames
if hostname_lower in _BLOCKED_HOSTNAMES:
raise ValueError(f"URL hostname '{hostname}' is not allowed.")
# Check for private/reserved IPs (raw IP literals only)
if _is_private_ip(hostname):
raise ValueError(f"URL points to a private/reserved address: {hostname}")
return url
async def validate_url_dns(url: str) -> str:
"""Async DNS resolution check for SSRF protection.
Resolves the URL's hostname and checks that it does not point to
a private/reserved IP address. Should be called after validate_url().
Args:
url: The URL to check (must already pass validate_url).
Returns:
The validated URL (unchanged).
Raises:
ValueError: If the hostname resolves to a private/reserved address.
"""
hostname = urlparse(url).hostname
if not hostname:
return url # Already validated by validate_url
loop = asyncio.get_running_loop()
try:
resolved = await loop.getaddrinfo(
hostname, None, family=socket.AF_UNSPEC, type=socket.SOCK_STREAM
)
for _family, _, _, _, sockaddr in resolved:
ip_str = sockaddr[0]
try:
addr = ipaddress.ip_address(ip_str)
if addr.is_private or addr.is_reserved or addr.is_loopback:
raise ValueError(
f"URL hostname '{hostname}' resolves to "
f"private/reserved address {ip_str}"
)
except ValueError:
raise
except socket.gaierror as e:
logger.warning("DNS resolution failed for '%s': %s — blocking URL", hostname, e)
raise ValueError(f"URL hostname '{hostname}' could not be resolved") from e
return url
async def validate_urls_async(
urls: list[str],
) -> tuple[list[str], list[FailedUrl]]:
"""Validate a list of URLs with async DNS resolution.
Runs DNS-based SSRF checks for all URLs in parallel.
Returns both validated URLs and failed URLs with error details.
Args:
urls: URLs to validate (must already pass validate_url).
Returns:
Tuple of (validated_urls, failed_urls).
"""
results = await asyncio.gather(
*(validate_url_dns(url) for url in urls), return_exceptions=True
)
validated: list[str] = []
dns_failed: list[FailedUrl] = []
for url, result in zip(urls, results):
if isinstance(result, Exception):
logger.warning("Dropping URL %s: %s", url, result)
dns_failed.append(FailedUrl(url=url, error=str(result)))
else:
validated.append(url)
return validated, dns_failed

View file

@ -0,0 +1,5 @@
"""Vision submodule - Screenshot + Vision LLM extraction."""
from web.vision.client import VisionClient
__all__ = ["VisionClient"]

View file

@ -0,0 +1,437 @@
"""Vision-based content extraction using Screenshot + Vision LLM."""
import asyncio
import base64
import hashlib
import time
from datetime import datetime, timezone
from typing import Any
import httpx
from web.config import WebSettings
from web.exceptions import (
WebError,
WebTimeoutError,
)
from web.llm.provider import LLMProviderChain
from web.logging import get_logger
from web.schemas.common import FailedUrl, PageContent, PageImage
from web.schemas.vision import (
ImageContext,
VisionExtractRequest,
VisionExtractResponse,
VisionPageResult,
)
logger = get_logger("vision.client")
# Optional Playwright import
try:
from playwright.async_api import (
Browser,
TimeoutError as PlaywrightTimeout,
async_playwright,
)
HAS_PLAYWRIGHT = True
except ImportError:
HAS_PLAYWRIGHT = False
logger.warning("playwright not installed, vision functionality unavailable")
class VisionClient:
"""Client for vision-based content extraction using Screenshot + LLM."""
def __init__(
self,
settings: WebSettings,
browser: Browser | None = None,
) -> None:
"""Initialize the client.
Args:
settings: Application settings.
browser: Optional shared browser instance. If provided, the client
will not launch its own browser and will not close this one.
"""
if not HAS_PLAYWRIGHT:
raise ImportError(
"playwright is required for VisionClient. "
"Install with: uv add playwright && uv run playwright install chromium"
)
self.settings = settings
self._playwright = None
self._browser: Browser | None = browser
self._owns_browser = browser is None
self._browser_lock = asyncio.Lock()
self._http_client: httpx.AsyncClient | None = None
self._llm_chain = LLMProviderChain(
settings,
self._get_http_client,
domain="vision",
local_base_url=settings.vision_base_url or settings.llm_base_url,
)
async def _get_browser(self) -> Browser:
"""Get or create the browser instance."""
async with self._browser_lock:
if self._browser is None or not self._browser.is_connected():
self._playwright = await async_playwright().start()
self._browser = await self._playwright.chromium.launch(
headless=True,
args=[
"--disable-gpu",
"--disable-dev-shm-usage",
"--no-sandbox",
],
)
return self._browser
async def _get_http_client(self) -> httpx.AsyncClient:
"""Get or create the HTTP client for LLM API calls."""
if self._http_client is None or self._http_client.is_closed:
self._http_client = httpx.AsyncClient(
timeout=httpx.Timeout(
connect=10.0,
read=120.0, # Vision LLM can be slow
write=30.0,
pool=60.0,
),
limits=httpx.Limits(
max_connections=self.settings.llm_pool_max_connections,
max_keepalive_connections=self.settings.llm_pool_max_keepalive,
),
)
return self._http_client
async def __aenter__(self) -> "VisionClient":
return self
async def __aexit__(self, *exc: object) -> None:
await self.close()
async def close(self) -> None:
"""Close all resources."""
if self._owns_browser:
if self._browser is not None:
await self._browser.close()
self._browser = None
if self._playwright is not None:
await self._playwright.stop()
self._playwright = None
if self._http_client is not None and not self._http_client.is_closed:
await self._http_client.aclose()
self._http_client = None
async def extract(
self,
request: VisionExtractRequest,
request_id: str | None = None,
) -> VisionExtractResponse:
"""Extract content from URLs using vision.
Args:
request: Vision extraction request.
request_id: Optional request ID for tracing.
Returns:
VisionExtractResponse: Extracted content.
"""
start_time = time.perf_counter()
pages: list[VisionPageResult] = []
failed: list[FailedUrl] = []
total_tokens = 0
browser = await self._get_browser()
sem = asyncio.Semaphore(self.settings.vision_max_concurrent)
async def extract_with_sem(url: str) -> VisionPageResult | None:
async with sem:
try:
return await self._extract_single(browser, url, request)
except Exception as e:
logger.error("Failed to extract %s: %s", url, e)
failed.append(FailedUrl(url=url, error=str(e)))
return None
results = await asyncio.gather(*[extract_with_sem(url) for url in request.urls])
for result in results:
if result is not None:
pages.append(result)
total_tokens += result.tokens_used
execution_time_ms = (time.perf_counter() - start_time) * 1000
return VisionExtractResponse(
request_id=request_id or "-",
pages=pages,
total_processed=len(pages),
total_failed=len(failed),
total_tokens_used=total_tokens,
execution_time_ms=round(execution_time_ms, 2),
failed_urls=failed,
)
async def _extract_single(
self,
browser: Browser,
url: str,
request: VisionExtractRequest,
) -> VisionPageResult:
"""Extract content from a single URL using vision.
Args:
browser: Playwright browser instance.
url: URL to extract.
request: Vision extraction request.
Returns:
VisionPageResult: Extracted content.
"""
start_time = time.perf_counter()
context = await browser.new_context(
viewport={
"width": self.settings.browse_viewport_width,
"height": self.settings.browse_viewport_height,
},
user_agent=self.settings.fetch_user_agent,
)
page = await context.new_page()
try:
# Navigate to URL
try:
await page.goto(
url,
wait_until="networkidle",
timeout=self.settings.browse_timeout,
)
except PlaywrightTimeout as e:
raise WebTimeoutError(
f"Timeout loading {url}",
timeout=self.settings.browse_timeout / 1000,
) from e
# Get page title
title = await page.title()
# Take screenshot(s)
screenshots_base64 = []
if request.full_page:
# Take full page screenshot
screenshot_bytes = await page.screenshot(
type="jpeg",
quality=self.settings.vision_screenshot_quality,
full_page=True,
)
screenshots_base64.append(base64.b64encode(screenshot_bytes).decode())
else:
# Take viewport screenshot only
screenshot_bytes = await page.screenshot(
type="jpeg",
quality=self.settings.vision_screenshot_quality,
full_page=False,
)
screenshots_base64.append(base64.b64encode(screenshot_bytes).decode())
# Extract content via Vision LLM
(
extracted_text,
tokens_used,
llm_provider,
model_used,
) = await self._call_vision_llm(
screenshots_base64,
request.context_query,
request.llm_provider,
request.model,
)
# Extract images if requested
images: list[ImageContext] = []
if request.extract_images:
images = await self._extract_images(page, request.context_query)
extraction_time_ms = (time.perf_counter() - start_time) * 1000
return VisionPageResult(
url=url,
title=title,
extracted_text=extracted_text,
images=images,
extraction_method="vision",
model_used=model_used,
llm_provider=llm_provider,
screenshots_processed=len(screenshots_base64),
tokens_used=tokens_used,
extraction_time_ms=round(extraction_time_ms, 2),
)
finally:
await context.close()
async def _call_vision_llm(
self,
screenshots_base64: list[str],
context_query: str | None,
llm_provider: str,
model: str | None,
) -> tuple[str, int, str, str]:
"""Call Vision LLM to extract text from screenshots.
Args:
screenshots_base64: Screenshots as base64 strings.
context_query: Optional context for extraction.
llm_provider: LLM provider to use.
model: Model to use.
Returns:
tuple: (extracted_text, tokens_used, provider_used, model_used)
"""
# Build prompt
context_line = (
f"\nFocus on content relevant to: {context_query}\n"
if context_query
else ""
)
prompt = f"""Extract all text content from this webpage screenshot.
{context_line}
Return the extracted text in a clear, readable format. Include:
- Main article/page content
- Important headings and subheadings
- Key facts and data
- Publication date if visible
Do NOT include:
- Navigation menus
- Advertisements
- Cookie banners
- Social media buttons"""
# Build OpenAI-format multimodal messages
content: list[dict[str, Any]] = []
for img_b64 in screenshots_base64:
content.append(
{
"type": "image_url",
"image_url": {
"url": f"data:image/jpeg;base64,{img_b64}",
},
}
)
content.append({"type": "text", "text": prompt})
messages = [{"role": "user", "content": content}]
return await self._llm_chain.call_chat(
messages=messages,
provider=llm_provider,
max_tokens=self.settings.vision_max_tokens,
model=model or self.settings.vision_model,
)
async def _extract_images(
self,
page: Any,
context_query: str | None,
) -> list[ImageContext]:
"""Extract and analyze images from the page.
Args:
page: Playwright page instance.
context_query: Optional context for relevance scoring.
Returns:
list[ImageContext]: Analyzed images.
"""
# Get all images from page
images_data = await page.evaluate("""
() => {
const images = [];
document.querySelectorAll('img').forEach(img => {
if (img.naturalWidth > 100 && img.naturalHeight > 100) {
images.push({
src: img.src,
alt: img.alt,
width: img.naturalWidth,
height: img.naturalHeight,
});
}
});
return images.slice(0, 5); // Limit to 5 images
}
""")
images: list[ImageContext] = []
for img in images_data:
images.append(
ImageContext(
image_url=img.get("src"),
alt_text=img.get("alt"),
description=img.get("alt") or "Image found on page",
extracted_text=None,
relevance_score=None,
)
)
return images
async def extract_to_page_content(
self,
url: str,
request: VisionExtractRequest | None = None,
) -> PageContent:
"""Convenience method to extract a single URL and return PageContent.
Args:
url: URL to extract.
request: Optional vision extraction request.
Returns:
PageContent: Extracted page content.
"""
if request is None:
request = VisionExtractRequest(urls=[url])
else:
request = VisionExtractRequest(
urls=[url],
extract_page_text=request.extract_page_text,
extract_images=request.extract_images,
context_query=request.context_query,
llm_provider=request.llm_provider,
)
response = await self.extract(request)
if not response.pages:
raise WebError(f"Failed to extract {url}")
page = response.pages[0]
retrieved_at = datetime.now(timezone.utc).isoformat()
return PageContent(
url=page.url,
canonical_url=None,
title=page.title,
text=page.extracted_text,
text_hash=hashlib.sha256(page.extracted_text.encode()).hexdigest(),
html=None,
extraction_method="vision",
fallback_chain=["http", "browse"],
published_at=None,
retrieved_at=retrieved_at,
extraction_time_ms=page.extraction_time_ms,
images=[
PageImage(url=img.image_url or "", alt=img.alt_text)
for img in page.images
]
if page.images
else None,
warnings=[],
)

View file

@ -0,0 +1 @@
"""Tests package."""

View file

@ -0,0 +1,115 @@
"""Pytest fixtures for Web module tests."""
from unittest.mock import AsyncMock
import pytest
from fastapi.testclient import TestClient
from web.api.app import create_app
from web.api.dependencies import init_concurrency_limiter
from web.config import SettingsCache, WebSettings
from web.fetch.client import FetchClient
from web.metasearch.client import SearXNGClient
from web.orchestrator import Orchestrator
from web.schemas.search import SearchResponse, SearchResult
@pytest.fixture(autouse=True)
def clear_settings_cache() -> None:
"""Clear settings cache before each test."""
SettingsCache.clear()
@pytest.fixture
def test_settings() -> WebSettings:
"""Create test settings with mocked values."""
return WebSettings(
searxng_base_url="http://localhost:55100",
llm_base_url="http://localhost:14011",
external_url="http://localhost:51100",
host="127.0.0.1",
port=51100,
api_tokens=None,
)
@pytest.fixture
def mock_search_response() -> SearchResponse:
"""Create a mock search response."""
return SearchResponse(
request_id="test-request-id",
results=[
SearchResult(
query="test query",
url="https://example.com/article",
title="Test Article Title",
snippet="This is a test snippet from the search result.",
rank=1,
site="example.com",
published_at="2024-01-15",
),
SearchResult(
query="test query",
url="https://news.example.com/story",
title="Another Test Result",
snippet="Another snippet for testing purposes.",
rank=2,
site="news.example.com",
published_at=None,
),
],
total_results=2,
execution_time_ms=150.5,
queries_processed=1,
)
@pytest.fixture
def mock_searxng_api_response() -> dict:
"""Create a mock SearXNG API response."""
return {
"results": [
{
"url": "https://example.com/article",
"title": "Test Article Title",
"content": "This is a test snippet from the search result.",
"publishedDate": "2024-01-15",
},
{
"url": "https://news.example.com/story",
"title": "Another Test Result",
"content": "Another snippet for testing purposes.",
"publishedDate": None,
},
]
}
@pytest.fixture
def client_with_mock(
test_settings: WebSettings, mock_search_response: SearchResponse
) -> SearXNGClient:
"""Create a SearXNGClient with mocked search method."""
SettingsCache.set(test_settings)
client = SearXNGClient(settings=test_settings)
client.search = AsyncMock(return_value=mock_search_response)
return client
@pytest.fixture
def app_client(test_settings: WebSettings) -> TestClient:
"""Create a FastAPI TestClient with test settings."""
SettingsCache.set(test_settings)
init_concurrency_limiter(test_settings.max_concurrent_requests)
app = create_app()
app.state.settings = test_settings
app.state.search_client = SearXNGClient(settings=test_settings)
app.state.fetch_client = FetchClient(settings=test_settings)
app.state.orchestrator = Orchestrator(settings=test_settings)
return TestClient(app)

View file

@ -0,0 +1,391 @@
"""Tests for API endpoints."""
from unittest.mock import AsyncMock, patch
import pytest
from fastapi.testclient import TestClient
from web.api.app import create_app
from web.api.dependencies import init_concurrency_limiter
from web.config import SettingsCache, WebSettings
from web.exceptions import (
ProviderError,
RateLimitError,
WebConnectionError,
WebTimeoutError,
)
from web.fetch.client import FetchClient
from web.metasearch.client import SearXNGClient
from web.orchestrator import Orchestrator
from web.schemas.search import SearchResponse, SearchResult
from web.search.paid import PaidSearchClient
@pytest.fixture
def mock_response() -> SearchResponse:
"""Create a mock search response."""
return SearchResponse(
request_id="test-id",
results=[
SearchResult(
query="test",
url="https://example.com",
title="Test",
snippet="Test snippet",
rank=1,
site="example.com",
published_at=None,
)
],
total_results=1,
execution_time_ms=100.0,
queries_processed=1,
)
@pytest.fixture
def test_client(test_settings: WebSettings) -> TestClient:
"""Create test client with mocked settings."""
SettingsCache.set(test_settings)
init_concurrency_limiter(test_settings.max_concurrent_requests)
app = create_app()
app.state.settings = test_settings
app.state.search_client = SearXNGClient(settings=test_settings)
app.state.search_client_free = SearXNGClient(settings=test_settings)
app.state.search_client_premium = PaidSearchClient(settings=test_settings)
app.state.fetch_client = FetchClient(settings=test_settings)
app.state.orchestrator_free = Orchestrator(settings=test_settings)
app.state.orchestrator_premium = Orchestrator(
settings=test_settings, llm_provider="openrouter"
)
return TestClient(app)
class TestHealthEndpoints:
"""Tests for health check endpoints."""
def test_ready_endpoint(self, test_client: TestClient) -> None:
"""Test /ready endpoint."""
response = test_client.get("/ready")
assert response.status_code == 200
assert response.json() == {"ready": True}
def test_health_endpoint(self, test_client: TestClient) -> None:
"""Test /health endpoint."""
with patch.object(
SearXNGClient, "health_check", new_callable=AsyncMock
) as mock_health:
mock_health.return_value = True
response = test_client.get("/health")
assert response.status_code == 200
data = response.json()
assert data["status"] == "healthy"
assert len(data["providers"]) == 1
assert data["providers"][0]["name"] == "searxng"
class TestSearchEndpoint:
"""Tests for /v1/search endpoint."""
def test_search_success(
self, test_client: TestClient, mock_response: SearchResponse
) -> None:
"""Test successful search."""
with patch.object(
SearXNGClient, "search", new_callable=AsyncMock
) as mock_search:
mock_search.return_value = mock_response
response = test_client.post(
"/v1/search",
json={"queries": ["test query"], "max_results": 5},
)
assert response.status_code == 200
data = response.json()
assert data["total_results"] == 1
assert len(data["results"]) == 1
assert data["results"][0]["url"] == "https://example.com"
def test_search_validation_empty_query(self, test_client: TestClient) -> None:
"""Test that empty query is rejected."""
response = test_client.post(
"/v1/search",
json={"queries": [""], "max_results": 5},
)
assert response.status_code == 422
def test_search_validation_too_many_queries(self, test_client: TestClient) -> None:
"""Test that too many queries are rejected."""
response = test_client.post(
"/v1/search",
json={"queries": [f"query{i}" for i in range(15)], "max_results": 5},
)
assert response.status_code == 422
def test_search_with_site_allowlist(
self, test_client: TestClient, mock_response: SearchResponse
) -> None:
"""Test search with site allowlist."""
with patch.object(
SearXNGClient, "search", new_callable=AsyncMock
) as mock_search:
mock_search.return_value = mock_response
response = test_client.post(
"/v1/search",
json={
"queries": ["test"],
"site_allowlist": ["example.com", "test.com"],
},
)
assert response.status_code == 200
def test_request_id_header(
self, test_client: TestClient, mock_response: SearchResponse
) -> None:
"""Test that X-Request-ID header is returned."""
with patch.object(
SearXNGClient, "search", new_callable=AsyncMock
) as mock_search:
mock_search.return_value = mock_response
response = test_client.post(
"/v1/search",
json={"queries": ["test"]},
headers={"X-Request-ID": "custom-request-id"},
)
assert response.status_code == 200
assert "X-Request-ID" in response.headers
assert response.headers["X-Request-ID"] == "custom-request-id"
class TestFetchEndpoint:
"""Tests for /v1/fetch endpoint."""
def test_fetch_rejects_private_urls(self, test_client: TestClient) -> None:
"""Test that private/localhost URLs are rejected (SSRF protection)."""
response = test_client.post(
"/v1/fetch",
json={"urls": ["http://localhost/secret"]},
)
assert response.status_code == 422
def test_fetch_rejects_non_http_schemes(self, test_client: TestClient) -> None:
"""Test that non-HTTP(S) schemes are rejected."""
response = test_client.post(
"/v1/fetch",
json={"urls": ["ftp://example.com/file"]},
)
assert response.status_code == 422
def test_fetch_validation_empty_urls(self, test_client: TestClient) -> None:
"""Test that empty URL list is rejected."""
response = test_client.post(
"/v1/fetch",
json={"urls": []},
)
assert response.status_code == 422
class TestInfoEndpoint:
"""Tests for /v1/info endpoint."""
def test_info_returns_resource(self, test_client: TestClient) -> None:
"""Test that /v1/info returns resource information."""
response = test_client.get("/v1/info")
assert response.status_code == 200
data = response.json()
assert "resource" in data
assert data["resource"]["slug"] == "web-factcheck"
def test_info_returns_functions(self, test_client: TestClient) -> None:
"""Test that /v1/info returns function definitions."""
response = test_client.get("/v1/info")
assert response.status_code == 200
data = response.json()
assert "functions" in data
assert len(data["functions"]) >= 2
slugs = [f["slug"] for f in data["functions"]]
assert "web-gather-evidence" in slugs
assert "web-search" in slugs
assert "web-fetch" in slugs
class TestSearchErrorResponses:
"""Tests for error response codes on /v1/search."""
def test_search_returns_429_on_rate_limit(self, test_client: TestClient) -> None:
"""Test that RateLimitError returns 429."""
with patch.object(
SearXNGClient, "search", new_callable=AsyncMock
) as mock_search:
mock_search.side_effect = RateLimitError(
"Rate limit exceeded", retry_after=5.0
)
response = test_client.post(
"/v1/search",
json={"queries": ["test"]},
)
assert response.status_code == 429
assert "Retry-After" in response.headers
def test_search_returns_502_on_connection_error(
self, test_client: TestClient
) -> None:
"""Test that WebConnectionError returns 502."""
with patch.object(
SearXNGClient, "search", new_callable=AsyncMock
) as mock_search:
mock_search.side_effect = WebConnectionError("searxng", "refused")
response = test_client.post(
"/v1/search",
json={"queries": ["test"]},
)
assert response.status_code == 502
data = response.json()
assert data["detail"]["error"] == "connection_error"
def test_search_returns_502_on_provider_error(
self, test_client: TestClient
) -> None:
"""Test that ProviderError returns 502."""
with patch.object(
SearXNGClient, "search", new_callable=AsyncMock
) as mock_search:
mock_search.side_effect = ProviderError("searxng", "Server error")
response = test_client.post(
"/v1/search",
json={"queries": ["test"]},
)
assert response.status_code == 502
data = response.json()
assert data["detail"]["error"] == "provider_error"
def test_search_returns_504_on_timeout(self, test_client: TestClient) -> None:
"""Test that WebTimeoutError returns 504."""
with patch.object(
SearXNGClient, "search", new_callable=AsyncMock
) as mock_search:
mock_search.side_effect = WebTimeoutError("Timeout", timeout=30.0)
response = test_client.post(
"/v1/search",
json={"queries": ["test"]},
)
assert response.status_code == 504
data = response.json()
assert data["detail"]["error"] == "timeout"
class TestAuthFlow:
"""Tests for Bearer token authentication."""
@pytest.fixture
def auth_settings(self) -> WebSettings:
"""Create settings with authentication enabled."""
return WebSettings(
searxng_base_url="http://localhost:55100",
llm_base_url="http://localhost:14011",
external_url="http://localhost:51100",
api_tokens="valid-token-123,valid-token-456",
)
@pytest.fixture
def auth_client(self, auth_settings: WebSettings) -> TestClient:
"""Create test client with auth enabled."""
SettingsCache.set(auth_settings)
init_concurrency_limiter(auth_settings.max_concurrent_requests)
app = create_app()
app.state.settings = auth_settings
app.state.search_client = SearXNGClient(settings=auth_settings)
app.state.search_client_free = SearXNGClient(settings=auth_settings)
app.state.search_client_premium = PaidSearchClient(settings=auth_settings)
app.state.fetch_client = FetchClient(settings=auth_settings)
app.state.orchestrator_free = Orchestrator(settings=auth_settings)
app.state.orchestrator_premium = Orchestrator(
settings=auth_settings, llm_provider="openrouter"
)
return TestClient(app)
def test_auth_required_returns_401_without_token(
self, auth_client: TestClient
) -> None:
"""Test that missing auth header returns 401."""
response = auth_client.post(
"/v1/search",
json={"queries": ["test"]},
)
assert response.status_code == 401
def test_auth_required_returns_401_with_invalid_token(
self, auth_client: TestClient
) -> None:
"""Test that invalid token returns 401."""
response = auth_client.post(
"/v1/search",
json={"queries": ["test"]},
headers={"Authorization": "Bearer wrong-token"},
)
assert response.status_code == 401
def test_auth_succeeds_with_valid_token(self, auth_client: TestClient) -> None:
"""Test that valid token allows access."""
with patch.object(
SearXNGClient, "search", new_callable=AsyncMock
) as mock_search:
mock_search.return_value = SearchResponse(
request_id="test",
results=[],
total_results=0,
execution_time_ms=10.0,
queries_processed=1,
)
response = auth_client.post(
"/v1/search",
json={"queries": ["test"]},
headers={"Authorization": "Bearer valid-token-123"},
)
assert response.status_code == 200
def test_auth_invalid_format_returns_401(self, auth_client: TestClient) -> None:
"""Test that non-Bearer auth format returns 401."""
response = auth_client.post(
"/v1/search",
json={"queries": ["test"]},
headers={"Authorization": "Basic dXNlcjpwYXNz"},
)
assert response.status_code == 401
def test_health_endpoints_skip_auth(self, auth_client: TestClient) -> None:
"""Test that health endpoints don't require auth."""
response = auth_client.get("/ready")
assert response.status_code == 200

View file

@ -0,0 +1,198 @@
"""Unit tests for BrowseClient with mocked Playwright."""
import asyncio
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from web.config import WebSettings
@pytest.fixture
def settings() -> WebSettings:
"""Create test settings."""
return WebSettings(
searxng_base_url="http://localhost:55100",
llm_base_url="http://localhost:14011",
external_url="http://localhost:51100",
)
class TestBrowseClientImport:
"""Tests for BrowseClient import handling."""
def test_raises_import_error_without_playwright(
self, settings: WebSettings
) -> None:
"""Test that BrowseClient raises ImportError without playwright."""
with patch("web.browse.client.HAS_PLAYWRIGHT", False):
from web.browse.client import BrowseClient
with pytest.raises(ImportError, match="playwright"):
BrowseClient(settings)
class TestBrowseClientBrowse:
"""Tests for BrowseClient.browse with mocked browser."""
def _make_mock_page(self) -> MagicMock:
"""Create a fresh mock Playwright page."""
page = AsyncMock()
page.goto = AsyncMock()
page.title = AsyncMock(return_value="Test Page Title")
page.url = "https://example.com/page"
page.content = AsyncMock(return_value="<html><body>Content</body></html>")
page.evaluate = AsyncMock(
side_effect=[
None, # remove unwanted elements
"Main content of the page with sufficient text for testing.",
"https://example.com/canonical", # canonical URL
"2024-01-15T00:00:00Z", # publication date
]
)
page.screenshot = AsyncMock(return_value=b"\x89PNG\r\n")
return page
def _make_mock_context(self, mock_page: MagicMock | None = None) -> MagicMock:
"""Create a mock browser context."""
if mock_page is None:
mock_page = self._make_mock_page()
context = AsyncMock()
context.new_page = AsyncMock(return_value=mock_page)
context.close = AsyncMock()
return context
def _make_mock_browser(self, mock_context: MagicMock | None = None) -> MagicMock:
"""Create a mock browser that creates fresh contexts each time."""
if mock_context is not None:
browser = MagicMock()
browser.is_connected = MagicMock(return_value=True)
browser.new_context = AsyncMock(return_value=mock_context)
browser.close = AsyncMock()
return browser
# Create a browser that returns fresh page mocks each time
browser = MagicMock()
browser.is_connected = MagicMock(return_value=True)
async def _new_context(**kwargs):
return self._make_mock_context()
browser.new_context = AsyncMock(side_effect=_new_context)
browser.close = AsyncMock()
return browser
@pytest.mark.asyncio
async def test_browse_single_page(self, settings: WebSettings) -> None:
"""Test browsing a single page returns content."""
with patch("web.browse.client.HAS_PLAYWRIGHT", True):
from web.browse.client import BrowseClient
from web.schemas.browse import BrowseRequest
mock_browser = self._make_mock_browser()
client = BrowseClient(settings, browser=mock_browser)
request = BrowseRequest(urls=["https://example.com/page"])
response = await client.browse(request)
assert response.total_browsed == 1
assert response.pages[0].title == "Test Page Title"
assert "Main content" in response.pages[0].text
@pytest.mark.asyncio
async def test_browse_handles_timeout(self, settings: WebSettings) -> None:
"""Test that Playwright timeout is properly wrapped."""
class MockPlaywrightTimeout(Exception):
pass
with (
patch("web.browse.client.HAS_PLAYWRIGHT", True),
patch(
"web.browse.client.PlaywrightTimeout",
MockPlaywrightTimeout,
create=True,
),
):
from web.browse.client import BrowseClient
from web.schemas.browse import BrowseRequest
mock_page = AsyncMock()
mock_page.goto = AsyncMock(side_effect=MockPlaywrightTimeout("Timeout"))
mock_context = self._make_mock_context(mock_page)
mock_browser = self._make_mock_browser(mock_context)
client = BrowseClient(settings, browser=mock_browser)
request = BrowseRequest(urls=["https://slow.example.com"])
response = await client.browse(request)
assert response.total_failed == 1
assert response.total_browsed == 0
@pytest.mark.asyncio
async def test_browse_multiple_urls(self, settings: WebSettings) -> None:
"""Test browsing multiple URLs concurrently."""
with patch("web.browse.client.HAS_PLAYWRIGHT", True):
from web.browse.client import BrowseClient
from web.schemas.browse import BrowseRequest
# Use browser that creates fresh contexts/pages
mock_browser = self._make_mock_browser()
client = BrowseClient(settings, browser=mock_browser)
request = BrowseRequest(
urls=["https://example.com/a", "https://example.com/b"],
parallel_browses=2,
)
response = await client.browse(request)
assert response.total_browsed == 2
@pytest.mark.asyncio
async def test_close_only_closes_owned_browser(self, settings: WebSettings) -> None:
"""Test that close() only closes browser if client owns it."""
with patch("web.browse.client.HAS_PLAYWRIGHT", True):
from web.browse.client import BrowseClient
mock_browser = self._make_mock_browser()
client = BrowseClient(settings, browser=mock_browser)
await client.close()
mock_browser.close.assert_not_called()
@pytest.mark.asyncio
async def test_concurrent_get_browser_single_instance(
self, settings: WebSettings
) -> None:
"""Test that concurrent _get_browser calls produce a single browser."""
with patch("web.browse.client.HAS_PLAYWRIGHT", True):
from web.browse.client import BrowseClient
mock_browser = self._make_mock_browser()
launch_count = 0
async def mock_launch(**kwargs):
nonlocal launch_count
launch_count += 1
await asyncio.sleep(0.05) # Simulate async work
return mock_browser
mock_pw = AsyncMock()
mock_pw.chromium.launch = mock_launch
mock_pw_ctx = AsyncMock()
mock_pw_ctx.start = AsyncMock(return_value=mock_pw)
client = BrowseClient(settings)
with patch("web.browse.client.async_playwright", return_value=mock_pw_ctx):
# Launch many concurrent _get_browser calls
results = await asyncio.gather(
*[client._get_browser() for _ in range(5)]
)
# Lock ensures only one browser was launched
assert launch_count == 1
# All results should be the same instance
assert all(r is results[0] for r in results)

Some files were not shown because too many files have changed in this diff Show more