diff --git a/.env.example b/.env.example index 92d991e..9a78489 100644 --- a/.env.example +++ b/.env.example @@ -1,21 +1,38 @@ -# Google AI API Key — ottenila da https://aistudio.google.com/apikey -GOOGLE_API_KEY=your-api-key-here +# ═══════════════════════════════════════════════════════════════ +# Sibilla Configuration Template +# ═══════════════════════════════════════════════════════════════ +# +# Copia questo file in .env e modifica i valori: +# cp .env.example .env -# ── Search backends (cascading: Degoog → SearXNG → DuckDuckGo) ── +# Google Gemini API key (obbligatoria) +# Ottienila gratis: https://aistudio.google.com/apikey +GOOGLE_API_KEY=la-tua-api-key -# Degoog (primary) — self-hosted metasearch, default port 4444 +# Flask secret key (cambiala in produzione!) +# Generane una con: python -c "import secrets; print(secrets.token_hex(32))" +SECRET_KEY=dev-secret-key-change-me + +# ── Server ─────────────────────────────────────────────────── + +# Host e porta del server Flask +SIBILLA_HOST=127.0.0.1 +SIBILLA_PORT=5000 + +# Debug mode (0 = off, 1 = on) +# ⚠️ NON usare SIBILLA_DEBUG=1 in produzione! +SIBILLA_DEBUG=1 + +# ── Search Engines ──────────────────────────────────────────── + +# Degoog (primary) — motore di ricerca self-hosted # Avvia con: docker run -d --name degoog -p 4444:4444 ghcr.io/degoog-org/degoog:latest DEGOOG_URL=http://localhost:4444 -# SearXNG (fallback 1) — self-hosted metasearch, default port 8080 +# SearXNG (secondary, fallback) — motore di ricerca self-hosted # Avvia con: docker run -d -p 8080:8080 searxng/searxng SEARXNG_URL=http://localhost:8080 -# DuckDuckGo (fallback 2) — nessun server richiesto +# DuckDuckGo fallback (1 = on, 0 = off) +# Funziona senza server — scraping HTML, nessuna API key necessaria SIBILLA_DDG_FALLBACK=1 - -# ── Flask ── -SECRET_KEY=change-me-to-a-random-string -SIBILLA_HOST=127.0.0.1 -SIBILLA_PORT=5000 -SIBILLA_DEBUG=1 diff --git a/README.md b/README.md index 9a1fabf..82245d9 100644 --- a/README.md +++ b/README.md @@ -1,101 +1,241 @@ -# 🔮 Sibilla +# 🔮 Sibilla — L'Oracolo AI -> *L'oracolo AI che cerca nel web e risponde alle tue domande.* -> Self-hosted Perplexity-like search engine. +> *Self-hosted Perplexity-like AI search engine.* +> *Chiedi, cerca, sintetizza — con fonti verificabili.* + +[](https://www.python.org/downloads/) +[](https://flask.palletsprojects.com/) +[](https://ai.google.dev/) +[](https://github.com/tuo-user/sibilla) +[](LICENSE) +[](https://modelcontextprotocol.io/) + +--- **Sibilla** è un motore di ricerca AI self-hosted che combina: -- 🧠 **Google Gemini** — LLM per sintesi e query expansion -- 🔍 **Degoog** — metasearch engine self-hosted (primary) con MCP nativo -- 🔄 **SearXNG + DuckDuckGo** — fallback a cascata se Degoog non risponde +- 🧠 **Google Gemini** — LLM per sintesi con citazioni inline [1], [2], ... +- 🔍 **Degoog** — metasearch engine self-hosted (primary) con API nativa +- 🔄 **SearXNG + DuckDuckGo** — fallback a cascata se il primary non risponde +- 🌐 **Interfaccia web** — UI responsive, dark theme, mobile-first +- 📡 **API REST** — endpoint per consumo programmatico e skill vocali (Alexa, Google Home) - 🔧 **MCP (Model Context Protocol)** — server stdio per agent AI esterni -- 🖥️ **Flask web app** — interfaccia pulita e reattiva +- 🗣️ **TTS-ready** — campo `speakable_text` ottimizzato per sintesi vocale ``` -Tu chiedi → Query expansion → Degoog search → Fetch pagine → Gemini sintesi → Risposta con fonti +Tu chiedi → Query expansion → Ricerca multi-engine → Fetch pagine → Gemini sintesi → Risposta con fonti ``` -## Architettura +--- -``` -sibilla/ -├── src/sibilla/ -│ ├── app.py # Flask app factory + pipeline /api/ask -│ ├── cli.py # Entry point server web -│ ├── search.py # DegoogClient, SearXNGClient, DuckDuckGoClient, PageFetcher -│ ├── ai.py # GeminiClient (sintesi + query rewriting) -│ ├── mcp.py # MCP server su stdio (per agent AI esterni) -│ └── prompts.py # System prompt stile Perplexity -├── templates/ -│ └── index.html # Interfaccia di ricerca -├── static/ -│ ├── style.css # Stili dark theme -│ └── app.js # Logica frontend -└── tests/ -``` +## 📚 Documentazione -## Pipeline di ricerca +| Documento | Descrizione | +|-----------|-------------| +| [Architettura](docs/ARCHITECTURE.md) | 🏗️ Visione d'insieme, stack tecnologico, componenti, modello dati | +| [Diagramma di Flusso](docs/FLOW.md) | 🔄 Pipeline di ricerca, flusso richieste, macchina a stati (Mermaid) | +| [API REST](docs/API.md) | 📡 Documentazione completa degli endpoint `/api/ask` e `/api/v1/query` | +| [Wiki](docs/WIKI.md) | 📚 FAQ, glossario, confronto con Perplexity, roadmap | +| [Deployment](docs/DEPLOYMENT.md) | 🚀 Guida al deployment in produzione (Gunicorn, Nginx, Docker, systemd) | +| [Contributing](docs/CONTRIBUTING.md) | 🤝 Guida per contribuire, convenzioni, test, struttura branch | -``` -POST /api/ask {query: "..."} - │ - ├─ 1. Gemini riscrive la query in 3 varianti (query expansion) - ├─ 2. Multi-query search: Degoog → SearXNG → DuckDuckGo (cascading) - ├─ 3. PageFetcher scarica il contenuto completo delle top pagine - ├─ 4. Prompt assembly con fonti numerate - ├─ 5. Gemini sintesi con citazioni [1], [2], ... - └─ 6. JSON {answer, sources[], model, search_engine, tokens_used} -``` +--- -## Setup +## 🚀 Quick Start ```bash +# 1. Clona e installa +git clone https://github.com/tuo-user/sibilla.git cd sibilla python3 -m venv .venv source .venv/bin/activate pip install -e ".[dev]" + +# 2. Avvia Degoog (motore di ricerca primary, opzionale ma consigliato) +docker run -d --name degoog -p 4444:4444 ghcr.io/degoog-org/degoog:latest + +# 3. Configura +# Crea un file .env con la tua Google API Key: +# GOOGLE_API_KEY=la-tua-api-key + +# 4. Avvia +sibilla +# → http://127.0.0.1:5000 ``` -### Prerequisiti +### Prerequisiti minimi -1. **Google API Key** — [https://aistudio.google.com/apikey](https://aistudio.google.com/apikey) -2. **Degoog** (consigliato) — motore di ricerca primary: - ```bash - docker run -d --name degoog -p 4444:4444 ghcr.io/degoog-org/degoog:latest - ``` -3. **SearXNG** (opzionale, fallback) — se vuoi un secondo motore di backup: - ```bash - docker run -d -p 8080:8080 searxng/searxng - ``` +- **Python 3.10+** +- **Google API Key** — [ottienila gratis](https://aistudio.google.com/apikey) +- Almeno un motore di ricerca (Degoog, SearXNG, o DuckDuckGo automatico) + +--- + +## 🔌 API + +### Ricerca via API (per skill Alexa, bot, app) + +```bash +# GET — il più semplice +curl "http://localhost:5000/api/v1/query?q=ultimo+film+sull%27Odissea" + +# POST — per query lunghe +curl -X POST http://localhost:5000/api/v1/query \ + -H "Content-Type: application/json" \ + -d '{"query": "quali sono i migliori ristoranti a Roma?"}' + +# Estrai solo il testo per TTS +curl -s "http://localhost:5000/api/v1/query?q=raggi+cosmici" \ + | python -c "import sys,json; print(json.load(sys.stdin)['speakable_text'])" +``` + +**Risposta:** +```json +{ + "query": "ultimo film sull'Odissea", + "answer": "L'ultimo adattamento è **The Return** (2024)...", + "speakable_text": "L'ultimo adattamento è The Return del 2024...", + "sources": [{"index": 1, "title": "...", "url": "..."}], + "model": "gemini-2.5-flash", + "search_engine": "degoog", + "tokens_used": 1847 +} +``` + +> **Documentazione completa:** [`docs/API.md`](docs/API.md) + +--- + +## 🏗️ Architettura + +``` +sibilla/ +├── docs/ # 📚 Documentazione +│ ├── ARCHITECTURE.md # Architettura di sistema +│ ├── FLOW.md # Diagramma di flusso (Mermaid) +│ ├── API.md # Documentazione API REST +│ ├── WIKI.md # Wiki + FAQ +│ ├── DEPLOYMENT.md # Guida al deployment +│ └── CONTRIBUTING.md # Guida alla contribuzione +│ +├── src/sibilla/ # 📦 Codice sorgente +│ ├── app.py # Flask app factory + pipeline condivisa +│ ├── search.py # Degoog, SearXNG, DuckDuckGo, PageFetcher +│ ├── ai.py # GeminiClient (sintesi + query rewriting) +│ ├── markdown.py # Markdown → HTML + strip per TTS +│ ├── prompts.py # System prompt stile Perplexity +│ ├── mcp.py # MCP server stdio (per agent AI) +│ └── cli.py # Entry point server +│ +├── templates/index.html # Interfaccia web +├── static/ # CSS, JS, favicon +├── tests/ # Test (pytest, 109 test) +└── pyproject.toml # Configurazione progetto +``` + +--- + +## 🔄 Pipeline di Ricerca + +``` +POST /api/ask {query: "..."} + │ + ├─ 1. 🔮 Gemini riscrive la query in 3 varianti (query expansion) + ├─ 2. 🔍 Multi-engine search: Degoog → SearXNG → DuckDuckGo (cascading) + ├─ 3. 📥 PageFetcher scarica il contenuto completo delle top pagine + ├─ 4. 📝 Prompt assembly con fonti numerate + data odierna + ├─ 5. 🧠 Gemini sintesi con citazioni inline [1], [2], ... + └─ 6. ✅ JSON {answer, html_answer, speakable_text, sources[], metadata} +``` + +--- + +## 🔧 MCP Server + +Sibilla espone le sue capacità di ricerca come tool MCP (Model Context Protocol) su stdio: + +```bash +sibilla-mcp +``` + +**Tool disponibili:** +- `web_search(query, num_results)` — ricerca web con fallback Degoog → SearXNG → DuckDuckGo +- `fetch_page(url)` — estrai contenuto testuale da una pagina web + +Compatibile con client MCP come Claude Desktop, Gemini Desktop e altri agent AI. + +--- + +## 🧪 Sviluppo + +```bash +# Installa dipendenze dev +pip install -e ".[dev]" + +# Lint +ruff check src tests + +# Test +pytest tests/ -v + +# Coverage +pip install pytest-cov +pytest tests/ --cov=src/sibilla --cov-report=html +``` + +--- + +## 🚀 Deployment + +Per il deployment in produzione: + +```bash +# Con Gunicorn +pip install gunicorn +gunicorn -w 4 -b 0.0.0.0:5000 "sibilla.app:create_app()" + +# Con Docker Compose (Sibilla + Degoog + SearXNG) +docker compose up -d +``` + +> **Guida completa:** [`docs/DEPLOYMENT.md`](docs/DEPLOYMENT.md) + +--- + +## 📋 Configurazione Crea un file `.env`: ```env GOOGLE_API_KEY=la-tua-api-key +SECRET_KEY=una-chiave-segreta-lunga-e-casuale +SIBILLA_HOST=127.0.0.1 +SIBILLA_PORT=5000 +SIBILLA_DEBUG=1 DEGOOG_URL=http://localhost:4444 +SEARXNG_URL=http://localhost:8080 +SIBILLA_DDG_FALLBACK=1 ``` -Senza Degoog, Sibilla userà automaticamente SearXNG e DuckDuckGo come fallback. +--- -## Uso +## 🤝 Contribuire -```bash -# Avvia la web app -sibilla -# → http://127.0.0.1:5000 +Contributi benvenuti! Vedi [`docs/CONTRIBUTING.md`](docs/CONTRIBUTING.md) per: -# Avvia il server MCP (per agent AI esterni) -sibilla-mcp -``` +- Setup dell'ambiente di sviluppo +- Convenzioni di codice e commit +- Come aggiungere search engine, endpoint, o tool MCP +- Struttura dei test -## Sviluppo +--- -```bash -pip install -e ".[dev,mcp]" -ruff check src tests -pytest -``` +## 📄 Licenza -## Licenza +MIT — vedi il file [LICENSE](LICENSE) per i dettagli. -MIT +--- + +
+ Fatto con 🔮 da Sibilla Team · Architettura · API · Deployment +
diff --git a/docs/API.md b/docs/API.md new file mode 100644 index 0000000..2e4f93c --- /dev/null +++ b/docs/API.md @@ -0,0 +1,315 @@ +# 📡 Sibilla — Documentazione API REST + +> **Versione:** 0.2.0 +> **Ultimo aggiornamento:** 18 Luglio 2026 + +--- + +## Indice + +1. [Panoramica](#panoramica) +2. [Health Check](#health-check) +3. [API Ask (Web)](#api-ask) +4. [API v1 Query (Programmatica)](#api-v1-query) +5. [Codici di errore](#codici-di-errore) +6. [Esempi curl](#esempi-curl) +7. [Integrazione Alexa Skill](#integrazione-alexa-skill) + +--- + +## Panoramica + +Tutti gli endpoint sono accessibili su `http://localhost:5000` (o l'host/porta configurati). + +| Metodo | Endpoint | Descrizione | Formato | +|--------|----------|-------------|---------| +| `GET` | `/` | Interfaccia web HTML | `text/html` | +| `GET` | `/api/health` | Health check | `application/json` | +| `POST` | `/api/ask` | Ricerca + sintesi (web UI) | `application/json` | +| `GET` | `/api/v1/query` | Ricerca + sintesi (API, query string) | `application/json` | +| `POST` | `/api/v1/query` | Ricerca + sintesi (API, JSON body) | `application/json` | + +**CORS:** Tutti gli endpoint includono header CORS permissivi (`Access-Control-Allow-Origin: *`). + +--- + +## Health Check + +### `GET /api/health` + +Verifica lo stato del server e la configurazione. + +**Request:** +```bash +curl http://localhost:5000/api/health +``` + +**Response `200 OK`:** +```json +{ + "status": "ok", + "version": "0.2.0", + "gemini_configured": true, + "degoog_url": "http://localhost:4444", + "searxng_url": "http://localhost:8080" +} +``` + +| Campo | Tipo | Descrizione | +|-------|------|-------------| +| `status` | `string` | Sempre `"ok"` se il server risponde | +| `version` | `string` | Versione di Sibilla | +| `gemini_configured` | `boolean` | `true` se `GOOGLE_API_KEY` è configurata | +| `degoog_url` | `string` | URL dell'istanza Degoog | +| `searxng_url` | `string` | URL dell'istanza SearXNG | + +--- + +## API Ask + +### `POST /api/ask` + +Endpoint principale per la ricerca con sintesi AI. Utilizzato dall'interfaccia web. + +**Request:** +```json +{ + "query": "Qual è stato l'ultimo film sull'Odissea?", + "num_sources": 5 +} +``` + +| Parametro | Tipo | Default | Range | Descrizione | +|-----------|------|---------|-------|-------------| +| `query` | `string` | *richiesto* | — | La domanda da cercare | +| `num_sources` | `integer` | `5` | `1–10` | Numero massimo di fonti da usare | + +**Response `200 OK`:** +```json +{ + "answer": "L'ultimo adattamento cinematografico dell'**Odissea** è *The Return* (2024)...", + "html_answer": "L'ultimo adattamento cinematografico dell'Odissea è The Return (2024)...
", + "speakable_text": "L'ultimo adattamento cinematografico dell'Odissea è The Return del 2024...", + "sources": [ + { + "index": 1, + "title": "The Return - Wikipedia", + "url": "https://it.wikipedia.org/wiki/The_Return_(film_2024)", + "snippet": "The Return è un film del 2024 diretto da Uberto Pasolini..." + } + ], + "model": "gemini-2.5-flash", + "search_engine": "degoog", + "tokens_used": 1847, + "error": null +} +``` + +| Campo | Tipo | Descrizione | +|-------|------|-------------| +| `answer` | `string` | Risposta in formato **Markdown** | +| `html_answer` | `string` | Risposta in **HTML** sicuro (sanitizzato) | +| `speakable_text` | `string` | Risposta in **plain text**, ottimizzata per TTS | +| `sources` | `array` | Lista delle fonti utilizzate | +| `sources[].index` | `integer` | Numero della fonte (1-based) | +| `sources[].title` | `string` | Titolo della pagina | +| `sources[].url` | `string` | URL della fonte | +| `sources[].snippet` | `string` | Estratto della pagina (max 200 caratteri) | +| `model` | `string` | Modello LLM utilizzato | +| `search_engine` | `string` | Motore di ricerca usato (`degoog`, `searxng`, `duckduckgo`, `none`) | +| `tokens_used` | `integer` | Token totali consumati | +| `error` | `string` or `null` | Eventuale messaggio di errore | + +**Response `400 Bad Request`:** +```json +{ + "error": "La query non può essere vuota." +} +``` + +**Response `502 Bad Gateway`:** +```json +{ + "error": "Ricerca web fallita: Connection refused", + "answer": "", + "sources": [] +} +``` + +--- + +## API v1 Query + +### `GET /api/v1/query` + +Endpoint semplificato per consumo programmatico. Accetta la query via query string. + +**Request:** +```bash +curl "http://localhost:5000/api/v1/query?q=ultimo+film+sull%27Odissea" +``` + +### `POST /api/v1/query` + +Stesso endpoint, con query nel body JSON. + +**Request:** +```json +{ + "query": "Qual è stato l'ultimo film sull'Odissea?" +} +``` + +| Parametro | Tipo | Default | Descrizione | +|-----------|------|---------|-------------| +| `q` (GET) | `string` | *richiesto* | La query di ricerca (URL-encoded) | +| `query` (POST) | `string` | *richiesto* | La query di ricerca | + +**Response `200 OK`:** +```json +{ + "query": "Qual è stato l'ultimo film sull'Odissea?", + "answer": "L'ultimo adattamento è **The Return** (2024), diretto da Uberto Pasolini...", + "speakable_text": "L'ultimo adattamento è The Return del 2024, diretto da Uberto Pasolini...", + "sources": [ + { + "index": 1, + "title": "The Return - Wikipedia", + "url": "https://it.wikipedia.org/wiki/The_Return_(film_2024)" + } + ], + "model": "gemini-2.5-flash", + "search_engine": "degoog", + "tokens_used": 1847 +} +``` + +| Campo | Tipo | Descrizione | +|-------|------|-------------| +| `query` | `string` | Echo della query originale | +| `answer` | `string` | Risposta in formato **Markdown** | +| `speakable_text` | `string` | **Plain text** ottimizzato per sintesi vocale (TTS) | +| `sources` | `array` | Fonti semplificate (solo `index`, `title`, `url`) | +| `model` | `string` | Modello LLM utilizzato | +| `search_engine` | `string` | Motore di ricerca usato | +| `tokens_used` | `integer` | Token totali consumati | + +> **Nota:** Il formato è più compatto di `/api/ask` — pensato per API consumer come skill Alexa, Lambda function, o mobile app. + +**Response `400 Bad Request`:** +```json +{ + "error": "Missing query parameter 'q'." +} +``` + +**Response `502 Bad Gateway`:** +```json +{ + "error": "Ricerca web fallita: Connection refused", + "answer": "", + "speakable_text": "" +} +``` + +--- + +## Codici di errore + +| HTTP Status | Significato | Quando | +|-------------|-------------|--------| +| `200` | OK | Richiesta completata con successo | +| `400` | Bad Request | Query mancante o vuota | +| `502` | Bad Gateway | Tutti i motori di ricerca sono down | + +**Errori interni gestiti:** +- Se tutti i motori falliscono ma Gemini è disponibile → fallback a risposta solo LLM (200 OK) +- Se Gemini non è configurato → `answer` vuoto con `error` nel JSON +- Se Gemini restituisce errore → messaggio di errore inline in `answer` + +--- + +## Esempi curl + +### Health check +```bash +curl -s http://localhost:5000/api/health | python -m json.tool +``` + +### Ricerca via API v1 (GET) +```bash +curl -s "http://localhost:5000/api/v1/query?q=cosa+sono+i+raggi+cosmici" | python -m json.tool +``` + +### Ricerca via API v1 (POST) +```bash +curl -s -X POST http://localhost:5000/api/v1/query \ + -H "Content-Type: application/json" \ + -d '{"query": "cosa sono i raggi cosmici"}' | python -m json.tool +``` + +### Estrai solo la risposta speakable (per TTS) +```bash +curl -s "http://localhost:5000/api/v1/query?q=raggi+cosmici" | python -c "import sys,json; print(json.load(sys.stdin)['speakable_text'])" +``` + +### Ricerca via web UI (POST /api/ask) +```bash +curl -s -X POST http://localhost:5000/api/ask \ + -H "Content-Type: application/json" \ + -d '{"query": "raggi cosmici", "num_sources": 3}' | python -m json.tool +``` + +### Verifica errori +```bash +# Query vuota → 400 +curl -i "http://localhost:5000/api/v1/query?q=" + +# Motori down → 502 (se tutti falliscono) +curl -i "http://localhost:5000/api/v1/query?q=test" +``` + +--- + +## Integrazione Alexa Skill + +Ecco un esempio di come usare l'endpoint `/api/v1/query` da una Lambda Python per una skill Alexa: + +```python +import json +import urllib.request + +SIBILLA_URL = "https://tuo-server.com/api/v1/query" + +def call_sibilla(query: str) -> dict: + """Chiama Sibilla e restituisce la risposta parlata.""" + params = urllib.parse.urlencode({"q": query}) + url = f"{SIBILLA_URL}?{params}" + + with urllib.request.urlopen(url, timeout=30) as resp: + data = json.loads(resp.read().decode()) + + return { + "answer": data["answer"], + "speakable_text": data["speakable_text"], + "sources": data["sources"], + } + +# In un handler Alexa: +def lambda_handler(event, context): + query = event["request"]["intent"]["slots"]["query"]["value"] + result = call_sibilla(query) + + return { + "version": "1.0", + "response": { + "outputSpeech": { + "type": "SSML", + "ssml": f"Versione HTML della risposta...
", + "speakable_text": "Testo pulito per TTS...", + "sources": [ + { + "index": 1, + "title": "Titolo della fonte", + "url": "https://example.com", + "snippet": "Estratto della pagina..." + } + ], + "model": "gemini-2.5-flash", + "search_engine": "degoog", + "tokens_used": 1234, + "error": null +} +``` + +### Response JSON (`/api/v1/query`) — formato semplificato per API +```json +{ + "query": "domanda originale", + "answer": "Risposta in Markdown...", + "speakable_text": "Risposta pulita per TTS...", + "sources": [ + {"index": 1, "title": "...", "url": "..."} + ], + "model": "gemini-2.5-flash", + "search_engine": "degoog", + "tokens_used": 1234 +} +``` + +--- + +## Flusso delle richieste + +### Web UI (`GET /`) +``` +Browser → GET / → Flask → render_template("index.html") + └─ Jinja2 template HTML + └─ Static assets (CSS, JS, favicon) + +Utente scrive query → POST /api/ask {query: "..."} + → app.js fetch → render risposta +``` + +### API REST (`GET/POST /api/v1/query`) +``` +curl / Lambda / Alexa Skill + │ + ├─ GET /api/v1/query?q=domanda + └─ POST /api/v1/query {"query": "domanda"} + │ + ▼ + _run_search_pipeline(query, num_sources=5) + │ + ▼ + JSON response con speakable_text +``` + +### MCP Server (`sibilla-mcp`) +``` +AI Agent (Claude/Gemini Desktop) + │ + ├─ stdio JSON-RPC + │ + ▼ +MCPServer.run() + ├─ tools/list → [{web_search}, {fetch_page}] + └─ tools/call → esecuzione tool → risultato text +``` + +--- + +## Strategia di fallback + +Sibilla implementa una **strategia di fallback a cascata** su tre livelli: + +``` +Livello 1: Degoog (primary) + │ + ├─ Disponibile? → Usa Degoog + └─ Non disponibile? ↓ + +Livello 2: SearXNG (secondary) + │ + ├─ Disponibile? → Usa SearXNG + └─ Non disponibile? ↓ + +Livello 3: DuckDuckGo (fallback) + │ + ├─ Disponibile? → Usa DuckDuckGo (HTML scraping) + └─ Non disponibile? ↓ + +Livello 4: Pure LLM (degraded mode) + └─ Gemini risponde senza fonti, spiegando i limiti +``` + +**Vantaggi:** +- Nessun single point of failure +- DuckDuckGo non richiede server esterni (scraping HTML) +- Pure LLM garantisce sempre una risposta (in degraded mode) + +--- + +## Sicurezza e sanitizzazione + +### Sanitizzazione HTML +Il Markdown renderizzato viene sanitizzato per prevenire XSS: + +- **Allowlist di tag HTML:** solo tag sicuri (`h1-h6`, `p`, `strong`, `a`, ecc.) +- **Allowlist di attributi:** `href`, `title`, `alt`, `src`, `class`, `id` +- **Blocco `javascript:` URL** su `href` e `src` +- **Nessun event handler** (`onclick`, `onerror`, ecc.) +- **Strip di `") + assert "" not in result + + def test_event_handlers_stripped(self): + """onerror/onclick handlers must be stripped even on allowed tags.""" + result = render('") >= 2 + + def test_single_line_break(self): + result = render("Line 1.\nLine 2.") + assert "Line 1." in result + assert "Line 2." in result + + def test_multiple_paragraphs(self): + text = "Primo paragrafo.\n\nSecondo paragrafo.\n\nTerzo." + result = render(text) + assert "Primo paragrafo" in result + assert "Secondo paragrafo" in result + assert "Terzo" in result + + +# ── strip_markdown (TTS-friendly plain text) ────────────────────── + +class TestStripMarkdown: + def test_empty_returns_empty(self): + assert strip_markdown("") == "" + assert strip_markdown(None) == "" + + def test_plain_text_passes_through(self): + text = "Ciao mondo, oggi è una bella giornata." + assert strip_markdown(text) == text + + def test_bold_double_asterisk(self): + result = strip_markdown("Questo è **importante** da sapere.") + assert result == "Questo è importante da sapere." + + def test_bold_double_underscore(self): + result = strip_markdown("Questo è __importante__ da sapere.") + assert result == "Questo è importante da sapere." + + def test_italic_single_asterisk(self): + result = strip_markdown("Una parola *italica* nel testo.") + assert result == "Una parola italica nel testo." + + def test_italic_single_underscore(self): + result = strip_markdown("Una parola _italica_ nel testo.") + assert result == "Una parola italica nel testo." + + def test_links_stripped(self): + result = strip_markdown( + "Visita [il nostro sito](https://example.com) per info." + ) + assert result == "Visita il nostro sito per info." + + def test_images_stripped(self): + result = strip_markdown( + "Ecco una foto: " + ) + assert result == "Ecco una foto: logo" + + def test_headings_stripped(self): + result = strip_markdown("# Titolo\n\nTesto del paragrafo.") + assert result == "Titolo\n\nTesto del paragrafo." + + def test_code_blocks_stripped(self): + result = strip_markdown("```python\nprint('hello')\n```\n\nFine.") + assert "```" not in result + assert "print('hello')" in result + assert "Fine." in result + + def test_inline_code_stripped(self): + result = strip_markdown("Usa il comando `pip install` per procedere.") + assert result == "Usa il comando pip install per procedere." + + def test_blockquotes_stripped(self): + result = strip_markdown("> Questa è una citazione.\n> Seconda riga.") + assert "Questa è una citazione." in result + assert ">" not in result + + def test_unordered_list_stripped(self): + result = strip_markdown("- Primo elemento\n- Secondo elemento\n- Terzo") + assert "Primo elemento" in result + assert "Secondo elemento" in result + assert "Terzo" in result + assert "-" not in result.split("Primo")[0] + + def test_ordered_list_stripped(self): + result = strip_markdown("1. Primo\n2. Secondo\n3. Terzo") + assert "Primo" in result + assert "Secondo" in result + assert "Terzo" in result + + def test_html_tags_stripped(self): + result = strip_markdown("Testo con HTML inline.") + assert result == "Testo con HTML inline." + + def test_horizontal_rules_stripped(self): + result = strip_markdown("Prima sezione\n---\nSeconda sezione") + assert "---" not in result + assert "Prima sezione" in result + assert "Seconda sezione" in result + + def test_multiple_blank_lines_collapsed(self): + result = strip_markdown("Riga 1\n\n\n\nRiga 2") + assert result == "Riga 1\n\nRiga 2" + + def test_complex_mixed_markdown(self): + """Realistic AI output with multiple markdown constructs.""" + text = """## Risposta + +**L'Odissea** è un poema epico attribuito a [Omero](https://it.wikipedia.org/wiki/Omero). + +Ecco alcuni punti chiave: +- Composto intorno all'*VIII secolo a.C.* +- Narra il viaggio di **Ulisse** +- È diviso in `24 libri` + +Per approfondire, vedi [Wikipedia](https://it.wikipedia.org).""" + result = strip_markdown(text) + # No markdown artifacts + assert "**" not in result + assert "*" not in result.split("VIII")[0] # no italic markers + assert "[" not in result + assert "](" not in result + assert "`" not in result + assert "##" not in result + assert "-" not in result.split("Composto")[0] + # Content preserved + assert "Odissea" in result + assert "Omero" in result + assert "VIII secolo a.C." in result + assert "Ulisse" in result + assert "24 libri" in result + assert "Wikipedia" in result