Switch default Qwen model and collapse Q&A snippets
Browse files- README.md +3 -7
- app.py +29 -3
- assets/example_bills/cybersecurity-amendment-bill-2025/metadata.json +2 -2
- config.py +2 -2
- tests/test_app.py +26 -0
README.md
CHANGED
|
@@ -10,7 +10,7 @@ app_file: app.py
|
|
| 10 |
pinned: false
|
| 11 |
short_description: Simplify complex legislation that affects you!
|
| 12 |
models:
|
| 13 |
-
- Qwen/Qwen3-
|
| 14 |
---
|
| 15 |
|
| 16 |
# Legislation Explainer
|
|
@@ -29,13 +29,9 @@ It is created for the Hugging Face Build Small Hackathon under the `Backyard AI`
|
|
| 29 |
|
| 30 |
- Track: `Backyard AI`
|
| 31 |
- Real user: Ghanaian citizens and digital-policy stakeholders who need a clearer view of a bill's practical effects.
|
| 32 |
-
- Small-model constraint: default provider is `Qwen/Qwen3-
|
| 33 |
- Required surface: Gradio app, ready for Hugging Face Spaces through `app.py`.
|
| 34 |
-
<<<<<<< HEAD
|
| 35 |
-
- Submission assets still needed: Space link, short demo video, and social post.
|
| 36 |
-
=======
|
| 37 |
- GitHub repo: https://github.com/KayO-GH/legislation-explainer
|
| 38 |
-
>>>>>>> hf
|
| 39 |
|
| 40 |
## What It Does
|
| 41 |
|
|
@@ -52,7 +48,7 @@ It is created for the Hugging Face Build Small Hackathon under the `Backyard AI`
|
|
| 52 |
|
| 53 |
## Model And Provider Notes
|
| 54 |
|
| 55 |
-
The hackathon-safe default is Qwen3
|
| 56 |
|
| 57 |
For future expansion, we will have a bring your own provider setting allowing users to connect to other models eg. from OpenAI, ANthropic, etc. if they so wish.
|
| 58 |
|
|
|
|
| 10 |
pinned: false
|
| 11 |
short_description: Simplify complex legislation that affects you!
|
| 12 |
models:
|
| 13 |
+
- Qwen/Qwen3-14B
|
| 14 |
---
|
| 15 |
|
| 16 |
# Legislation Explainer
|
|
|
|
| 29 |
|
| 30 |
- Track: `Backyard AI`
|
| 31 |
- Real user: Ghanaian citizens and digital-policy stakeholders who need a clearer view of a bill's practical effects.
|
| 32 |
+
- Small-model constraint: default provider is `Qwen/Qwen3-14B:cheapest` through the Hugging Face router, staying within the hackathon's `<= 32B` model cap while giving a better speed/cost tradeoff for this app.
|
| 33 |
- Required surface: Gradio app, ready for Hugging Face Spaces through `app.py`.
|
|
|
|
|
|
|
|
|
|
| 34 |
- GitHub repo: https://github.com/KayO-GH/legislation-explainer
|
|
|
|
| 35 |
|
| 36 |
## What It Does
|
| 37 |
|
|
|
|
| 48 |
|
| 49 |
## Model And Provider Notes
|
| 50 |
|
| 51 |
+
The hackathon-safe default is Qwen3 14B through the Hugging Face router. Bring-your-own provider support is a proposed future expansion and is currently commented out so the hackathon build stays focused on one documented `<= 32B` model path.
|
| 52 |
|
| 53 |
For future expansion, we will have a bring your own provider setting allowing users to connect to other models eg. from OpenAI, ANthropic, etc. if they so wish.
|
| 54 |
|
app.py
CHANGED
|
@@ -383,6 +383,25 @@ def _format_analysis(analysis: AnalysisResult | None) -> str:
|
|
| 383 |
)
|
| 384 |
|
| 385 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 386 |
def _format_chat_entry(answer_text: str, citations: list[dict[str, Any]], *, provenance: str = "analysis_based") -> str:
|
| 387 |
prefix = ""
|
| 388 |
if provenance == "analysis_based":
|
|
@@ -392,8 +411,7 @@ def _format_chat_entry(answer_text: str, citations: list[dict[str, Any]], *, pro
|
|
| 392 |
|
| 393 |
if not citations:
|
| 394 |
return prefix + answer_text
|
| 395 |
-
|
| 396 |
-
return f"{prefix}{answer_text}\n\nSupporting snippets:\n{citations_text}"
|
| 397 |
|
| 398 |
|
| 399 |
def _stream_chat_entry(
|
|
@@ -403,13 +421,21 @@ def _stream_chat_entry(
|
|
| 403 |
*,
|
| 404 |
provenance: str = "analysis_based",
|
| 405 |
):
|
| 406 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 407 |
streamed_answer = ""
|
| 408 |
for character in formatted_answer:
|
| 409 |
streamed_answer += character
|
| 410 |
yield base_history + [{"role": "assistant", "content": streamed_answer}]
|
| 411 |
if CHAT_STREAM_DELAY_SECONDS:
|
| 412 |
time.sleep(CHAT_STREAM_DELAY_SECONDS)
|
|
|
|
|
|
|
| 413 |
|
| 414 |
|
| 415 |
def _deeper_answer_updates(visible: bool, label: str = "Run deeper full-document answer") -> gr.update:
|
|
|
|
| 383 |
)
|
| 384 |
|
| 385 |
|
| 386 |
+
def _render_supporting_snippets(citations: list[dict[str, Any]]) -> str:
|
| 387 |
+
if not citations:
|
| 388 |
+
return ""
|
| 389 |
+
|
| 390 |
+
items = []
|
| 391 |
+
for item in citations:
|
| 392 |
+
ref_id = html.escape(str(item["ref_id"]))
|
| 393 |
+
snippet = html.escape(item["snippet"])
|
| 394 |
+
items.append(f"<li><strong>[ref{ref_id}]</strong> {snippet}</li>")
|
| 395 |
+
|
| 396 |
+
count = len(citations)
|
| 397 |
+
label = "snippet" if count == 1 else "snippets"
|
| 398 |
+
return (
|
| 399 |
+
f"<details><summary>Supporting {label} ({count})</summary>"
|
| 400 |
+
f"<ul>{''.join(items)}</ul>"
|
| 401 |
+
"</details>"
|
| 402 |
+
)
|
| 403 |
+
|
| 404 |
+
|
| 405 |
def _format_chat_entry(answer_text: str, citations: list[dict[str, Any]], *, provenance: str = "analysis_based") -> str:
|
| 406 |
prefix = ""
|
| 407 |
if provenance == "analysis_based":
|
|
|
|
| 411 |
|
| 412 |
if not citations:
|
| 413 |
return prefix + answer_text
|
| 414 |
+
return f"{prefix}{answer_text}\n\n{_render_supporting_snippets(citations)}"
|
|
|
|
| 415 |
|
| 416 |
|
| 417 |
def _stream_chat_entry(
|
|
|
|
| 421 |
*,
|
| 422 |
provenance: str = "analysis_based",
|
| 423 |
):
|
| 424 |
+
prefix = ""
|
| 425 |
+
if provenance == "analysis_based":
|
| 426 |
+
prefix = "_Based on the summary and analysis._\n\n"
|
| 427 |
+
elif provenance == "full_document":
|
| 428 |
+
prefix = "_Deeper full-document answer._\n\n"
|
| 429 |
+
|
| 430 |
+
formatted_answer = prefix + answer_text
|
| 431 |
streamed_answer = ""
|
| 432 |
for character in formatted_answer:
|
| 433 |
streamed_answer += character
|
| 434 |
yield base_history + [{"role": "assistant", "content": streamed_answer}]
|
| 435 |
if CHAT_STREAM_DELAY_SECONDS:
|
| 436 |
time.sleep(CHAT_STREAM_DELAY_SECONDS)
|
| 437 |
+
if citations:
|
| 438 |
+
yield base_history + [{"role": "assistant", "content": _format_chat_entry(answer_text, citations, provenance=provenance)}]
|
| 439 |
|
| 440 |
|
| 441 |
def _deeper_answer_updates(visible: bool, label: str = "Run deeper full-document answer") -> gr.update:
|
assets/example_bills/cybersecurity-amendment-bill-2025/metadata.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
| 1 |
{
|
| 2 |
"generated_at": "2026-06-01T11:48:29.371678+00:00",
|
| 3 |
"provider": "qwen",
|
| 4 |
-
"model": "Qwen/Qwen3-
|
| 5 |
"source_url": "https://www.csa.gov.gh/resources/Cybersecurity%20%28Amendment%29%20Draft%20Bill%202025%20final%2015102025.pdf",
|
| 6 |
"document_hash": "d879a4473b3631906b7c4e965195d53f4693a722ce7644e187ab76fca33ef763",
|
| 7 |
"chunk_size": 350,
|
| 8 |
"chunk_overlap": 60,
|
| 9 |
"embedding_model": "sentence-transformers/all-MiniLM-L6-v2"
|
| 10 |
-
}
|
|
|
|
| 1 |
{
|
| 2 |
"generated_at": "2026-06-01T11:48:29.371678+00:00",
|
| 3 |
"provider": "qwen",
|
| 4 |
+
"model": "Qwen/Qwen3-14B:cheapest",
|
| 5 |
"source_url": "https://www.csa.gov.gh/resources/Cybersecurity%20%28Amendment%29%20Draft%20Bill%202025%20final%2015102025.pdf",
|
| 6 |
"document_hash": "d879a4473b3631906b7c4e965195d53f4693a722ce7644e187ab76fca33ef763",
|
| 7 |
"chunk_size": 350,
|
| 8 |
"chunk_overlap": 60,
|
| 9 |
"embedding_model": "sentence-transformers/all-MiniLM-L6-v2"
|
| 10 |
+
}
|
config.py
CHANGED
|
@@ -34,7 +34,7 @@ warnings.filterwarnings(
|
|
| 34 |
|
| 35 |
SUPPORTED_PROVIDERS = ["qwen", "openai", "anthropic", "gemini", "cohere"]
|
| 36 |
DEFAULT_PROVIDER: str = "qwen"
|
| 37 |
-
DEFAULT_QWEN_MODEL = "Qwen/Qwen3-
|
| 38 |
DEFAULT_EMBEDDING_MODEL = "sentence-transformers/all-MiniLM-L6-v2"
|
| 39 |
OPENAI_REASONING_EFFORT = "medium"
|
| 40 |
ANTHROPIC_THINKING_BUDGET = 2048
|
|
@@ -90,7 +90,7 @@ PROVIDER_METADATA: list[ProviderConfig] = [
|
|
| 90 |
ProviderConfig(
|
| 91 |
name="qwen",
|
| 92 |
key_prefix=None,
|
| 93 |
-
display_name="Qwen3
|
| 94 |
instructions=(
|
| 95 |
"Use your Hugging Face token for the router-backed Qwen model. Leave blank to use HF_TOKEN from .env if configured."
|
| 96 |
),
|
|
|
|
| 34 |
|
| 35 |
SUPPORTED_PROVIDERS = ["qwen", "openai", "anthropic", "gemini", "cohere"]
|
| 36 |
DEFAULT_PROVIDER: str = "qwen"
|
| 37 |
+
DEFAULT_QWEN_MODEL = "Qwen/Qwen3-14B:cheapest"
|
| 38 |
DEFAULT_EMBEDDING_MODEL = "sentence-transformers/all-MiniLM-L6-v2"
|
| 39 |
OPENAI_REASONING_EFFORT = "medium"
|
| 40 |
ANTHROPIC_THINKING_BUDGET = 2048
|
|
|
|
| 90 |
ProviderConfig(
|
| 91 |
name="qwen",
|
| 92 |
key_prefix=None,
|
| 93 |
+
display_name="Qwen3 14B",
|
| 94 |
instructions=(
|
| 95 |
"Use your Hugging Face token for the router-backed Qwen model. Leave blank to use HF_TOKEN from .env if configured."
|
| 96 |
),
|
tests/test_app.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from app import _format_chat_entry, _stream_chat_entry
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
def test_format_chat_entry_wraps_supporting_snippets_in_details() -> None:
|
| 5 |
+
result = _format_chat_entry(
|
| 6 |
+
"Answer text",
|
| 7 |
+
[{"ref_id": 1, "snippet": "Quoted <clause> & detail"}],
|
| 8 |
+
)
|
| 9 |
+
|
| 10 |
+
assert "Answer text" in result
|
| 11 |
+
assert "<details><summary>Supporting snippet (1)</summary>" in result
|
| 12 |
+
assert "[ref1]" in result
|
| 13 |
+
assert "Quoted <clause> & detail" in result
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def test_stream_chat_entry_appends_collapsible_snippets_after_text_stream() -> None:
|
| 17 |
+
frames = list(
|
| 18 |
+
_stream_chat_entry(
|
| 19 |
+
[{"role": "user", "content": "Question"}],
|
| 20 |
+
"Answer text",
|
| 21 |
+
[{"ref_id": 1, "snippet": "Snippet text"}],
|
| 22 |
+
)
|
| 23 |
+
)
|
| 24 |
+
|
| 25 |
+
assert frames[-2][-1]["content"] == "_Based on the summary and analysis._\n\nAnswer text"
|
| 26 |
+
assert "<details><summary>Supporting snippet (1)</summary>" in frames[-1][-1]["content"]
|