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.* + +[![Python 3.10+](https://img.shields.io/badge/python-3.10+-blue.svg)](https://www.python.org/downloads/) +[![Flask](https://img.shields.io/badge/flask-3.x-green.svg)](https://flask.palletsprojects.com/) +[![Gemini](https://img.shields.io/badge/LLM-Gemini%202.5%20Flash-purple.svg)](https://ai.google.dev/) +[![Version](https://img.shields.io/badge/version-0.2.0-blue.svg)](https://github.com/tuo-user/sibilla) +[![License](https://img.shields.io/badge/license-MIT-brightgreen.svg)](LICENSE) +[![MCP](https://img.shields.io/badge/MCP-compatible-orange.svg)](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"{result['speakable_text']}" + }, + "shouldEndSession": True + } + } +``` + +> **Nota:** Il campo `speakable_text` è già pulito da markup Markdown, URL, grassetti e codice — pronto per essere letto da un motore TTS. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..bf47522 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,444 @@ +# 🏗️ Sibilla — Architettura di Sistema + +> **Autore:** Sibilla Team +> **Versione:** 0.2.0 +> **Ultimo aggiornamento:** 18 Luglio 2026 + +--- + +## Indice + +1. [Visione d'insieme](#visione-dinsieme) +2. [Stack tecnologico](#stack-tecnologico) +3. [Componenti principali](#componenti-principali) +4. [Struttura del progetto](#struttura-del-progetto) +5. [Pipeline di ricerca](#pipeline-di-ricerca) +6. [Modello dati](#modello-dati) +7. [Flusso delle richieste](#flusso-delle-richieste) +8. [Strategia di fallback](#strategia-di-fallback) +9. [Sicurezza e sanitizzazione](#sicurezza-e-sanitizzazione) +10. [Estendibilità](#estendibilità) + +--- + +## Visione d'insieme + +**Sibilla** è un motore di ricerca AI self-hosted che emula l'esperienza di Perplexity, combinando: + +- **Ricerca web multi-engine** con fallback a cascata +- **Sintesi LLM** tramite Google Gemini +- **Query expansion** per migliorare il recall +- **Fetching full-content** delle pagine per una sintesi accurata +- **Interfaccia web** reattiva e mobile-first +- **API REST** per consumo programmatico e skill vocali (Alexa) +- **Server MCP** per integrazione con agent AI esterni + +``` +┌──────────────────────────────────────────────────────────────────┐ +│ SIBILLA SYSTEM │ +│ │ +│ ┌──────────┐ ┌──────────┐ ┌──────────────────────────┐ │ +│ │ Browser │ │ curl / │ │ AI Agent (MCP Client) │ │ +│ │ (Web UI) │ │ Alexa │ │ Claude/Gemini Desktop │ │ +│ └────┬─────┘ └────┬─────┘ └───────────┬──────────────┘ │ +│ │ │ │ │ +│ │ HTTP/HTML │ REST/JSON │ stdio JSON-RPC │ +│ ▼ ▼ ▼ │ +│ ┌──────────────────────────────────────────────────────────┐ │ +│ │ Flask Application │ │ +│ │ │ │ +│ │ GET / → Web UI (index.html) │ │ +│ │ POST /api/ask → Search pipeline (Web) │ │ +│ │ GET/POST /api/v1/query → Search pipeline (API) │ │ +│ │ GET /api/health → Health check │ │ +│ │ │ │ +│ │ ┌──────────────────────────────────────────────────┐ │ │ +│ │ │ _run_search_pipeline() ← shared │ │ │ +│ │ └──────────────────────────────────────────────────┘ │ │ +│ └──────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌──────────────────────────────────────────────────────────┐ │ +│ │ MCP Server (stdlib) │ │ +│ │ │ │ +│ │ tools: web_search(query) → Degoog → SearXNG → DDG │ │ +│ │ tools: fetch_page(url) → PageFetcher │ │ +│ └──────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌───────────┐ ┌───────────────┐ ┌──────────────┐ ┌────────┐ │ +│ │ Degoog │ │ SearXNG │ │ DuckDuckGo │ │ Gemini │ │ +│ │ (primary) │ │ (secondary) │ │ (fallback) │ │ LLM │ │ +│ └───────────┘ └───────────────┘ └──────────────┘ └────────┘ │ +└──────────────────────────────────────────────────────────────────┘ +``` + +--- + +## Stack tecnologico + +| Livello | Tecnologia | Ruolo | +|---------|-----------|-------| +| **Web framework** | Flask 3.x | Application server, routing, API | +| **LLM** | Google Gemini (gemini-2.5-flash) | Sintesi, query expansion | +| **SDK LLM** | google-genai ≥ 1.0 | Client Python per Gemini | +| **Search engines** | Degoog, SearXNG, DuckDuckGo | Ricerca web multi-source | +| **HTML parsing** | BeautifulSoup 4 | Scraping e estrazione contenuti | +| **Markdown** | Python-Markdown 3.5+ | Rendering Markdown → HTML | +| **Frontend** | Vanilla HTML/CSS/JS | Interfaccia utente mobile-first | +| **Testing** | Pytest | Unit e integration test | +| **Linting** | Ruff | Code quality e formattazione | +| **Protocol** | MCP (Model Context Protocol) | Integrazione agent AI esterni | + +--- + +## Componenti principali + +### 1. `app.py` — Flask Application Factory + +Entry point dell'app web. Utilizza il pattern **Application Factory** per creare un'istanza Flask configurabile. + +```python +def create_app(testing: bool = False) -> Flask: +``` + +**Responsabilità:** +- Inizializzazione lazy dei servizi (Degoog, SearXNG, DuckDuckGo, PageFetcher, Gemini) +- Routing degli endpoint (`/`, `/api/ask`, `/api/v1/query`, `/api/health`) +- Pipeline di ricerca condivisa (`_run_search_pipeline`) +- Gestione CORS per accesso cross-origin +- Configurazione via variabili d'ambiente (`.env`) + +### 2. `search.py` — Client di Ricerca + +Implementa tre backend di ricerca con interfaccia unificata: + +| Classe | Backend | Tipo | +|--------|---------|------| +| `DegoogClient` | Degoog (self-hosted) | Primario | +| `SearXNGClient` | SearXNG (self-hosted) | Secondario | +| `DuckDuckGoClient` | DuckDuckGo HTML | Fallback | +| `PageFetcher` | HTTP + BeautifulSoup | Content extraction | + +Ogni client restituisce `SearchResponse` contenente `list[SearchResult]`. + +### 3. `ai.py` — Gemini LLM Client + +Wrapper intorno all'SDK `google-genai` con due funzionalità: + +- `synthesize()` — Sintesi di risposte basate su fonti +- `rewrite_query()` — Generazione di varianti di query per migliorare il recall + +Gestione robusta degli errori con `_clean_error()`. + +### 4. `markdown.py` — Conversione Markdown + +Due funzioni principali: + +- `render()` — Converte Markdown → HTML con sanitizzazione XSS +- `strip_markdown()` — Converte Markdown → plain text per TTS (Alexa, Google Home) + +### 5. `prompts.py` — Template dei Prompt + +Contiene i system prompt che guidano il comportamento di Gemini: + +- `SYNTHESIS_SYSTEM_PROMPT` — Prompt principale per la sintesi con citazioni +- `FALLBACK_PROMPT` — Prompt quando non ci sono risultati di ricerca +- `QUERY_REWRITE_PROMPT` — Prompt per la query expansion +- `build_synthesis_prompt()` — Assembla il prompt completo con fonti + +### 6. `mcp.py` — MCP Server + +Server Model Context Protocol su stdio che espone due tool: + +- `web_search(query)` — Ricerca web con fallback a cascata +- `fetch_page(url)` — Estrazione contenuto pagina web + +### 7. `cli.py` / `__main__.py` — Entry Point + +Avvio del server web Flask con configurazione via variabili d'ambiente. + +--- + +## Struttura del progetto + +``` +sibilla/ +├── docs/ # 📚 Documentazione +│ ├── ARCHITECTURE.md # Architettura di sistema (questo file) +│ ├── FLOW.md # Diagramma di flusso della pipeline +│ ├── API.md # Documentazione API REST +│ ├── WIKI.md # Wiki generale +│ ├── DEPLOYMENT.md # Guida al deployment +│ └── CONTRIBUTING.md # Guida alla contribuzione +│ +├── src/sibilla/ # 📦 Codice sorgente +│ ├── __init__.py # Package init +│ ├── __main__.py # python -m sibilla +│ ├── app.py # Flask app factory + pipeline +│ ├── cli.py # CLI entry point +│ ├── search.py # Degoog, SearXNG, DDG, PageFetcher +│ ├── ai.py # GeminiClient (sintesi + query rewriting) +│ ├── markdown.py # Markdown → HTML + Markdown → plain text +│ ├── prompts.py # System prompt templates +│ └── mcp.py # MCP server stdio +│ +├── templates/ # 🖼️ Template HTML (Jinja2) +│ └── index.html # Interfaccia di ricerca +│ +├── static/ # 🎨 Asset statici +│ ├── style.css # Stili CSS (mobile-first, dark theme) +│ ├── app.js # Logica frontend (fetch, render, UX) +│ └── favicon.svg # Icona del sito +│ +├── tests/ # 🧪 Test +│ ├── __init__.py +│ ├── test_app.py # Test endpoint Flask +│ ├── test_ai.py # Test GeminiClient +│ ├── test_search.py # Test client di ricerca +│ ├── test_prompts.py # Test template prompt +│ └── test_markdown.py # Test conversione Markdown +│ +├── pyproject.toml # Configurazione progetto Python +├── README.md # README principale +└── .gitignore +``` + +--- + +## Pipeline di ricerca + +La pipeline di ricerca è il cuore di Sibilla. Eseguita dalla funzione condivisa `_run_search_pipeline()`: + +``` +1. INPUT: query utente + num_sources + │ +2. QUERY EXPANSION + ┌─ Gemini genera 3 varianti della query originale + └─ Output: [query_originale, variante1, variante2, variante3] + │ +3. MULTI-ENGINE SEARCH (cascading fallback) + ┌─ Degoog: cerca con le prime 2 varianti + │ └─ Successo? → risultati aggregati + │ └─ Fallimento? → passa a SearXNG + ├─ SearXNG: cerca con le prime 2 varianti + │ └─ Successo? → risultati aggregati + │ └─ Fallimento? → passa a DuckDuckGo + └─ DuckDuckGo: cerca con la query originale + └─ Successo? → risultati aggregati + └─ Fallimento? → fallback pure LLM + │ +4. DEDUPLICAZIONE + ┌─ Elimina URL duplicati (set di seen_urls) + └─ Ordine di priorità mantenuto + │ +5. FETCH CONTENT + ┌─ PageFetcher scarica fino a num_sources pagine + ├─ Estrazione testo con BeautifulSoup + ├─ Rimozione script, nav, ads, sidebar + └─ Rate limiting: 300ms tra una fetch e l'altra + │ +6. PROMPT ASSEMBLY + ┌─ build_synthesis_prompt() assembla: + │ ├─ Contesto temporale (data di oggi) + │ ├─ Domanda dell'utente + │ └─ Fonti numerate con titolo, URL, contenuto + └─ Istruzioni per citazioni inline [1], [2], ... + │ +7. LLM SYNTHESIS + ┌─ Gemini genera risposta con: + │ ├─ Sistema: SYNTHESIS_SYSTEM_PROMPT + │ ├─ Utente: prompt assemblato + │ ├─ Temperatura: 0.4 (bassa per fedeltà alle fonti) + │ └─ Max token: 4096 + └─ Output: SynthesisResult (answer, model, tokens_used, error) + │ +8. OUTPUT: JSON con answer, sources, metadata +``` + +--- + +## Modello dati + +### SearchResult +```python +@dataclass +class SearchResult: + title: str # Titolo del risultato + url: str # URL della pagina + snippet: str # Snippet dal motore di ricerca + full_content: str # Contenuto completo (dopo fetch) + fetched: bool # Flag: contenuto scaricato? +``` + +### SearchResponse +```python +@dataclass +class SearchResponse: + query: str # Query originale + results: list[SearchResult] # Lista risultati + total_results: int # Totale risultati trovati + engine: str # Motore utilizzato + error: Optional[str] # Eventuale errore +``` + +### SynthesisResult +```python +@dataclass +class SynthesisResult: + answer: str # Testo della risposta + model: str # Modello LLM usato + tokens_used: int # Token consumati + error: Optional[str] # Eventuale errore +``` + +### Response JSON (`/api/ask`) +```json +{ + "answer": "Testo in Markdown della risposta...", + "html_answer": "

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('') + assert "onerror" not in result + assert 'src="x"' in result # legitimate attrs preserved + + def test_javascript_url_stripped(self): + result = render("[click](javascript:alert(1))") + # The link text might appear, but the javascript: href must not + assert "javascript:" not in result.lower() + assert "click" in result + + def test_regular_html_preserved_in_markdown(self): + """Sanitization should NOT strip tags that come from markdown rendering.""" + result = render("**bold** and *italic*") + assert "bold" in result + assert "italic" in result + + +class TestParagraphs: + def test_paragraph_wrapping(self): + result = render("Line 1.\n\nLine 2.") + assert result.count("

") >= 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: ![logo](https://a.com/logo.png)" + ) + 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