feat: v0.2.0 — API REST, TTS support, documentazione completa e UI mobile-first

✨ Nuove funzionalità:
- Nuovo endpoint API REST /api/v1/query (GET+POST) per consumo programmatico
- Campo speakable_text ottimizzato per sintesi vocale (Alexa, Google Home)
- Funzione strip_markdown() per conversione Markdown → plain text TTS
- Supporto CORS per accesso cross-origin
- UI responsive mobile-first con micro-interazioni e stati
- Nuovo pulsante 'Nuova ricerca' e metadata bar

📚 Documentazione completa:
- docs/ARCHITECTURE.md — architettura di sistema, stack, componenti
- docs/FLOW.md — diagrammi Mermaid della pipeline e flussi
- docs/API.md — documentazione endpoint REST con esempi curl
- docs/WIKI.md — FAQ, glossario, roadmap
- docs/DEPLOYMENT.md — guida deployment (Gunicorn, Nginx, Docker, systemd)
- docs/CONTRIBUTING.md — guida alla contribuzione

🔧 Migliorie tecniche:
- Pipeline di ricerca condivisa in _run_search_pipeline()
- Gestione errori Gemini migliorata con _clean_error()
- Sanitizzazione HTML con allowlist per prevenzione XSS
- .env.example completo con commenti

🧪 Test: 109/109 passati
This commit is contained in:
Sibilla committed 2026-07-18 10:05:46 +00:00
1 parent f3fe13c60e
commit 76bc667adf
20 files changed
+3385 -245

No files matched your search

