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:
1 parent
f3fe13c60e
commit
76bc667adf
20 files changed
+3385
-245
No files matched your search
+29
-12
@@ -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
|
||||
@@ -1,101 +1,241 @@
|
||||
# 🔮 Sibilla
|
||||
# 🔮 Sibilla — L'Oracolo AI
|
||||
|
||||
> *L'oracolo AI che cerca nel web e risponde alle tue domande.*
|
||||
> Self-hosted Perplexity-like search engine.
|
||||
> *Self-hosted Perplexity-like AI search engine.*
|
||||
> *Chiedi, cerca, sintetizza — con fonti verificabili.*
|
||||
|
||||
[](https://www.python.org/downloads/)
|
||||
[](https://flask.palletsprojects.com/)
|
||||
[](https://ai.google.dev/)
|
||||
[](https://github.com/tuo-user/sibilla)
|
||||
[](LICENSE)
|
||||
[](https://modelcontextprotocol.io/)
|
||||
|
||||
---
|
||||
|
||||
**Sibilla** è un motore di ricerca AI self-hosted che combina:
|
||||
|
||||
- 🧠 **Google Gemini** — LLM per sintesi e query expansion
|
||||
- 🔍 **Degoog** — metasearch engine self-hosted (primary) con MCP nativo
|
||||
- 🔄 **SearXNG + DuckDuckGo** — fallback a cascata se Degoog non risponde
|
||||
- 🧠 **Google Gemini** — LLM per sintesi con citazioni inline [1], [2], ...
|
||||
- 🔍 **Degoog** — metasearch engine self-hosted (primary) con API nativa
|
||||
- 🔄 **SearXNG + DuckDuckGo** — fallback a cascata se il primary non risponde
|
||||
- 🌐 **Interfaccia web** — UI responsive, dark theme, mobile-first
|
||||
- 📡 **API REST** — endpoint per consumo programmatico e skill vocali (Alexa, Google Home)
|
||||
- 🔧 **MCP (Model Context Protocol)** — server stdio per agent AI esterni
|
||||
- 🖥️ **Flask web app** — interfaccia pulita e reattiva
|
||||
- 🗣️ **TTS-ready** — campo `speakable_text` ottimizzato per sintesi vocale
|
||||
|
||||
```
|
||||
Tu chiedi → Query expansion → Degoog search → Fetch pagine → Gemini sintesi → Risposta con fonti
|
||||
Tu chiedi → Query expansion → Ricerca multi-engine → Fetch pagine → Gemini sintesi → Risposta con fonti
|
||||
```
|
||||
|
||||
## Architettura
|
||||
---
|
||||
|
||||
```
|
||||
sibilla/
|
||||
├── src/sibilla/
|
||||
│ ├── app.py # Flask app factory + pipeline /api/ask
|
||||
│ ├── cli.py # Entry point server web
|
||||
│ ├── search.py # DegoogClient, SearXNGClient, DuckDuckGoClient, PageFetcher
|
||||
│ ├── ai.py # GeminiClient (sintesi + query rewriting)
|
||||
│ ├── mcp.py # MCP server su stdio (per agent AI esterni)
|
||||
│ └── prompts.py # System prompt stile Perplexity
|
||||
├── templates/
|
||||
│ └── index.html # Interfaccia di ricerca
|
||||
├── static/
|
||||
│ ├── style.css # Stili dark theme
|
||||
│ └── app.js # Logica frontend
|
||||
└── tests/
|
||||
```
|
||||
## 📚 Documentazione
|
||||
|
||||
## Pipeline di ricerca
|
||||
| Documento | Descrizione |
|
||||
|-----------|-------------|
|
||||
| [Architettura](docs/ARCHITECTURE.md) | 🏗️ Visione d'insieme, stack tecnologico, componenti, modello dati |
|
||||
| [Diagramma di Flusso](docs/FLOW.md) | 🔄 Pipeline di ricerca, flusso richieste, macchina a stati (Mermaid) |
|
||||
| [API REST](docs/API.md) | 📡 Documentazione completa degli endpoint `/api/ask` e `/api/v1/query` |
|
||||
| [Wiki](docs/WIKI.md) | 📚 FAQ, glossario, confronto con Perplexity, roadmap |
|
||||
| [Deployment](docs/DEPLOYMENT.md) | 🚀 Guida al deployment in produzione (Gunicorn, Nginx, Docker, systemd) |
|
||||
| [Contributing](docs/CONTRIBUTING.md) | 🤝 Guida per contribuire, convenzioni, test, struttura branch |
|
||||
|
||||
```
|
||||
POST /api/ask {query: "..."}
|
||||
│
|
||||
├─ 1. Gemini riscrive la query in 3 varianti (query expansion)
|
||||
├─ 2. Multi-query search: Degoog → SearXNG → DuckDuckGo (cascading)
|
||||
├─ 3. PageFetcher scarica il contenuto completo delle top pagine
|
||||
├─ 4. Prompt assembly con fonti numerate
|
||||
├─ 5. Gemini sintesi con citazioni [1], [2], ...
|
||||
└─ 6. JSON {answer, sources[], model, search_engine, tokens_used}
|
||||
```
|
||||
---
|
||||
|
||||
## Setup
|
||||
## 🚀 Quick Start
|
||||
|
||||
```bash
|
||||
# 1. Clona e installa
|
||||
git clone https://github.com/tuo-user/sibilla.git
|
||||
cd sibilla
|
||||
python3 -m venv .venv
|
||||
source .venv/bin/activate
|
||||
pip install -e ".[dev]"
|
||||
|
||||
# 2. Avvia Degoog (motore di ricerca primary, opzionale ma consigliato)
|
||||
docker run -d --name degoog -p 4444:4444 ghcr.io/degoog-org/degoog:latest
|
||||
|
||||
# 3. Configura
|
||||
# Crea un file .env con la tua Google API Key:
|
||||
# GOOGLE_API_KEY=la-tua-api-key
|
||||
|
||||
# 4. Avvia
|
||||
sibilla
|
||||
# → http://127.0.0.1:5000
|
||||
```
|
||||
|
||||
### Prerequisiti
|
||||
### Prerequisiti minimi
|
||||
|
||||
1. **Google API Key** — [https://aistudio.google.com/apikey](https://aistudio.google.com/apikey)
|
||||
2. **Degoog** (consigliato) — motore di ricerca primary:
|
||||
```bash
|
||||
docker run -d --name degoog -p 4444:4444 ghcr.io/degoog-org/degoog:latest
|
||||
```
|
||||
3. **SearXNG** (opzionale, fallback) — se vuoi un secondo motore di backup:
|
||||
```bash
|
||||
docker run -d -p 8080:8080 searxng/searxng
|
||||
```
|
||||
- **Python 3.10+**
|
||||
- **Google API Key** — [ottienila gratis](https://aistudio.google.com/apikey)
|
||||
- Almeno un motore di ricerca (Degoog, SearXNG, o DuckDuckGo automatico)
|
||||
|
||||
---
|
||||
|
||||
## 🔌 API
|
||||
|
||||
### Ricerca via API (per skill Alexa, bot, app)
|
||||
|
||||
```bash
|
||||
# GET — il più semplice
|
||||
curl "http://localhost:5000/api/v1/query?q=ultimo+film+sull%27Odissea"
|
||||
|
||||
# POST — per query lunghe
|
||||
curl -X POST http://localhost:5000/api/v1/query \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"query": "quali sono i migliori ristoranti a Roma?"}'
|
||||
|
||||
# Estrai solo il testo per TTS
|
||||
curl -s "http://localhost:5000/api/v1/query?q=raggi+cosmici" \
|
||||
| python -c "import sys,json; print(json.load(sys.stdin)['speakable_text'])"
|
||||
```
|
||||
|
||||
**Risposta:**
|
||||
```json
|
||||
{
|
||||
"query": "ultimo film sull'Odissea",
|
||||
"answer": "L'ultimo adattamento è **The Return** (2024)...",
|
||||
"speakable_text": "L'ultimo adattamento è The Return del 2024...",
|
||||
"sources": [{"index": 1, "title": "...", "url": "..."}],
|
||||
"model": "gemini-2.5-flash",
|
||||
"search_engine": "degoog",
|
||||
"tokens_used": 1847
|
||||
}
|
||||
```
|
||||
|
||||
> **Documentazione completa:** [`docs/API.md`](docs/API.md)
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ Architettura
|
||||
|
||||
```
|
||||
sibilla/
|
||||
├── docs/ # 📚 Documentazione
|
||||
│ ├── ARCHITECTURE.md # Architettura di sistema
|
||||
│ ├── FLOW.md # Diagramma di flusso (Mermaid)
|
||||
│ ├── API.md # Documentazione API REST
|
||||
│ ├── WIKI.md # Wiki + FAQ
|
||||
│ ├── DEPLOYMENT.md # Guida al deployment
|
||||
│ └── CONTRIBUTING.md # Guida alla contribuzione
|
||||
│
|
||||
├── src/sibilla/ # 📦 Codice sorgente
|
||||
│ ├── app.py # Flask app factory + pipeline condivisa
|
||||
│ ├── search.py # Degoog, SearXNG, DuckDuckGo, PageFetcher
|
||||
│ ├── ai.py # GeminiClient (sintesi + query rewriting)
|
||||
│ ├── markdown.py # Markdown → HTML + strip per TTS
|
||||
│ ├── prompts.py # System prompt stile Perplexity
|
||||
│ ├── mcp.py # MCP server stdio (per agent AI)
|
||||
│ └── cli.py # Entry point server
|
||||
│
|
||||
├── templates/index.html # Interfaccia web
|
||||
├── static/ # CSS, JS, favicon
|
||||
├── tests/ # Test (pytest, 109 test)
|
||||
└── pyproject.toml # Configurazione progetto
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔄 Pipeline di Ricerca
|
||||
|
||||
```
|
||||
POST /api/ask {query: "..."}
|
||||
│
|
||||
├─ 1. 🔮 Gemini riscrive la query in 3 varianti (query expansion)
|
||||
├─ 2. 🔍 Multi-engine search: Degoog → SearXNG → DuckDuckGo (cascading)
|
||||
├─ 3. 📥 PageFetcher scarica il contenuto completo delle top pagine
|
||||
├─ 4. 📝 Prompt assembly con fonti numerate + data odierna
|
||||
├─ 5. 🧠 Gemini sintesi con citazioni inline [1], [2], ...
|
||||
└─ 6. ✅ JSON {answer, html_answer, speakable_text, sources[], metadata}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔧 MCP Server
|
||||
|
||||
Sibilla espone le sue capacità di ricerca come tool MCP (Model Context Protocol) su stdio:
|
||||
|
||||
```bash
|
||||
sibilla-mcp
|
||||
```
|
||||
|
||||
**Tool disponibili:**
|
||||
- `web_search(query, num_results)` — ricerca web con fallback Degoog → SearXNG → DuckDuckGo
|
||||
- `fetch_page(url)` — estrai contenuto testuale da una pagina web
|
||||
|
||||
Compatibile con client MCP come Claude Desktop, Gemini Desktop e altri agent AI.
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Sviluppo
|
||||
|
||||
```bash
|
||||
# Installa dipendenze dev
|
||||
pip install -e ".[dev]"
|
||||
|
||||
# Lint
|
||||
ruff check src tests
|
||||
|
||||
# Test
|
||||
pytest tests/ -v
|
||||
|
||||
# Coverage
|
||||
pip install pytest-cov
|
||||
pytest tests/ --cov=src/sibilla --cov-report=html
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Deployment
|
||||
|
||||
Per il deployment in produzione:
|
||||
|
||||
```bash
|
||||
# Con Gunicorn
|
||||
pip install gunicorn
|
||||
gunicorn -w 4 -b 0.0.0.0:5000 "sibilla.app:create_app()"
|
||||
|
||||
# Con Docker Compose (Sibilla + Degoog + SearXNG)
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
> **Guida completa:** [`docs/DEPLOYMENT.md`](docs/DEPLOYMENT.md)
|
||||
|
||||
---
|
||||
|
||||
## 📋 Configurazione
|
||||
|
||||
Crea un file `.env`:
|
||||
|
||||
```env
|
||||
GOOGLE_API_KEY=la-tua-api-key
|
||||
SECRET_KEY=una-chiave-segreta-lunga-e-casuale
|
||||
SIBILLA_HOST=127.0.0.1
|
||||
SIBILLA_PORT=5000
|
||||
SIBILLA_DEBUG=1
|
||||
DEGOOG_URL=http://localhost:4444
|
||||
SEARXNG_URL=http://localhost:8080
|
||||
SIBILLA_DDG_FALLBACK=1
|
||||
```
|
||||
|
||||
Senza Degoog, Sibilla userà automaticamente SearXNG e DuckDuckGo come fallback.
|
||||
---
|
||||
|
||||
## Uso
|
||||
## 🤝 Contribuire
|
||||
|
||||
```bash
|
||||
# Avvia la web app
|
||||
sibilla
|
||||
# → http://127.0.0.1:5000
|
||||
Contributi benvenuti! Vedi [`docs/CONTRIBUTING.md`](docs/CONTRIBUTING.md) per:
|
||||
|
||||
# Avvia il server MCP (per agent AI esterni)
|
||||
sibilla-mcp
|
||||
```
|
||||
- Setup dell'ambiente di sviluppo
|
||||
- Convenzioni di codice e commit
|
||||
- Come aggiungere search engine, endpoint, o tool MCP
|
||||
- Struttura dei test
|
||||
|
||||
## Sviluppo
|
||||
---
|
||||
|
||||
```bash
|
||||
pip install -e ".[dev,mcp]"
|
||||
ruff check src tests
|
||||
pytest
|
||||
```
|
||||
## 📄 Licenza
|
||||
|
||||
## Licenza
|
||||
MIT — vedi il file [LICENSE](LICENSE) per i dettagli.
|
||||
|
||||
MIT
|
||||
---
|
||||
|
||||
<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
@@ -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.
|
||||
@@ -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`
|
||||
@@ -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! 🔮
|
||||
@@ -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
@@ -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
@@ -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
@@ -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,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
@@ -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
@@ -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
|
||||
@@ -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 (must run BEFORE link stripping,
|
||||
# otherwise the link regex eats the [alt](url) part of )
|
||||
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
@@ -148,7 +148,7 @@ class MCPServer:
|
||||
"capabilities": {"tools": {}},
|
||||
"serverInfo": {
|
||||
"name": "sibilla-mcp",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
+55
-4
@@ -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 '';
|
||||
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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."
|
||||
@@ -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: "
|
||||
)
|
||||
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
|
||||
Reference in new issue
Block a user