feat: add nemotron voice input
Browse filesCo-authored-by: Codex <noreply@openai.com>
- DESIGN.md +34 -70
- README.md +11 -2
- app.py +44 -5
- hackathon_advisor/asr_runtime.py +159 -0
- hackathon_advisor/prize_ledger.py +9 -4
- packages.txt +2 -0
- pyproject.toml +6 -0
- requirements.txt +3 -0
- static/app.js +145 -0
- static/index.html +19 -0
- static/styles.css +15 -0
- tests/test_app.py +69 -0
- tests/test_asr_runtime.py +29 -0
- tests/test_frontend_copy.py +4 -0
DESIGN.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
# Build Small Hackathon Advisor — Design & Implementation Notes
|
| 2 |
|
| 3 |
-
> A **small-model agent**
|
| 4 |
> for the [Build Small Hackathon](https://huggingface.co/build-small-hackathon) and brainstorms an original new design
|
| 5 |
> *with you*. Output = streaming text + live visuals (no TTS). All models small, open-weight, run locally.
|
| 6 |
>
|
|
@@ -21,13 +21,13 @@ authoritative decision log; the rest of the doc is written to be consistent with
|
|
| 21 |
archivist divines a fate-page; ink **bleeds and cites the real Spaces** you overlap (page 47, page 112…), or
|
| 22 |
**blooms gold + sprouts a leaf** when it's unwritten. Engine unchanged underneath (crawl → whitespace/originality →
|
| 23 |
score). The dry "advisor" stays under the hood. Full spec + de-risking grafts in §2.
|
| 24 |
-
2. **Text-first.**
|
| 25 |
-
ASR
|
|
|
|
| 26 |
3. **Add a 🎯 Well-Tuned fine-tune** — a small LoRA (MiniCPM5 advisor persona / tool-calling), trained on Modal,
|
| 27 |
published to the Hub → 6/6 badges → strong shot at 🎖️ Bonus Quest Champion ($2,000).
|
| 28 |
-
4. **ASR = Nemotron
|
| 29 |
-
|
| 30 |
-
if the ZeroGPU *environment* fights it, drop in `nvidia/parakeet-tdt-0.6b-v3` (native transformers, same batch API).
|
| 31 |
|
| 32 |
**Verified corrections:**
|
| 33 |
- **Drop SGLang.** It needs a persistent GPU process → incompatible with ZeroGPU (same root cause as vLLM). Run
|
|
@@ -58,7 +58,7 @@ authoritative decision log; the rest of the doc is written to be consistent with
|
|
| 58 |
- Trivial `@spaces.GPU` hello-cuda build GREEN on torch 2.8+, deps pinned, heavy deps added one at a time.
|
| 59 |
- `gr.Server` minimal: static `index.html` + one same-origin `/api/agent-turn` NDJSON stream, plus the retained
|
| 60 |
`@app.api()` generator for external clients, on the real ZeroGPU Space.
|
| 61 |
-
- Nemotron `nemo_toolkit[asr]` install + one batch `transcribe()` inside `@spaces.GPU` (decision #4
|
| 62 |
|
| 63 |
---
|
| 64 |
|
|
@@ -110,7 +110,7 @@ UNWRITTEN." People compile draws into a "chapter" and dare friends to get a page
|
|
| 110 |
|
| 111 |
**Defaults (revisit if time):** single-page artifact first (chapter compiler later); page-numbers visible, real titles
|
| 112 |
on hover (keep the burn aimed at the idea, not a named builder); seal animation = safe typewriter + static-stamp floor
|
| 113 |
-
first, bespoke ink-reveal last
|
| 114 |
|
| 115 |
Input is **text-first**; the experience is fully delightful with typed input alone.
|
| 116 |
|
|
@@ -124,18 +124,16 @@ investigate → ideate → score loop — the experience collapses without the m
|
|
| 124 |
|
| 125 |
| Role | Model | Params | Runtime | License | Prize hook |
|
| 126 |
|---|---|---|---|---|---|
|
| 127 |
-
| STT (batch
|
| 128 |
-
| ↳ fallback | `nvidia/parakeet-tdt-0.6b-v3` | 0.6B | **transformers** (no NeMo) | CC-BY-4.0 | 🟩 (Quest brand — verify, §5.1) |
|
| 129 |
| LLM brain | **`openbmb/MiniCPM5-1B`** ("OpenCPM5") | 1.08B | **transformers** (self-parse XML) / llama.cpp | **Apache-2.0** | 🏮 OpenBMB |
|
| 130 |
-
| Turn detection (voice-later) | **`pipecat-ai/smart-turn-v3`** | ~8M | ONNX Runtime (browser) | BSD-2 | (natural voice UX) |
|
| 131 |
| Embedder | **`ggml-org/embeddinggemma-300M-qat-q4_0-GGUF`** | ~300M | llama.cpp / llama-cpp-python | Gemma | 🔌 Off the Grid · 🦙 Llama Champion · 🟢 Modal |
|
| 132 |
| Fine-tune | LoRA on MiniCPM5 → published to Hub | — | PEFT / HF Jobs | — | 🎯 Well-Tuned |
|
| 133 |
|
| 134 |
-
**Total ≈ 1.
|
| 135 |
|
| 136 |
> Naming: "OpenCPM5 1B" = `openbmb/MiniCPM5-1B` (MiniCPM 5.0, ~May 2026). "EmbeddingGemma 270M" =
|
| 137 |
> `google/embeddinggemma-300m` (308M total; 270M = non-embedding transformer params). **SGLang dropped** (ZeroGPU
|
| 138 |
-
> incompatible). STT used in **batch
|
| 139 |
|
| 140 |
---
|
| 141 |
|
|
@@ -148,21 +146,21 @@ With **text-first + batch ASR**, the old "streaming ASR vs ZeroGPU" Config A/B t
|
|
| 148 |
- **Text-first runtime loop:** user types → custom `/api/agent-turn` NDJSON endpoint → one `@spaces.GPU` call runs
|
| 149 |
MiniCPM5 (tool loop, in `transformers`) → streamed text tokens + live visual updates. The `@app.api()` endpoint
|
| 150 |
remains as the Gradio-client contract for external checks.
|
| 151 |
-
- **Voice
|
| 152 |
-
Nemotron
|
|
|
|
| 153 |
- **Modal (build-time only):** crawl the org + build the llama.cpp EmbeddingGemma vector index offline; the Space ships
|
| 154 |
with checked-in project vectors. Runtime never calls Modal → 🔌 Off the Grid holds (see §10).
|
| 155 |
|
| 156 |
> Off the Grid = no proprietary cloud inference APIs. Open weights on an HF GPU Space / local box / Modal all qualify.
|
| 157 |
|
| 158 |
-
**Deferred
|
| 159 |
-
Silero VAD turn detection, FastRTC. Documented but not on the text-first critical path.
|
| 160 |
|
| 161 |
---
|
| 162 |
|
| 163 |
## 5. Per-model implementation notes
|
| 164 |
|
| 165 |
-
### 5.1 ASR — `nvidia/nemotron-speech-streaming-en-0.6b` (batch)
|
| 166 |
|
| 167 |
- **Primary, batch usage (simple):**
|
| 168 |
```python
|
|
@@ -170,20 +168,10 @@ Silero VAD turn detection, FastRTC. Documented but not on the text-first critica
|
|
| 170 |
asr = nemo_asr.models.ASRModel.from_pretrained("nvidia/nemotron-speech-streaming-en-0.6b")
|
| 171 |
text = asr.transcribe(["utterance.wav"]) # 16 kHz mono WAV in; punctuated EN text out
|
| 172 |
```
|
| 173 |
-
|
| 174 |
-
`
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
- **Fallback (if the ZeroGPU env fights NeMo):** `nvidia/parakeet-tdt-0.6b-v3` has **native transformers** support —
|
| 178 |
-
```python
|
| 179 |
-
from transformers import pipeline
|
| 180 |
-
asr = pipeline("automatic-speech-recognition", model="nvidia/parakeet-tdt-0.6b-v3")
|
| 181 |
-
text = asr("utterance.wav")
|
| 182 |
-
```
|
| 183 |
-
Same 0.6B, CC-BY-4.0, 25 languages, one clean `@spaces.GPU` batch call, no NeMo/git build.
|
| 184 |
-
- **Quest caveat:** the award says "standout **Nemotron** builds." Parakeet is the encoder *inside* the Nemotron-Speech
|
| 185 |
-
family but is branded "Parakeet" → if we ship Parakeet, **confirm Quest eligibility with organizers**, or keep
|
| 186 |
-
Nemotron documented as the Quest narrative. Hosted NVIDIA NIM API would break Off the Grid — never in the badge demo.
|
| 187 |
|
| 188 |
### 5.2 MiniCPM5-1B brain — `openbmb/MiniCPM5-1B` (transformers, self-parsed XML)
|
| 189 |
|
|
@@ -203,24 +191,6 @@ Silero VAD turn detection, FastRTC. Documented but not on the text-first critica
|
|
| 203 |
tool-calling is a pending PR — verify before relying on it for the badge runtime.)
|
| 204 |
- **1B discipline:** small tool schemas, few params each, clear descriptions, low temp, single-hop tool calls.
|
| 205 |
|
| 206 |
-
### 5.3 Smart Turn v3 — `pipecat-ai/smart-turn-v3` (voice-later)
|
| 207 |
-
|
| 208 |
-
- Whisper-tiny encoder + linear head, ~8M params, ONNX (`smart-turn-v3.1.onnx`), 8 MB int8 / 32 MB fp32, BSD-2.
|
| 209 |
-
- Input: 16 kHz mono PCM float32, ≤8 s (front-padded). Output: sigmoid prob; **>0.5 = "user finished."** ~12 ms CPU.
|
| 210 |
-
```python
|
| 211 |
-
import numpy as np, onnxruntime as ort
|
| 212 |
-
from transformers import WhisperFeatureExtractor
|
| 213 |
-
fe = WhisperFeatureExtractor(chunk_length=8); sess = ort.InferenceSession("smart-turn-v3.1.onnx")
|
| 214 |
-
def turn_complete(audio):
|
| 215 |
-
x = fe(audio, sampling_rate=16000, return_tensors="np", padding="max_length",
|
| 216 |
-
max_length=8*16000, truncation=True, do_normalize=True).input_features.astype("float32")
|
| 217 |
-
return sess.run(None, {"input_features": x})[0][0].item() > 0.5
|
| 218 |
-
```
|
| 219 |
-
- **Browser path (voice-later):** runs via ONNX Runtime Web / Transformers.js. The `.onnx` needs Whisper **log-mel
|
| 220 |
-
input_features** (128 mel bins, 8 s, NOT whisper-tiny's 80-mel) — no upstream JS example exists, so a small POC is
|
| 221 |
-
required before relying on it; fallbacks: port pipecat's numpy-only mel extractor to JS, or do feature-extraction +
|
| 222 |
-
onnx **server-side** per posted blob. Pair with `@ricky0123/vad-web` (Silero) for the speech start/stop gate.
|
| 223 |
-
|
| 224 |
### 5.4 EmbeddingGemma GGUF — `ggml-org/embeddinggemma-300M-qat-q4_0-GGUF`
|
| 225 |
|
| 226 |
- Active retrieval model: `embeddinggemma-300M-qat-Q4_0.gguf`, 768-dimensional normalized embeddings.
|
|
@@ -239,8 +209,7 @@ llama.cpp on Modal, and runtime query embeddings use the same llama.cpp path.
|
|
| 239 |
|---|---|---|---|
|
| 240 |
| `openbmb/MiniCPM5-1B` | ✅ planned only | llama.cpp / Ollama | Not used for deployed tool-calling; Transformers + LoRA is the deployed brain. |
|
| 241 |
| `ggml-org/embeddinggemma-300M-qat-q4_0-GGUF` | ✅ active | llama.cpp / llama-cpp-python | Builds project vectors on Modal and embeds runtime queries in the Space. |
|
| 242 |
-
| ASR (Nemotron
|
| 243 |
-
| `pipecat-ai/smart-turn-v3` | ❌ | ONNX Runtime | Whisper encoder + classifier head |
|
| 244 |
|
| 245 |
If retrieval quality becomes the bottleneck, compare Q4_0 against Q8_0, but do not keep two runtime retrieval paths.
|
| 246 |
|
|
@@ -393,12 +362,12 @@ open grimoire as the hero component.
|
|
| 393 |
| Target | How it's earned |
|
| 394 |
|---|---|
|
| 395 |
| 🍄 Thousand Token Wood | **The Unwritten Almanac** (§2) — the bleed-citation wow IS the engine rendered; AI load-bearing; original |
|
| 396 |
-
| 🐜 Tiny Titan (special, $1.5k) | total ~1.
|
| 397 |
| 🔌 Off the Grid (badge) | all open weights run locally; offline index; no cloud inference at runtime |
|
| 398 |
| 🎯 Well-Tuned (badge) | published LoRA fine-tune of MiniCPM5 on the Hub (§10) → **6/6 badges** |
|
| 399 |
| 🎨 Off-Brand (badge + $1.5k) | `gr.Server` custom UI is the agent's output surface |
|
| 400 |
| 🏮 OpenBMB ($10k) | brain = MiniCPM5-1B ("OpenBMB pick") |
|
| 401 |
-
| 🟩 NVIDIA Quest (2× RTX 5080) | ASR = Nemotron (
|
| 402 |
| 🦙 Llama Champion (badge) | EmbeddingGemma GGUF retrieval index and runtime query embeddings run through llama.cpp (§5.5) |
|
| 403 |
| 📡 Sharing is Caring (badge) | publish the agent's tool-call trace to the Hub |
|
| 404 |
| 📓 Field Notes (badge) | this DESIGN.md → a build blog post |
|
|
@@ -417,22 +386,20 @@ lever is §11 custom-UI polish.
|
|
| 417 |
|
| 418 |
## 13. Risks / open items
|
| 419 |
|
| 420 |
-
1. **
|
| 421 |
-
|
| 422 |
2. **EmbeddingGemma is gated** — accept Gemma terms + `HF_TOKEN` before any crawl/build.
|
| 423 |
3. **MiniCPM5 tool-call reliability at 1B** — covered by the degradation ladder (§8); validate name+args in code.
|
| 424 |
-
4. **
|
| 425 |
-
primary (§5.1).
|
| 426 |
-
5. **Concept skin** — **chosen: The Unwritten Almanac** (§2). Make-or-break is the bleed/bloom hero animation; build the
|
| 427 |
safe typewriter + static-stamp floor first (graceful degradation), upgrade ink last. Watch the thin-org echo
|
| 428 |
threshold + the dry-but-benevolent tone (real cited Spaces, never punch at a named builder).
|
| 429 |
-
|
| 430 |
|
| 431 |
---
|
| 432 |
|
| 433 |
## 14. Build order
|
| 434 |
|
| 435 |
-
**Text-first vertical slice first; voice
|
| 436 |
|
| 437 |
0. **Day-1 spikes** (§1) — get the three go/no-go builds green.
|
| 438 |
1. **`crawler.py` + Modal index** — crawl the org, embed with EmbeddingGemma, build the local index. *You immediately
|
|
@@ -442,27 +409,24 @@ lever is §11 custom-UI polish.
|
|
| 442 |
4. **`app.py`** — `gr.Server` custom frontend (idea board, project/whitespace wall, streaming text), called via
|
| 443 |
first-party `/api/...` endpoints; concept skin applied.
|
| 444 |
5. **Well-Tuned LoRA** — small fine-tune on Modal → publish to Hub (→ 6/6 badges).
|
| 445 |
-
6. **
|
|
|
|
| 446 |
write up Field Notes (📓).
|
| 447 |
|
| 448 |
-
**
|
| 449 |
-
**Deferred:** real-time streaming ASR, in-browser Smart Turn + Silero VAD, FastRTC + TURN. (Original streaming design is
|
| 450 |
-
preserved in git history if revisited.)
|
| 451 |
|
| 452 |
---
|
| 453 |
|
| 454 |
## 15. Sources
|
| 455 |
|
| 456 |
**Models:** [nemotron-speech-streaming-en-0.6b](https://huggingface.co/nvidia/nemotron-speech-streaming-en-0.6b) ·
|
| 457 |
-
[parakeet-tdt-0.6b-v3](https://huggingface.co/nvidia/parakeet-tdt-0.6b-v3) ·
|
| 458 |
-
[parakeet transformers doc](https://huggingface.co/docs/transformers/en/model_doc/parakeet) ·
|
| 459 |
[MiniCPM5-1B](https://huggingface.co/openbmb/MiniCPM5-1B) · [MiniCPM5-1B-GGUF](https://huggingface.co/openbmb/MiniCPM5-1B-GGUF) ·
|
| 460 |
-
[
|
| 461 |
|
| 462 |
**Platforms:** [ZeroGPU docs](https://huggingface.co/docs/hub/spaces-zerogpu) ·
|
| 463 |
[Introducing gradio.Server](https://huggingface.co/blog/introducing-gradio-server) · [Gradio Server Mode guide](https://www.gradio.app/guides/server-mode) ·
|
| 464 |
[Modal GPU](https://modal.com/docs/guide/gpu) · [Modal model weights](https://modal.com/docs/guide/model-weights) · [Modal pricing](https://modal.com/pricing) ·
|
| 465 |
[Build Small Hackathon](https://huggingface.co/build-small-hackathon)
|
| 466 |
|
| 467 |
-
*Verify-before-ship: Nemotron-in-ZeroGPU
|
| 468 |
-
|
|
|
|
| 1 |
# Build Small Hackathon Advisor — Design & Implementation Notes
|
| 2 |
|
| 3 |
+
> A **small-model agent** with text and voice input that investigates what other people have already built
|
| 4 |
> for the [Build Small Hackathon](https://huggingface.co/build-small-hackathon) and brainstorms an original new design
|
| 5 |
> *with you*. Output = streaming text + live visuals (no TTS). All models small, open-weight, run locally.
|
| 6 |
>
|
|
|
|
| 21 |
archivist divines a fate-page; ink **bleeds and cites the real Spaces** you overlap (page 47, page 112…), or
|
| 22 |
**blooms gold + sprouts a leaf** when it's unwritten. Engine unchanged underneath (crawl → whitespace/originality →
|
| 23 |
score). The dry "advisor" stays under the hood. Full spec + de-risking grafts in §2.
|
| 24 |
+
2. **Text-first with voice input.** The core workflow remains typed/editable text. Voice records or uploads a note,
|
| 25 |
+
transcribes it with batch ASR, and places the transcript in the same idea box. Real-time streaming + in-browser turn
|
| 26 |
+
detection are **deferred**.
|
| 27 |
3. **Add a 🎯 Well-Tuned fine-tune** — a small LoRA (MiniCPM5 advisor persona / tool-calling), trained on Modal,
|
| 28 |
published to the Hub → 6/6 badges → strong shot at 🎖️ Bonus Quest Champion ($2,000).
|
| 29 |
+
4. **ASR = Nemotron batch.** `nvidia/nemotron-speech-streaming-en-0.6b` runs through NVIDIA NeMo in a ZeroGPU function.
|
| 30 |
+
Audio is normalized to mono WAV before calling `transcribe([wav])`.
|
|
|
|
| 31 |
|
| 32 |
**Verified corrections:**
|
| 33 |
- **Drop SGLang.** It needs a persistent GPU process → incompatible with ZeroGPU (same root cause as vLLM). Run
|
|
|
|
| 58 |
- Trivial `@spaces.GPU` hello-cuda build GREEN on torch 2.8+, deps pinned, heavy deps added one at a time.
|
| 59 |
- `gr.Server` minimal: static `index.html` + one same-origin `/api/agent-turn` NDJSON stream, plus the retained
|
| 60 |
`@app.api()` generator for external clients, on the real ZeroGPU Space.
|
| 61 |
+
- Nemotron `nemo_toolkit[asr]` install + one batch `transcribe()` inside `@spaces.GPU` (decision #4).
|
| 62 |
|
| 63 |
---
|
| 64 |
|
|
|
|
| 110 |
|
| 111 |
**Defaults (revisit if time):** single-page artifact first (chapter compiler later); page-numbers visible, real titles
|
| 112 |
on hover (keep the burn aimed at the idea, not a named builder); seal animation = safe typewriter + static-stamp floor
|
| 113 |
+
first, bespoke ink-reveal last. Voice input is batch ASR that fills the same idea box before the user presses Ink.
|
| 114 |
|
| 115 |
Input is **text-first**; the experience is fully delightful with typed input alone.
|
| 116 |
|
|
|
|
| 124 |
|
| 125 |
| Role | Model | Params | Runtime | License | Prize hook |
|
| 126 |
|---|---|---|---|---|---|
|
| 127 |
+
| STT (batch voice input) | **`nvidia/nemotron-speech-streaming-en-0.6b`** | 0.6B | NeMo, GPU+CUDA | NVIDIA Open Model (commercial OK) | 🟩 NVIDIA Nemotron Quest |
|
|
|
|
| 128 |
| LLM brain | **`openbmb/MiniCPM5-1B`** ("OpenCPM5") | 1.08B | **transformers** (self-parse XML) / llama.cpp | **Apache-2.0** | 🏮 OpenBMB |
|
|
|
|
| 129 |
| Embedder | **`ggml-org/embeddinggemma-300M-qat-q4_0-GGUF`** | ~300M | llama.cpp / llama-cpp-python | Gemma | 🔌 Off the Grid · 🦙 Llama Champion · 🟢 Modal |
|
| 130 |
| Fine-tune | LoRA on MiniCPM5 → published to Hub | — | PEFT / HF Jobs | — | 🎯 Well-Tuned |
|
| 131 |
|
| 132 |
+
**Total ≈ 1.98B params → ≤4B → 🐜 Tiny Titan eligible.** All open-weight, all runnable locally → 🔌 Off the Grid.
|
| 133 |
|
| 134 |
> Naming: "OpenCPM5 1B" = `openbmb/MiniCPM5-1B` (MiniCPM 5.0, ~May 2026). "EmbeddingGemma 270M" =
|
| 135 |
> `google/embeddinggemma-300m` (308M total; 270M = non-embedding transformer params). **SGLang dropped** (ZeroGPU
|
| 136 |
+
> incompatible). STT is used in **batch voice-note** mode, not a persistent stream.
|
| 137 |
|
| 138 |
---
|
| 139 |
|
|
|
|
| 146 |
- **Text-first runtime loop:** user types → custom `/api/agent-turn` NDJSON endpoint → one `@spaces.GPU` call runs
|
| 147 |
MiniCPM5 (tool loop, in `transformers`) → streamed text tokens + live visual updates. The `@app.api()` endpoint
|
| 148 |
remains as the Gradio-client contract for external checks.
|
| 149 |
+
- **Voice input:** push-to-talk records an utterance or uploads a voice note → `/api/transcribe` normalizes audio with
|
| 150 |
+
ffmpeg → one `@spaces.GPU` call runs Nemotron ASR through NeMo → transcript fills the idea box. No persistent stream,
|
| 151 |
+
no WebRTC, **no TURN server**.
|
| 152 |
- **Modal (build-time only):** crawl the org + build the llama.cpp EmbeddingGemma vector index offline; the Space ships
|
| 153 |
with checked-in project vectors. Runtime never calls Modal → 🔌 Off the Grid holds (see §10).
|
| 154 |
|
| 155 |
> Off the Grid = no proprietary cloud inference APIs. Open weights on an HF GPU Space / local box / Modal all qualify.
|
| 156 |
|
| 157 |
+
**Deferred:** real-time streaming ASR and turn detection are not part of the shipped app.
|
|
|
|
| 158 |
|
| 159 |
---
|
| 160 |
|
| 161 |
## 5. Per-model implementation notes
|
| 162 |
|
| 163 |
+
### 5.1 ASR — `nvidia/nemotron-speech-streaming-en-0.6b` (batch)
|
| 164 |
|
| 165 |
- **Primary, batch usage (simple):**
|
| 166 |
```python
|
|
|
|
| 168 |
asr = nemo_asr.models.ASRModel.from_pretrained("nvidia/nemotron-speech-streaming-en-0.6b")
|
| 169 |
text = asr.transcribe(["utterance.wav"]) # 16 kHz mono WAV in; punctuated EN text out
|
| 170 |
```
|
| 171 |
+
Runtime install: `packages.txt` provides `ffmpeg` and `libsndfile1`; `requirements.txt` pins
|
| 172 |
+
`nemo_toolkit[asr]==2.7.3` plus Cython and packaging. The app records or uploads audio, normalizes it to mono
|
| 173 |
+
16 kHz WAV, runs NeMo in a ZeroGPU function, then returns the transcript to the idea box. Hosted NVIDIA NIM API would
|
| 174 |
+
break Off the Grid, so it is not used.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 175 |
|
| 176 |
### 5.2 MiniCPM5-1B brain — `openbmb/MiniCPM5-1B` (transformers, self-parsed XML)
|
| 177 |
|
|
|
|
| 191 |
tool-calling is a pending PR — verify before relying on it for the badge runtime.)
|
| 192 |
- **1B discipline:** small tool schemas, few params each, clear descriptions, low temp, single-hop tool calls.
|
| 193 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 194 |
### 5.4 EmbeddingGemma GGUF — `ggml-org/embeddinggemma-300M-qat-q4_0-GGUF`
|
| 195 |
|
| 196 |
- Active retrieval model: `embeddinggemma-300M-qat-Q4_0.gguf`, 768-dimensional normalized embeddings.
|
|
|
|
| 209 |
|---|---|---|---|
|
| 210 |
| `openbmb/MiniCPM5-1B` | ✅ planned only | llama.cpp / Ollama | Not used for deployed tool-calling; Transformers + LoRA is the deployed brain. |
|
| 211 |
| `ggml-org/embeddinggemma-300M-qat-q4_0-GGUF` | ✅ active | llama.cpp / llama-cpp-python | Builds project vectors on Modal and embeds runtime queries in the Space. |
|
| 212 |
+
| ASR (Nemotron) | ❌ | NeMo | FastConformer-RNNT |
|
|
|
|
| 213 |
|
| 214 |
If retrieval quality becomes the bottleneck, compare Q4_0 against Q8_0, but do not keep two runtime retrieval paths.
|
| 215 |
|
|
|
|
| 362 |
| Target | How it's earned |
|
| 363 |
|---|---|
|
| 364 |
| 🍄 Thousand Token Wood | **The Unwritten Almanac** (§2) — the bleed-citation wow IS the engine rendered; AI load-bearing; original |
|
| 365 |
+
| 🐜 Tiny Titan (special, $1.5k) | total ~1.98B, every model ≤4B; largest single = MiniCPM5 1.08B |
|
| 366 |
| 🔌 Off the Grid (badge) | all open weights run locally; offline index; no cloud inference at runtime |
|
| 367 |
| 🎯 Well-Tuned (badge) | published LoRA fine-tune of MiniCPM5 on the Hub (§10) → **6/6 badges** |
|
| 368 |
| 🎨 Off-Brand (badge + $1.5k) | `gr.Server` custom UI is the agent's output surface |
|
| 369 |
| 🏮 OpenBMB ($10k) | brain = MiniCPM5-1B ("OpenBMB pick") |
|
| 370 |
+
| 🟩 NVIDIA Quest (2× RTX 5080) | ASR = Nemotron (§5.1) |
|
| 371 |
| 🦙 Llama Champion (badge) | EmbeddingGemma GGUF retrieval index and runtime query embeddings run through llama.cpp (§5.5) |
|
| 372 |
| 📡 Sharing is Caring (badge) | publish the agent's tool-call trace to the Hub |
|
| 373 |
| 📓 Field Notes (badge) | this DESIGN.md → a build blog post |
|
|
|
|
| 386 |
|
| 387 |
## 13. Risks / open items
|
| 388 |
|
| 389 |
+
1. **Deployment smoke tests are mandatory:** ZeroGPU Space build, same-origin NDJSON browser streaming, and Nemotron
|
| 390 |
+
batch ASR in `@spaces.GPU` must be verified after every runtime dependency change.
|
| 391 |
2. **EmbeddingGemma is gated** — accept Gemma terms + `HF_TOKEN` before any crawl/build.
|
| 392 |
3. **MiniCPM5 tool-call reliability at 1B** — covered by the degradation ladder (§8); validate name+args in code.
|
| 393 |
+
4. **Concept skin** — **chosen: The Unwritten Almanac** (§2). Make-or-break is the bleed/bloom hero animation; build the
|
|
|
|
|
|
|
| 394 |
safe typewriter + static-stamp floor first (graceful degradation), upgrade ink last. Watch the thin-org echo
|
| 395 |
threshold + the dry-but-benevolent tone (real cited Spaces, never punch at a named builder).
|
| 396 |
+
5. **Param-budget claim** — document the 1.98B total in the README/Space card for Tiny Titan judging.
|
| 397 |
|
| 398 |
---
|
| 399 |
|
| 400 |
## 14. Build order
|
| 401 |
|
| 402 |
+
**Text-first vertical slice first; voice input is now part of the app.** Always keep a demoable artifact.
|
| 403 |
|
| 404 |
0. **Day-1 spikes** (§1) — get the three go/no-go builds green.
|
| 405 |
1. **`crawler.py` + Modal index** — crawl the org, embed with EmbeddingGemma, build the local index. *You immediately
|
|
|
|
| 409 |
4. **`app.py`** — `gr.Server` custom frontend (idea board, project/whitespace wall, streaming text), called via
|
| 410 |
first-party `/api/...` endpoints; concept skin applied.
|
| 411 |
5. **Well-Tuned LoRA** — small fine-tune on Modal → publish to Hub (→ 6/6 badges).
|
| 412 |
+
6. **Voice input** — push-to-talk record and voice-note upload through Nemotron batch ASR in `/api/transcribe`.
|
| 413 |
+
7. **Polish + submission** — demo video + social post (Best Demo / Community Choice), publish agent trace (📡),
|
| 414 |
write up Field Notes (📓).
|
| 415 |
|
| 416 |
+
**Deferred:** real-time streaming ASR and turn detection. The shipped path stays batch audio → transcript → editable idea.
|
|
|
|
|
|
|
| 417 |
|
| 418 |
---
|
| 419 |
|
| 420 |
## 15. Sources
|
| 421 |
|
| 422 |
**Models:** [nemotron-speech-streaming-en-0.6b](https://huggingface.co/nvidia/nemotron-speech-streaming-en-0.6b) ·
|
|
|
|
|
|
|
| 423 |
[MiniCPM5-1B](https://huggingface.co/openbmb/MiniCPM5-1B) · [MiniCPM5-1B-GGUF](https://huggingface.co/openbmb/MiniCPM5-1B-GGUF) ·
|
| 424 |
+
[embeddinggemma-300m](https://huggingface.co/google/embeddinggemma-300m)
|
| 425 |
|
| 426 |
**Platforms:** [ZeroGPU docs](https://huggingface.co/docs/hub/spaces-zerogpu) ·
|
| 427 |
[Introducing gradio.Server](https://huggingface.co/blog/introducing-gradio-server) · [Gradio Server Mode guide](https://www.gradio.app/guides/server-mode) ·
|
| 428 |
[Modal GPU](https://modal.com/docs/guide/gpu) · [Modal model weights](https://modal.com/docs/guide/model-weights) · [Modal pricing](https://modal.com/pricing) ·
|
| 429 |
[Build Small Hackathon](https://huggingface.co/build-small-hackathon)
|
| 430 |
|
| 431 |
+
*Verify-before-ship: Nemotron-in-ZeroGPU after dependency changes; MiniCPM5 license on the live card; llama.cpp MiniCPM5
|
| 432 |
+
tool-calling remains planned only and is not used by the deployed brain.*
|
README.md
CHANGED
|
@@ -29,6 +29,7 @@ The current milestone is a deployed ZeroGPU + MiniCPM5 LoRA advisor:
|
|
| 29 |
|
| 30 |
- Local snapshot of public `build-small-hackathon` Spaces.
|
| 31 |
- Modal-built EmbeddingGemma GGUF retrieval index, with runtime query embeddings computed through llama.cpp.
|
|
|
|
| 32 |
- Jargon correction for hackathon/model terms.
|
| 33 |
- MiniCPM5 tool-call planning with a published PEFT LoRA adapter, plus deterministic local rules for tests and CPU-only
|
| 34 |
development.
|
|
@@ -88,6 +89,13 @@ Users can also click any Idea Board page to make it current before pressing `Pla
|
|
| 88 |
If the board is empty, `Plan` and `Compare` do not create placeholder pages; they prompt the user to write an idea or
|
| 89 |
press `Gap` first.
|
| 90 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 91 |
## Gap Exploration
|
| 92 |
|
| 93 |
The `Gap` command walks through unused whitespace candidates instead of repeating the same first suggestion. Each chosen
|
|
@@ -180,12 +188,13 @@ ADVISOR_ADAPTER_ID=build-small-hackathon/hackathon-advisor-minicpm5-lora
|
|
| 180 |
ADVISOR_ADAPTER_REVISION=25de69bcde397e1bcdd852923b56a42f10222650
|
| 181 |
ADVISOR_EMBEDDING_MODEL_REPO=ggml-org/embeddinggemma-300M-qat-q4_0-GGUF
|
| 182 |
ADVISOR_EMBEDDING_MODEL_FILE=embeddinggemma-300M-qat-Q4_0.gguf
|
|
|
|
| 183 |
```
|
| 184 |
|
| 185 |
`agent_turn` wraps the engine call with `spaces.GPU` when `ADVISOR_ZERO_GPU=1`, so model loading and generation run on
|
| 186 |
the ZeroGPU allocation. The retrieval query embedder downloads the GGUF model through `huggingface_hub` unless
|
| 187 |
-
`ADVISOR_EMBEDDING_MODEL_PATH` points to a local file.
|
| 188 |
-
`ADVISOR_MODEL_BACKEND=rules`.
|
| 189 |
|
| 190 |
## Test
|
| 191 |
|
|
|
|
| 29 |
|
| 30 |
- Local snapshot of public `build-small-hackathon` Spaces.
|
| 31 |
- Modal-built EmbeddingGemma GGUF retrieval index, with runtime query embeddings computed through llama.cpp.
|
| 32 |
+
- Nemotron Speech Streaming voice input through NVIDIA NeMo ASR on ZeroGPU.
|
| 33 |
- Jargon correction for hackathon/model terms.
|
| 34 |
- MiniCPM5 tool-call planning with a published PEFT LoRA adapter, plus deterministic local rules for tests and CPU-only
|
| 35 |
development.
|
|
|
|
| 89 |
If the board is empty, `Plan` and `Compare` do not create placeholder pages; they prompt the user to write an idea or
|
| 90 |
press `Gap` first.
|
| 91 |
|
| 92 |
+
## Voice Input
|
| 93 |
+
|
| 94 |
+
The `Speak` and `Voice note` controls send audio to `/api/transcribe`. The backend normalizes the uploaded audio with
|
| 95 |
+
ffmpeg, then transcribes it with `nvidia/nemotron-speech-streaming-en-0.6b` through NVIDIA NeMo inside the same ZeroGPU
|
| 96 |
+
runtime used by the advisor. The transcript is placed back in the idea box so the user can edit it before pressing
|
| 97 |
+
`Ink`.
|
| 98 |
+
|
| 99 |
## Gap Exploration
|
| 100 |
|
| 101 |
The `Gap` command walks through unused whitespace candidates instead of repeating the same first suggestion. Each chosen
|
|
|
|
| 188 |
ADVISOR_ADAPTER_REVISION=25de69bcde397e1bcdd852923b56a42f10222650
|
| 189 |
ADVISOR_EMBEDDING_MODEL_REPO=ggml-org/embeddinggemma-300M-qat-q4_0-GGUF
|
| 190 |
ADVISOR_EMBEDDING_MODEL_FILE=embeddinggemma-300M-qat-Q4_0.gguf
|
| 191 |
+
ADVISOR_ASR_MODEL_ID=nvidia/nemotron-speech-streaming-en-0.6b
|
| 192 |
```
|
| 193 |
|
| 194 |
`agent_turn` wraps the engine call with `spaces.GPU` when `ADVISOR_ZERO_GPU=1`, so model loading and generation run on
|
| 195 |
the ZeroGPU allocation. The retrieval query embedder downloads the GGUF model through `huggingface_hub` unless
|
| 196 |
+
`ADVISOR_EMBEDDING_MODEL_PATH` points to a local file. `/api/transcribe` uses the same ZeroGPU wrapper for Nemotron ASR.
|
| 197 |
+
Local tests and CPU-only development still default to `ADVISOR_MODEL_BACKEND=rules`.
|
| 198 |
|
| 199 |
## Test
|
| 200 |
|
app.py
CHANGED
|
@@ -3,14 +3,16 @@ from __future__ import annotations
|
|
| 3 |
import json
|
| 4 |
import os
|
| 5 |
from pathlib import Path
|
|
|
|
| 6 |
from typing import Any, Iterator
|
| 7 |
|
| 8 |
-
from fastapi import Body
|
| 9 |
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, Response, StreamingResponse
|
| 10 |
from gradio import Server
|
| 11 |
|
| 12 |
from hackathon_advisor.agent import AdvisorEngine
|
| 13 |
from hackathon_advisor.artifact_bundle import BUNDLE_FILENAME, build_demo_bundle_zip
|
|
|
|
| 14 |
from hackathon_advisor.chapter import build_chapter_markdown
|
| 15 |
from hackathon_advisor.data import ProjectIndex
|
| 16 |
from hackathon_advisor.demo_rehearsal import build_demo_rehearsal
|
|
@@ -31,9 +33,11 @@ STATIC_DIR = ROOT / "static"
|
|
| 31 |
DATA_PATH = ROOT / "data" / "projects.json"
|
| 32 |
INDEX_PATH = ROOT / "data" / "project_index.json"
|
| 33 |
PROFILE_FIELDS = ["skills", "time", "preferences", "constraints"]
|
|
|
|
| 34 |
|
| 35 |
index = ProjectIndex.from_files(DATA_PATH, INDEX_PATH)
|
| 36 |
engine = AdvisorEngine(index)
|
|
|
|
| 37 |
app = Server()
|
| 38 |
|
| 39 |
|
|
@@ -46,6 +50,11 @@ def _engine_turn(message: str, session: dict[str, Any]):
|
|
| 46 |
return engine.turn(message, session)
|
| 47 |
|
| 48 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
def _session_from_json(session_json: str = "{}") -> dict[str, Any]:
|
| 50 |
try:
|
| 51 |
session = json.loads(session_json or "{}")
|
|
@@ -107,6 +116,7 @@ def health() -> dict:
|
|
| 107 |
"ok": True,
|
| 108 |
"projects": len(index.projects),
|
| 109 |
"runtime": engine.runtime_status(),
|
|
|
|
| 110 |
**trace_metadata(index),
|
| 111 |
}
|
| 112 |
|
|
@@ -117,6 +127,7 @@ def bootstrap() -> dict:
|
|
| 117 |
return {
|
| 118 |
"project_count": len(index.projects),
|
| 119 |
"runtime": runtime_status,
|
|
|
|
| 120 |
**trace_metadata(index),
|
| 121 |
"top_projects": [project.to_public_dict() for project in index.top_projects(limit=8)],
|
| 122 |
"whitespace": [item.to_dict() for item in index.find_whitespace(limit=5)],
|
|
@@ -134,7 +145,7 @@ def runtime() -> dict:
|
|
| 134 |
|
| 135 |
@app.get("/api/prize-ledger")
|
| 136 |
def prize_ledger_endpoint() -> dict:
|
| 137 |
-
return prize_ledger(engine.runtime_status(), trace_metadata(index))
|
| 138 |
|
| 139 |
|
| 140 |
@app.get("/api/tool-contracts")
|
|
@@ -153,7 +164,7 @@ def demo_session() -> dict:
|
|
| 153 |
@app.get("/api/demo-bundle.zip")
|
| 154 |
def demo_bundle() -> Response:
|
| 155 |
runtime_status = engine.runtime_status()
|
| 156 |
-
ledger = prize_ledger(runtime_status, trace_metadata(index))
|
| 157 |
metadata = {
|
| 158 |
**trace_metadata(index),
|
| 159 |
"project_count": len(index.projects),
|
|
@@ -190,6 +201,34 @@ def agent_turn_stream(payload: dict[str, Any] | None = Body(default=None)) -> St
|
|
| 190 |
return StreamingResponse(stream(), media_type="application/x-ndjson")
|
| 191 |
|
| 192 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 193 |
@app.post("/api/field-notes")
|
| 194 |
def field_notes_api(payload: dict[str, Any] | None = Body(default=None)) -> Response:
|
| 195 |
session = _session_from_payload(payload)
|
|
@@ -219,7 +258,7 @@ def chapter_api(payload: dict[str, Any] | None = Body(default=None)) -> Response
|
|
| 219 |
@app.get("/api/lora-training-kit.zip")
|
| 220 |
def lora_training_kit() -> Response:
|
| 221 |
runtime_status = engine.runtime_status()
|
| 222 |
-
ledger = prize_ledger(runtime_status, trace_metadata(index))
|
| 223 |
metadata = {
|
| 224 |
**trace_metadata(index),
|
| 225 |
"project_count": len(index.projects),
|
|
@@ -291,7 +330,7 @@ def submission_packet_artifact(session_json: str = "{}") -> str:
|
|
| 291 |
**trace_metadata(index),
|
| 292 |
"project_count": len(index.projects),
|
| 293 |
},
|
| 294 |
-
prize_ledger(runtime_status, trace_metadata(index)),
|
| 295 |
)
|
| 296 |
|
| 297 |
|
|
|
|
| 3 |
import json
|
| 4 |
import os
|
| 5 |
from pathlib import Path
|
| 6 |
+
import tempfile
|
| 7 |
from typing import Any, Iterator
|
| 8 |
|
| 9 |
+
from fastapi import Body, File, HTTPException, UploadFile
|
| 10 |
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, Response, StreamingResponse
|
| 11 |
from gradio import Server
|
| 12 |
|
| 13 |
from hackathon_advisor.agent import AdvisorEngine
|
| 14 |
from hackathon_advisor.artifact_bundle import BUNDLE_FILENAME, build_demo_bundle_zip
|
| 15 |
+
from hackathon_advisor.asr_runtime import create_asr_transcriber
|
| 16 |
from hackathon_advisor.chapter import build_chapter_markdown
|
| 17 |
from hackathon_advisor.data import ProjectIndex
|
| 18 |
from hackathon_advisor.demo_rehearsal import build_demo_rehearsal
|
|
|
|
| 33 |
DATA_PATH = ROOT / "data" / "projects.json"
|
| 34 |
INDEX_PATH = ROOT / "data" / "project_index.json"
|
| 35 |
PROFILE_FIELDS = ["skills", "time", "preferences", "constraints"]
|
| 36 |
+
MAX_AUDIO_UPLOAD_BYTES = 25 * 1024 * 1024
|
| 37 |
|
| 38 |
index = ProjectIndex.from_files(DATA_PATH, INDEX_PATH)
|
| 39 |
engine = AdvisorEngine(index)
|
| 40 |
+
voice_transcriber = create_asr_transcriber()
|
| 41 |
app = Server()
|
| 42 |
|
| 43 |
|
|
|
|
| 50 |
return engine.turn(message, session)
|
| 51 |
|
| 52 |
|
| 53 |
+
@gpu_task
|
| 54 |
+
def _transcribe_voice(audio_path: str) -> dict[str, Any]:
|
| 55 |
+
return voice_transcriber.transcribe(Path(audio_path)).to_dict()
|
| 56 |
+
|
| 57 |
+
|
| 58 |
def _session_from_json(session_json: str = "{}") -> dict[str, Any]:
|
| 59 |
try:
|
| 60 |
session = json.loads(session_json or "{}")
|
|
|
|
| 116 |
"ok": True,
|
| 117 |
"projects": len(index.projects),
|
| 118 |
"runtime": engine.runtime_status(),
|
| 119 |
+
"voice": voice_transcriber.status().to_dict(),
|
| 120 |
**trace_metadata(index),
|
| 121 |
}
|
| 122 |
|
|
|
|
| 127 |
return {
|
| 128 |
"project_count": len(index.projects),
|
| 129 |
"runtime": runtime_status,
|
| 130 |
+
"voice": voice_transcriber.status().to_dict(),
|
| 131 |
**trace_metadata(index),
|
| 132 |
"top_projects": [project.to_public_dict() for project in index.top_projects(limit=8)],
|
| 133 |
"whitespace": [item.to_dict() for item in index.find_whitespace(limit=5)],
|
|
|
|
| 145 |
|
| 146 |
@app.get("/api/prize-ledger")
|
| 147 |
def prize_ledger_endpoint() -> dict:
|
| 148 |
+
return prize_ledger(engine.runtime_status(), trace_metadata(index), voice_transcriber.status().to_dict())
|
| 149 |
|
| 150 |
|
| 151 |
@app.get("/api/tool-contracts")
|
|
|
|
| 164 |
@app.get("/api/demo-bundle.zip")
|
| 165 |
def demo_bundle() -> Response:
|
| 166 |
runtime_status = engine.runtime_status()
|
| 167 |
+
ledger = prize_ledger(runtime_status, trace_metadata(index), voice_transcriber.status().to_dict())
|
| 168 |
metadata = {
|
| 169 |
**trace_metadata(index),
|
| 170 |
"project_count": len(index.projects),
|
|
|
|
| 201 |
return StreamingResponse(stream(), media_type="application/x-ndjson")
|
| 202 |
|
| 203 |
|
| 204 |
+
@app.post("/api/transcribe")
|
| 205 |
+
async def transcribe_audio(audio: UploadFile = File(...)) -> dict[str, Any]:
|
| 206 |
+
content_type = str(audio.content_type or "")
|
| 207 |
+
if content_type and not content_type.startswith("audio/"):
|
| 208 |
+
raise HTTPException(status_code=415, detail="Voice input must be an audio file.")
|
| 209 |
+
with tempfile.TemporaryDirectory(prefix="advisor-upload-") as directory:
|
| 210 |
+
filename = Path(str(audio.filename or "voice-note")).name
|
| 211 |
+
suffix = Path(filename).suffix or ".audio"
|
| 212 |
+
source = Path(directory) / f"voice{suffix}"
|
| 213 |
+
await _save_audio_upload(audio, source)
|
| 214 |
+
return _transcribe_voice(str(source))
|
| 215 |
+
|
| 216 |
+
|
| 217 |
+
async def _save_audio_upload(upload: UploadFile, target: Path) -> None:
|
| 218 |
+
total = 0
|
| 219 |
+
with target.open("wb") as handle:
|
| 220 |
+
while True:
|
| 221 |
+
chunk = await upload.read(1024 * 1024)
|
| 222 |
+
if not chunk:
|
| 223 |
+
break
|
| 224 |
+
total += len(chunk)
|
| 225 |
+
if total > MAX_AUDIO_UPLOAD_BYTES:
|
| 226 |
+
raise HTTPException(status_code=413, detail="Voice note is too large.")
|
| 227 |
+
handle.write(chunk)
|
| 228 |
+
if total == 0:
|
| 229 |
+
raise HTTPException(status_code=400, detail="Voice note is empty.")
|
| 230 |
+
|
| 231 |
+
|
| 232 |
@app.post("/api/field-notes")
|
| 233 |
def field_notes_api(payload: dict[str, Any] | None = Body(default=None)) -> Response:
|
| 234 |
session = _session_from_payload(payload)
|
|
|
|
| 258 |
@app.get("/api/lora-training-kit.zip")
|
| 259 |
def lora_training_kit() -> Response:
|
| 260 |
runtime_status = engine.runtime_status()
|
| 261 |
+
ledger = prize_ledger(runtime_status, trace_metadata(index), voice_transcriber.status().to_dict())
|
| 262 |
metadata = {
|
| 263 |
**trace_metadata(index),
|
| 264 |
"project_count": len(index.projects),
|
|
|
|
| 330 |
**trace_metadata(index),
|
| 331 |
"project_count": len(index.projects),
|
| 332 |
},
|
| 333 |
+
prize_ledger(runtime_status, trace_metadata(index), voice_transcriber.status().to_dict()),
|
| 334 |
)
|
| 335 |
|
| 336 |
|
hackathon_advisor/asr_runtime.py
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from dataclasses import dataclass
|
| 4 |
+
import os
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
import shutil
|
| 7 |
+
import subprocess
|
| 8 |
+
import tempfile
|
| 9 |
+
from typing import Any
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
DEFAULT_ASR_MODEL_ID = "nvidia/nemotron-speech-streaming-en-0.6b"
|
| 13 |
+
DEFAULT_ASR_BACKEND = "nemo-asr"
|
| 14 |
+
DEFAULT_ASR_SAMPLE_RATE = 16_000
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
@dataclass(frozen=True)
|
| 18 |
+
class AsrTranscript:
|
| 19 |
+
transcript: str
|
| 20 |
+
model_id: str
|
| 21 |
+
backend: str
|
| 22 |
+
sample_rate: int
|
| 23 |
+
|
| 24 |
+
def to_dict(self) -> dict[str, Any]:
|
| 25 |
+
return {
|
| 26 |
+
"transcript": self.transcript,
|
| 27 |
+
"model_id": self.model_id,
|
| 28 |
+
"backend": self.backend,
|
| 29 |
+
"sample_rate": self.sample_rate,
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
@dataclass(frozen=True)
|
| 34 |
+
class AsrStatus:
|
| 35 |
+
backend: str
|
| 36 |
+
model_id: str
|
| 37 |
+
loaded: bool
|
| 38 |
+
sample_rate: int
|
| 39 |
+
|
| 40 |
+
def to_dict(self) -> dict[str, Any]:
|
| 41 |
+
return {
|
| 42 |
+
"backend": self.backend,
|
| 43 |
+
"model_id": self.model_id,
|
| 44 |
+
"loaded": self.loaded,
|
| 45 |
+
"sample_rate": self.sample_rate,
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
class NemotronAsrTranscriber:
|
| 50 |
+
backend = DEFAULT_ASR_BACKEND
|
| 51 |
+
|
| 52 |
+
def __init__(
|
| 53 |
+
self,
|
| 54 |
+
model_id: str = DEFAULT_ASR_MODEL_ID,
|
| 55 |
+
sample_rate: int = DEFAULT_ASR_SAMPLE_RATE,
|
| 56 |
+
) -> None:
|
| 57 |
+
self.model_id = model_id.strip() or DEFAULT_ASR_MODEL_ID
|
| 58 |
+
self.sample_rate = sample_rate
|
| 59 |
+
self._model = None
|
| 60 |
+
|
| 61 |
+
def status(self) -> AsrStatus:
|
| 62 |
+
return AsrStatus(
|
| 63 |
+
backend=self.backend,
|
| 64 |
+
model_id=self.model_id,
|
| 65 |
+
loaded=self._model is not None,
|
| 66 |
+
sample_rate=self.sample_rate,
|
| 67 |
+
)
|
| 68 |
+
|
| 69 |
+
def transcribe(self, audio_path: Path) -> AsrTranscript:
|
| 70 |
+
source = Path(audio_path)
|
| 71 |
+
if not source.is_file():
|
| 72 |
+
raise RuntimeError("Voice note was not saved before transcription.")
|
| 73 |
+
self._ensure_loaded()
|
| 74 |
+
with tempfile.TemporaryDirectory(prefix="advisor-asr-") as directory:
|
| 75 |
+
wav_path = Path(directory) / "voice.wav"
|
| 76 |
+
normalize_audio_for_asr(source, wav_path, self.sample_rate)
|
| 77 |
+
outputs = self._model.transcribe([str(wav_path)], batch_size=1)
|
| 78 |
+
transcript = extract_transcript(outputs).strip()
|
| 79 |
+
if not transcript:
|
| 80 |
+
raise RuntimeError("Nemotron ASR returned an empty transcript.")
|
| 81 |
+
return AsrTranscript(
|
| 82 |
+
transcript=transcript,
|
| 83 |
+
model_id=self.model_id,
|
| 84 |
+
backend=self.backend,
|
| 85 |
+
sample_rate=self.sample_rate,
|
| 86 |
+
)
|
| 87 |
+
|
| 88 |
+
def _ensure_loaded(self) -> None:
|
| 89 |
+
if self._model is not None:
|
| 90 |
+
return
|
| 91 |
+
try:
|
| 92 |
+
import torch
|
| 93 |
+
import nemo.collections.asr as nemo_asr
|
| 94 |
+
except ImportError as error:
|
| 95 |
+
raise RuntimeError(
|
| 96 |
+
"Nemotron voice input requires NVIDIA NeMo ASR. Install `nemo_toolkit[asr]` "
|
| 97 |
+
"before enabling voice transcription."
|
| 98 |
+
) from error
|
| 99 |
+
model = nemo_asr.models.ASRModel.from_pretrained(model_name=self.model_id)
|
| 100 |
+
device = os.environ.get("ADVISOR_ASR_DEVICE", "").strip()
|
| 101 |
+
if not device:
|
| 102 |
+
device = "cuda" if torch.cuda.is_available() else "cpu"
|
| 103 |
+
model.to(device)
|
| 104 |
+
model.eval()
|
| 105 |
+
self._model = model
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
def create_asr_transcriber() -> NemotronAsrTranscriber:
|
| 109 |
+
sample_rate = int(os.environ.get("ADVISOR_ASR_SAMPLE_RATE", str(DEFAULT_ASR_SAMPLE_RATE)))
|
| 110 |
+
if sample_rate <= 0:
|
| 111 |
+
raise RuntimeError("ADVISOR_ASR_SAMPLE_RATE must be a positive integer.")
|
| 112 |
+
return NemotronAsrTranscriber(
|
| 113 |
+
model_id=os.environ.get("ADVISOR_ASR_MODEL_ID", DEFAULT_ASR_MODEL_ID),
|
| 114 |
+
sample_rate=sample_rate,
|
| 115 |
+
)
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def normalize_audio_for_asr(source: Path, target: Path, sample_rate: int = DEFAULT_ASR_SAMPLE_RATE) -> None:
|
| 119 |
+
ffmpeg = shutil.which("ffmpeg")
|
| 120 |
+
if not ffmpeg:
|
| 121 |
+
raise RuntimeError("Voice transcription requires ffmpeg to normalize audio.")
|
| 122 |
+
command = [
|
| 123 |
+
ffmpeg,
|
| 124 |
+
"-hide_banner",
|
| 125 |
+
"-loglevel",
|
| 126 |
+
"error",
|
| 127 |
+
"-y",
|
| 128 |
+
"-i",
|
| 129 |
+
str(source),
|
| 130 |
+
"-ac",
|
| 131 |
+
"1",
|
| 132 |
+
"-ar",
|
| 133 |
+
str(sample_rate),
|
| 134 |
+
"-sample_fmt",
|
| 135 |
+
"s16",
|
| 136 |
+
str(target),
|
| 137 |
+
]
|
| 138 |
+
completed = subprocess.run(command, check=False, capture_output=True, text=True)
|
| 139 |
+
if completed.returncode != 0:
|
| 140 |
+
message = completed.stderr.strip() or "ffmpeg could not read this audio file."
|
| 141 |
+
raise RuntimeError(message)
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
def extract_transcript(outputs: Any) -> str:
|
| 145 |
+
if isinstance(outputs, str):
|
| 146 |
+
return outputs
|
| 147 |
+
if isinstance(outputs, dict):
|
| 148 |
+
return str(outputs.get("text") or outputs.get("transcript") or "")
|
| 149 |
+
if isinstance(outputs, (list, tuple)):
|
| 150 |
+
if not outputs:
|
| 151 |
+
return ""
|
| 152 |
+
return extract_transcript(outputs[0])
|
| 153 |
+
text = getattr(outputs, "text", None)
|
| 154 |
+
if text is not None:
|
| 155 |
+
return str(text)
|
| 156 |
+
transcript = getattr(outputs, "transcript", None)
|
| 157 |
+
if transcript is not None:
|
| 158 |
+
return str(transcript)
|
| 159 |
+
return str(outputs)
|
hackathon_advisor/prize_ledger.py
CHANGED
|
@@ -20,11 +20,11 @@ MODEL_STACK = [
|
|
| 20 |
"runtime": "Modal-built llama.cpp GGUF index + runtime llama.cpp query embeddings",
|
| 21 |
},
|
| 22 |
{
|
| 23 |
-
"role": "Voice
|
| 24 |
"model": "nvidia/nemotron-speech-streaming-en-0.6b",
|
| 25 |
"params_b": 0.60,
|
| 26 |
-
"status": "
|
| 27 |
-
"runtime": "
|
| 28 |
},
|
| 29 |
]
|
| 30 |
|
|
@@ -87,12 +87,17 @@ TRAINING_ARTIFACTS = [
|
|
| 87 |
]
|
| 88 |
|
| 89 |
|
| 90 |
-
def prize_ledger(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 91 |
total_params = round(sum(float(item["params_b"]) for item in MODEL_STACK), 2)
|
| 92 |
largest = max(MODEL_STACK, key=lambda item: float(item["params_b"]))
|
| 93 |
return {
|
| 94 |
"runtime": runtime,
|
| 95 |
"retrieval_index": index_metadata or {},
|
|
|
|
| 96 |
"model_stack": MODEL_STACK,
|
| 97 |
"total_params_b": total_params,
|
| 98 |
"largest_model": {
|
|
|
|
| 20 |
"runtime": "Modal-built llama.cpp GGUF index + runtime llama.cpp query embeddings",
|
| 21 |
},
|
| 22 |
{
|
| 23 |
+
"role": "Voice input",
|
| 24 |
"model": "nvidia/nemotron-speech-streaming-en-0.6b",
|
| 25 |
"params_b": 0.60,
|
| 26 |
+
"status": "deployed",
|
| 27 |
+
"runtime": "ZeroGPU + NVIDIA NeMo ASR",
|
| 28 |
},
|
| 29 |
]
|
| 30 |
|
|
|
|
| 87 |
]
|
| 88 |
|
| 89 |
|
| 90 |
+
def prize_ledger(
|
| 91 |
+
runtime: dict[str, Any],
|
| 92 |
+
index_metadata: dict[str, Any] | None = None,
|
| 93 |
+
voice_metadata: dict[str, Any] | None = None,
|
| 94 |
+
) -> dict[str, Any]:
|
| 95 |
total_params = round(sum(float(item["params_b"]) for item in MODEL_STACK), 2)
|
| 96 |
largest = max(MODEL_STACK, key=lambda item: float(item["params_b"]))
|
| 97 |
return {
|
| 98 |
"runtime": runtime,
|
| 99 |
"retrieval_index": index_metadata or {},
|
| 100 |
+
"voice": voice_metadata or {},
|
| 101 |
"model_stack": MODEL_STACK,
|
| 102 |
"total_params_b": total_params,
|
| 103 |
"largest_model": {
|
packages.txt
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
ffmpeg
|
| 2 |
+
libsndfile1
|
pyproject.toml
CHANGED
|
@@ -8,9 +8,12 @@ license = { text = "MIT" }
|
|
| 8 |
authors = [{ name = "Jacob LinCool" }]
|
| 9 |
dependencies = [
|
| 10 |
"accelerate>=1.0,<2",
|
|
|
|
| 11 |
"gradio>=6.16.0,<7",
|
| 12 |
"huggingface-hub>=0.36,<1",
|
| 13 |
"llama-cpp-python>=0.3.26,<1",
|
|
|
|
|
|
|
| 14 |
"peft>=0.13,<1",
|
| 15 |
"pillow>=10,<13",
|
| 16 |
"spaces>=0.50,<1",
|
|
@@ -24,7 +27,10 @@ dev = [
|
|
| 24 |
]
|
| 25 |
model = [
|
| 26 |
"accelerate>=1.0,<2",
|
|
|
|
| 27 |
"llama-cpp-python>=0.3.26,<1",
|
|
|
|
|
|
|
| 28 |
"peft>=0.13,<1",
|
| 29 |
"spaces>=0.50,<1",
|
| 30 |
"torch>=2.8,<3",
|
|
|
|
| 8 |
authors = [{ name = "Jacob LinCool" }]
|
| 9 |
dependencies = [
|
| 10 |
"accelerate>=1.0,<2",
|
| 11 |
+
"Cython>=3,<4",
|
| 12 |
"gradio>=6.16.0,<7",
|
| 13 |
"huggingface-hub>=0.36,<1",
|
| 14 |
"llama-cpp-python>=0.3.26,<1",
|
| 15 |
+
"nemo_toolkit[asr]==2.7.3",
|
| 16 |
+
"packaging>=24,<26",
|
| 17 |
"peft>=0.13,<1",
|
| 18 |
"pillow>=10,<13",
|
| 19 |
"spaces>=0.50,<1",
|
|
|
|
| 27 |
]
|
| 28 |
model = [
|
| 29 |
"accelerate>=1.0,<2",
|
| 30 |
+
"Cython>=3,<4",
|
| 31 |
"llama-cpp-python>=0.3.26,<1",
|
| 32 |
+
"nemo_toolkit[asr]==2.7.3",
|
| 33 |
+
"packaging>=24,<26",
|
| 34 |
"peft>=0.13,<1",
|
| 35 |
"spaces>=0.50,<1",
|
| 36 |
"torch>=2.8,<3",
|
requirements.txt
CHANGED
|
@@ -1,9 +1,12 @@
|
|
| 1 |
--extra-index-url https://abetlen.github.io/llama-cpp-python/whl/cpu
|
| 2 |
|
| 3 |
accelerate>=1.0,<2
|
|
|
|
| 4 |
gradio>=6.16.0,<7
|
| 5 |
huggingface-hub>=0.36,<1
|
| 6 |
llama-cpp-python==0.3.26
|
|
|
|
|
|
|
| 7 |
peft>=0.13,<1
|
| 8 |
pillow>=10,<13
|
| 9 |
spaces>=0.50,<1
|
|
|
|
| 1 |
--extra-index-url https://abetlen.github.io/llama-cpp-python/whl/cpu
|
| 2 |
|
| 3 |
accelerate>=1.0,<2
|
| 4 |
+
Cython>=3,<4
|
| 5 |
gradio>=6.16.0,<7
|
| 6 |
huggingface-hub>=0.36,<1
|
| 7 |
llama-cpp-python==0.3.26
|
| 8 |
+
nemo_toolkit[asr]==2.7.3
|
| 9 |
+
packaging>=24,<26
|
| 10 |
peft>=0.13,<1
|
| 11 |
pillow>=10,<13
|
| 12 |
spaces>=0.50,<1
|
static/app.js
CHANGED
|
@@ -26,6 +26,9 @@ const exportButton = document.querySelector("#export-artifact");
|
|
| 26 |
const exportNotesButton = document.querySelector("#export-notes");
|
| 27 |
const exportChapterButton = document.querySelector("#export-chapter");
|
| 28 |
const resetButton = document.querySelector("#reset-session");
|
|
|
|
|
|
|
|
|
|
| 29 |
|
| 30 |
const SESSION_STORAGE_KEY = "hackathon-advisor-session-v2";
|
| 31 |
const FIELD_NOTES_FILENAME = "hackathon-advisor-field-notes.md";
|
|
@@ -43,6 +46,10 @@ let sawTurnToken = false;
|
|
| 43 |
let bootstrapData = null;
|
| 44 |
let sessionRevision = 0;
|
| 45 |
let sessionControlsLocked = false;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
|
| 47 |
bootstrap().catch(handleBootstrapError);
|
| 48 |
|
|
@@ -87,6 +94,22 @@ resetButton.addEventListener("click", () => {
|
|
| 87 |
resetSession();
|
| 88 |
});
|
| 89 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 90 |
goalsEl.addEventListener("change", (event) => {
|
| 91 |
const target = event.target;
|
| 92 |
if (!(target instanceof HTMLInputElement) || !target.dataset.goal) return;
|
|
@@ -210,6 +233,121 @@ async function runCommand(command) {
|
|
| 210 |
await runTurn(command);
|
| 211 |
}
|
| 212 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 213 |
async function bootstrap() {
|
| 214 |
const response = await fetch("/api/bootstrap");
|
| 215 |
if (!response.ok) throw new Error(`project index failed with ${response.status}`);
|
|
@@ -344,6 +482,13 @@ function setSessionControlsDisabled(disabled) {
|
|
| 344 |
whitespaceEl.querySelectorAll("button[data-gap-prompt]").forEach((gap) => {
|
| 345 |
gap.disabled = disabled;
|
| 346 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 347 |
}
|
| 348 |
|
| 349 |
function resetSession() {
|
|
|
|
| 26 |
const exportNotesButton = document.querySelector("#export-notes");
|
| 27 |
const exportChapterButton = document.querySelector("#export-chapter");
|
| 28 |
const resetButton = document.querySelector("#reset-session");
|
| 29 |
+
const recordVoiceButton = document.querySelector("#record-voice");
|
| 30 |
+
const uploadVoiceButton = document.querySelector("#upload-voice");
|
| 31 |
+
const voiceFileInput = document.querySelector("#voice-file");
|
| 32 |
|
| 33 |
const SESSION_STORAGE_KEY = "hackathon-advisor-session-v2";
|
| 34 |
const FIELD_NOTES_FILENAME = "hackathon-advisor-field-notes.md";
|
|
|
|
| 46 |
let bootstrapData = null;
|
| 47 |
let sessionRevision = 0;
|
| 48 |
let sessionControlsLocked = false;
|
| 49 |
+
let voiceBusy = false;
|
| 50 |
+
let voiceRecorder = null;
|
| 51 |
+
let voiceStream = null;
|
| 52 |
+
let voiceChunks = [];
|
| 53 |
|
| 54 |
bootstrap().catch(handleBootstrapError);
|
| 55 |
|
|
|
|
| 94 |
resetSession();
|
| 95 |
});
|
| 96 |
|
| 97 |
+
recordVoiceButton.addEventListener("click", async () => {
|
| 98 |
+
await toggleVoiceRecording();
|
| 99 |
+
});
|
| 100 |
+
|
| 101 |
+
uploadVoiceButton.addEventListener("click", () => {
|
| 102 |
+
if (uploadVoiceButton.disabled || voiceBusy || sessionControlsLocked) return;
|
| 103 |
+
voiceFileInput.click();
|
| 104 |
+
});
|
| 105 |
+
|
| 106 |
+
voiceFileInput.addEventListener("change", async () => {
|
| 107 |
+
const file = voiceFileInput.files?.[0] || null;
|
| 108 |
+
voiceFileInput.value = "";
|
| 109 |
+
if (!file) return;
|
| 110 |
+
await transcribeVoiceBlob(file, file.name || "voice-note.audio");
|
| 111 |
+
});
|
| 112 |
+
|
| 113 |
goalsEl.addEventListener("change", (event) => {
|
| 114 |
const target = event.target;
|
| 115 |
if (!(target instanceof HTMLInputElement) || !target.dataset.goal) return;
|
|
|
|
| 233 |
await runTurn(command);
|
| 234 |
}
|
| 235 |
|
| 236 |
+
async function toggleVoiceRecording() {
|
| 237 |
+
if (voiceRecorder?.state === "recording") {
|
| 238 |
+
voiceRecorder.stop();
|
| 239 |
+
return;
|
| 240 |
+
}
|
| 241 |
+
await startVoiceRecording();
|
| 242 |
+
}
|
| 243 |
+
|
| 244 |
+
async function startVoiceRecording() {
|
| 245 |
+
if (sessionControlsLocked || voiceBusy) return;
|
| 246 |
+
if (!navigator.mediaDevices?.getUserMedia || !window.MediaRecorder) {
|
| 247 |
+
setSessionStatus("Voice recording is not available in this browser. Upload a voice note instead.");
|
| 248 |
+
return;
|
| 249 |
+
}
|
| 250 |
+
try {
|
| 251 |
+
voiceStream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
| 252 |
+
voiceChunks = [];
|
| 253 |
+
const mimeType = recordingMimeType();
|
| 254 |
+
voiceRecorder = new MediaRecorder(voiceStream, mimeType ? { mimeType } : undefined);
|
| 255 |
+
voiceRecorder.addEventListener("dataavailable", (event) => {
|
| 256 |
+
if (event.data?.size) voiceChunks.push(event.data);
|
| 257 |
+
});
|
| 258 |
+
voiceRecorder.addEventListener("stop", () => {
|
| 259 |
+
const recorderMimeType = voiceRecorder?.mimeType || mimeType || "audio/webm";
|
| 260 |
+
stopVoiceStream();
|
| 261 |
+
const extension = recorderMimeType.includes("mp4")
|
| 262 |
+
? "m4a"
|
| 263 |
+
: recorderMimeType.includes("ogg")
|
| 264 |
+
? "ogg"
|
| 265 |
+
: "webm";
|
| 266 |
+
const blob = new Blob(voiceChunks, { type: recorderMimeType });
|
| 267 |
+
voiceRecorder = null;
|
| 268 |
+
voiceChunks = [];
|
| 269 |
+
setActionButtonLabel(recordVoiceButton, "Speak");
|
| 270 |
+
setVoiceControlsDisabled(false);
|
| 271 |
+
transcribeVoiceBlob(blob, `recorded-idea.${extension}`);
|
| 272 |
+
});
|
| 273 |
+
voiceRecorder.start();
|
| 274 |
+
setActionButtonLabel(recordVoiceButton, "Stop");
|
| 275 |
+
setVoiceControlsDisabled(false);
|
| 276 |
+
setSessionStatus("Listening. Press Stop when your idea is ready.");
|
| 277 |
+
} catch (error) {
|
| 278 |
+
stopVoiceStream();
|
| 279 |
+
voiceRecorder = null;
|
| 280 |
+
setActionButtonLabel(recordVoiceButton, "Speak");
|
| 281 |
+
setVoiceControlsDisabled(false);
|
| 282 |
+
setSessionStatus(`Voice recording could not start: ${error.message}`);
|
| 283 |
+
}
|
| 284 |
+
}
|
| 285 |
+
|
| 286 |
+
function recordingMimeType() {
|
| 287 |
+
const candidates = ["audio/webm;codecs=opus", "audio/webm", "audio/ogg;codecs=opus", "audio/mp4"];
|
| 288 |
+
return candidates.find((type) => MediaRecorder.isTypeSupported(type)) || "";
|
| 289 |
+
}
|
| 290 |
+
|
| 291 |
+
function stopVoiceStream() {
|
| 292 |
+
if (!voiceStream) return;
|
| 293 |
+
voiceStream.getTracks().forEach((track) => track.stop());
|
| 294 |
+
voiceStream = null;
|
| 295 |
+
}
|
| 296 |
+
|
| 297 |
+
async function transcribeVoiceBlob(blob, filename) {
|
| 298 |
+
if (sessionControlsLocked || voiceBusy) return false;
|
| 299 |
+
if (!blob?.size) {
|
| 300 |
+
setSessionStatus("Voice note is empty.");
|
| 301 |
+
return false;
|
| 302 |
+
}
|
| 303 |
+
const revision = bumpSessionRevision();
|
| 304 |
+
voiceBusy = true;
|
| 305 |
+
submit.disabled = true;
|
| 306 |
+
input.disabled = true;
|
| 307 |
+
setCommandDisabled(true);
|
| 308 |
+
setSessionControlsDisabled(true);
|
| 309 |
+
setVoiceControlsDisabled(true);
|
| 310 |
+
setActionButtonLabel(recordVoiceButton, "Hearing...");
|
| 311 |
+
setSessionStatus("Transcribing voice note.");
|
| 312 |
+
try {
|
| 313 |
+
const formData = new FormData();
|
| 314 |
+
formData.append("audio", blob, filename || "voice-note.audio");
|
| 315 |
+
const response = await fetch("/api/transcribe", {
|
| 316 |
+
method: "POST",
|
| 317 |
+
body: formData,
|
| 318 |
+
});
|
| 319 |
+
if (!response.ok) throw new Error(`voice note failed with ${response.status}`);
|
| 320 |
+
const data = await response.json();
|
| 321 |
+
const transcript = String(data.transcript || "").trim();
|
| 322 |
+
if (!transcript) throw new Error("empty transcript");
|
| 323 |
+
if (!isCurrentSessionRevision(revision)) return false;
|
| 324 |
+
input.value = mergeDraftWithTranscript(input.value, transcript);
|
| 325 |
+
session.ui_status = "Voice note transcribed. Edit the draft or press Ink.";
|
| 326 |
+
corrections.textContent = session.ui_status;
|
| 327 |
+
saveSession();
|
| 328 |
+
return true;
|
| 329 |
+
} catch (error) {
|
| 330 |
+
if (isCurrentSessionRevision(revision)) setSessionStatus(`Voice note could not be transcribed: ${error.message}`);
|
| 331 |
+
return false;
|
| 332 |
+
} finally {
|
| 333 |
+
voiceBusy = false;
|
| 334 |
+
setActionButtonLabel(recordVoiceButton, "Speak");
|
| 335 |
+
if (isCurrentSessionRevision(revision)) {
|
| 336 |
+
submit.disabled = false;
|
| 337 |
+
input.disabled = false;
|
| 338 |
+
setSessionControlsDisabled(false);
|
| 339 |
+
setCommandDisabled(false);
|
| 340 |
+
setVoiceControlsDisabled(false);
|
| 341 |
+
input.focus();
|
| 342 |
+
}
|
| 343 |
+
}
|
| 344 |
+
}
|
| 345 |
+
|
| 346 |
+
function mergeDraftWithTranscript(draft, transcript) {
|
| 347 |
+
const current = String(draft || "").trim();
|
| 348 |
+
return current ? `${current}\n${transcript}` : transcript;
|
| 349 |
+
}
|
| 350 |
+
|
| 351 |
async function bootstrap() {
|
| 352 |
const response = await fetch("/api/bootstrap");
|
| 353 |
if (!response.ok) throw new Error(`project index failed with ${response.status}`);
|
|
|
|
| 482 |
whitespaceEl.querySelectorAll("button[data-gap-prompt]").forEach((gap) => {
|
| 483 |
gap.disabled = disabled;
|
| 484 |
});
|
| 485 |
+
setVoiceControlsDisabled(disabled);
|
| 486 |
+
}
|
| 487 |
+
|
| 488 |
+
function setVoiceControlsDisabled(disabled) {
|
| 489 |
+
const recording = voiceRecorder?.state === "recording";
|
| 490 |
+
recordVoiceButton.disabled = voiceBusy || (disabled && !recording) || !bootstrapData;
|
| 491 |
+
uploadVoiceButton.disabled = voiceBusy || recording || disabled || !bootstrapData;
|
| 492 |
}
|
| 493 |
|
| 494 |
function resetSession() {
|
static/index.html
CHANGED
|
@@ -36,6 +36,13 @@
|
|
| 36 |
<symbol id="icon-check" viewBox="0 0 24 24">
|
| 37 |
<path d="M5 12l4 4 10-11" />
|
| 38 |
</symbol>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
</svg>
|
| 40 |
|
| 41 |
<div class="desk-glow"></div>
|
|
@@ -112,6 +119,18 @@
|
|
| 112 |
></textarea>
|
| 113 |
<div class="underline"></div>
|
| 114 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 115 |
<div class="toolbar command-row" aria-label="Advisor commands">
|
| 116 |
<button id="submit" class="btn btn-ink" type="submit" title="Score this idea">
|
| 117 |
<svg class="icon"><use href="#icon-quill"></use></svg>
|
|
|
|
| 36 |
<symbol id="icon-check" viewBox="0 0 24 24">
|
| 37 |
<path d="M5 12l4 4 10-11" />
|
| 38 |
</symbol>
|
| 39 |
+
<symbol id="icon-mic" viewBox="0 0 24 24">
|
| 40 |
+
<path d="M12 3a3 3 0 0 0-3 3v6a3 3 0 0 0 6 0V6a3 3 0 0 0-3-3z" />
|
| 41 |
+
<path d="M5 11a7 7 0 0 0 14 0M12 18v3M9 21h6" />
|
| 42 |
+
</symbol>
|
| 43 |
+
<symbol id="icon-upload" viewBox="0 0 24 24">
|
| 44 |
+
<path d="M12 17V4M7 9l5-5 5 5M5 20h14" />
|
| 45 |
+
</symbol>
|
| 46 |
</svg>
|
| 47 |
|
| 48 |
<div class="desk-glow"></div>
|
|
|
|
| 119 |
></textarea>
|
| 120 |
<div class="underline"></div>
|
| 121 |
|
| 122 |
+
<div class="toolbar voice-row" aria-label="Voice input">
|
| 123 |
+
<button id="record-voice" class="btn btn-voice" type="button" title="Record an idea">
|
| 124 |
+
<svg class="icon"><use href="#icon-mic"></use></svg>
|
| 125 |
+
Speak
|
| 126 |
+
</button>
|
| 127 |
+
<button id="upload-voice" class="btn btn-ghost" type="button" title="Transcribe a voice note">
|
| 128 |
+
<svg class="icon"><use href="#icon-upload"></use></svg>
|
| 129 |
+
Voice note
|
| 130 |
+
</button>
|
| 131 |
+
<input id="voice-file" type="file" accept="audio/*" hidden />
|
| 132 |
+
</div>
|
| 133 |
+
|
| 134 |
<div class="toolbar command-row" aria-label="Advisor commands">
|
| 135 |
<button id="submit" class="btn btn-ink" type="submit" title="Score this idea">
|
| 136 |
<svg class="icon"><use href="#icon-quill"></use></svg>
|
static/styles.css
CHANGED
|
@@ -302,6 +302,10 @@ textarea:disabled {
|
|
| 302 |
flex: 1 1 20px;
|
| 303 |
}
|
| 304 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 305 |
.btn {
|
| 306 |
display: inline-flex;
|
| 307 |
min-height: 36px;
|
|
@@ -349,6 +353,17 @@ textarea:disabled {
|
|
| 349 |
border-color: #38260f;
|
| 350 |
}
|
| 351 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 352 |
.btn-ghost {
|
| 353 |
color: var(--ink-soft);
|
| 354 |
background: transparent;
|
|
|
|
| 302 |
flex: 1 1 20px;
|
| 303 |
}
|
| 304 |
|
| 305 |
+
.voice-row {
|
| 306 |
+
margin-top: 10px;
|
| 307 |
+
}
|
| 308 |
+
|
| 309 |
.btn {
|
| 310 |
display: inline-flex;
|
| 311 |
min-height: 36px;
|
|
|
|
| 353 |
border-color: #38260f;
|
| 354 |
}
|
| 355 |
|
| 356 |
+
.btn-voice {
|
| 357 |
+
color: #f6ecd2;
|
| 358 |
+
background: var(--leaf);
|
| 359 |
+
border-color: var(--leaf);
|
| 360 |
+
}
|
| 361 |
+
|
| 362 |
+
.btn-voice:hover:not(:disabled) {
|
| 363 |
+
background: #265935;
|
| 364 |
+
border-color: #265935;
|
| 365 |
+
}
|
| 366 |
+
|
| 367 |
.btn-ghost {
|
| 368 |
color: var(--ink-soft);
|
| 369 |
background: transparent;
|
tests/test_app.py
CHANGED
|
@@ -21,6 +21,7 @@ from app import (
|
|
| 21 |
prize_ledger_endpoint,
|
| 22 |
runtime,
|
| 23 |
submission_packet_artifact,
|
|
|
|
| 24 |
tool_contract_check,
|
| 25 |
tool_contracts,
|
| 26 |
trace_artifact,
|
|
@@ -34,6 +35,28 @@ async def _read_streaming_response(response) -> str:
|
|
| 34 |
return "".join(chunks)
|
| 35 |
|
| 36 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
def test_health_exposes_index_metadata() -> None:
|
| 38 |
payload = health()
|
| 39 |
|
|
@@ -41,6 +64,7 @@ def test_health_exposes_index_metadata() -> None:
|
|
| 41 |
assert payload["projects"] == len(index.projects)
|
| 42 |
assert payload["index_algorithm"] == "llama-cpp-embedding-v1"
|
| 43 |
assert payload["runtime"]["backend"] == "rules"
|
|
|
|
| 44 |
assert len(payload["snapshot_digest"]) == 64
|
| 45 |
|
| 46 |
|
|
@@ -51,6 +75,7 @@ def test_bootstrap_exposes_index_metadata() -> None:
|
|
| 51 |
assert payload["index_generated_at"]
|
| 52 |
assert payload["snapshot_digest"]
|
| 53 |
assert payload["runtime"]["tool_count"] >= 8
|
|
|
|
| 54 |
assert payload["top_projects"]
|
| 55 |
assert payload["default_goals"] == payload["goal_options"][:3]
|
| 56 |
assert [goal["id"] for goal in payload["goal_profiles"]] == payload["goal_options"]
|
|
@@ -78,6 +103,48 @@ def test_agent_turn_stream_endpoint_exports_ndjson_events() -> None:
|
|
| 78 |
assert lines[-1]["state"]["ideas"]
|
| 79 |
|
| 80 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 81 |
def test_markdown_api_endpoints_return_plain_markdown() -> None:
|
| 82 |
state = engine.turn("A local-first archive cartographer for family photos", {}).state
|
| 83 |
|
|
@@ -246,8 +313,10 @@ def test_prize_ledger_endpoint_reports_submission_evidence() -> None:
|
|
| 246 |
|
| 247 |
assert payload["runtime"]["backend"] == "rules"
|
| 248 |
assert payload["tiny_titan_eligible"] is True
|
|
|
|
| 249 |
assert any(badge["name"] == "Sharing is Caring" for badge in payload["badges"])
|
| 250 |
assert {badge["name"]: badge["status"] for badge in payload["badges"]}["Llama Champion"] == "ready"
|
|
|
|
| 251 |
assert payload["retrieval_index"]["index_algorithm"] == "llama-cpp-embedding-v1"
|
| 252 |
assert payload["retrieval_index"]["embedding_runtime"] == "llama.cpp via llama-cpp-python"
|
| 253 |
assert payload["training_artifacts"][0]["endpoint"] == "lora_dataset"
|
|
|
|
| 21 |
prize_ledger_endpoint,
|
| 22 |
runtime,
|
| 23 |
submission_packet_artifact,
|
| 24 |
+
transcribe_audio,
|
| 25 |
tool_contract_check,
|
| 26 |
tool_contracts,
|
| 27 |
trace_artifact,
|
|
|
|
| 35 |
return "".join(chunks)
|
| 36 |
|
| 37 |
|
| 38 |
+
class DummyUpload:
|
| 39 |
+
def __init__(
|
| 40 |
+
self,
|
| 41 |
+
content: bytes,
|
| 42 |
+
filename: str = "voice.wav",
|
| 43 |
+
content_type: str = "audio/wav",
|
| 44 |
+
) -> None:
|
| 45 |
+
self._content = content
|
| 46 |
+
self._offset = 0
|
| 47 |
+
self.filename = filename
|
| 48 |
+
self.content_type = content_type
|
| 49 |
+
|
| 50 |
+
async def read(self, size: int = -1) -> bytes:
|
| 51 |
+
if self._offset >= len(self._content):
|
| 52 |
+
return b""
|
| 53 |
+
if size is None or size < 0:
|
| 54 |
+
size = len(self._content) - self._offset
|
| 55 |
+
start = self._offset
|
| 56 |
+
self._offset = min(len(self._content), self._offset + size)
|
| 57 |
+
return self._content[start : self._offset]
|
| 58 |
+
|
| 59 |
+
|
| 60 |
def test_health_exposes_index_metadata() -> None:
|
| 61 |
payload = health()
|
| 62 |
|
|
|
|
| 64 |
assert payload["projects"] == len(index.projects)
|
| 65 |
assert payload["index_algorithm"] == "llama-cpp-embedding-v1"
|
| 66 |
assert payload["runtime"]["backend"] == "rules"
|
| 67 |
+
assert payload["voice"]["model_id"] == "nvidia/nemotron-speech-streaming-en-0.6b"
|
| 68 |
assert len(payload["snapshot_digest"]) == 64
|
| 69 |
|
| 70 |
|
|
|
|
| 75 |
assert payload["index_generated_at"]
|
| 76 |
assert payload["snapshot_digest"]
|
| 77 |
assert payload["runtime"]["tool_count"] >= 8
|
| 78 |
+
assert payload["voice"]["backend"] == "nemo-asr"
|
| 79 |
assert payload["top_projects"]
|
| 80 |
assert payload["default_goals"] == payload["goal_options"][:3]
|
| 81 |
assert [goal["id"] for goal in payload["goal_profiles"]] == payload["goal_options"]
|
|
|
|
| 103 |
assert lines[-1]["state"]["ideas"]
|
| 104 |
|
| 105 |
|
| 106 |
+
def test_transcribe_audio_endpoint_saves_audio(monkeypatch) -> None:
|
| 107 |
+
captured = {}
|
| 108 |
+
|
| 109 |
+
def fake_transcribe(path: str) -> dict:
|
| 110 |
+
captured["path"] = path
|
| 111 |
+
return {
|
| 112 |
+
"transcript": "A local-first memory archive.",
|
| 113 |
+
"model_id": "nvidia/nemotron-speech-streaming-en-0.6b",
|
| 114 |
+
"backend": "nemo-asr",
|
| 115 |
+
"sample_rate": 16000,
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
monkeypatch.setattr("app._transcribe_voice", fake_transcribe)
|
| 119 |
+
|
| 120 |
+
payload = asyncio.run(transcribe_audio(DummyUpload(b"RIFF....WAVE")))
|
| 121 |
+
|
| 122 |
+
assert payload["transcript"] == "A local-first memory archive."
|
| 123 |
+
assert captured["path"].endswith(".wav")
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
def test_transcribe_audio_endpoint_rejects_non_audio() -> None:
|
| 127 |
+
upload = DummyUpload(b"hello", filename="note.txt", content_type="text/plain")
|
| 128 |
+
|
| 129 |
+
try:
|
| 130 |
+
asyncio.run(transcribe_audio(upload))
|
| 131 |
+
except Exception as error:
|
| 132 |
+
assert getattr(error, "status_code", None) == 415
|
| 133 |
+
else:
|
| 134 |
+
raise AssertionError("non-audio upload should fail")
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
def test_transcribe_audio_endpoint_rejects_empty_audio() -> None:
|
| 138 |
+
upload = DummyUpload(b"", filename="empty.wav", content_type="audio/wav")
|
| 139 |
+
|
| 140 |
+
try:
|
| 141 |
+
asyncio.run(transcribe_audio(upload))
|
| 142 |
+
except Exception as error:
|
| 143 |
+
assert getattr(error, "status_code", None) == 400
|
| 144 |
+
else:
|
| 145 |
+
raise AssertionError("empty audio upload should fail")
|
| 146 |
+
|
| 147 |
+
|
| 148 |
def test_markdown_api_endpoints_return_plain_markdown() -> None:
|
| 149 |
state = engine.turn("A local-first archive cartographer for family photos", {}).state
|
| 150 |
|
|
|
|
| 313 |
|
| 314 |
assert payload["runtime"]["backend"] == "rules"
|
| 315 |
assert payload["tiny_titan_eligible"] is True
|
| 316 |
+
assert payload["voice"]["model_id"] == "nvidia/nemotron-speech-streaming-en-0.6b"
|
| 317 |
assert any(badge["name"] == "Sharing is Caring" for badge in payload["badges"])
|
| 318 |
assert {badge["name"]: badge["status"] for badge in payload["badges"]}["Llama Champion"] == "ready"
|
| 319 |
+
assert {item["role"]: item["status"] for item in payload["model_stack"]}["Voice input"] == "deployed"
|
| 320 |
assert payload["retrieval_index"]["index_algorithm"] == "llama-cpp-embedding-v1"
|
| 321 |
assert payload["retrieval_index"]["embedding_runtime"] == "llama.cpp via llama-cpp-python"
|
| 322 |
assert payload["training_artifacts"][0]["endpoint"] == "lora_dataset"
|
tests/test_asr_runtime.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from dataclasses import dataclass
|
| 2 |
+
|
| 3 |
+
from hackathon_advisor.asr_runtime import (
|
| 4 |
+
DEFAULT_ASR_MODEL_ID,
|
| 5 |
+
NemotronAsrTranscriber,
|
| 6 |
+
extract_transcript,
|
| 7 |
+
)
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
@dataclass
|
| 11 |
+
class Hypothesis:
|
| 12 |
+
text: str
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def test_nemotron_transcriber_status_is_lazy() -> None:
|
| 16 |
+
transcriber = NemotronAsrTranscriber()
|
| 17 |
+
|
| 18 |
+
status = transcriber.status().to_dict()
|
| 19 |
+
|
| 20 |
+
assert status["backend"] == "nemo-asr"
|
| 21 |
+
assert status["model_id"] == DEFAULT_ASR_MODEL_ID
|
| 22 |
+
assert status["loaded"] is False
|
| 23 |
+
assert status["sample_rate"] == 16_000
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def test_extract_transcript_accepts_nemo_output_shapes() -> None:
|
| 27 |
+
assert extract_transcript(["A spoken idea."]) == "A spoken idea."
|
| 28 |
+
assert extract_transcript([{"text": "A mapped archive."}]) == "A mapped archive."
|
| 29 |
+
assert extract_transcript([Hypothesis("A private timeline.")]) == "A private timeline."
|
tests/test_frontend_copy.py
CHANGED
|
@@ -13,6 +13,8 @@ def test_main_interface_copy_is_builder_facing() -> None:
|
|
| 13 |
assert "Example idea board loaded with a plan and share page." in app_js
|
| 14 |
assert "/api/artifact.png" in app_js
|
| 15 |
assert "/api/agent-turn" in app_js
|
|
|
|
|
|
|
| 16 |
assert "readNdjson" in app_js
|
| 17 |
assert "@gradio/client" not in app_js
|
| 18 |
assert "renderArtifactCanvas" not in app_js
|
|
@@ -20,6 +22,8 @@ def test_main_interface_copy_is_builder_facing() -> None:
|
|
| 20 |
assert 'aria-label="Export build notes"' in html
|
| 21 |
assert 'aria-label="Export idea-board chapter"' in html
|
| 22 |
assert 'aria-label="Export current page as PNG"' in html
|
|
|
|
|
|
|
| 23 |
assert "ideaCardAriaLabel" in app_js
|
| 24 |
assert "Select idea:" in app_js
|
| 25 |
|
|
|
|
| 13 |
assert "Example idea board loaded with a plan and share page." in app_js
|
| 14 |
assert "/api/artifact.png" in app_js
|
| 15 |
assert "/api/agent-turn" in app_js
|
| 16 |
+
assert "/api/transcribe" in app_js
|
| 17 |
+
assert "MediaRecorder" in app_js
|
| 18 |
assert "readNdjson" in app_js
|
| 19 |
assert "@gradio/client" not in app_js
|
| 20 |
assert "renderArtifactCanvas" not in app_js
|
|
|
|
| 22 |
assert 'aria-label="Export build notes"' in html
|
| 23 |
assert 'aria-label="Export idea-board chapter"' in html
|
| 24 |
assert 'aria-label="Export current page as PNG"' in html
|
| 25 |
+
assert 'aria-label="Voice input"' in html
|
| 26 |
+
assert "Voice note" in html
|
| 27 |
assert "ideaCardAriaLabel" in app_js
|
| 28 |
assert "Select idea:" in app_js
|
| 29 |
|