+29 -12
View File
@@ -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
+204 -64
View File
@@ -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
---
<p align="center">
<sub>Fatto con 🔮 da Sibilla Team · <a href="docs/ARCHITECTURE.md">Architettura</a> · <a href="docs/API.md">API</a> · <a href="docs/DEPLOYMENT.md">Deployment</a></sub>
</p>
+315
View File
@@ -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": "<p>L'ultimo adattamento cinematografico dell'<strong>Odissea</strong> è <em>The Return</em> (2024)...</p>",
"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"<speak>{result['speakable_text']}</speak>"
},
"shouldEndSession": True
}
}
```
> **Nota:** Il campo `speakable_text` è già pulito da markup Markdown, URL, grassetti e codice — pronto per essere letto da un motore TTS.
+444
View File
@@ -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": "<p>Versione HTML della risposta...</p>",
"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 `<script>`, `<iframe>`, `<object>`**
### CORS
- `Access-Control-Allow-Origin: *` — permissivo per API pubbliche
- `Access-Control-Allow-Methods: GET, POST, OPTIONS`
- `Access-Control-Allow-Headers: Content-Type, Authorization`
### Rate Limiting
- PageFetcher: 300ms delay tra richieste consecutive
- Non implementato rate limiting a livello API (da aggiungere in futuro)
---
## Estendibilità
### Aggiungere un nuovo search engine
1. Creare una classe con interfaccia `search(query, num_results) → SearchResponse`
2. Aggiungere il client in `_run_search_pipeline()` nella catena di fallback
3. Aggiungere test in `tests/test_search.py`
### Aggiungere un nuovo endpoint API
1. Aggiungere una route in `app.py`
2. Riutilizzare `_run_search_pipeline()` per la logica condivisa
3. Aggiungere test in `tests/test_app.py`
### Aggiungere un nuovo tool MCP
1. Aggiungere il tool in `MCPServer._build_tools()`
2. Implementare il metodo handler
3. Registrare in `_handle_request()` sotto `tools/call`
### Aggiungere un nuovo modello LLM
1. Creare un client con interfaccia `synthesize(system_prompt, user_prompt, ...) → SynthesisResult`
2. Iniettare il client in `_run_search_pipeline()`
3. Aggiungere test in `tests/test_ai.py`
+217
View File
@@ -0,0 +1,217 @@
# 🤝 Sibilla — Guida alla Contribuzione
> **Versione:** 0.2.0
> **Ultimo aggiornamento:** 18 Luglio 2026
---
Grazie per il tuo interesse a contribuire a Sibilla! 🎉
## Codice di Condotta
Sii rispettoso, costruttivo e inclusivo. Tratta gli altri come vorresti essere trattato.
---
## Come Contribuire
### 1. Segnala un Bug
Apri una **issue** su GitHub con:
- **Titolo descrittivo** (es. "Errore 502 quando Degoog è lento")
- **Passi per riprodurre il bug**
- **Comportamento atteso** vs **comportamento osservato**
- **Log di errore** (se disponibili)
- **Ambiente:** versione Python, OS, versione Sibilla
### 2. Proponi una Feature
Apri una issue con il tag `enhancement` descrivendo:
- **Problema che risolve**
- **Soluzione proposta**
- **Alternative considerate**
- **Impatto sul codice esistente**
### 3. Invia una Pull Request
#### Setup sviluppo
```bash
# 1. Forka il repository
# 2. Clona il tuo fork
git clone https://github.com/tuo-user/sibilla.git
cd sibilla
# 3. Aggiungi l'upstream
git remote add upstream https://github.com/original/sibilla.git
# 4. Crea ambiente virtuale
python3 -m venv .venv
source .venv/bin/activate
# 5. Installa dipendenze di sviluppo
pip install -e ".[dev]"
# 6. Configura .env
cp .env.example .env
nano .env # Inserisci GOOGLE_API_KEY
```
#### Flusso di lavoro
```bash
# 1. Sincronizza con upstream
git fetch upstream
git checkout master
git merge upstream/master
# 2. Crea un branch
git checkout -b feature/nome-feature
# oppure
git checkout -b fix/nome-bug
# 3. Sviluppa
# ... scrivi codice ...
# 4. Testa
pytest tests/ -v
# 5. Linta
ruff check src tests
# 6. Committa con messaggi chiari
git add .
git commit -m "feat: aggiungi caching risposte con Redis"
# 7. Pusha
git push origin feature/nome-feature
# 8. Apri Pull Request su GitHub
```
---
## Convenzioni del Codice
### Stile
- **Python 3.10+** con type hints
- **Ruff** per linting e formattazione (`ruff check src tests`)
- **Line length:** 100 caratteri
- **Docstring:** stile Google (`"""Descrizione breve.` con `Args:`, `Returns:`, `Raises:`)
### Commit Messages
Usiamo [Conventional Commits](https://www.conventionalcommits.org/):
```
feat: aggiungi caching risposte con Redis
fix: correggi race condition in PageFetcher
docs: aggiorna README con esempi API v1
refactor: estrai pipeline condivisa in helper
test: aggiungi test per strip_markdown
chore: aggiorna dipendenze
```
### Struttura dei Branch
| Prefisso | Uso |
|----------|-----|
| `feature/` | Nuove funzionalità |
| `fix/` | Bug fix |
| `docs/` | Documentazione |
| `refactor/` | Refactoring |
| `test/` | Test |
| `chore/` | Manutenzione |
---
## Test
### Esecuzione
```bash
# Tutti i test
pytest tests/ -v
# Test specifico
pytest tests/test_app.py -v
# Con coverage
pip install pytest-cov
pytest tests/ --cov=src/sibilla --cov-report=html
```
### Scrivere Test
- **Nuove feature = nuovi test.** Ogni endpoint, funzione pubblica e classe deve essere testata.
- **Naming:** `test_<funzione>_<scenario>()` (es. `test_strip_markdown_with_images`)
- **Fixtures:** usa `create_app(testing=True)` per testare senza `.env`
Esempio:
```python
def test_api_v1_query_returns_speakable_text(client):
"""Verifica che /api/v1/query includa speakable_text."""
# Dato che Gemini non è configurato, verifica la risposta di errore
resp = client.get("/api/v1/query?q=test")
data = resp.get_json()
assert "speakable_text" in data
```
---
## Pipeline di Ricerca
Per aggiungere un nuovo passaggio alla pipeline:
1. **Identifica il punto di inserimento** in `_run_search_pipeline()` in `app.py`
2. **Implementa la logica** in una funzione o classe separata
3. **Aggiungi test** per il nuovo comportamento
4. **Documenta** in `docs/ARCHITECTURE.md`
### Aggiungere un Search Engine
1. Crea una classe in `search.py` che implementi `search(query, num_results) → SearchResponse`
2. Aggiungi il client in `_run_search_pipeline()` nella catena di fallback
3. Aggiungi test in `tests/test_search.py`
```python
class MySearchClient:
def search(self, query: str, num_results: int = 8) -> SearchResponse:
# ... implementazione ...
return SearchResponse(query=query, results=results, engine="myengine")
```
### Aggiungere un Endpoint API
1. Aggiungi una route in `app.py`
2. Riutilizza `_run_search_pipeline()` per la logica condivisa
3. Aggiungi test in `tests/test_app.py`
```python
@app.route("/api/v2/custom", methods=["POST"])
def api_v2_custom():
# ... usa _run_search_pipeline() ...
```
---
## Documentazione
La documentazione vive in `docs/`. Quando modifichi una funzionalità, aggiorna:
- `docs/API.md` — se modifichi endpoint o formati di risposta
- `docs/ARCHITECTURE.md` — se modifichi l'architettura
- `docs/FLOW.md` — se modifichi la pipeline
- `docs/WIKI.md` — per FAQ e roadmap
---
## Domande?
Apri una issue su GitHub o contatta i maintainer.
Grazie per contribuire a Sibilla! 🔮
+402
View File
@@ -0,0 +1,402 @@
# 🚀 Sibilla — Guida al Deployment
> **Versione:** 0.2.0
> **Ultimo aggiornamento:** 18 Luglio 2026
---
## Indice
1. [Prerequisiti](#prerequisiti)
2. [Installazione rapida](#installazione-rapida)
3. [Configurazione](#configurazione)
4. [Avvio](#avvio)
5. [Deployment in produzione](#deployment-in-produzione)
6. [Docker Compose](#docker-compose)
7. [Reverse Proxy](#reverse-proxy)
8. [Systemd Service](#systemd-service)
9. [Monitoraggio](#monitoraggio)
10. [Troubleshooting](#troubleshooting)
---
## Prerequisiti
### Software
| Componente | Versione minima | Note |
|-----------|----------------|------|
| Python | 3.10+ | |
| pip | 21.0+ | |
| Git | — | Opzionale, per clonare il repo |
### Servizi esterni
| Servizio | Obbligatorio? | Come ottenerlo |
|----------|:---:|---------------|
| Google API Key | ✅ Sì | [Google AI Studio](https://aistudio.google.com/apikey) |
| Degoog | Consigliato | `docker run -d -p 4444:4444 ghcr.io/degoog-org/degoog:latest` |
| SearXNG | No (fallback) | `docker run -d -p 8080:8080 searxng/searxng` |
| DuckDuckGo | No (automatico) | Nessuna configurazione necessaria |
---
## Installazione rapida
```bash
# 1. Clona il repository
git clone https://github.com/tuo-user/sibilla.git
cd sibilla
# 2. Crea ambiente virtuale
python3 -m venv .venv
source .venv/bin/activate # Linux/Mac
# .venv\Scripts\activate # Windows
# 3. Installa dipendenze
pip install -e ".[dev]"
# 4. Configura
cp .env.example .env # se esiste, altrimenti crealo
nano .env # Inserisci GOOGLE_API_KEY
# 5. Avvia (sviluppo)
sibilla
# → http://127.0.0.1:5000
```
---
## Configurazione
Crea un file `.env` nella root del progetto:
```env
# ═══════════════════════════════════════════════════════════════
# Sibilla Configuration
# ═══════════════════════════════════════════════════════════════
# Google Gemini API key (obbligatoria)
GOOGLE_API_KEY=AIzaSyD-xxxxxxxxxxxxxxxxxxxxxxxxxxxx
# Flask secret key (cambiala in produzione!)
SECRET_KEY=una-chiave-segreta-molto-lunga-e-casuale
# ── Server ───────────────────────────────────────────────────
# Host e porta del server Flask
SIBILLA_HOST=127.0.0.1
SIBILLA_PORT=5000
# Debug mode (0 = off, 1 = on)
# NON usare in produzione!
SIBILLA_DEBUG=1
# ── Search Engines ────────────────────────────────────────────
# Degoog (primary)
DEGOOG_URL=http://localhost:4444
# SearXNG (secondary)
SEARXNG_URL=http://localhost:8080
# DuckDuckGo fallback (1 = on, 0 = off)
SIBILLA_DDG_FALLBACK=1
```
### Variabili d'ambiente complete
| Variabile | Default | Descrizione |
|-----------|---------|-------------|
| `GOOGLE_API_KEY` | `""` | **Obbligatoria.** API key di Google AI |
| `SECRET_KEY` | `"dev-secret-key-change-me"` | Secret key Flask |
| `SIBILLA_HOST` | `"127.0.0.1"` | Host su cui ascoltare |
| `SIBILLA_PORT` | `"5000"` | Porta del server |
| `SIBILLA_DEBUG` | `"1"` | Debug mode (0/1) |
| `DEGOOG_URL` | `"http://localhost:4444"` | URL dell'istanza Degoog |
| `SEARXNG_URL` | `"http://localhost:8080"` | URL dell'istanza SearXNG |
| `SIBILLA_DDG_FALLBACK` | `"1"` | Abilita fallback DuckDuckGo (0/1) |
---
## Avvio
### Sviluppo
```bash
# Modo 1: CLI
sibilla
# Modo 2: Python module
python -m sibilla
# Modo 3: Flask diretto (con hot reload)
python -c "from sibilla.app import create_app; create_app().run(debug=True)"
```
### Produzione (Gunicorn)
```bash
# Installa Gunicorn
pip install gunicorn
# Avvia
gunicorn -w 4 -b 0.0.0.0:5000 "sibilla.app:create_app()"
# Con opzioni aggiuntive
gunicorn \
-w 4 \
-b 0.0.0.0:5000 \
--timeout 120 \
--access-logfile /var/log/sibilla/access.log \
--error-logfile /var/log/sibilla/error.log \
"sibilla.app:create_app()"
```
---
## Deployment in produzione
### Checklist pre-deployment
- [ ] `SIBILLA_DEBUG=0` (MAI in debug in produzione)
- [ ] `SECRET_KEY` cambiata con una chiave casuale lunga
- [ ] `GOOGLE_API_KEY` configurata
- [ ] Reverse proxy (nginx/Caddy) configurato con HTTPS
- [ ] Firewall: solo porta 80/443 esposta
- [ ] Rate limiting configurato (nginx o middleware)
- [ ] Logging configurato
- [ ] Health check monitorato
### Architettura di produzione consigliata
```
Internet
│
▼
┌──────────────────┐
│ Nginx / Caddy │ ← Reverse proxy + HTTPS (Let's Encrypt)
│ Porta 80/443 │ + Rate limiting
└────────┬─────────┘
│
▼
┌──────────────────┐
│ Gunicorn │ ← WSGI server
│ 4 workers │ Porta 5000 (internal)
└────────┬─────────┘
│
▼
┌──────────────────┐
│ Sibilla Flask │ ← Application
└────────┬─────────┘
│
┌────┴────┐
▼ ▼
┌────────┐ ┌────────┐
│ Degoog │ │ Gemini │
│ :4444 │ │ API │
└────────┘ └────────┘
```
---
## Docker Compose
Ecco un esempio di `docker-compose.yml` per avviare Sibilla + Degoog + SearXNG:
```yaml
version: "3.8"
services:
sibilla:
build: .
ports:
- "5000:5000"
environment:
- GOOGLE_API_KEY=${GOOGLE_API_KEY}
- SECRET_KEY=${SECRET_KEY:-changeme} # ⚠️ Cambiare in produzione!
- SIBILLA_HOST=0.0.0.0
- SIBILLA_PORT=5000
- SIBILLA_DEBUG=0
- DEGOOG_URL=http://degoog:4444
- SEARXNG_URL=http://searxng:8080
- SIBILLA_DDG_FALLBACK=1
depends_on:
- degoog
- searxng
restart: unless-stopped
degoog:
image: ghcr.io/degoog-org/degoog:latest
ports:
- "4444:4444"
restart: unless-stopped
searxng:
image: searxng/searxng:latest
ports:
- "8080:8080"
environment:
- SEARXNG_BASE_URL=http://localhost:8080/
volumes:
- ./searxng:/etc/searxng:rw
restart: unless-stopped
```
Avvia con:
```bash
docker compose up -d
```
---
## Reverse Proxy
### Nginx
```nginx
server {
listen 80;
server_name sibilla.tuo-dominio.com;
# Rate limiting
limit_req_zone $binary_remote_addr zone=sibilla:10m rate=10r/m;
limit_req zone=sibilla burst=5 nodelay;
location / {
proxy_pass http://127.0.0.1:5000;
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_read_timeout 120s;
}
# API endpoint with higher rate limit
location /api/ {
proxy_pass http://127.0.0.1:5000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_read_timeout 120s;
}
}
```
### Caddy (più semplice, auto-HTTPS)
```
sibilla.tuo-dominio.com {
reverse_proxy 127.0.0.1:5000
}
```
---
## Systemd Service
Crea `/etc/systemd/system/sibilla.service`:
```ini
[Unit]
Description=Sibilla AI Search Engine
After=network.target
[Service]
Type=simple
User=sibilla
Group=sibilla
WorkingDirectory=/opt/sibilla
EnvironmentFile=/opt/sibilla/.env
ExecStart=/opt/sibilla/.venv/bin/gunicorn -w 4 -b 127.0.0.1:5000 "sibilla.app:create_app()"
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
```
Attiva:
```bash
sudo systemctl daemon-reload
sudo systemctl enable --now sibilla
sudo systemctl status sibilla
```
---
## Monitoraggio
### Health check endpoint
```bash
curl http://localhost:5000/api/health
```
Monitora con:
- **Uptime Kuma** — monitoraggio self-hosted
- **Healthchecks.io** — cron monitoring
- **Prometheus + Grafana** — metriche avanzate (da aggiungere)
### Log
Con Gunicorn:
```bash
# Access log
tail -f /var/log/sibilla/access.log
# Error log
tail -f /var/log/sibilla/error.log
```
---
## Troubleshooting
### "GOOGLE_API_KEY is not set"
Il file `.env` non è stato caricato o manca la variabile:
```bash
# Verifica che il file .env esista
ls -la .env
# Verifica il contenuto
cat .env | grep GOOGLE_API_KEY
# Se usi systemd, assicurati che EnvironmentFile punti al percorso corretto
```
### "Connection refused" per Degoog/SearXNG
Il motore di ricerca non è in esecuzione:
```bash
# Verifica se Degoog è attivo
curl http://localhost:4444/api/search?q=test
# Verifica se SearXNG è attivo
curl http://localhost:8080/search?q=test&format=json
# Se non rispondono, Sibilla userà automaticamente DuckDuckGo
```
### "502 Bad Gateway" — Tutti i motori down
Verifica che DuckDuckGo fallback sia abilitato:
```env
SIBILLA_DDG_FALLBACK=1
```
Se anche DuckDuckGo non funziona, Gemini risponderà in modalità "pure LLM" (senza fonti).
### Timeout su richieste lente
Aumenta il timeout in Gunicorn:
```bash
gunicorn --timeout 180 ...
```
E nel reverse proxy:
```nginx
proxy_read_timeout 180s;
```
+268
View File
@@ -0,0 +1,268 @@
# 🔄 Sibilla — Diagramma di Flusso
> **Versione:** 0.2.0
> **Ultimo aggiornamento:** 18 Luglio 2026
---
## Pipeline di Ricerca Completa (Mermaid)
```mermaid
flowchart TD
A["👤 Utente invia query"] --> B["🌐 Web UI (POST /api/ask)<br/>o<br/>🔗 API (GET/POST /api/v1/query)"]
B --> C["🔮 Query Expansion<br/>Gemini genera 3 varianti"]
C --> D{"🔍 Degoog<br/>disponibile?"}
D -->|✅ Sì| E["Cerca con Degoog<br/>prime 2 varianti"]
D -->|❌ No| F{"🔍 SearXNG<br/>disponibile?"}
E --> G{"Risultati<br/>trovati?"}
G -->|✅ Sì| H["Aggrega risultati<br/>Dedup URL"]
G -->|❌ No| F
F -->|✅ Sì| I["Cerca con SearXNG<br/>prime 2 varianti"]
F -->|❌ No| J["🔍 DuckDuckGo<br/>fallback"]
I --> K{"Risultati<br/>trovati?"}
K -->|✅ Sì| H
K -->|❌ No| J
J --> L{"Risultati<br/>trovati?"}
L -->|✅ Sì| H
L -->|❌ No| M{"Tutti i backend<br/>falliti?"}
M -->|✅ Sì| N["⚠️ Fallback Pure LLM<br/>Gemini risponde senza fonti"]
M -->|❌ No| O["❌ Errore: 502<br/>Restituisci errore"]
H --> P["📥 PageFetcher<br/>Scarica full content<br/>max 5 pagine, 300ms delay"]
P --> Q["📝 Build Prompt<br/>Fonti numerate + data"]
Q --> R["🧠 Gemini Synthesis<br/>SYNTHESIS_SYSTEM_PROMPT<br/>temp=0.4, max_tokens=4096"]
R --> S["✅ JSON Response<br/>answer + html_answer<br/>+ speakable_text + sources"]
N --> T["✅ JSON Response<br/>solo LLM, nessuna fonte"]
style A fill:#a78bfa,color:#fff
style S fill:#34d399,color:#000
style T fill:#fbbf24,color:#000
style O fill:#f87171,color:#fff
style R fill:#8b5cf6,color:#fff
```
---
## Flusso Richiesta Web (Frontend)
```mermaid
sequenceDiagram
actor U as 👤 Utente
participant B as Browser (app.js)
participant F as Flask /api/ask
participant P as Pipeline
participant G as Gemini
participant S as Search Engines
U->>B: Scrivi query e clicca "Interroga"
B->>B: Disabilita form, mostra spinner
B->>F: POST /api/ask {query}
F->>P: _run_search_pipeline(query, num_sources)
P->>G: rewrite_query(query, 3 varianti)
G-->>P: [originale, var1, var2, var3]
P->>S: Degoog.search(var1) + Degoog.search(var2)
S-->>P: SearchResponse {results[]}
P->>S: PageFetcher.fetch_results(top5)
S-->>P: SearchResult[] con full_content
P->>G: synthesize(system_prompt, user_prompt)
G-->>P: SynthesisResult {answer, tokens}
P-->>F: enriched[], response_data
F-->>B: JSON {answer, html_answer, speakable_text, sources[]}
B->>B: Render HTML answer, mostra fonti
B->>U: Mostra risultato
```
---
## Flusso API v1
```mermaid
sequenceDiagram
actor C as 🔗 Client (curl/Lambda)
participant F as Flask /api/v1/query
participant P as Pipeline
participant G as Gemini
participant S as Search Engines
C->>F: GET /api/v1/query?q=domanda
F->>F: Estrai query da query string
F->>P: _run_search_pipeline(query, num_sources=5)
P->>G: rewrite_query(query, 3 varianti)
G-->>P: [originale, var1, var2, var3]
alt Degoog OK
P->>S: Degoog.search(varianti)
S-->>P: Risultati
else SearXNG fallback
P->>S: SearXNG.search(varianti)
S-->>P: Risultati
else DuckDuckGo fallback
P->>S: DDG.search(query)
S-->>P: Risultati
else Pure LLM
P->>G: synthesize(FALLBACK_PROMPT, query)
G-->>P: Risposta senza fonti
end
P->>S: PageFetcher.fetch_results(top5)
S-->>P: Contenuti completi
P->>G: synthesize(SYNTHESIS_PROMPT, fonti)
G-->>P: SynthesisResult
P-->>F: enriched[], response_data
F-->>C: JSON {query, answer, speakable_text, sources[]}
```
---
## Flusso MCP Server
```mermaid
sequenceDiagram
actor A as 🤖 AI Agent (Claude/Gemini)
participant M as MCP Server (stdio)
participant D as DegoogClient
participant S as SearXNGClient
participant G as DuckDuckGoClient
participant F as PageFetcher
A->>M: JSON-RPC: initialize
M-->>A: {protocolVersion, capabilities, serverInfo}
A->>M: JSON-RPC: tools/list
M-->>A: {tools: [web_search, fetch_page]}
A->>M: JSON-RPC: tools/call {name: "web_search", arguments: {query: "..."}}
M->>D: search(query)
alt Degoog ha risultati
D-->>M: SearchResponse
else Degoog vuoto
M->>S: search(query)
alt SearXNG ha risultati
S-->>M: SearchResponse
else Entrambi vuoti
M->>G: search(query)
G-->>M: SearchResponse
end
end
M-->>A: {content: [{text: "Risultati formattati..."}]}
A->>M: JSON-RPC: tools/call {name: "fetch_page", arguments: {url: "..."}}
M->>F: fetch(url)
F-->>M: Testo estratto (max 8000 chars)
M-->>A: {content: [{text: "Contenuto pagina..."}]}
```
---
## Macchina a Stati: Pipeline di Ricerca
```mermaid
stateDiagram-v2
[*] --> ReceivingQuery: Utente invia query
ReceivingQuery --> QueryExpansion: Query valida
ReceivingQuery --> Error400: Query vuota
QueryExpansion --> DegoogSearch: Gemini espande query
DegoogSearch --> AggregatingResults: Risultati trovati
DegoogSearch --> SearXNGFallback: Nessun risultato
SearXNGFallback --> AggregatingResults: Risultati trovati
SearXNGFallback --> DDGFallback: Nessun risultato
DDGFallback --> AggregatingResults: Risultati trovati
DDGFallback --> PureLLMFallback: Nessun risultato
AggregatingResults --> FetchingContent: Dedup URL completato
FetchingContent --> BuildingPrompt: Full content scaricato
BuildingPrompt --> Synthesizing: Prompt assemblato
Synthesizing --> ReturningResponse: Sintesi completata
Synthesizing --> ReturningErrorResponse: Errore Gemini
PureLLMFallback --> SynthesizingFallback: Usa FALLBACK_PROMPT
SynthesizingFallback --> ReturningFallback: Risposta senza fonti
Error400 --> [*]
ReturningResponse --> [*]
ReturningErrorResponse --> [*]
ReturningFallback --> [*]
```
---
## Diagramma dei Componenti
```mermaid
graph TB
subgraph "Interfacce Utente"
WEB["🌐 Web UI<br/>templates/index.html<br/>static/app.js"]
API["🔗 REST API<br/>/api/ask<br/>/api/v1/query"]
MCP["🤖 MCP Server<br/>stdio JSON-RPC"]
end
subgraph "Flask Application"
APP["app.py<br/>Flask App Factory"]
PIPELINE["_run_search_pipeline()<br/>Pipeline condivisa"]
end
subgraph "Servizi Core"
AI["ai.py<br/>GeminiClient"]
SEARCH["search.py"]
MD["markdown.py<br/>Render + Strip"]
PROMPTS["prompts.py<br/>System Prompts"]
end
subgraph "Backend Esterni"
GEMINI["🧠 Google Gemini API<br/>gemini-2.5-flash"]
DEGOOG["🔍 Degoog<br/>self-hosted"]
SEARXNG["🔍 SearXNG<br/>self-hosted"]
DDG["🔍 DuckDuckGo<br/>HTML scraping"]
end
WEB --> APP
API --> APP
MCP --> SEARCH
APP --> PIPELINE
PIPELINE --> AI
PIPELINE --> SEARCH
PIPELINE --> PROMPTS
PIPELINE --> MD
AI --> GEMINI
SEARCH --> DEGOOG
SEARCH --> SEARXNG
SEARCH --> DDG
```
---
## Strategia di Query Expansion
```mermaid
flowchart LR
Q["Query originale:<br/>'ultimo film sull Odissea'"] --> GE["Gemini rewrite_query()"]
GE --> V1["Variante 1:<br/>film Odissea 2026 recensione"]
GE --> V2["Variante 2:<br/>ultimo adattamento cinematografico Odissea"]
GE --> V3["Variante 3:<br/>The Odyssey movie latest release"]
V1 --> D1["Degoog search"]
V2 --> D2["Degoog search"]
D1 --> AGG["Aggregazione<br/>+ Deduplicazione"]
D2 --> AGG
```
---
## Legenda
| Simbolo | Significato |
|---------|------------|
| 🔍 | Motore di ricerca |
| 🧠 | LLM / AI |
| 📥 | Fetch / Download |
| 📝 | Costruzione prompt |
| ✅ | Successo |
| ❌ | Errore / Fallimento |
| ⚠️ | Degraded mode |
| 👤 | Interazione utente |
| 🔗 | API / Programmatico |
| 🤖 | Agente AI esterno |
+143
View File
@@ -0,0 +1,143 @@
# 📚 Sibilla — Wiki
> Wiki tecnica del progetto Sibilla — motore di ricerca AI self-hosted stile Perplexity.
---
## Cosa è Sibilla?
**Sibilla** è un'applicazione Python/Flask che implementa un motore di ricerca AI self-hosted, simile a Perplexity AI. Ti permette di:
- Fare domande in linguaggio naturale
- Ottenere risposte sintetizzate da Google Gemini basate su fonti web reali
- Visualizzare le fonti con link e citazioni inline
- Usare l'API REST per integrare Sibilla in altri servizi (skill Alexa, bot, app mobile)
- Esporre la ricerca web come tool via MCP (Model Context Protocol) per agent AI
---
## Perché si chiama Sibilla?
Le **Sibille** erano figure della mitologia greco-romana: profetesse che pronunciavano oracoli. Come l'oracolo, Sibilla "vede" nel web e "profetizza" risposte basate sulle fonti che consulta.
---
## Funzionalità
### ✅ Implementate
| Funzionalità | Descrizione |
|-------------|-------------|
| 🔍 **Multi-engine search** | Degoog → SearXNG → DuckDuckGo con fallback a cascata |
| 🧠 **Sintesi AI** | Google Gemini sintetizza risposte con citazioni [1], [2], ... |
| 📝 **Query expansion** | Gemini genera varianti di query per migliorare il recall |
| 📄 **Full content fetching** | Scarica e analizza il contenuto completo delle pagine |
| 🌐 **Interfaccia web** | UI responsive, dark theme, mobile-first |
| 📡 **API REST** | Endpoint `/api/ask` (web) e `/api/v1/query` (API) |
| 🗣️ **TTS-ready** | Campo `speakable_text` ottimizzato per sintesi vocale |
| 🔧 **MCP server** | Server Model Context Protocol per agent AI esterni |
| 🛡️ **XSS protection** | Sanitizzazione HTML con allowlist |
| 🔄 **CORS** | Header cross-origin per accesso da qualsiasi dominio |
| 📊 **Metadati** | Token count, modello usato, search engine usato |
### 🚧 In programma
| Funzionalità | Priorità |
|-------------|----------|
| Rate limiting API | Alta |
| Caching risposte (Redis) | Media |
| Multi-lingua (oltre IT/EN) | Media |
| Autenticazione API key | Media |
| Streaming risposta (SSE) | Bassa |
| Supporto immagini nei risultati | Bassa |
| Docker Compose (Sibilla + Degoog + SearXNG) | Bassa |
---
## Glossario
| Termine | Definizione |
|---------|------------|
| **Sintesi** | Processo con cui Gemini genera una risposta basata sulle fonti |
| **Query Expansion** | Generazione di varianti di una query per migliorare la ricerca |
| **Cascading Fallback** | Strategia di fallback a catena: prova A, poi B, poi C |
| **Full Content** | Contenuto testuale completo di una pagina web (dopo scraping) |
| **Sanitizzazione HTML** | Rimozione di tag/attributi pericolosi per prevenire XSS |
| **MCP** | Model Context Protocol — standard per esporre tool a LLM |
| **TTS** | Text-to-Speech — sintesi vocale (es. Alexa, Google Home) |
| **Degoog** | Metasearch engine self-hosted, alternativa leggera a SearXNG |
---
## Domande Frequenti (FAQ)
### Cosa serve per far funzionare Sibilla?
Come minimo:
1. **Python 3.10+**
2. **Google API Key** (per Gemini)
3. Almeno un motore di ricerca:
- **Degoog** (consigliato) — `docker run -d -p 4444:4444 ghcr.io/degoog-org/degoog:latest`
- **SearXNG** — `docker run -d -p 8080:8080 searxng/searxng`
- **DuckDuckGo** — funziona senza server (fallback automatico)
### Posso usare Sibilla senza Degoog/SearXNG?
Sì! Sibilla userà automaticamente DuckDuckGo come fallback (scraping HTML, nessuna API key necessaria). Puoi disabilitarlo con `SIBILLA_DDG_FALLBACK=0`.
### Quanto costa?
- **Sibilla:** gratuito e open-source (MIT)
- **Degoog:** gratuito, self-hosted
- **SearXNG:** gratuito, self-hosted
- **Gemini API:** ha un tier gratuito (vedi [Google AI Studio](https://aistudio.google.com/apikey))
### Posso cambiare il modello LLM?
Sì! Modifica il parametro `model` quando crei `GeminiClient` o cambia il default in `ai.py`. Supporta qualsiasi modello Gemini (es. `gemini-2.5-pro`, `gemini-1.5-flash`).
### Sibilla supporta altre lingue?
Sì! I prompt sono in italiano ma Gemini risponde nella lingua della domanda. Puoi modificare i prompt in `prompts.py` per altre lingue.
### Come integro Sibilla con la mia app?
Usa l'endpoint `/api/v1/query`:
```bash
curl "http://localhost:5000/api/v1/query?q=tua+domanda"
```
Vedi `docs/API.md` per la documentazione completa.
### Sibilla funziona dietro un reverse proxy (nginx, Caddy)?
Sì! Sibilla è una normale app Flask/WSGI. Puoi usare `gunicorn` come server WSGI e nginx/Caddy come reverse proxy. Vedi `docs/DEPLOYMENT.md`.
---
## Confronto con Perplexity
| Caratteristica | Perplexity | Sibilla |
|---------------|-----------|---------|
| **Hosting** | Cloud (perplexity.ai) | Self-hosted |
| **Costo** | $20/mese (Pro) | Gratuito (+ costi API Gemini) |
| **Privacy** | Dati su server Perplexity | Dati sul tuo server |
| **Motori di ricerca** | Interni di Perplexity | Degoog, SearXNG, DuckDuckGo |
| **LLM** | Modelli proprietari | Google Gemini |
| **API** | Via perplexity.ai | Locale, REST/MCP |
| **Personalizzazione** | Limitata | Completa (open source) |
| **MCP** | No | Sì |
| **TTS support** | No | Sì (via `speakable_text`) |
---
## Roadmap
```
v0.1.0 ✅ — MVP: ricerca + sintesi con Degoog/SearXNG/DDG + Gemini
v0.2.0 ✅ — API v1, TTS, CORS, documentazione completa
v0.3.0 📋 — Rate limiting, caching, autenticazione API key
v0.4.0 📋 — Docker Compose, streaming SSE, multi-lingua
v1.0.0 📋 — Stable release, test coverage 90%+
```
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "sibilla"
version = "0.1.0"
version = "0.2.0"
description = "sibilla - Self-hosted Perplexity-like AI search engine with Google Gemini + MCP tool use, powered by Degoog/SearXNG, built with Flask"
readme = "README.md"
requires-python = ">=3.10"
+1 -1
View File
@@ -1,3 +1,3 @@
"""sibilla — L'oracolo AI che risponde alle tue domande cercando nel web."""
__version__ = "0.1.0"
__version__ = "0.2.0"
+32 -1
View File
@@ -12,6 +12,37 @@ from google import genai
from google.genai import types
def _clean_error(exc: Exception) -> str:
"""Extract a human-readable error message from a Gemini API exception.
The google-genai SDK may embed structured error data in the
exception string (e.g. ``"503 UNAVAILABLE. {'error': {...}}"``).
This helper strips the raw dict and returns only the readable part.
"""
# Try structured attributes first (newer SDK versions)
code = getattr(exc, "code", None) or getattr(exc, "status_code", None)
message = getattr(exc, "message", None)
if code and message:
return f"{code} {message}"
# Fallback: clean up the string representation.
# Strip any trailing dict/JSON blob (e.g. " {'error': {...}}").
# Uses brace-depth counting to handle nested dicts correctly.
raw = str(exc)
if raw.rstrip().endswith("}"):
depth = 0
for i in range(len(raw) - 1, -1, -1):
if raw[i] == "}":
depth += 1
elif raw[i] == "{":
depth -= 1
if depth == 0:
raw = raw[:i].rstrip()
break
return raw
@dataclass
class SynthesisResult:
"""Result of an AI synthesis with structured citations."""
@@ -115,7 +146,7 @@ class GeminiClient:
return SynthesisResult(
answer="",
model=self.model,
error=str(e),
error=_clean_error(e),
)
def rewrite_query(
+238 -106
View File
@@ -8,6 +8,8 @@ from flask import Flask, render_template, request, jsonify
from sibilla.search import DegoogClient, SearXNGClient, DuckDuckGoClient, PageFetcher
from sibilla.ai import GeminiClient
from sibilla.markdown import render as render_markdown
from sibilla.markdown import strip_markdown
from sibilla.prompts import (
SYNTHESIS_SYSTEM_PROMPT,
FALLBACK_PROMPT,
@@ -81,6 +83,156 @@ def create_app(testing: bool = False) -> Flask:
_gemini = GeminiClient(api_key=app.config["GOOGLE_API_KEY"])
return _gemini
# ── CORS ─────────────────────────────────────────────────────
@app.after_request
def add_cors_headers(response):
"""Add permissive CORS headers so the API can be called from
browser-based services, Alexa Skill Lambdas, etc."""
response.headers["Access-Control-Allow-Origin"] = "*"
response.headers["Access-Control-Allow-Methods"] = (
"GET, POST, OPTIONS"
)
response.headers["Access-Control-Allow-Headers"] = (
"Content-Type, Authorization"
)
return response
# ── Shared search pipeline ───────────────────────────────────
def _run_search_pipeline(
query: str,
num_sources: int,
) -> tuple[
list, # enriched results
str, # primary_engine
Optional[str], # search_error
Optional[dict], # early_response (None if pipeline continues)
]:
"""Run the full multi-engine search + fetch pipeline.
Tries Degoog → SearXNG → DuckDuckGo in order, fetches page
content, and synthesizes with Gemini.
Returns a 4-tuple:
enriched — list of SearchResult with full_content populated
primary_engine — which search engine succeeded
search_error — error from the last backend (or None)
early_response — dict for a premature JSON response
(no results / all backends down), or None
"""
gemini = get_gemini()
degoog = get_degoog()
searxng = get_searxng()
ddg = get_ddg()
fetcher = get_fetcher()
# ── Query expansion ───────────────────────────────────
expanded_queries = gemini.rewrite_query(query, num_variants=3)
# ── Multi-query search (Degoog → SearXNG → DDG) ───────
all_results: list = []
seen_urls: set[str] = set()
search_error: Optional[str] = None
primary_engine = "degoog"
for eq in expanded_queries[:2]:
resp = degoog.search(eq, num_results=num_sources)
if resp.results:
for r in resp.results:
if r.url not in seen_urls:
seen_urls.add(r.url)
all_results.append(r)
if resp.error:
search_error = resp.error
if not all_results:
for eq in expanded_queries[:2]:
resp = searxng.search(eq, num_results=num_sources)
if resp.results:
primary_engine = "searxng"
for r in resp.results:
if r.url not in seen_urls:
seen_urls.add(r.url)
all_results.append(r)
if resp.error and not search_error:
search_error = resp.error
if not all_results and app.config["SIBILLA_DDG_FALLBACK"]:
ddg_resp = ddg.search(query, num_results=num_sources)
if ddg_resp.results:
primary_engine = "duckduckgo"
for r in ddg_resp.results:
if r.url not in seen_urls:
seen_urls.add(r.url)
all_results.append(r)
if ddg_resp.error and not search_error:
search_error = ddg_resp.error
# ── Handle no results ─────────────────────────────────
if not all_results:
if search_error:
return [], primary_engine, search_error, {
"error": f"Ricerca web fallita: {search_error}",
"answer": "",
"sources": [],
}
# No error but no results — fallback to pure LLM
result = gemini.synthesize(
system_prompt=FALLBACK_PROMPT,
user_prompt=f"Query: {query}",
temperature=0.5,
)
answer_text = result.answer if not result.error else (
f"Nessun risultato trovato per: {query}"
)
return [], "none", None, {
"answer": answer_text,
"html_answer": render_markdown(answer_text),
"speakable_text": strip_markdown(answer_text),
"sources": [],
"model": gemini.model,
"search_engine": "none",
"tokens_used": result.tokens_used,
"error": result.error if result.error else None,
}
# ── Fetch & synthesize ────────────────────────────────
enriched = fetcher.fetch_results(all_results, max_results=num_sources)
today = datetime.now().strftime("%d %B %Y")
sources_for_prompt = [
{
"index": i,
"title": r.title,
"url": r.url,
"content": r.full_content or r.snippet,
}
for i, r in enumerate(enriched[:num_sources], 1)
]
prompt = build_synthesis_prompt(
query, sources_for_prompt, f"Data di oggi: {today}"
)
synthesis = gemini.synthesize(
system_prompt=SYNTHESIS_SYSTEM_PROMPT,
user_prompt=prompt,
temperature=0.4,
max_tokens=4096,
)
return enriched, primary_engine, None, {
"answer": (
synthesis.answer
if not synthesis.error
else f"⚠️ Errore nella sintesi AI: {synthesis.error}"
),
"model": gemini.model,
"search_engine": primary_engine,
"tokens_used": synthesis.tokens_used,
"error": synthesis.error if synthesis.error else None,
}
# ── Routes ────────────────────────────────────────────────────
@app.route("/")
@@ -120,109 +272,21 @@ def create_app(testing: bool = False) -> Flask:
if not query:
return jsonify({"error": "La query non può essere vuota."}), 400
gemini = get_gemini()
degoog = get_degoog()
searxng = get_searxng()
ddg = get_ddg()
fetcher = get_fetcher()
enriched, primary_engine, search_error, response_data = (
_run_search_pipeline(query, num_sources)
)
# ── Step 1: Query expansion (improves search recall) ──────
expanded_queries = gemini.rewrite_query(query, num_variants=3)
# ── Step 2: Multi-query search (Degoog → SearXNG → DDG) ───
all_results: list = []
seen_urls: set[str] = set()
search_error: Optional[str] = None
primary_engine = "degoog"
for eq in expanded_queries[:2]: # original + 1 variant
resp = degoog.search(eq, num_results=num_sources)
if resp.results:
for r in resp.results:
if r.url not in seen_urls:
seen_urls.add(r.url)
all_results.append(r)
if resp.error:
search_error = resp.error
# Fallback to SearXNG if Degoog returned nothing
if not all_results:
for eq in expanded_queries[:2]:
resp = searxng.search(eq, num_results=num_sources)
if resp.results:
primary_engine = "searxng"
for r in resp.results:
if r.url not in seen_urls:
seen_urls.add(r.url)
all_results.append(r)
if resp.error and not search_error:
search_error = resp.error
# Fallback to DuckDuckGo if both Degoog and SearXNG failed
if not all_results and app.config["SIBILLA_DDG_FALLBACK"]:
ddg_resp = ddg.search(query, num_results=num_sources)
if ddg_resp.results:
primary_engine = "duckduckgo"
for r in ddg_resp.results:
if r.url not in seen_urls:
seen_urls.add(r.url)
all_results.append(r)
if ddg_resp.error and not search_error:
search_error = ddg_resp.error
# ── Handle no results ─────────────────────────────────────
if not all_results:
# Early exit for error / no-results cases
if not enriched or search_error:
if search_error:
return jsonify({
"error": f"Ricerca web fallita: {search_error}",
"answer": "",
"sources": [],
}), 502
return jsonify(response_data), 502
# pure LLM fallback / no results
return jsonify(response_data)
# No error but no results — fallback to pure LLM
result = gemini.synthesize(
system_prompt=FALLBACK_PROMPT,
user_prompt=f"Query: {query}",
temperature=0.5,
)
return jsonify({
"answer": result.answer if not result.error else (
f"Nessun risultato trovato per: {query}"
),
"sources": [],
"model": gemini.model,
"search_engine": "none",
"tokens_used": result.tokens_used,
"error": result.error if result.error else None,
})
# ── Build full response with sources ──────────────────────
gemini = get_gemini()
answer_text = response_data["answer"]
# ── Step 3: Fetch page content ────────────────────────────
enriched = fetcher.fetch_results(all_results, max_results=num_sources)
# ── Step 4: Build synthesis prompt ────────────────────────
today = datetime.now().strftime("%d %B %Y")
sources_for_prompt = [
{
"index": i,
"title": r.title,
"url": r.url,
"content": r.full_content or r.snippet,
}
for i, r in enumerate(enriched[:num_sources], 1)
]
prompt = build_synthesis_prompt(
query, sources_for_prompt, f"Data di oggi: {today}"
)
# ── Step 5: AI Synthesis ──────────────────────────────────
synthesis = gemini.synthesize(
system_prompt=SYNTHESIS_SYSTEM_PROMPT,
user_prompt=prompt,
temperature=0.4,
max_tokens=4096,
)
# ── Step 6: Build response ────────────────────────────────
sources_out = [
{
"index": i,
@@ -234,14 +298,82 @@ def create_app(testing: bool = False) -> Flask:
]
return jsonify({
"answer": synthesis.answer if not synthesis.error else (
f"⚠️ Errore nella sintesi AI: {synthesis.error}"
),
"answer": answer_text,
"html_answer": render_markdown(answer_text),
"speakable_text": strip_markdown(answer_text),
"sources": sources_out,
"model": gemini.model,
"search_engine": primary_engine,
"tokens_used": synthesis.tokens_used,
"error": synthesis.error if synthesis.error else None,
"model": response_data["model"],
"search_engine": response_data["search_engine"],
"tokens_used": response_data["tokens_used"],
"error": response_data.get("error"),
})
# ── API v1 ───────────────────────────────────────────────────
@app.route("/api/v1/query", methods=["GET", "POST"])
def api_v1_query():
"""Simplified API endpoint for programmatic/voice-assistant usage.
Accepts both GET (``?q=...``) and POST (``{"query": "..."}``).
Runs the full search + synthesis pipeline and returns a clean
JSON response with a ``speakable_text`` field optimized for TTS.
GET example::
curl "http://localhost:5000/api/v1/query?q=ultimo+film+odissea"
POST example::
curl -X POST http://localhost:5000/api/v1/query \\
-H "Content-Type: application/json" \\
-d '{"query": "ultimo film odissea"}'
"""
# Extract query from GET or POST
if request.method == "GET":
query = request.args.get("q", "").strip()
else:
data = request.get_json(silent=True) or {}
query = data.get("query", "").strip()
if not query:
return jsonify({"error": "Missing query parameter 'q'."}), 400
num_sources = 5
enriched, primary_engine, search_error, response_data = (
_run_search_pipeline(query, num_sources)
)
# Early exit for error / no-results cases
if not enriched or search_error:
if search_error:
return jsonify({
"error": response_data["error"],
"answer": "",
"speakable_text": "",
}), 502
# pure LLM fallback
return jsonify({
"query": query,
"answer": response_data["answer"],
"speakable_text": response_data["speakable_text"],
"sources": [],
"model": response_data["model"],
"search_engine": response_data["search_engine"],
"tokens_used": response_data["tokens_used"],
})
answer_text = response_data["answer"]
return jsonify({
"query": query,
"answer": answer_text,
"speakable_text": strip_markdown(answer_text),
"sources": [
{"index": i, "title": r.title, "url": r.url}
for i, r in enumerate(enriched[:num_sources], 1)
],
"model": response_data["model"],
"search_engine": response_data["search_engine"],
"tokens_used": response_data["tokens_used"],
})
return app
+185
View File
@@ -0,0 +1,185 @@
"""sibilla markdown — server-side Markdown → safe HTML conversion.
Uses Python-Markdown with extensions for full GFM-style rendering,
plus HTML sanitization to prevent XSS.
"""
import re
import markdown as _md
# Extensions that give us GitHub-Flavored Markdown (GFM) features
_EXTENSIONS = [
"extra", # tables, fenced_code, footnotes, attr_list, def_list, abbr
"sane_lists", # better list handling
]
# HTML tags and attributes we allow after markdown conversion.
# Everything else gets stripped — XSS protection.
_ALLOWED_TAGS: set[str] = {
"h1", "h2", "h3", "h4", "h5", "h6",
"p", "br", "hr",
"strong", "em", "b", "i", "u", "del", "s",
"a", "img",
"ul", "ol", "li",
"code", "pre",
"table", "thead", "tbody", "tr", "th", "td",
"blockquote",
"div", "span",
"sup", "sub",
"dl", "dt", "dd",
"abbr",
}
_ALLOWED_ATTRS: set[str] = {
"href", "title", "alt", "src",
"class", "id",
"width", "height",
"target", "rel",
}
# Regex to strip dangerous attributes/protocols from allowed tags
_JAVASCRIPT_URL_RE = re.compile(r"^\s*javascript:", re.IGNORECASE)
def _sanitize_html(html: str) -> str:
"""Strip dangerous tags/attributes from rendered HTML.
Uses a simple allowlist approach. For production use with untrusted
content, consider ``bleach`` or ``nh3`` instead.
"""
# Strip all tags except the allowlist
def _allow_tag(tag: str) -> bool:
return tag.lower() in _ALLOWED_TAGS
# Strip dangerous attributes
def _clean_attrs(attrs_str: str) -> str:
parts = re.findall(
r'(\w[\w-]*)\s*=\s*("[^"]*"|\'[^\']*\'|[^\s>]+)',
attrs_str,
re.IGNORECASE,
)
clean: list[str] = []
for attr, value in parts:
if attr.lower() not in _ALLOWED_ATTRS:
continue
# Strip quotes
raw = value.strip("\"'")
# Block javascript: URLs on href/src
if attr.lower() in ("href", "src") and _JAVASCRIPT_URL_RE.match(raw):
continue
clean.append(f'{attr}="{raw}"')
return " ".join(clean)
# Rebuild HTML with only allowed tags and attrs
result = re.sub(
r"</?(\w+)([^>]*)>",
lambda m: (
# Opening/self-closing tag
f"<{m.group(1)}{' ' + a if (a := _clean_attrs(m.group(2))) else ''}>"
if _allow_tag(m.group(1))
else ""
)
if not m.group(0).startswith("</")
# Closing tag
else (f"</{m.group(1)}>" if _allow_tag(m.group(1)) else ""),
html,
)
return result
def strip_markdown(text: str | None) -> str:
"""Strip Markdown formatting, returning clean plain text suitable for TTS.
Converts common Markdown constructs to their plain-text equivalents
so that a voice assistant (Alexa, Google Home, etc.) can read them
naturally without artifacts like ``**bold**`` or ``[link](url)``.
Args:
text: Raw Markdown string. If None or empty, returns "".
Returns:
Plain text string safe for text-to-speech engines.
Example::
>>> strip_markdown("**Ciao!** Visita [il sito](https://example.com)")
'Ciao! Visita il sito'
"""
if not text:
return ""
result = text
# 1. Strip code blocks (triple backticks) — keep inner content
result = re.sub(r"```[^\n]*\n", "", result)
result = result.replace("```", "")
# 2. Convert ![alt](url) → alt (must run BEFORE link stripping,
# otherwise the link regex eats the [alt](url) part of ![alt](url))
result = re.sub(r"!\[([^\]]*)\]\([^)]+\)", r"\1", result)
# 3. Convert [text](url) → text
result = re.sub(r"\[([^\]]+)\]\([^)]+\)", r"\1", result)
# 4. Strip bold/italic markers (**, __, *, _)
result = re.sub(r"\*\*(.+?)\*\*", r"\1", result)
result = re.sub(r"__(.+?)__", r"\1", result)
result = re.sub(r"\*(.+?)\*", r"\1", result)
result = re.sub(r"_(.+?)_", r"\1", result)
# 5. Strip heading markers (#, ##, etc.)
result = re.sub(r"^#{1,6}\s+", "", result, flags=re.MULTILINE)
# 6. Strip horizontal rules
result = re.sub(r"^[-*_]{3,}\s*$", "", result, flags=re.MULTILINE)
# 7. Strip inline code markers (single backtick)
result = re.sub(r"`([^`]+)`", r"\1", result)
# 8. Strip blockquote markers
result = re.sub(r"^>\s?", "", result, flags=re.MULTILINE)
# 9. Strip unordered list markers, keep the text
result = re.sub(r"^[\s]*[-*+]\s+", "", result, flags=re.MULTILINE)
# 10. Strip ordered list markers (1., 2., etc.)
result = re.sub(r"^[\s]*\d+\.\s+", "", result, flags=re.MULTILINE)
# 11. Strip any remaining HTML tags
result = re.sub(r"<[^>]+>", "", result)
# 12. Collapse multiple blank lines into one
result = re.sub(r"\n{3,}", "\n\n", result)
# 13. Strip leading/trailing whitespace
return result.strip()
def render(text: str | None, safe: bool = True) -> str:
"""Convert Markdown text to safe HTML.
Args:
text: Raw Markdown string. If None or empty, returns "".
safe: If True (default), sanitize output HTML (strip scripts,
event handlers, javascript: URLs, etc.).
Returns:
HTML string ready for injection into a page.
Example::
>>> render("**Hello!** [link](https://example.com)")
'<p><strong>Hello!</strong> <a href="https://example.com">link</a></p>'
"""
if not text:
return ""
md = _md.Markdown(extensions=_EXTENSIONS, output_format="html")
html = md.convert(text)
if safe:
html = _sanitize_html(html)
return html.strip()
+1 -1
View File
@@ -148,7 +148,7 @@ class MCPServer:
"capabilities": {"tools": {}},
"serverInfo": {
"name": "sibilla-mcp",
"version": "0.1.0",
"version": "0.2.0",
},
})
+55 -4
View File
@@ -1,5 +1,6 @@
/**
* Sibilla — Frontend search interface
* Mobile-friendly, smooth UX
*/
document.addEventListener('DOMContentLoaded', () => {
@@ -10,19 +11,34 @@ document.addEventListener('DOMContentLoaded', () => {
const loading = document.getElementById('loading');
const answerDiv = document.getElementById('answer');
const sourcesDiv = document.getElementById('sources');
const metaBar = document.getElementById('meta-bar');
const newSearchBtn = document.getElementById('new-search-btn');
const btnText = submitBtn.querySelector('span').textContent;
let isSearching = false;
form.addEventListener('submit', async (e) => {
e.preventDefault();
if (isSearching) return;
const query = queryInput.value.trim();
if (!query) return;
// Show response area + loading
// Reset and show UI
isSearching = true;
responseArea.style.display = 'block';
loading.style.display = 'flex';
answerDiv.innerHTML = '';
sourcesDiv.innerHTML = '';
metaBar.innerHTML = '';
metaBar.style.display = 'none';
newSearchBtn.classList.remove('visible');
submitBtn.disabled = true;
submitBtn.querySelector('span').textContent = 'Consultando...';
// Scroll to loading indicator on mobile
loading.scrollIntoView({ behavior: 'smooth', block: 'start' });
try {
const res = await fetch('/api/ask', {
@@ -38,29 +54,63 @@ document.addEventListener('DOMContentLoaded', () => {
const data = await res.json();
// Render answer as Markdown
answerDiv.innerHTML = renderMarkdown(data.answer);
// Render answer — prefer server-rendered HTML, fall back to client-side markdown
if (data.html_answer) {
answerDiv.innerHTML = data.html_answer;
} else {
answerDiv.innerHTML = renderMarkdown(data.answer);
}
// Render metadata bar
if (data.search_engine || data.tokens_used) {
let metaHtml = '';
if (data.search_engine && data.search_engine !== 'none') {
metaHtml += `<span class="meta-badge engine">🔍 ${data.search_engine}</span>`;
}
if (data.model) {
metaHtml += `<span class="meta-badge">🧠 ${data.model}</span>`;
}
if (data.tokens_used) {
metaHtml += `<span class="meta-badge">📊 ~${data.tokens_used} token</span>`;
}
metaBar.innerHTML = metaHtml;
metaBar.style.display = 'flex';
}
// Render sources
if (data.sources && data.sources.length > 0) {
sourcesDiv.innerHTML = '<h3>📚 Fonti</h3>' +
data.sources.map(s =>
`<a href="${escapeHtml(s.url)}" target="_blank" rel="noopener" class="source-item">
<span class="source-index">${s.index}</span>
<span class="source-title">${escapeHtml(s.title)}</span>
<span class="source-url">${escapeHtml(s.url)}</span>
</a>`
).join('');
}
// Show "new search" button and scroll to results
newSearchBtn.classList.add('visible');
answerDiv.scrollIntoView({ behavior: 'smooth', block: 'start' });
} catch (err) {
answerDiv.innerHTML = `<div class="error-box">⚠️ ${escapeHtml(err.message)}</div>`;
answerDiv.scrollIntoView({ behavior: 'smooth', block: 'start' });
} finally {
loading.style.display = 'none';
submitBtn.disabled = false;
submitBtn.querySelector('span').textContent = btnText;
isSearching = false;
}
});
// Allow Ctrl+Enter to submit
// "New search" button — scroll back to query input
newSearchBtn.addEventListener('click', () => {
queryInput.focus();
queryInput.scrollIntoView({ behavior: 'smooth', block: 'center' });
});
// Allow Ctrl+Enter or Cmd+Enter to submit
queryInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
form.dispatchEvent(new Event('submit'));
@@ -69,6 +119,7 @@ document.addEventListener('DOMContentLoaded', () => {
});
// ── Tiny Markdown renderer ─────────────────────────────────────
function renderMarkdown(text) {
if (!text) return '';
+53
View File
@@ -0,0 +1,53 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
<defs>
<!-- Background gradient: deep indigo to purple -->
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#312E81"/>
<stop offset="50%" stop-color="#4F46E5"/>
<stop offset="100%" stop-color="#7C3AED"/>
</linearGradient>
<!-- Glow for the eye -->
<radialGradient id="eyeGlow" cx="0.45" cy="0.4" r="0.55">
<stop offset="0%" stop-color="#FFFFFF"/>
<stop offset="60%" stop-color="#E0E7FF"/>
<stop offset="100%" stop-color="#A5B4FC"/>
</radialGradient>
<!-- Pupil gradient -->
<radialGradient id="pupil" cx="0.4" cy="0.35" r="0.5">
<stop offset="0%" stop-color="#1E1B4B"/>
<stop offset="100%" stop-color="#0F0D2E"/>
</radialGradient>
<!-- Subtle shadow -->
<filter id="shadow" x="-20%" y="-20%" width="140%" height="140%">
<feDropShadow dx="0" dy="0" stdDeviation="2" flood-color="#1E1B4B" flood-opacity="0.5"/>
</filter>
</defs>
<!-- Base circle -->
<circle cx="32" cy="32" r="30" fill="url(#bg)" filter="url(#shadow)"/>
<!-- Outer ring -->
<circle cx="32" cy="32" r="28" fill="none" stroke="#A5B4FC" stroke-width="0.8" opacity="0.5"/>
<circle cx="32" cy="32" r="25" fill="none" stroke="#C7D2FE" stroke-width="0.5" opacity="0.25"/>
<!-- Eye shape (almond) -->
<ellipse cx="32" cy="31" rx="14" ry="10" fill="url(#eyeGlow)" opacity="0.95"/>
<!-- Upper eyelid arc -->
<path d="M18 30 Q32 18 46 30" fill="none" stroke="#312E81" stroke-width="1.5" stroke-linecap="round" opacity="0.8"/>
<!-- Lower lid hint -->
<path d="M20 33 Q32 42 44 33" fill="none" stroke="#312E81" stroke-width="0.8" stroke-linecap="round" opacity="0.4"/>
<!-- Pupil -->
<circle cx="32" cy="31" r="5.5" fill="url(#pupil)"/>
<!-- Pupil highlight / sparkle -->
<circle cx="30" cy="29" r="1.8" fill="#FFFFFF" opacity="0.85"/>
<circle cx="34" cy="33" r="0.8" fill="#FFFFFF" opacity="0.4"/>
<!-- Small oracle sparkle in top-right -->
<g transform="translate(48, 16)" opacity="0.9">
<path d="M0 -6 L1.5 -1.5 L6 0 L1.5 1.5 L0 6 L-1.5 1.5 L-6 0 L-1.5 -1.5 Z" fill="#FDE68A"/>
<circle cx="0" cy="0" r="1" fill="#FEF3C7"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

+323 -51
View File
@@ -1,5 +1,6 @@
/* ═══════════════════════════════════════════════════════════════
Sibilla — L'Oracolo AI
Mobile-first responsive stylesheet
═══════════════════════════════════════════════════════════════ */
:root {
@@ -14,6 +15,7 @@
--success: #34d399;
--danger: #f87171;
--radius: 12px;
--radius-sm: 8px;
--transition: 0.2s ease;
}
@@ -23,46 +25,84 @@
box-sizing: border-box;
}
html {
scroll-behavior: smooth;
-webkit-text-size-adjust: 100%;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
min-height: 100dvh;
display: flex;
justify-content: center;
line-height: 1.6;
-webkit-font-smoothing: antialiased;
}
/* Subtle background ambience */
body::before {
content: '';
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background:
radial-gradient(ellipse at 50% 0%, rgba(167, 139, 250, 0.06) 0%, transparent 60%),
radial-gradient(ellipse at 80% 100%, rgba(139, 92, 246, 0.04) 0%, transparent 50%);
pointer-events: none;
z-index: 0;
}
.container {
position: relative;
z-index: 1;
max-width: 800px;
width: 100%;
padding: 2rem 1.5rem;
padding: 1rem 1rem;
display: flex;
flex-direction: column;
min-height: 100vh;
min-height: 100dvh;
}
/* ── Header ──────────────────────────────────────────────────── */
header {
text-align: center;
margin-bottom: 3rem;
padding-top: 3rem;
margin-bottom: 1.5rem;
padding-top: 1.5rem;
}
header h1 {
font-size: 2.8rem;
font-size: 1.8rem;
font-weight: 700;
background: linear-gradient(135deg, var(--accent), var(--accent-glow));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
margin-bottom: 0.5rem;
margin-bottom: 0.35rem;
letter-spacing: -0.02em;
display: flex;
align-items: center;
justify-content: center;
gap: 0.4rem;
}
.logo {
width: 1.8rem;
height: 1.8rem;
filter: drop-shadow(0 0 10px rgba(167, 139, 250, 0.45));
flex-shrink: 0;
}
.subtitle {
color: var(--text-muted);
font-size: 1.05rem;
font-size: 0.9rem;
padding: 0 0.5rem;
}
/* ── Search Box ──────────────────────────────────────────────── */
@@ -70,23 +110,24 @@ header h1 {
.search-box {
display: flex;
flex-direction: column;
gap: 0.75rem;
margin-bottom: 2rem;
gap: 0.6rem;
margin-bottom: 1.5rem;
}
.search-box textarea {
width: 100%;
padding: 1rem 1.25rem;
padding: 0.85rem 1rem;
background: var(--surface);
border: 2px solid var(--border);
border-radius: var(--radius);
color: var(--text);
font-size: 1.05rem;
font-size: 1rem;
font-family: inherit;
resize: vertical;
min-height: 80px;
min-height: 70px;
transition: border-color var(--transition), box-shadow var(--transition);
outline: none;
-webkit-appearance: none;
}
.search-box textarea:focus {
@@ -96,11 +137,12 @@ header h1 {
.search-box textarea::placeholder {
color: var(--text-muted);
opacity: 0.7;
}
#submit-btn {
align-self: flex-end;
padding: 0.75rem 1.75rem;
width: 100%;
padding: 0.85rem 1.5rem;
background: linear-gradient(135deg, var(--accent), #8b5cf6);
border: none;
border-radius: var(--radius);
@@ -109,6 +151,12 @@ header h1 {
font-weight: 600;
cursor: pointer;
transition: transform var(--transition), box-shadow var(--transition), opacity var(--transition);
-webkit-tap-highlight-color: transparent;
display: flex;
align-items: center;
justify-content: center;
gap: 0.4rem;
min-height: 48px;
}
#submit-btn:hover {
@@ -117,13 +165,14 @@ header h1 {
}
#submit-btn:active {
transform: translateY(0);
transform: scale(0.98);
}
#submit-btn:disabled {
opacity: 0.6;
opacity: 0.55;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
/* ── Response Area ───────────────────────────────────────────── */
@@ -144,14 +193,14 @@ header h1 {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
padding: 2rem;
gap: 0.85rem;
padding: 2rem 1rem;
color: var(--text-muted);
}
.spinner {
width: 36px;
height: 36px;
width: 32px;
height: 32px;
border: 3px solid var(--border);
border-top-color: var(--accent);
border-radius: 50%;
@@ -162,61 +211,149 @@ header h1 {
to { transform: rotate(360deg); }
}
.loading p {
font-size: 0.9rem;
}
/* ── New Search ──────────────────────────────────────────────── */
.new-search-btn {
display: none;
width: 100%;
padding: 0.65rem 1rem;
background: transparent;
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text-muted);
font-size: 0.9rem;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
margin-bottom: 1rem;
transition: border-color var(--transition), color var(--transition);
min-height: 44px;
}
.new-search-btn:hover {
border-color: var(--accent);
color: var(--accent-glow);
}
.new-search-btn.visible {
display: flex;
align-items: center;
justify-content: center;
gap: 0.35rem;
}
/* ── Answer ──────────────────────────────────────────────────── */
.answer {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1.5rem;
margin-bottom: 1.5rem;
padding: 1.15rem;
margin-bottom: 1rem;
line-height: 1.7;
font-size: 1.02rem;
font-size: 0.95rem;
word-wrap: break-word;
overflow-wrap: break-word;
}
.answer:empty {
display: none;
}
.answer h1, .answer h2, .answer h3 {
margin-top: 1.25rem;
margin-bottom: 0.5rem;
margin-top: 1.1rem;
margin-bottom: 0.4rem;
color: var(--accent-glow);
line-height: 1.3;
}
.answer h1 { font-size: 1.5rem; }
.answer h2 { font-size: 1.25rem; }
.answer h3 { font-size: 1.1rem; }
.answer h1 { font-size: 1.35rem; }
.answer h2 { font-size: 1.15rem; }
.answer h3 { font-size: 1.05rem; }
.answer p {
margin-bottom: 0.75rem;
margin-bottom: 0.65rem;
}
.answer ul, .answer ol {
padding-left: 1.5rem;
margin-bottom: 0.75rem;
padding-left: 1.25rem;
margin-bottom: 0.65rem;
}
.answer li {
margin-bottom: 0.35rem;
margin-bottom: 0.3rem;
}
.answer a {
color: var(--accent-glow);
text-decoration: underline;
text-underline-offset: 2px;
}
.answer a:hover {
color: var(--accent);
}
.answer code {
background: rgba(167, 139, 250, 0.1);
padding: 0.15rem 0.4rem;
border-radius: 4px;
font-size: 0.9em;
font-size: 0.88em;
word-break: break-word;
}
.answer pre {
background: rgba(0, 0, 0, 0.3);
padding: 1rem;
border-radius: 8px;
background: rgba(0, 0, 0, 0.35);
padding: 0.85rem;
border-radius: var(--radius-sm);
overflow-x: auto;
margin-bottom: 0.75rem;
margin-bottom: 0.65rem;
font-size: 0.85em;
-webkit-overflow-scrolling: touch;
}
.answer pre code {
background: none;
padding: 0;
border-radius: 0;
}
.answer blockquote {
border-left: 3px solid var(--accent);
padding-left: 1rem;
padding-left: 0.85rem;
color: var(--text-muted);
margin-bottom: 0.65rem;
font-style: italic;
}
/* ── Metadata bar ────────────────────────────────────────────── */
.meta-bar {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-bottom: 0.75rem;
font-size: 0.75rem;
color: var(--text-muted);
align-items: center;
}
.meta-badge {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.2rem 0.5rem;
background: rgba(167, 139, 250, 0.08);
border-radius: 100px;
font-size: 0.72rem;
}
.meta-badge.engine {
background: rgba(52, 211, 153, 0.1);
color: var(--success);
}
/* ── Sources ─────────────────────────────────────────────────── */
@@ -225,32 +362,57 @@ header h1 {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1.25rem;
padding: 1rem;
margin-bottom: 1rem;
}
.sources:empty {
display: none;
}
.sources h3 {
color: var(--accent-glow);
font-size: 0.95rem;
margin-bottom: 0.75rem;
font-size: 0.82rem;
margin-bottom: 0.6rem;
text-transform: uppercase;
letter-spacing: 0.05em;
letter-spacing: 0.06em;
display: flex;
align-items: center;
gap: 0.35rem;
}
.source-item {
display: block;
padding: 0.6rem 0.75rem;
border-radius: 8px;
padding: 0.55rem 0.65rem;
border-radius: var(--radius-sm);
color: var(--text);
text-decoration: none;
transition: background var(--transition);
margin-bottom: 0.25rem;
font-size: 0.9rem;
margin-bottom: 0.15rem;
font-size: 0.85rem;
-webkit-tap-highlight-color: transparent;
}
.source-item:active,
.source-item:hover {
background: var(--surface-hover);
}
.source-item .source-index {
display: inline-block;
width: 1.3rem;
height: 1.3rem;
line-height: 1.3rem;
text-align: center;
background: rgba(167, 139, 250, 0.15);
color: var(--accent-glow);
border-radius: 4px;
font-size: 0.7rem;
font-weight: 700;
margin-right: 0.4rem;
flex-shrink: 0;
}
.source-item .source-title {
font-weight: 500;
color: var(--accent-glow);
@@ -258,28 +420,138 @@ header h1 {
.source-item .source-url {
color: var(--text-muted);
font-size: 0.8rem;
font-size: 0.75rem;
display: block;
margin-top: 0.15rem;
margin-top: 0.1rem;
word-break: break-all;
}
/* ── Error ───────────────────────────────────────────────────── */
.error-box {
background: rgba(248, 113, 113, 0.1);
background: rgba(248, 113, 113, 0.08);
border: 1px solid var(--danger);
border-radius: var(--radius);
padding: 1rem 1.25rem;
padding: 0.85rem 1rem;
color: var(--danger);
font-size: 0.9rem;
}
/* ── Footer ──────────────────────────────────────────────────── */
footer {
text-align: center;
padding: 1.5rem 0;
padding: 1.25rem 0 0.75rem;
color: var(--text-muted);
font-size: 0.8rem;
font-size: 0.72rem;
margin-top: auto;
}
/* ═══════════════════════════════════════════════════════════════
Tablet / Desktop ≥ 640px
═══════════════════════════════════════════════════════════════ */
@media (min-width: 640px) {
.container {
padding: 2rem 2rem;
}
header {
margin-bottom: 2.5rem;
padding-top: 2.5rem;
}
header h1 {
font-size: 2.4rem;
gap: 0.5rem;
}
.logo {
width: 2.4rem;
height: 2.4rem;
}
.subtitle {
font-size: 1rem;
}
.search-box {
gap: 0.75rem;
margin-bottom: 2rem;
}
.search-box textarea {
padding: 1rem 1.25rem;
font-size: 1.05rem;
min-height: 80px;
}
#submit-btn {
width: auto;
align-self: flex-end;
padding: 0.75rem 2rem;
}
.answer {
padding: 1.5rem;
font-size: 1rem;
}
.answer h1 { font-size: 1.5rem; }
.answer h2 { font-size: 1.25rem; }
.answer h3 { font-size: 1.1rem; }
.sources {
padding: 1.25rem;
}
.source-item {
font-size: 0.9rem;
}
}
/* ═══════════════════════════════════════════════════════════════
Desktop ≥ 900px
═══════════════════════════════════════════════════════════════ */
@media (min-width: 900px) {
header {
padding-top: 3.5rem;
margin-bottom: 3rem;
}
header h1 {
font-size: 2.8rem;
}
.logo {
width: 2.8rem;
height: 2.8rem;
}
.answer {
padding: 1.75rem;
}
}
/* ═══════════════════════════════════════════════════════════════
Reduce motion for accessibility
═══════════════════════════════════════════════════════════════ */
@media (prefers-reduced-motion: reduce) {
html {
scroll-behavior: auto;
}
.response-area {
animation: none;
}
.spinner {
animation-duration: 1.5s;
}
* {
transition-duration: 0.01ms !important;
}
}
+9 -4
View File
@@ -2,14 +2,17 @@
<html lang="it">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<meta name="theme-color" content="#0f0f14">
<meta name="description" content="Sibilla — L'oracolo AI che cerca nel web e risponde alle tue domande">
<title>Sibilla — L'Oracolo AI</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<div class="container">
<header>
<h1>🔮 Sibilla</h1>
<h1><img src="/static/favicon.svg" alt="Sibilla logo" class="logo"> Sibilla</h1>
<p class="subtitle">L'oracolo AI che cerca nel web e risponde alle tue domande</p>
</header>
@@ -23,7 +26,7 @@
autofocus
></textarea>
<button type="submit" id="submit-btn">
<span>Interroga l'Oracolo</span>
<span>🔮 Interroga l'Oracolo</span>
</button>
</form>
@@ -32,13 +35,15 @@
<span class="spinner"></span>
<p>Sibilla sta consultando le fonti...</p>
</div>
<button id="new-search-btn" class="new-search-btn">↩ Nuova ricerca</button>
<div id="meta-bar" class="meta-bar" style="display: none;"></div>
<div id="answer" class="answer"></div>
<div id="sources" class="sources"></div>
</div>
</main>
<footer>
<p>Sibilla v0.1.0 — Self-hosted AI Search</p>
<p>Sibilla v0.2.0 — Self-hosted AI Search</p>
</footer>
</div>
+201
View File
@@ -250,3 +250,204 @@ class TestAskEndpoint:
def test_ask_without_json_body(self, client):
resp = client.post("/api/ask", data="not json")
assert resp.status_code == 400
class TestApiV1QueryEndpoint:
"""Tests for the /api/v1/query endpoint (voice-assistant / curl-friendly API)."""
def test_get_missing_query_returns_400(self, client):
resp = client.get("/api/v1/query")
assert resp.status_code == 400
assert "Missing query" in resp.get_json()["error"]
def test_get_empty_query_returns_400(self, client):
resp = client.get("/api/v1/query?q=")
assert resp.status_code == 400
def test_post_empty_query_returns_400(self, client):
resp = client.post("/api/v1/query", json={"query": ""})
assert resp.status_code == 400
def test_cors_headers_present(self, client):
resp = client.get("/api/v1/query?q=test")
assert resp.headers.get("Access-Control-Allow-Origin") == "*"
assert "GET" in resp.headers.get("Access-Control-Allow-Methods", "")
def test_get_query_full_pipeline(self, app):
"""Full pipeline via GET /api/v1/query with mocked services."""
client = app.test_client()
def mock_degoog_search(self, query, num_results=8, language="it"):
return SearchResponse(
query=query,
results=[
SearchResult(title="R1", url="https://a.com", snippet="Content A"),
],
engine="degoog",
)
def mock_synthesize(self, system_prompt, user_prompt, temperature=0.4, max_tokens=4096):
return SynthesisResult(
answer="Ecco la **risposta** con [link](https://x.com) e `code`.",
model="gemini-2.5-flash",
tokens_used=50,
)
def mock_rewrite_query(self, original_query, num_variants=3):
return [original_query]
with mock.patch(
"sibilla.app.DegoogClient.search", mock_degoog_search
), mock.patch(
"sibilla.app.GeminiClient.synthesize", mock_synthesize
), mock.patch(
"sibilla.app.GeminiClient.rewrite_query", mock_rewrite_query
), mock.patch(
"sibilla.app.PageFetcher.fetch_results",
lambda self, results, max_results=5: results,
):
resp = client.get("/api/v1/query?q=test+query")
assert resp.status_code == 200
data = resp.get_json()
assert data["query"] == "test query"
assert "risposta" in data["answer"]
assert "speakable_text" in data
# speakable_text must be clean of markdown
assert "**" not in data["speakable_text"]
assert "[" not in data["speakable_text"]
assert "`" not in data["speakable_text"]
assert "Ecco la risposta con link e code." in data["speakable_text"]
assert data["model"] == "gemini-2.5-flash"
assert len(data["sources"]) == 1
def test_post_query(self, app):
"""POST /api/v1/query with JSON body."""
client = app.test_client()
def mock_degoog_search(self, query, num_results=8, language="it"):
return SearchResponse(
query=query,
results=[
SearchResult(title="R1", url="https://a.com", snippet="S"),
],
engine="degoog",
)
def mock_synthesize(self, system_prompt, user_prompt, temperature=0.4, max_tokens=4096):
return SynthesisResult(answer="Risposta semplice.", model="g", tokens_used=10)
def mock_rewrite_query(self, original_query, num_variants=3):
return [original_query]
with mock.patch(
"sibilla.app.DegoogClient.search", mock_degoog_search
), mock.patch(
"sibilla.app.GeminiClient.synthesize", mock_synthesize
), mock.patch(
"sibilla.app.GeminiClient.rewrite_query", mock_rewrite_query
), mock.patch(
"sibilla.app.PageFetcher.fetch_results",
lambda self, results, max_results=5: results,
):
resp = client.post("/api/v1/query", json={"query": "hello"})
assert resp.status_code == 200
data = resp.get_json()
assert data["query"] == "hello"
assert data["answer"] == "Risposta semplice."
assert data["speakable_text"] == "Risposta semplice."
def test_no_results_fallback(self, app):
"""When no search results, falls back to pure LLM."""
client = app.test_client()
def mock_empty_search(self, *args, **kwargs):
return SearchResponse(query="q", results=[], engine="none")
def mock_synthesize(self, system_prompt, user_prompt, temperature=0.4, max_tokens=4096):
return SynthesisResult(answer="Risposta senza fonti.", model="g", tokens_used=5)
def mock_rewrite_query(self, original_query, num_variants=3):
return [original_query]
with mock.patch(
"sibilla.app.DegoogClient.search", mock_empty_search
), mock.patch(
"sibilla.app.SearXNGClient.search", mock_empty_search
), mock.patch(
"sibilla.app.DuckDuckGoClient.search", mock_empty_search
), mock.patch(
"sibilla.app.GeminiClient.synthesize", mock_synthesize
), mock.patch(
"sibilla.app.GeminiClient.rewrite_query", mock_rewrite_query
):
resp = client.get("/api/v1/query?q=test")
assert resp.status_code == 200
data = resp.get_json()
assert data["speakable_text"] == "Risposta senza fonti."
assert data["sources"] == []
def test_search_error_returns_502(self, app):
"""When all backends error, return 502."""
client = app.test_client()
def mock_error_search(self, *args, **kwargs):
return SearchResponse(query="q", results=[], error="Down", engine="degoog")
def mock_rewrite_query(self, original_query, num_variants=3):
return [original_query]
with mock.patch(
"sibilla.app.DegoogClient.search", mock_error_search
), mock.patch(
"sibilla.app.SearXNGClient.search", mock_error_search
), mock.patch(
"sibilla.app.DuckDuckGoClient.search", mock_error_search
), mock.patch(
"sibilla.app.GeminiClient.rewrite_query", mock_rewrite_query
):
resp = client.get("/api/v1/query?q=test")
assert resp.status_code == 502
def test_speakable_text_in_ask_endpoint(self, app):
"""Verify that /api/ask also includes speakable_text after our changes."""
client = app.test_client()
def mock_degoog_search(self, query, num_results=8, language="it"):
return SearchResponse(
query=query,
results=[
SearchResult(title="R1", url="https://a.com", snippet="Content A"),
],
engine="degoog",
)
def mock_synthesize(self, system_prompt, user_prompt, temperature=0.4, max_tokens=4096):
return SynthesisResult(
answer="Test **bold** answer.",
model="g",
tokens_used=5,
)
def mock_rewrite_query(self, original_query, num_variants=3):
return [original_query]
with mock.patch(
"sibilla.app.DegoogClient.search", mock_degoog_search
), mock.patch(
"sibilla.app.GeminiClient.synthesize", mock_synthesize
), mock.patch(
"sibilla.app.GeminiClient.rewrite_query", mock_rewrite_query
), mock.patch(
"sibilla.app.PageFetcher.fetch_results",
lambda self, results, max_results=5: results,
):
resp = client.post("/api/ask", json={"query": "t"})
assert resp.status_code == 200
data = resp.get_json()
assert "speakable_text" in data
assert data["speakable_text"] == "Test bold answer."
+264
View File
@@ -0,0 +1,264 @@
"""Tests for sibilla.markdown — server-side Markdown → HTML conversion."""
from sibilla.markdown import render, strip_markdown
class TestBasicMarkdown:
def test_empty_returns_empty(self):
assert render("") == ""
assert render(None) == ""
def test_plain_text(self):
result = render("Ciao mondo")
assert "Ciao mondo" in result
assert result.startswith("<p>")
def test_bold(self):
result = render("Test **bold** text")
assert "<strong>bold</strong>" in result
def test_italic(self):
result = render("Test *italic* text")
assert "<em>italic</em>" in result
def test_bold_italic(self):
result = render("Test ***bold italic*** text")
assert "<strong>" in result
assert "<em>" in result
def test_inline_code(self):
result = render("Use `print()` function")
assert "<code>print()</code>" in result
def test_headers(self):
result = render("# H1\n## H2\n### H3")
assert "<h1>H1</h1>" in result
assert "<h2>H2</h2>" in result
assert "<h3>H3</h3>" in result
class TestLinks:
def test_basic_link(self):
result = render("[Google](https://google.com)")
assert '<a href="https://google.com">Google</a>' in result
def test_link_with_title(self):
result = render('[Home](https://example.com "Example Home")')
assert 'href="https://example.com"' in result
assert 'title="Example Home"' in result
def test_link_target_blank_is_preserved(self):
"""The markdown library doesn't add target, but if already in HTML..."""
# This tests that our sanitizer doesn't strip legitimate attrs
result = render("[link](https://a.com)")
assert "href" in result
class TestLists:
def test_unordered_list(self):
result = render("- Item 1\n- Item 2\n- Item 3")
assert "<ul>" in result
assert "<li>Item 1</li>" in result
assert "<li>Item 2</li>" in result
assert "<li>Item 3</li>" in result
def test_ordered_list(self):
result = render("1. First\n2. Second\n3. Third")
assert "<ol>" in result
assert "<li>First</li>" in result
assert "<li>Second</li>" in result
def test_nested_list(self):
result = render("- A\n - A1\n - A2\n- B")
assert result.count("<li>A</li>") == 1
assert result.count("<li>") >= 3 # A, A1, A2
class TestCodeBlocks:
def test_fenced_code_block(self):
result = render("```python\nprint('hello')\n```")
assert "<code>" in result or "<pre>" in result
def test_indented_code_block(self):
result = render(" code line 1\n code line 2")
assert "<code>" in result or "<pre>" in result
class TestBlockquotes:
def test_blockquote(self):
result = render("> This is a quote\n> Second line")
assert "<blockquote>" in result
assert "This is a quote" in result
class TestTables:
def test_table(self):
result = render("| A | B |\n|---|---|\n| 1 | 2 |")
assert "<table>" in result
assert "<th>A</th>" in result
assert "<td>1</td>" in result
class TestHorizontalRule:
def test_hr(self):
result = render("---")
assert "<hr" in result.lower()
class TestSanitization:
def test_script_tag_stripped(self):
"""<script> tags must be absent from rendered output."""
result = render("<script>alert('xss')</script>")
assert "<script>" not in result
assert "</script>" not in result
def test_event_handlers_stripped(self):
"""onerror/onclick handlers must be stripped even on allowed tags."""
result = render('<img src="x" onerror="alert(1)" />')
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 "<strong>bold</strong>" in result
assert "<em>italic</em>" in result
class TestParagraphs:
def test_paragraph_wrapping(self):
result = render("Line 1.\n\nLine 2.")
assert result.count("<p>") >= 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 <b>HTML</b> 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