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
@@ -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."
|
||||
Reference in new issue
Block a user