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."
|
||||
@@ -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