Connect Dream QA voice input to Modal ASR
#24
by ADJCJH - opened
- README.md +2 -1
- docs/handoff.md +1 -1
- docs/smoke/2026-06-10-modal-asr-voice-input-smoke.md +19 -0
- docs/superpowers/plans/2026-06-10-modal-asr-voice-input.md +35 -0
- dream_customs/app_logic.py +23 -5
- dream_customs/defaults.py +1 -0
- dream_customs/ui/app.py +19 -9
- dream_customs/ui/copy.py +4 -0
- dream_customs/ui/styles.py +20 -0
- modal_backend/contracts.py +12 -0
- modal_backend/dream_customs_modal.py +52 -0
- tests/test_app_logic.py +12 -1
- tests/test_modal_contract.py +9 -0
- tests/test_ui_actions.py +10 -0
README.md
CHANGED
|
@@ -103,11 +103,12 @@ The public Space stays lightweight and can call private Modal endpoints through
|
|
| 103 |
|
| 104 |
- `DREAM_CUSTOMS_TEXT_ENDPOINT`: Modal text route for `openbmb/MiniCPM5-1B`.
|
| 105 |
- `DREAM_CUSTOMS_VISION_ENDPOINT`: Modal vision route for `openbmb/MiniCPM-V-4.6`.
|
|
|
|
| 106 |
- `DREAM_CUSTOMS_HOSTED_TOKEN`: shared bearer token checked by Modal and sent by the Space.
|
| 107 |
|
| 108 |
Set these only as Hugging Face Space repository secrets or local shell variables. Do not store values in `.env`, docs, logs, screenshots, or git. Missing endpoints or route failures fall back to deterministic demo behavior.
|
| 109 |
|
| 110 |
-
The Gradio UI defaults to `modal` for
|
| 111 |
|
| 112 |
The Hugging Face Space may run on ZeroGPU for hackathon hardware eligibility. `dream_customs.zerogpu` registers a lightweight `@spaces.GPU` startup probe so ZeroGPU accepts the app, but real MiniCPM inference still happens on the private Modal backend.
|
| 113 |
|
|
|
|
| 103 |
|
| 104 |
- `DREAM_CUSTOMS_TEXT_ENDPOINT`: Modal text route for `openbmb/MiniCPM5-1B`.
|
| 105 |
- `DREAM_CUSTOMS_VISION_ENDPOINT`: Modal vision route for `openbmb/MiniCPM-V-4.6`.
|
| 106 |
+
- `DREAM_CUSTOMS_ASR_ENDPOINT`: Modal ASR adapter route for voice-note transcription.
|
| 107 |
- `DREAM_CUSTOMS_HOSTED_TOKEN`: shared bearer token checked by Modal and sent by the Space.
|
| 108 |
|
| 109 |
Set these only as Hugging Face Space repository secrets or local shell variables. Do not store values in `.env`, docs, logs, screenshots, or git. Missing endpoints or route failures fall back to deterministic demo behavior.
|
| 110 |
|
| 111 |
+
The Gradio UI defaults to `modal` for text, vision, and voice transcription backends, so a configured Space enters the private Modal routes first. The `demo` backend remains available in developer settings and as the deterministic fallback path when hosted routes are missing or fail.
|
| 112 |
|
| 113 |
The Hugging Face Space may run on ZeroGPU for hackathon hardware eligibility. `dream_customs.zerogpu` registers a lightweight `@spaces.GPU` startup probe so ZeroGPU accepts the app, but real MiniCPM inference still happens on the private Modal backend.
|
| 114 |
|
docs/handoff.md
CHANGED
|
@@ -181,7 +181,7 @@ Hugging Face Space Gradio UI
|
|
| 181 |
-> MiniCPM5-1B text generation and MiniCPM-V-4.6 visual clue extraction
|
| 182 |
```
|
| 183 |
|
| 184 |
-
The Space defaults to `modal` for
|
| 185 |
|
| 186 |
The public Space can run on ZeroGPU after `dream_customs.zerogpu` registers the lightweight `@spaces.GPU` startup probe. Modal credits still pay for the hidden GPU backend; the ZeroGPU probe is only there to make the HF hardware setting valid for this Gradio frontend.
|
| 187 |
|
|
|
|
| 181 |
-> MiniCPM5-1B text generation and MiniCPM-V-4.6 visual clue extraction
|
| 182 |
```
|
| 183 |
|
| 184 |
+
The Space defaults to `modal` for text, vision, and voice transcription backends, so a configured Space enters the private Modal routes first. The `model` auto route remains available for compatibility, and the `demo` backend remains available in developer settings as the deterministic fallback path.
|
| 185 |
|
| 186 |
The public Space can run on ZeroGPU after `dream_customs.zerogpu` registers the lightweight `@spaces.GPU` startup probe. Modal credits still pay for the hidden GPU backend; the ZeroGPU probe is only there to make the HF hardware setting valid for this Gradio frontend.
|
| 187 |
|
docs/smoke/2026-06-10-modal-asr-voice-input-smoke.md
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Modal ASR Voice Input Smoke - 2026-06-10
|
| 2 |
+
|
| 3 |
+
## Scope
|
| 4 |
+
|
| 5 |
+
Verify that Dream QA voice input uses a real Gradio audio component and that the backend can route recorded/uploaded audio to Modal ASR.
|
| 6 |
+
|
| 7 |
+
No endpoint URLs, bearer tokens, Hugging Face tokens, Modal tokens, or secret values should be printed or recorded.
|
| 8 |
+
|
| 9 |
+
## Evidence
|
| 10 |
+
|
| 11 |
+
- Focused regression: `.venv/bin/python -m pytest tests/test_app_logic.py::test_defaults_use_modal_model_entrypoint tests/test_ui_actions.py::test_voice_input_uses_gradio_audio_file_for_modal_asr tests/test_modal_contract.py::test_decode_audio_payload_accepts_audio_key -q` -> 3 passed.
|
| 12 |
+
- Full pytest: `.venv/bin/python -m pytest -q` -> 101 passed, 2 warnings.
|
| 13 |
+
- Today Tip eval: `.venv/bin/python scripts/evaluate_today_tip_quality.py` -> 11 cases passed.
|
| 14 |
+
- Whitespace check: `git diff --check` -> passed.
|
| 15 |
+
- Local Gradio config: `Voice note` is a real `audio` component with `sources=["microphone", "upload"]`, `format="wav"`; `Voice input` default value is `modal`.
|
| 16 |
+
- Modal deploy: `dream-customs-minicpm-backend` redeployed with a new `asr` web function. CLI output URLs were redacted.
|
| 17 |
+
- Modal ASR auth smoke: route reachable without printing URL; unauthenticated request returned `status="unauthorized"`, confirming the route exists and requires the hosted token.
|
| 18 |
+
- HF Space secret update attempt: API returned 403 for writing `DREAM_CUSTOMS_ASR_ENDPOINT`. The app now derives the ASR endpoint from the existing Modal text endpoint when the explicit ASR endpoint secret is missing, so no endpoint value was printed or stored.
|
| 19 |
+
- Public Space check: pending.
|
docs/superpowers/plans/2026-06-10-modal-asr-voice-input.md
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Modal ASR Voice Input Plan - 2026-06-10
|
| 2 |
+
|
| 3 |
+
## Goal
|
| 4 |
+
|
| 5 |
+
Replace the browser-only voice placeholder with a real Gradio audio recording/upload input that sends a filepath into the existing Dream QA intake, then connect voice transcription to a Modal-hosted ASR adapter.
|
| 6 |
+
|
| 7 |
+
## Root Cause
|
| 8 |
+
|
| 9 |
+
- The visible round microphone was custom JavaScript using browser speech recognition.
|
| 10 |
+
- The backend `audio_input` was only `gr.State(None)`, so no recorded file reached `HostedASRClient`.
|
| 11 |
+
- The Modal backend had text and vision routes but no `/asr` route.
|
| 12 |
+
|
| 13 |
+
## Guardrails
|
| 14 |
+
|
| 15 |
+
- ASR only transcribes audio; dream understanding remains MiniCPM-driven.
|
| 16 |
+
- Do not store or print endpoint URLs, hosted tokens, HF tokens, Modal tokens, or secret values.
|
| 17 |
+
- Keep text-only fallback working when audio permission, recording, upload, or hosted ASR fails.
|
| 18 |
+
|
| 19 |
+
## Tasks
|
| 20 |
+
|
| 21 |
+
- [x] Add failing tests for default Modal ASR, real Gradio audio input, and audio payload decoding.
|
| 22 |
+
- [x] Change default ASR backend to `modal`.
|
| 23 |
+
- [x] Replace the fake browser dictation input with `gr.Audio(sources=["microphone", "upload"], type="filepath")`.
|
| 24 |
+
- [x] Add Modal `/asr` route with a small ASR adapter.
|
| 25 |
+
- [x] Run full tests, eval, local UI smoke, and Modal deploy/smoke.
|
| 26 |
+
- [ ] Run public Space verification after HF merge.
|
| 27 |
+
- [ ] Commit, push, sync to Hugging Face Space, merge, and re-check Chrome.
|
| 28 |
+
|
| 29 |
+
## Progress
|
| 30 |
+
|
| 31 |
+
- Full pytest passes with 101 tests.
|
| 32 |
+
- Today Tip eval passes 11 cases.
|
| 33 |
+
- Local Gradio config confirms real audio input and Modal ASR default.
|
| 34 |
+
- Modal deploy created the ASR route; unauthenticated route smoke confirmed the route is reachable and protected.
|
| 35 |
+
- HF API cannot write Space secrets with the current token, so code derives the ASR endpoint from the existing Modal text endpoint when `DREAM_CUSTOMS_ASR_ENDPOINT` is absent.
|
dream_customs/app_logic.py
CHANGED
|
@@ -1,6 +1,7 @@
|
|
| 1 |
import json
|
| 2 |
import os
|
| 3 |
from typing import Any, Tuple
|
|
|
|
| 4 |
|
| 5 |
from dream_customs.models import (
|
| 6 |
FakeASRClient,
|
|
@@ -12,7 +13,7 @@ from dream_customs.models import (
|
|
| 12 |
OllamaTextClient,
|
| 13 |
OllamaVisionClient,
|
| 14 |
)
|
| 15 |
-
from dream_customs.defaults import DEFAULT_TEXT_BACKEND, DEFAULT_VISION_BACKEND
|
| 16 |
from dream_customs.pipeline import (
|
| 17 |
add_evidence,
|
| 18 |
answer_question,
|
|
@@ -75,6 +76,19 @@ def _as_int(value: Any, default: int) -> int:
|
|
| 75 |
return default
|
| 76 |
|
| 77 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 78 |
def _client_settings(
|
| 79 |
text_endpoint: str = "",
|
| 80 |
vision_endpoint: str = "",
|
|
@@ -88,15 +102,19 @@ def _client_settings(
|
|
| 88 |
vision_temperature: Any = DEFAULT_VISION_TEMPERATURE,
|
| 89 |
text_max_tokens: Any = DEFAULT_TEXT_MAX_TOKENS,
|
| 90 |
vision_max_tokens: Any = DEFAULT_VISION_MAX_TOKENS,
|
| 91 |
-
asr_backend: str =
|
| 92 |
asr_endpoint: str = "",
|
| 93 |
asr_timeout_seconds: Any = DEFAULT_ASR_TIMEOUT_SECONDS,
|
| 94 |
text_latency_budget_ms: Any = DEFAULT_TEXT_LATENCY_BUDGET_MS,
|
| 95 |
vision_latency_budget_ms: Any = DEFAULT_VISION_LATENCY_BUDGET_MS,
|
| 96 |
asr_latency_budget_ms: Any = DEFAULT_ASR_LATENCY_BUDGET_MS,
|
| 97 |
) -> dict:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 98 |
return {
|
| 99 |
-
"text_endpoint":
|
| 100 |
"vision_endpoint": (vision_endpoint or os.getenv("DREAM_CUSTOMS_VISION_ENDPOINT", "")).strip(),
|
| 101 |
"hosted_token": (hosted_token or os.getenv("DREAM_CUSTOMS_HOSTED_TOKEN", "")).strip(),
|
| 102 |
"ollama_url": (ollama_url or os.getenv("DREAM_CUSTOMS_OLLAMA_URL", "http://localhost:11434")).strip(),
|
|
@@ -108,8 +126,8 @@ def _client_settings(
|
|
| 108 |
"vision_temperature": max(0.0, min(_as_float(vision_temperature, DEFAULT_VISION_TEMPERATURE), 0.7)),
|
| 109 |
"text_max_tokens": max(64, min(_as_int(text_max_tokens, DEFAULT_TEXT_MAX_TOKENS), 1200)),
|
| 110 |
"vision_max_tokens": max(64, min(_as_int(vision_max_tokens, DEFAULT_VISION_MAX_TOKENS), 800)),
|
| 111 |
-
"asr_backend": (asr_backend or
|
| 112 |
-
"asr_endpoint":
|
| 113 |
"asr_timeout_seconds": max(1.0, _as_float(asr_timeout_seconds, DEFAULT_ASR_TIMEOUT_SECONDS)),
|
| 114 |
"text_latency_budget_ms": max(0, _as_int(text_latency_budget_ms, DEFAULT_TEXT_LATENCY_BUDGET_MS)),
|
| 115 |
"vision_latency_budget_ms": max(0, _as_int(vision_latency_budget_ms, DEFAULT_VISION_LATENCY_BUDGET_MS)),
|
|
|
|
| 1 |
import json
|
| 2 |
import os
|
| 3 |
from typing import Any, Tuple
|
| 4 |
+
from urllib.parse import urlparse, urlunparse
|
| 5 |
|
| 6 |
from dream_customs.models import (
|
| 7 |
FakeASRClient,
|
|
|
|
| 13 |
OllamaTextClient,
|
| 14 |
OllamaVisionClient,
|
| 15 |
)
|
| 16 |
+
from dream_customs.defaults import DEFAULT_ASR_BACKEND, DEFAULT_TEXT_BACKEND, DEFAULT_VISION_BACKEND
|
| 17 |
from dream_customs.pipeline import (
|
| 18 |
add_evidence,
|
| 19 |
answer_question,
|
|
|
|
| 76 |
return default
|
| 77 |
|
| 78 |
|
| 79 |
+
def _derive_modal_asr_endpoint(text_endpoint: str) -> str:
|
| 80 |
+
if not text_endpoint:
|
| 81 |
+
return ""
|
| 82 |
+
parsed = urlparse(text_endpoint.strip())
|
| 83 |
+
if parsed.netloc.endswith("-text.modal.run"):
|
| 84 |
+
netloc = f"{parsed.netloc.removesuffix('-text.modal.run')}-asr.modal.run"
|
| 85 |
+
return urlunparse(parsed._replace(netloc=netloc))
|
| 86 |
+
if parsed.path.rstrip("/").endswith("/text"):
|
| 87 |
+
path = f"{parsed.path.rstrip('/')[:-len('/text')]}/asr"
|
| 88 |
+
return urlunparse(parsed._replace(path=path))
|
| 89 |
+
return ""
|
| 90 |
+
|
| 91 |
+
|
| 92 |
def _client_settings(
|
| 93 |
text_endpoint: str = "",
|
| 94 |
vision_endpoint: str = "",
|
|
|
|
| 102 |
vision_temperature: Any = DEFAULT_VISION_TEMPERATURE,
|
| 103 |
text_max_tokens: Any = DEFAULT_TEXT_MAX_TOKENS,
|
| 104 |
vision_max_tokens: Any = DEFAULT_VISION_MAX_TOKENS,
|
| 105 |
+
asr_backend: str = DEFAULT_ASR_BACKEND,
|
| 106 |
asr_endpoint: str = "",
|
| 107 |
asr_timeout_seconds: Any = DEFAULT_ASR_TIMEOUT_SECONDS,
|
| 108 |
text_latency_budget_ms: Any = DEFAULT_TEXT_LATENCY_BUDGET_MS,
|
| 109 |
vision_latency_budget_ms: Any = DEFAULT_VISION_LATENCY_BUDGET_MS,
|
| 110 |
asr_latency_budget_ms: Any = DEFAULT_ASR_LATENCY_BUDGET_MS,
|
| 111 |
) -> dict:
|
| 112 |
+
resolved_text_endpoint = (text_endpoint or os.getenv("DREAM_CUSTOMS_TEXT_ENDPOINT", "")).strip()
|
| 113 |
+
resolved_asr_endpoint = (asr_endpoint or os.getenv("DREAM_CUSTOMS_ASR_ENDPOINT", "")).strip()
|
| 114 |
+
if not resolved_asr_endpoint:
|
| 115 |
+
resolved_asr_endpoint = _derive_modal_asr_endpoint(resolved_text_endpoint)
|
| 116 |
return {
|
| 117 |
+
"text_endpoint": resolved_text_endpoint,
|
| 118 |
"vision_endpoint": (vision_endpoint or os.getenv("DREAM_CUSTOMS_VISION_ENDPOINT", "")).strip(),
|
| 119 |
"hosted_token": (hosted_token or os.getenv("DREAM_CUSTOMS_HOSTED_TOKEN", "")).strip(),
|
| 120 |
"ollama_url": (ollama_url or os.getenv("DREAM_CUSTOMS_OLLAMA_URL", "http://localhost:11434")).strip(),
|
|
|
|
| 126 |
"vision_temperature": max(0.0, min(_as_float(vision_temperature, DEFAULT_VISION_TEMPERATURE), 0.7)),
|
| 127 |
"text_max_tokens": max(64, min(_as_int(text_max_tokens, DEFAULT_TEXT_MAX_TOKENS), 1200)),
|
| 128 |
"vision_max_tokens": max(64, min(_as_int(vision_max_tokens, DEFAULT_VISION_MAX_TOKENS), 800)),
|
| 129 |
+
"asr_backend": (asr_backend or DEFAULT_ASR_BACKEND).lower(),
|
| 130 |
+
"asr_endpoint": resolved_asr_endpoint,
|
| 131 |
"asr_timeout_seconds": max(1.0, _as_float(asr_timeout_seconds, DEFAULT_ASR_TIMEOUT_SECONDS)),
|
| 132 |
"text_latency_budget_ms": max(0, _as_int(text_latency_budget_ms, DEFAULT_TEXT_LATENCY_BUDGET_MS)),
|
| 133 |
"vision_latency_budget_ms": max(0, _as_int(vision_latency_budget_ms, DEFAULT_VISION_LATENCY_BUDGET_MS)),
|
dream_customs/defaults.py
CHANGED
|
@@ -1,2 +1,3 @@
|
|
| 1 |
DEFAULT_TEXT_BACKEND = "modal"
|
| 2 |
DEFAULT_VISION_BACKEND = "modal"
|
|
|
|
|
|
| 1 |
DEFAULT_TEXT_BACKEND = "modal"
|
| 2 |
DEFAULT_VISION_BACKEND = "modal"
|
| 3 |
+
DEFAULT_ASR_BACKEND = "modal"
|
dream_customs/ui/app.py
CHANGED
|
@@ -17,7 +17,7 @@ from dream_customs.app_logic import (
|
|
| 17 |
DEFAULT_VISION_MODEL,
|
| 18 |
DEFAULT_VISION_TEMPERATURE,
|
| 19 |
)
|
| 20 |
-
from dream_customs.defaults import DEFAULT_TEXT_BACKEND, DEFAULT_VISION_BACKEND
|
| 21 |
from dream_customs.ui.actions import (
|
| 22 |
answer_to_card_action,
|
| 23 |
initial_mobile_state,
|
|
@@ -370,6 +370,10 @@ def _mic_html(language: str = DEFAULT_LANGUAGE) -> str:
|
|
| 370 |
""".strip()
|
| 371 |
|
| 372 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 373 |
def _field_tip_html(language: str = DEFAULT_LANGUAGE) -> str:
|
| 374 |
return f"<p class=\"dc-field-tip\">{escape(copy_for(language)['field_tip'])}</p>"
|
| 375 |
|
|
@@ -403,7 +407,7 @@ def build_demo() -> gr.Blocks:
|
|
| 403 |
initial = _load_view(initial_view)
|
| 404 |
initial_copy = copy_for(DEFAULT_LANGUAGE)
|
| 405 |
|
| 406 |
-
with gr.Blocks(css=CSS,
|
| 407 |
session_state = gr.State(initial_state)
|
| 408 |
view_state = gr.State(initial_view)
|
| 409 |
|
|
@@ -423,8 +427,14 @@ def build_demo() -> gr.Blocks:
|
|
| 423 |
value="",
|
| 424 |
elem_classes=["dc-dream-text"],
|
| 425 |
)
|
| 426 |
-
|
| 427 |
-
audio_input = gr.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 428 |
field_tip_html = gr.HTML(_field_tip_html(DEFAULT_LANGUAGE))
|
| 429 |
with gr.Row(elem_classes=["dc-submit-row"]):
|
| 430 |
example_button = gr.Button(initial_copy["example_button"], variant="secondary")
|
|
@@ -494,11 +504,11 @@ def build_demo() -> gr.Blocks:
|
|
| 494 |
asr_backend = gr.Dropdown(
|
| 495 |
label="Voice input",
|
| 496 |
choices=[
|
| 497 |
-
("
|
| 498 |
-
("
|
| 499 |
("Hugging Face ASR endpoint, planned", "huggingface"),
|
| 500 |
],
|
| 501 |
-
value=
|
| 502 |
)
|
| 503 |
with gr.Accordion("Advanced endpoints", open=False, elem_classes=["dc-dev-advanced"]):
|
| 504 |
text_endpoint = gr.Textbox(label="Text endpoint", value="")
|
|
@@ -653,7 +663,7 @@ def build_demo() -> gr.Blocks:
|
|
| 653 |
_notice_html({"notice": copy["notice_record"], "status": "record"}),
|
| 654 |
_section_title_html(1, copy["dream_label"]),
|
| 655 |
gr.update(label=copy["dream_label"], placeholder=copy["dream_placeholder"]),
|
| 656 |
-
|
| 657 |
_field_tip_html(selected_language),
|
| 658 |
gr.update(value=copy["example_button"]),
|
| 659 |
gr.update(value=copy["submit_button"]),
|
|
@@ -682,7 +692,7 @@ def build_demo() -> gr.Blocks:
|
|
| 682 |
notice,
|
| 683 |
dream_section_html,
|
| 684 |
dream_text,
|
| 685 |
-
|
| 686 |
field_tip_html,
|
| 687 |
example_button,
|
| 688 |
submit_button,
|
|
|
|
| 17 |
DEFAULT_VISION_MODEL,
|
| 18 |
DEFAULT_VISION_TEMPERATURE,
|
| 19 |
)
|
| 20 |
+
from dream_customs.defaults import DEFAULT_ASR_BACKEND, DEFAULT_TEXT_BACKEND, DEFAULT_VISION_BACKEND
|
| 21 |
from dream_customs.ui.actions import (
|
| 22 |
answer_to_card_action,
|
| 23 |
initial_mobile_state,
|
|
|
|
| 370 |
""".strip()
|
| 371 |
|
| 372 |
|
| 373 |
+
def _voice_help_html(language: str = DEFAULT_LANGUAGE) -> str:
|
| 374 |
+
return f"<p class=\"dc-voice-help\">{escape(copy_for(language)['voice_help'])}</p>"
|
| 375 |
+
|
| 376 |
+
|
| 377 |
def _field_tip_html(language: str = DEFAULT_LANGUAGE) -> str:
|
| 378 |
return f"<p class=\"dc-field-tip\">{escape(copy_for(language)['field_tip'])}</p>"
|
| 379 |
|
|
|
|
| 407 |
initial = _load_view(initial_view)
|
| 408 |
initial_copy = copy_for(DEFAULT_LANGUAGE)
|
| 409 |
|
| 410 |
+
with gr.Blocks(css=CSS, title=APP_TITLE) as demo:
|
| 411 |
session_state = gr.State(initial_state)
|
| 412 |
view_state = gr.State(initial_view)
|
| 413 |
|
|
|
|
| 427 |
value="",
|
| 428 |
elem_classes=["dc-dream-text"],
|
| 429 |
)
|
| 430 |
+
voice_help_html = gr.HTML(_voice_help_html(DEFAULT_LANGUAGE))
|
| 431 |
+
audio_input = gr.Audio(
|
| 432 |
+
label=initial_copy["voice_label"],
|
| 433 |
+
sources=["microphone", "upload"],
|
| 434 |
+
type="filepath",
|
| 435 |
+
format="wav",
|
| 436 |
+
elem_classes=["dc-voice-input"],
|
| 437 |
+
)
|
| 438 |
field_tip_html = gr.HTML(_field_tip_html(DEFAULT_LANGUAGE))
|
| 439 |
with gr.Row(elem_classes=["dc-submit-row"]):
|
| 440 |
example_button = gr.Button(initial_copy["example_button"], variant="secondary")
|
|
|
|
| 504 |
asr_backend = gr.Dropdown(
|
| 505 |
label="Voice input",
|
| 506 |
choices=[
|
| 507 |
+
("Modal ASR endpoint", "modal"),
|
| 508 |
+
("Demo: mark voice received", "demo"),
|
| 509 |
("Hugging Face ASR endpoint, planned", "huggingface"),
|
| 510 |
],
|
| 511 |
+
value=DEFAULT_ASR_BACKEND,
|
| 512 |
)
|
| 513 |
with gr.Accordion("Advanced endpoints", open=False, elem_classes=["dc-dev-advanced"]):
|
| 514 |
text_endpoint = gr.Textbox(label="Text endpoint", value="")
|
|
|
|
| 663 |
_notice_html({"notice": copy["notice_record"], "status": "record"}),
|
| 664 |
_section_title_html(1, copy["dream_label"]),
|
| 665 |
gr.update(label=copy["dream_label"], placeholder=copy["dream_placeholder"]),
|
| 666 |
+
_voice_help_html(selected_language),
|
| 667 |
_field_tip_html(selected_language),
|
| 668 |
gr.update(value=copy["example_button"]),
|
| 669 |
gr.update(value=copy["submit_button"]),
|
|
|
|
| 692 |
notice,
|
| 693 |
dream_section_html,
|
| 694 |
dream_text,
|
| 695 |
+
voice_help_html,
|
| 696 |
field_tip_html,
|
| 697 |
example_button,
|
| 698 |
submit_button,
|
dream_customs/ui/copy.py
CHANGED
|
@@ -23,6 +23,8 @@ APP_COPY = {
|
|
| 23 |
"mic_listening": "Listening. Say the dream fragment when you are ready.",
|
| 24 |
"mic_done": "Added to the dream note.",
|
| 25 |
"mic_empty": "No speech detected. Tap again if you want to retry.",
|
|
|
|
|
|
|
| 26 |
"field_tip": "People, places, feelings, colors, or the question you woke up with are all useful.",
|
| 27 |
"example_button": "Try example",
|
| 28 |
"submit_button": "Continue",
|
|
@@ -72,6 +74,8 @@ APP_COPY = {
|
|
| 72 |
"mic_listening": "正在听。准备好后说出梦境片段。",
|
| 73 |
"mic_done": "已加入梦境记录。",
|
| 74 |
"mic_empty": "没有检测到语音。想重试的话,再点一次麦克风。",
|
|
|
|
|
|
|
| 75 |
"field_tip": "可以补充人物、地点、情绪、颜色,或醒来后最在意的疑问。",
|
| 76 |
"example_button": "试试示例",
|
| 77 |
"submit_button": "继续解梦 →",
|
|
|
|
| 23 |
"mic_listening": "Listening. Say the dream fragment when you are ready.",
|
| 24 |
"mic_done": "Added to the dream note.",
|
| 25 |
"mic_empty": "No speech detected. Tap again if you want to retry.",
|
| 26 |
+
"voice_label": "Voice note",
|
| 27 |
+
"voice_help": "Record or upload a short voice note. It is transcribed by the ASR adapter when you continue.",
|
| 28 |
"field_tip": "People, places, feelings, colors, or the question you woke up with are all useful.",
|
| 29 |
"example_button": "Try example",
|
| 30 |
"submit_button": "Continue",
|
|
|
|
| 74 |
"mic_listening": "正在听。准备好后说出梦境片段。",
|
| 75 |
"mic_done": "已加入梦境记录。",
|
| 76 |
"mic_empty": "没有检测到语音。想重试的话,再点一次麦克风。",
|
| 77 |
+
"voice_label": "语音片段",
|
| 78 |
+
"voice_help": "可以录音或上传一小段语音。点击继续后,ASR 适配器会先转写它。",
|
| 79 |
"field_tip": "可以补充人物、地点、情绪、颜色,或醒来后最在意的疑问。",
|
| 80 |
"example_button": "试试示例",
|
| 81 |
"submit_button": "继续解梦 →",
|
dream_customs/ui/styles.py
CHANGED
|
@@ -394,6 +394,26 @@ body,
|
|
| 394 |
display: none !important;
|
| 395 |
}
|
| 396 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 397 |
.dc-mic-control {
|
| 398 |
align-items: end;
|
| 399 |
bottom: 68px;
|
|
|
|
| 394 |
display: none !important;
|
| 395 |
}
|
| 396 |
|
| 397 |
+
.dc-voice-help {
|
| 398 |
+
color: var(--dc-muted);
|
| 399 |
+
font-size: 0.86rem;
|
| 400 |
+
line-height: 1.4;
|
| 401 |
+
margin: 12px 0 6px;
|
| 402 |
+
}
|
| 403 |
+
|
| 404 |
+
.dc-voice-input {
|
| 405 |
+
margin-top: 0 !important;
|
| 406 |
+
}
|
| 407 |
+
|
| 408 |
+
.dc-voice-input .wrap,
|
| 409 |
+
.dc-voice-input .container,
|
| 410 |
+
.dc-voice-input .input-container {
|
| 411 |
+
background: rgba(255, 253, 248, 0.9) !important;
|
| 412 |
+
border-color: var(--dc-line) !important;
|
| 413 |
+
border-radius: var(--dc-radius-sm) !important;
|
| 414 |
+
color: var(--dc-ink) !important;
|
| 415 |
+
}
|
| 416 |
+
|
| 417 |
.dc-mic-control {
|
| 418 |
align-items: end;
|
| 419 |
bottom: 68px;
|
modal_backend/contracts.py
CHANGED
|
@@ -54,6 +54,18 @@ def decode_image_payload(payload: Dict[str, Any]) -> bytes:
|
|
| 54 |
return base64.b64decode(str(encoded))
|
| 55 |
|
| 56 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 57 |
def ensure_authorized(authorization_header: str, expected_token: str) -> None:
|
| 58 |
expected_token = expected_token.strip()
|
| 59 |
if not expected_token:
|
|
|
|
| 54 |
return base64.b64decode(str(encoded))
|
| 55 |
|
| 56 |
|
| 57 |
+
def decode_audio_payload(payload: Dict[str, Any]) -> tuple[bytes, str]:
|
| 58 |
+
encoded = payload.get("audio")
|
| 59 |
+
if not encoded and isinstance(payload.get("audios"), list) and payload["audios"]:
|
| 60 |
+
encoded = payload["audios"][0]
|
| 61 |
+
if not encoded:
|
| 62 |
+
raise ValueError("Missing audio payload.")
|
| 63 |
+
if isinstance(encoded, bytes):
|
| 64 |
+
encoded = encoded.decode("ascii")
|
| 65 |
+
filename = _clean_text(payload.get("filename")) or "dream-voice.wav"
|
| 66 |
+
return base64.b64decode(str(encoded)), filename
|
| 67 |
+
|
| 68 |
+
|
| 69 |
def ensure_authorized(authorization_header: str, expected_token: str) -> None:
|
| 70 |
expected_token = expected_token.strip()
|
| 71 |
if not expected_token:
|
modal_backend/dream_customs_modal.py
CHANGED
|
@@ -9,6 +9,7 @@ from fastapi import Body, Header
|
|
| 9 |
|
| 10 |
from modal_backend.contracts import (
|
| 11 |
AuthError,
|
|
|
|
| 12 |
decode_image_payload,
|
| 13 |
ensure_authorized,
|
| 14 |
normalize_text_payload,
|
|
@@ -19,6 +20,7 @@ from modal_backend.contracts import (
|
|
| 19 |
APP_NAME = "dream-customs-minicpm-backend"
|
| 20 |
TEXT_MODEL = "openbmb/MiniCPM5-1B"
|
| 21 |
VISION_MODEL = "openbmb/MiniCPM-V-4.6"
|
|
|
|
| 22 |
MINUTES = 60
|
| 23 |
|
| 24 |
app = modal.App(APP_NAME)
|
|
@@ -44,6 +46,8 @@ image = (
|
|
| 44 |
"torch",
|
| 45 |
"torchvision",
|
| 46 |
"transformers>=4.56",
|
|
|
|
|
|
|
| 47 |
)
|
| 48 |
.add_local_dir("modal_backend", remote_path="/root/modal_backend")
|
| 49 |
)
|
|
@@ -57,6 +61,7 @@ secrets = [
|
|
| 57 |
|
| 58 |
_TEXT_PIPE = None
|
| 59 |
_VISION_PIPE = None
|
|
|
|
| 60 |
|
| 61 |
|
| 62 |
def _expected_token() -> str:
|
|
@@ -113,6 +118,20 @@ def _load_vision_pipe():
|
|
| 113 |
return _VISION_PIPE
|
| 114 |
|
| 115 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 116 |
def _messages_from_payload(payload: Dict[str, Any], prompt: str) -> list[Dict[str, str]]:
|
| 117 |
messages = payload.get("messages")
|
| 118 |
if isinstance(messages, list) and messages:
|
|
@@ -299,6 +318,7 @@ def health() -> Dict[str, str]:
|
|
| 299 |
"app": APP_NAME,
|
| 300 |
"text_model": TEXT_MODEL,
|
| 301 |
"vision_model": VISION_MODEL,
|
|
|
|
| 302 |
}
|
| 303 |
|
| 304 |
|
|
@@ -390,3 +410,35 @@ async def vision(
|
|
| 390 |
]
|
| 391 |
result = pipe(text=messages, max_new_tokens=int(payload.get("max_tokens", 320)))
|
| 392 |
return response_payload(_stringify_pipeline_result(result))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
|
| 10 |
from modal_backend.contracts import (
|
| 11 |
AuthError,
|
| 12 |
+
decode_audio_payload,
|
| 13 |
decode_image_payload,
|
| 14 |
ensure_authorized,
|
| 15 |
normalize_text_payload,
|
|
|
|
| 20 |
APP_NAME = "dream-customs-minicpm-backend"
|
| 21 |
TEXT_MODEL = "openbmb/MiniCPM5-1B"
|
| 22 |
VISION_MODEL = "openbmb/MiniCPM-V-4.6"
|
| 23 |
+
ASR_MODEL = "openai/whisper-tiny"
|
| 24 |
MINUTES = 60
|
| 25 |
|
| 26 |
app = modal.App(APP_NAME)
|
|
|
|
| 46 |
"torch",
|
| 47 |
"torchvision",
|
| 48 |
"transformers>=4.56",
|
| 49 |
+
"librosa",
|
| 50 |
+
"soundfile",
|
| 51 |
)
|
| 52 |
.add_local_dir("modal_backend", remote_path="/root/modal_backend")
|
| 53 |
)
|
|
|
|
| 61 |
|
| 62 |
_TEXT_PIPE = None
|
| 63 |
_VISION_PIPE = None
|
| 64 |
+
_ASR_PIPE = None
|
| 65 |
|
| 66 |
|
| 67 |
def _expected_token() -> str:
|
|
|
|
| 118 |
return _VISION_PIPE
|
| 119 |
|
| 120 |
|
| 121 |
+
def _load_asr_pipe():
|
| 122 |
+
global _ASR_PIPE
|
| 123 |
+
if _ASR_PIPE is None:
|
| 124 |
+
from transformers import pipeline
|
| 125 |
+
|
| 126 |
+
_ASR_PIPE = pipeline(
|
| 127 |
+
"automatic-speech-recognition",
|
| 128 |
+
model=os.getenv("DREAM_CUSTOMS_ASR_MODEL", ASR_MODEL),
|
| 129 |
+
device_map="auto",
|
| 130 |
+
torch_dtype="auto",
|
| 131 |
+
)
|
| 132 |
+
return _ASR_PIPE
|
| 133 |
+
|
| 134 |
+
|
| 135 |
def _messages_from_payload(payload: Dict[str, Any], prompt: str) -> list[Dict[str, str]]:
|
| 136 |
messages = payload.get("messages")
|
| 137 |
if isinstance(messages, list) and messages:
|
|
|
|
| 318 |
"app": APP_NAME,
|
| 319 |
"text_model": TEXT_MODEL,
|
| 320 |
"vision_model": VISION_MODEL,
|
| 321 |
+
"asr_model": ASR_MODEL,
|
| 322 |
}
|
| 323 |
|
| 324 |
|
|
|
|
| 410 |
]
|
| 411 |
result = pipe(text=messages, max_new_tokens=int(payload.get("max_tokens", 320)))
|
| 412 |
return response_payload(_stringify_pipeline_result(result))
|
| 413 |
+
|
| 414 |
+
|
| 415 |
+
@app.function(
|
| 416 |
+
image=image,
|
| 417 |
+
gpu="L4",
|
| 418 |
+
timeout=10 * MINUTES,
|
| 419 |
+
scaledown_window=5 * MINUTES,
|
| 420 |
+
volumes={"/root/.cache/huggingface": hf_cache},
|
| 421 |
+
secrets=secrets,
|
| 422 |
+
)
|
| 423 |
+
@modal.fastapi_endpoint(method="POST", docs=True)
|
| 424 |
+
async def asr(
|
| 425 |
+
payload: Dict[str, Any] = Body(...),
|
| 426 |
+
authorization: str = Header(""),
|
| 427 |
+
):
|
| 428 |
+
try:
|
| 429 |
+
ensure_authorized(authorization, _expected_token())
|
| 430 |
+
except AuthError as exc:
|
| 431 |
+
return _json_error(str(exc), status="unauthorized")
|
| 432 |
+
try:
|
| 433 |
+
audio_bytes, filename = decode_audio_payload(payload)
|
| 434 |
+
except ValueError as exc:
|
| 435 |
+
return _json_error(str(exc))
|
| 436 |
+
|
| 437 |
+
suffix = os.path.splitext(filename)[1] or ".wav"
|
| 438 |
+
pipe = _load_asr_pipe()
|
| 439 |
+
with tempfile.NamedTemporaryFile(suffix=suffix) as temp_file:
|
| 440 |
+
temp_file.write(audio_bytes)
|
| 441 |
+
temp_file.flush()
|
| 442 |
+
result = pipe(temp_file.name)
|
| 443 |
+
transcript = _stringify_pipeline_result(result)
|
| 444 |
+
return {"status": "ok", "transcript": transcript, "response": transcript}
|
tests/test_app_logic.py
CHANGED
|
@@ -2,6 +2,7 @@ import json
|
|
| 2 |
|
| 3 |
from dream_customs.app_logic import (
|
| 4 |
_clients,
|
|
|
|
| 5 |
_debug_json,
|
| 6 |
add_material_action,
|
| 7 |
ask_another_question_action,
|
|
@@ -12,13 +13,14 @@ from dream_customs.app_logic import (
|
|
| 12 |
seal_pact_action,
|
| 13 |
start_declaration_action,
|
| 14 |
)
|
| 15 |
-
from dream_customs.defaults import DEFAULT_TEXT_BACKEND, DEFAULT_VISION_BACKEND
|
| 16 |
from dream_customs.models import HostedASRClient, HostedMiniCPMTextClient, HostedMiniCPMVisionClient
|
| 17 |
|
| 18 |
|
| 19 |
def test_defaults_use_modal_model_entrypoint():
|
| 20 |
assert DEFAULT_TEXT_BACKEND == "modal"
|
| 21 |
assert DEFAULT_VISION_BACKEND == "modal"
|
|
|
|
| 22 |
|
| 23 |
|
| 24 |
def test_run_customs_once_generates_demo_outputs():
|
|
@@ -137,3 +139,12 @@ def test_developer_settings_configure_hosted_clients():
|
|
| 137 |
assert vision_client.temperature == 0.22
|
| 138 |
assert text_client.max_tokens == 256
|
| 139 |
assert vision_client.max_tokens == 128
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
from dream_customs.app_logic import (
|
| 4 |
_clients,
|
| 5 |
+
_client_settings,
|
| 6 |
_debug_json,
|
| 7 |
add_material_action,
|
| 8 |
ask_another_question_action,
|
|
|
|
| 13 |
seal_pact_action,
|
| 14 |
start_declaration_action,
|
| 15 |
)
|
| 16 |
+
from dream_customs.defaults import DEFAULT_ASR_BACKEND, DEFAULT_TEXT_BACKEND, DEFAULT_VISION_BACKEND
|
| 17 |
from dream_customs.models import HostedASRClient, HostedMiniCPMTextClient, HostedMiniCPMVisionClient
|
| 18 |
|
| 19 |
|
| 20 |
def test_defaults_use_modal_model_entrypoint():
|
| 21 |
assert DEFAULT_TEXT_BACKEND == "modal"
|
| 22 |
assert DEFAULT_VISION_BACKEND == "modal"
|
| 23 |
+
assert DEFAULT_ASR_BACKEND == "modal"
|
| 24 |
|
| 25 |
|
| 26 |
def test_run_customs_once_generates_demo_outputs():
|
|
|
|
| 139 |
assert vision_client.temperature == 0.22
|
| 140 |
assert text_client.max_tokens == 256
|
| 141 |
assert vision_client.max_tokens == 128
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
def test_asr_endpoint_derives_from_modal_text_endpoint_when_secret_is_missing():
|
| 145 |
+
settings = _client_settings(
|
| 146 |
+
text_endpoint="https://workspace--dream-customs-minicpm-backend-text.modal.run",
|
| 147 |
+
asr_endpoint="",
|
| 148 |
+
)
|
| 149 |
+
|
| 150 |
+
assert settings["asr_endpoint"] == "https://workspace--dream-customs-minicpm-backend-asr.modal.run"
|
tests/test_modal_contract.py
CHANGED
|
@@ -2,6 +2,7 @@ import base64
|
|
| 2 |
|
| 3 |
from modal_backend.contracts import (
|
| 4 |
AuthError,
|
|
|
|
| 5 |
decode_image_payload,
|
| 6 |
ensure_authorized,
|
| 7 |
normalize_text_payload,
|
|
@@ -39,6 +40,14 @@ def test_decode_image_payload_accepts_images_list():
|
|
| 39 |
assert decode_image_payload({"images": [encoded]}) == b"fake-image-bytes"
|
| 40 |
|
| 41 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
def test_response_payload_uses_existing_client_shape():
|
| 43 |
assert response_payload("hello") == {"response": "hello"}
|
| 44 |
|
|
|
|
| 2 |
|
| 3 |
from modal_backend.contracts import (
|
| 4 |
AuthError,
|
| 5 |
+
decode_audio_payload,
|
| 6 |
decode_image_payload,
|
| 7 |
ensure_authorized,
|
| 8 |
normalize_text_payload,
|
|
|
|
| 40 |
assert decode_image_payload({"images": [encoded]}) == b"fake-image-bytes"
|
| 41 |
|
| 42 |
|
| 43 |
+
def test_decode_audio_payload_accepts_audio_key():
|
| 44 |
+
encoded = base64.b64encode(b"fake-audio-bytes").decode("ascii")
|
| 45 |
+
audio_bytes, filename = decode_audio_payload({"audio": encoded, "filename": "dream.wav"})
|
| 46 |
+
|
| 47 |
+
assert audio_bytes == b"fake-audio-bytes"
|
| 48 |
+
assert filename == "dream.wav"
|
| 49 |
+
|
| 50 |
+
|
| 51 |
def test_response_payload_uses_existing_client_shape():
|
| 52 |
assert response_payload("hello") == {"response": "hello"}
|
| 53 |
|
tests/test_ui_actions.py
CHANGED
|
@@ -22,6 +22,16 @@ def test_runtime_settings_are_collapsed_for_public_flow():
|
|
| 22 |
assert 'gr.Accordion("Advanced", open=False' in source
|
| 23 |
|
| 24 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
def test_processing_note_is_story_copy_not_backend_jargon():
|
| 26 |
lowered = PROCESSING_NOTE.lower()
|
| 27 |
|
|
|
|
| 22 |
assert 'gr.Accordion("Advanced", open=False' in source
|
| 23 |
|
| 24 |
|
| 25 |
+
def test_voice_input_uses_gradio_audio_file_for_modal_asr():
|
| 26 |
+
source = inspect.getsource(ui_app.build_demo)
|
| 27 |
+
|
| 28 |
+
assert "audio_input = gr.Audio(" in source
|
| 29 |
+
assert 'sources=["microphone", "upload"]' in source
|
| 30 |
+
assert 'type="filepath"' in source
|
| 31 |
+
assert "audio_input = gr.State(None)" not in source
|
| 32 |
+
assert 'value=DEFAULT_ASR_BACKEND' in source
|
| 33 |
+
|
| 34 |
+
|
| 35 |
def test_processing_note_is_story_copy_not_backend_jargon():
|
| 36 |
lowered = PROCESSING_NOTE.lower()
|
| 37 |
|