MSG commited on
Commit
bd75839
·
1 Parent(s): 727cb75

Feat/monday 3 sprint (#20)

Browse files

* model gguf check model path

* plan last sprint skills

* conv helpers

* context slide conv

* studio fix ui

* server slide studio

* modal wip finetuning

.cursor/plans/knowledge_mindmap_view_f479b154.plan.md ADDED
@@ -0,0 +1,207 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ name: Knowledge mindmap view
3
+ overview: "Sprint 2 (after teaching loop): visualize the Research library as an interactive concept map — LLM-extracted hierarchy from indexed chunks, rendered in Studio with click-to-chat grounding."
4
+ todos:
5
+ - id: concept-map-schema
6
+ content: Add ConceptMap pydantic models + SQLite cache table in researchmind store
7
+ status: pending
8
+ - id: concept-map-extract
9
+ content: "Implement concept_map.py: chunk sampling, LLM JSON extract, repair/fallback"
10
+ status: pending
11
+ - id: concept-map-api
12
+ content: Add api_build_concept_map in studio.py with trace + cache
13
+ status: pending
14
+ - id: studio-map-view
15
+ content: "Studio Map sidebar view: Build map button, Mermaid/D3 renderer, zoom/pan"
16
+ status: pending
17
+ - id: map-click-to-chat
18
+ content: Node click → summary panel + Ask about this → Research chat with scoped RAG
19
+ status: pending
20
+ - id: map-tests-docs
21
+ content: Unit/integration tests + README demo step for Map view
22
+ status: pending
23
+ isProject: false
24
+ ---
25
+
26
+ # Knowledge mindmap view
27
+
28
+ ## Why sprint 2 (not sprint 1)
29
+
30
+ You chose **teaching loop first**: slides-from-chat + quiz. Mindmap has **no prior plan or code** in the repo (grep finds zero matches). ResearchMind stores chunk `edges` in SQLite ([`store.py`](libs/researchmind/src/researchmind/store.py)) but only for RAG neighbor expansion — not a concept graph UI.
31
+
32
+ **Wahou moment:** After ingesting sources, the teacher sees their **library as a living map** — click a node → ask a focused RAG question. Complements citations in chat with spatial memory.
33
+
34
+ ```mermaid
35
+ flowchart TB
36
+ subgraph ingest [Existing]
37
+ Docs[Indexed documents]
38
+ Chunks[Chunks + embeddings]
39
+ end
40
+ subgraph new_backend [New]
41
+ Extract[extract_concept_map API]
42
+ LLM[Local model JSON]
43
+ Graph[ConceptGraph nodes/edges]
44
+ end
45
+ subgraph new_ui [New Studio view]
46
+ Map[Mindmap canvas]
47
+ Click[Click node]
48
+ Chat[Prefilled RAG question]
49
+ end
50
+ Docs --> Extract
51
+ Chunks --> Extract
52
+ Extract --> LLM --> Graph
53
+ Graph --> Map
54
+ Map --> Click --> Chat
55
+ ```
56
+
57
+ ---
58
+
59
+ ## Scope choices (recommended MVP)
60
+
61
+ | Approach | Pros | Cons |
62
+ |----------|------|------|
63
+ | **A. LLM concept tree from session** (recommended) | Teacher-friendly hierarchy; works with any ingest | Extra LLM call; needs JSON repair |
64
+ | B. Chunk adjacency graph only | No LLM | Linear doc chains, not a mindmap |
65
+ | C. Full entity extraction pipeline | Rich graph | Too heavy for hackathon scope |
66
+
67
+ **MVP = Approach A:** 1-level tree + optional 2nd-level children (15–30 nodes max).
68
+
69
+ ---
70
+
71
+ ## Part 1 — Data model + backend
72
+
73
+ ### 1.1 Concept graph schema
74
+
75
+ New in [`libs/researchmind/src/researchmind/models.py`](libs/researchmind/src/researchmind/models.py) (or `libs/agent/models.py`):
76
+
77
+ ```python
78
+ class ConceptNode(BaseModel):
79
+ id: str
80
+ label: str
81
+ summary: str = ""
82
+ chunk_ids: list[str] = [] # grounding refs
83
+
84
+ class ConceptEdge(BaseModel):
85
+ source: str
86
+ target: str
87
+ rel: str = "contains" # contains | related
88
+
89
+ class ConceptMap(BaseModel):
90
+ title: str
91
+ nodes: list[ConceptNode]
92
+ edges: list[ConceptEdge]
93
+ ```
94
+
95
+ ### 1.2 Extraction API
96
+
97
+ New module [`libs/researchmind/src/researchmind/concept_map.py`](libs/researchmind/src/researchmind/concept_map.py):
98
+
99
+ 1. Load session docs + top chunks per doc (cap ~20 chunks, ~8k chars)
100
+ 2. Prompt local model: emit `ConceptMap` JSON (topic = workspace topic or session title)
101
+ 3. Repair/retry/fallback: single root + 5 bullet children from doc titles if JSON fails
102
+ 4. Cache result in SQLite table `concept_maps(session_id, updated_at, json)` to avoid re-running on every page load
103
+
104
+ ### 1.3 Studio endpoint
105
+
106
+ In [`api/studio.py`](apps/gradio-space/src/gradio_space/api/studio.py):
107
+
108
+ ```python
109
+ @server.api(name="build_concept_map")
110
+ def api_build_concept_map(session_id, topic="", force_refresh=False)
111
+ ```
112
+
113
+ Returns: `map_json`, `preview_svg` or `graph_html`, `status`, optional `trace_json`.
114
+
115
+ Optional: `api_concept_map_from_slides(outline_json)` — derive map from last generated slide titles (no extra ingest).
116
+
117
+ ---
118
+
119
+ ## Part 2 — Visualization (Studio)
120
+
121
+ ### 2.1 New sidebar view
122
+
123
+ [`index.html`](apps/gradio-space/static/studio/index.html):
124
+
125
+ - Nav item **Map** (`data-view="map"`, icon `hub` or `account_tree`)
126
+ - Place after **Research** in sidebar (natural flow: ingest → map → ask)
127
+
128
+ ### 2.2 Renderer (prefer zero build step)
129
+
130
+ **Option 1 (recommended):** Inline **Mermaid** `mindmap` or `flowchart TB` generated server-side from `ConceptMap` — no npm bundle, works in static Studio.
131
+
132
+ **Option 2:** Lightweight **D3** force graph in `studio.js` if Mermaid layout is too rigid (~200 lines).
133
+
134
+ UI elements:
135
+
136
+ - **Build map** button (disabled if no indexed docs)
137
+ - Loading state with honest GPU/CPU hint (reuse slide overlay pattern)
138
+ - Zoom/pan container
139
+ - Node click → side panel: label, summary, linked chunk excerpts, **Ask about this** button
140
+
141
+ ### 2.3 Click-to-chat integration
142
+
143
+ On node click:
144
+
145
+ - Prefill Research chat input: `Explain "{label}" in the context of {topic}`
146
+ - Set RAG doc scope to node's `chunk_ids` parent docs if available
147
+ - Switch to Research view (or open inline mini-chat drawer on Map view — pick one for v1)
148
+
149
+ ### 2.4 Regeneration triggers
150
+
151
+ - Auto-offer **Refresh map** after new ingest completes
152
+ - Stale badge if ingest timestamp > map `updated_at`
153
+
154
+ ---
155
+
156
+ ## Part 3 — Classic parity (optional)
157
+
158
+ Small section in ResearchMind tab: "Concept map" HTML component calling same `build_concept_map` — lower priority than Studio.
159
+
160
+ ---
161
+
162
+ ## Part 4 — Tests + docs
163
+
164
+ - Unit: JSON repair, fallback map from doc titles, cache hit/miss
165
+ - Integration: ingest fixture session → `build_concept_map` returns ≥3 nodes
166
+ - README: Map view in judge demo script (step 1b after ingest)
167
+
168
+ ---
169
+
170
+ ## Risks
171
+
172
+ | Risk | Mitigation |
173
+ |------|------------|
174
+ | Model invents concepts not in sources | Require `chunk_ids` in prompt; validate IDs against store |
175
+ | Mermaid mindmap syntax limits | Cap nodes; fall back to flowchart |
176
+ | Large libraries | Sample top-K chunks by embedding centrality or doc order |
177
+ | Performance | Cache in SQLite; show cached map instantly, refresh in background |
178
+
179
+ ---
180
+
181
+ ## Relationship to other sprint plans
182
+
183
+ | Feature | Interaction |
184
+ |---------|-------------|
185
+ | Slides from chat | Optional `concept_map_from_slides` shortcut |
186
+ | Quiz | Future: "Quiz this branch" on node click (defer) |
187
+ | Research RAG | Map nodes link back to same `session_id` store |
188
+
189
+ ---
190
+
191
+ ## Files
192
+
193
+ **Create:** `concept_map.py`, `concept_maps` DB migration in `store.py`, Studio Map view markup/JS/CSS, tests
194
+
195
+ **Modify:** `api/studio.py`, `store.py`, `index.html`, `studio.js`, `studio.css`, README
196
+
197
+ ## Estimated effort
198
+
199
+ | Block | Time |
200
+ |-------|------|
201
+ | Schema + extraction + cache | 4–5h |
202
+ | Studio API + Build map UX | 2h |
203
+ | Mermaid/D3 renderer + click-to-chat | 4–6h |
204
+ | Tests + docs | 2h |
205
+ | **Total** | **~2 days** |
206
+
207
+ Ship after quiz + slides-from-chat presenter are stable.
.cursor/plans/modal_lora_training_loop_1a8a7b4b.plan.md ADDED
@@ -0,0 +1,285 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ name: Modal LoRA Training Loop
3
+ overview: Run a sequential Math → Coding → Reasoning LoRA loop on the warm Modal GPU worker (`slm-gpu-worker`), smoke-train each skill, evaluate against profile baselines, publish passing adapters to Hugging Face, and pull artifacts locally — escalating to full training only when gates fail.
4
+ todos:
5
+ - id: preflight
6
+ content: Verify slm-gpu-worker deployed, HF secret, and inspect Volume state (math-lora, coding-lora)
7
+ status: pending
8
+ - id: math-eval
9
+ content: Run math-lora --eval-only on warm worker; gate + publish + pull if passed
10
+ status: pending
11
+ - id: math-escalate
12
+ content: "If math gate fails: smoke retrain (--max-steps 20) then full (--max-steps 150)"
13
+ status: pending
14
+ - id: coding-smoke
15
+ content: Run coding-lora smoke pipeline (--max-steps 20); escalate to 100 steps if gate fails
16
+ status: pending
17
+ - id: reasoning-smoke
18
+ content: Run reasoning-lora smoke pipeline (--max-steps 20); escalate to 100 steps if gate fails
19
+ status: pending
20
+ - id: review-publish
21
+ content: Review gate.checks + comparison.md for each skill; publish-only retry for any near-misses
22
+ status: pending
23
+ - id: pull-local
24
+ content: Ensure adapters + eval results pulled to ./models/finetuned/ and ./results/lm_eval/
25
+ status: pending
26
+ - id: models-yaml
27
+ content: Add minicpm5-1b-coding-hub and minicpm5-1b-reasoning-hub presets to models.yaml
28
+ status: pending
29
+ - id: loop-ergonomics
30
+ content: "Optional: add --jobs multi-job flag + loop_skills.sh script; fix README math dataset drift"
31
+ status: pending
32
+ isProject: false
33
+ ---
34
+
35
+ # Modal LoRA Training Loop (Math → Coding → Reasoning)
36
+
37
+ ## Current state (verified on Volume)
38
+
39
+ The warm worker is **deployed and active** (`slm-gpu-worker`, 1 task). The `slm-finetune` Volume already contains adapters and eval results:
40
+
41
+ | Job | Volume state | Eval on Volume |
42
+ |-----|-------------|----------------|
43
+ | `math-lora` | Full train (checkpoints 100 + 150, adapter + README) | `math-lora__math` — gsm8k **0.35** (+0.02 vs baseline 0.33) |
44
+ | `coding-lora` | Smoke only (checkpoint-20) | `coding-lora__code` exists |
45
+ | `reasoning-lora` | Not trained yet | No baseline/candidate yet |
46
+
47
+ **Math gate outlook** (from pulled [`comparison.md`](/tmp/slm-check/math-lora__math/comparison.md)):
48
+ - Primary `gsm8k`: 0.35 ≥ 0.05 and +0.02 improve — **passes**
49
+ - Guards: `arc_challenge` improved; `hellaswag` flat; `piqa` regress exactly 0.03 (at limit) — **likely passes**
50
+
51
+ Coding needs a proper smoke/full retrain. Reasoning is greenfield.
52
+
53
+ ```mermaid
54
+ flowchart LR
55
+ subgraph warmWorker [slm-gpu-worker warm GPU]
56
+ smoke["Smoke train\n--max-steps 20"]
57
+ eval["Profile lm-eval\nvs baseline"]
58
+ gate["check_gate\ngoals in experiments.yaml"]
59
+ pub["publish_adapter\nif passed"]
60
+ pull["pull_artifacts\nlocal models + results"]
61
+ full["Escalate to full steps\nif gate fails"]
62
+ smoke --> eval --> gate
63
+ gate -->|pass| pub --> pull
64
+ gate -->|fail| full --> eval
65
+ end
66
+ math["math-lora"] --> coding["coding-lora"] --> reasoning["reasoning-lora"]
67
+ math --> warmWorker
68
+ coding --> warmWorker
69
+ reasoning --> warmWorker
70
+ ```
71
+
72
+ ---
73
+
74
+ ## Phase 0 — Preflight (5 min)
75
+
76
+ From repo root on `feat/monday_3_sprint` (or current branch):
77
+
78
+ 1. Confirm worker is warm:
79
+ ```bash
80
+ modal app list # slm-gpu-worker should be deployed
81
+ modal run research/modal/server_app.py --ping
82
+ ```
83
+
84
+ 2. Confirm HF secret exists (required for publish):
85
+ ```bash
86
+ modal secret list # must include huggingface / HF_TOKEN
87
+ ```
88
+
89
+ 3. Inspect existing Volume artifacts before re-running:
90
+ ```bash
91
+ modal volume ls slm-finetune
92
+ modal volume ls slm-finetune results/lm_eval
93
+ ```
94
+
95
+ ---
96
+
97
+ ## Phase 1 — Math (`math-lora`)
98
+
99
+ **Strategy:** Smoke first per your preference. Since math already has a full 150-step adapter on Volume, use `--eval-only` first to confirm gate without burning GPU on retrain.
100
+
101
+ ### Step 1a — Re-eval existing adapter (no retrain)
102
+
103
+ ```bash
104
+ modal run research/modal/server_app.py --pipeline --eval-only --job math-lora
105
+ ```
106
+
107
+ This runs: baseline `minicpm5-1b__baseline__math` (cached if present) → candidate eval on `/vol/finetuned/math-lora` → gate → publish if passed → auto-pull to `./models/finetuned/` and `./results/lm_eval/`.
108
+
109
+ **Expected publish target:** `MSGEncrypted/minicpm5-1b-math-lora` ([`experiments.yaml`](research/modal/experiments.yaml) lines 71–105)
110
+
111
+ ### Step 1b — Smoke retrain only if gate fails or you want fresh weights
112
+
113
+ ```bash
114
+ modal run research/modal/server_app.py --pipeline --job math-lora --max-steps 20
115
+ ```
116
+
117
+ ### Step 1c — Escalate to full math if smoke gate fails
118
+
119
+ ```bash
120
+ modal run research/modal/server_app.py --pipeline --job math-lora --max-steps 150
121
+ ```
122
+
123
+ Dataset config (already tuned): MetaMathQA 3000 + alpaca replay 600, `lora_r=32`, NEFTune — see [`experiments.yaml`](research/modal/experiments.yaml).
124
+
125
+ **Eval profile:** [`research/evals/configs/lm_eval_math.yaml`](research/evals/configs/lm_eval_math.yaml) — gsm8k, arc_challenge, hellaswag, piqa (limit 100).
126
+
127
+ ---
128
+
129
+ ## Phase 2 — Coding (`coding-lora`)
130
+
131
+ Coding only has checkpoint-20 today. Always smoke-train first.
132
+
133
+ ### Step 2a — Smoke train + eval + gate + publish
134
+
135
+ ```bash
136
+ modal run research/modal/server_app.py --pipeline --job coding-lora --max-steps 20
137
+ ```
138
+
139
+ **Dataset:** `iamtarun/python_code_instructions_18k_alpaca`, 1000 samples max ([`experiments.yaml`](research/modal/experiments.yaml) lines 108–127).
140
+
141
+ **Eval profile:** [`research/evals/configs/lm_eval_code.yaml`](research/evals/configs/lm_eval_code.yaml) — humaneval, mbpp + guards (limit 50, unsafe code enabled).
142
+
143
+ **Gate:** primary `mbpp` min_score 0.05, min_improve 0.01.
144
+
145
+ ### Step 2b — Escalate if gate fails
146
+
147
+ ```bash
148
+ modal run research/modal/server_app.py --pipeline --job coding-lora --max-steps 100
149
+ ```
150
+
151
+ **Expected publish target:** `MSGEncrypted/minicpm5-1b-coding-lora`
152
+
153
+ ---
154
+
155
+ ## Phase 3 — Reasoning (`reasoning-lora`)
156
+
157
+ ### Step 3a — Smoke train + eval + gate + publish
158
+
159
+ ```bash
160
+ modal run research/modal/server_app.py --pipeline --job reasoning-lora --max-steps 20
161
+ ```
162
+
163
+ **Dataset:** `HuggingFaceTB/smoltalk` (config `all`, 500 samples).
164
+
165
+ **Eval profile:** [`research/evals/configs/lm_eval_reasoning.yaml`](research/evals/configs/lm_eval_reasoning.yaml) — gsm8k primary + hellaswag guard.
166
+
167
+ ### Step 3b — Escalate if gate fails
168
+
169
+ ```bash
170
+ modal run research/modal/server_app.py --pipeline --job reasoning-lora --max-steps 100
171
+ ```
172
+
173
+ **Expected publish target:** `MSGEncrypted/minicpm5-1b-reasoning-lora`
174
+
175
+ ---
176
+
177
+ ## Phase 4 — Review results and decide publish
178
+
179
+ After each pipeline run, read the JSON summary printed to terminal. Key fields per job row:
180
+
181
+ - `gate.passed` — whether Hub publish is allowed
182
+ - `gate.checks` — per-task scores vs thresholds
183
+ - `publish.published` / `publish.url` — Hub link if pushed
184
+
185
+ **Manual gate + publish** (if eval already done, gate failed earlier, you fixed thresholds):
186
+
187
+ ```bash
188
+ modal run research/modal/server_app.py --publish-only --job math-lora
189
+ modal run research/modal/server_app.py --publish-only --job coding-lora
190
+ modal run research/modal/server_app.py --publish-only --job reasoning-lora
191
+ ```
192
+
193
+ **Pull without re-running** (if `--no-pull` was used):
194
+
195
+ ```bash
196
+ modal run research/modal/finetune_app.py::pull --job math-lora
197
+ modal volume get slm-finetune math-lora ./models/finetuned/
198
+ modal volume get slm-finetuned results/lm_eval/math-lora__math ./results/lm_eval/
199
+ ```
200
+
201
+ Local paths after pull:
202
+ - Adapters: `./models/finetuned/{job}/`
203
+ - Eval: `./results/lm_eval/{job}__{profile}/summary.md` + `comparison.md`
204
+
205
+ ---
206
+
207
+ ## Phase 5 — Small code gaps to close (during loop)
208
+
209
+ These are optional but recommended so published adapters are usable in the Space:
210
+
211
+ | Gap | File | Change |
212
+ |-----|------|--------|
213
+ | No `coding-hub` preset | [`models.yaml`](models.yaml) | Add `minicpm5-1b-coding-hub` pointing to `MSGEncrypted/minicpm5-1b-coding-lora` (mirror `minicpm5-1b-math-hub`) |
214
+ | No `reasoning-hub` preset | [`models.yaml`](models.yaml) | Add `minicpm5-1b-reasoning-hub` |
215
+ | README doc drift | [`research/modal/README.md`](research/modal/README.md) | Update math dataset row: MetaMathQA mix, not MathInstruct |
216
+ | No multi-job CLI | [`research/modal/server_app.py`](research/modal/server_app.py) | Add `--jobs math-lora,coding-lora,reasoning-lora` → pass `job_names` list to `run_pipeline` (already supports `job_names: list[str]`) |
217
+
218
+ **Optional loop script** (new file `research/modal/loop_skills.sh`):
219
+ ```bash
220
+ #!/usr/bin/env bash
221
+ set -euo pipefail
222
+ JOBS=(math-lora coding-lora reasoning-lora)
223
+ SMOKE_STEPS=20
224
+ FULL_STEPS=(150 100 100) # per job in experiments.yaml
225
+ for i in "${!JOBS[@]}"; do
226
+ job="${JOBS[$i]}"
227
+ modal run research/modal/server_app.py --pipeline --job "$job" --max-steps "$SMOKE_STEPS" || true
228
+ # inspect gate in output; if failed, rerun with FULL_STEPS[i]
229
+ done
230
+ ```
231
+
232
+ ---
233
+
234
+ ## Gate reference (publish thresholds)
235
+
236
+ From [`research/modal/experiments.yaml`](research/modal/experiments.yaml):
237
+
238
+ | Job | Primary task | min_score | min_improve | Guards |
239
+ |-----|-------------|-----------|-------------|--------|
240
+ | math-lora | gsm8k | 0.05 | 0.02 | arc_challenge, hellaswag, piqa (max_regress 0.03) |
241
+ | coding-lora | mbpp | 0.05 | 0.01 | hellaswag, piqa |
242
+ | reasoning-lora | gsm8k | 0.05 | 0.01 | hellaswag |
243
+
244
+ Publish logic lives in [`research/modal/_common.py`](research/modal/_common.py) `publish_adapter_files()` — writes model card README, `HfApi.upload_folder` to `publish.hub_repo`.
245
+
246
+ ---
247
+
248
+ ## Escalation decision tree
249
+
250
+ ```mermaid
251
+ flowchart TD
252
+ start["Run smoke pipeline\n--max-steps 20"]
253
+ gate{"gate.passed?"}
254
+ pub["Auto publish + pull"]
255
+ full["Rerun full steps\nmath:150 coding:100 reasoning:100"]
256
+ tune["Tune goals or dataset\nin experiments.yaml"]
257
+ done["Adapter local + on Hub"]
258
+ start --> gate
259
+ gate -->|yes| pub --> done
260
+ gate -->|no| full --> gate2{"gate.passed?"}
261
+ gate2 -->|yes| pub
262
+ gate2 -->|no| tune
263
+ ```
264
+
265
+ ---
266
+
267
+ ## Time / cost estimate (warm worker)
268
+
269
+ | Phase | Smoke (~20 steps) | Full escalation |
270
+ |-------|-------------------|-----------------|
271
+ | math eval-only | ~15–30 min (lm-eval only) | — |
272
+ | math retrain | ~30–45 min | ~60–90 min (150 steps) |
273
+ | coding | ~30–45 min | ~60 min (100 steps) |
274
+ | reasoning | ~30–45 min | ~60 min (100 steps) |
275
+
276
+ Warm worker avoids cold starts between phases — all three jobs can run sequentially in the same container via separate `modal run` invocations.
277
+
278
+ ---
279
+
280
+ ## Success criteria
281
+
282
+ 1. Each skill has `summary.md` + `comparison.md` under `./results/lm_eval/{job}__{profile}/`
283
+ 2. Passing adapters in `./models/finetuned/{job}/` with `adapter_config.json`
284
+ 3. Hub repos live at `https://huggingface.co/MSGEncrypted/minicpm5-1b-{math,coding,reasoning}-lora`
285
+ 4. `models.yaml` hub presets wired for Space demo (`ACTIVE_MODEL=minicpm5-1b-math-hub`, etc.)
.cursor/plans/quiz_maker_skill_52f29d14.plan.md ADDED
@@ -0,0 +1,222 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ name: Quiz maker skill
3
+ overview: "Sprint 1 (teaching loop): ship a quiz-maker skill mirroring education-pptx — topic/grade/RAG-grounded MCQ → DOCX worksheet + HTML preview — with a new Studio Quiz view and optional quiz-from-slides shortcut."
4
+ todos:
5
+ - id: quiz-skill-backend
6
+ content: Create quiz-maker skill, QuizOutline models, prompts, create_quiz tool, iter_quiz_maker runner
7
+ status: pending
8
+ - id: quiz-tests
9
+ content: "Agent tests: JSON repair, fallback_quiz, docx/html smoke"
10
+ status: pending
11
+ - id: quiz-classic-tab
12
+ content: Add tabs/quiz_maker.py with source modes + wire Classic Gradio tab
13
+ status: pending
14
+ - id: quiz-studio-ui
15
+ content: Add api_generate_quiz + Studio Quiz sidebar view with DOCX/HTML downloads
16
+ status: pending
17
+ - id: quiz-teaching-cta
18
+ content: "Slides view CTA: Create quiz on this topic (pre-fill topic/grade/session)"
19
+ status: pending
20
+ isProject: false
21
+ ---
22
+
23
+ # Quiz maker skill
24
+
25
+ ## Why this sprint
26
+
27
+ Pairs with **slides-from-chat** in the teaching loop: *research → teach (slides) → assess (quiz)*. Backend design is fully specified in [`slides_from_chat_+_quiz_f53701a3.plan.md`](.cursor/plans/slides_from_chat_+_quiz_f53701a3.plan.md) **Part B** and [`skill_agent_pptx_5413e3c2.plan.md`](.cursor/plans/skill_agent_pptx_5413e3c2.plan.md) Phase 2 — **zero implementation today**.
28
+
29
+ **Wahou moment:** Same topic the teacher just researched becomes a **printable quiz with answer key** — grounded in their library, trace visible, no cloud API.
30
+
31
+ ```mermaid
32
+ flowchart LR
33
+ Topic[Topic + grade] --> Skill[quiz-maker SKILL.md]
34
+ RAG[Optional RAG / web sources] --> Runner[iter_quiz_maker]
35
+ Skill --> Runner
36
+ Runner --> Outline[QuizOutline JSON]
37
+ Outline --> Tool[create_quiz]
38
+ Tool --> DOCX[Student worksheet DOCX]
39
+ Tool --> HTML[HTML preview]
40
+ DOCX --> Studio[Studio Quiz view]
41
+ HTML --> Studio
42
+ ```
43
+
44
+ ## Pattern to copy (do not reinvent)
45
+
46
+ Mirror [`skills/education-pptx/SKILL.md`](skills/education-pptx/SKILL.md) layering:
47
+
48
+ | Layer | Slides reference | Quiz equivalent |
49
+ |-------|------------------|-----------------|
50
+ | Skill | `education-pptx` | `quiz-maker` |
51
+ | Pydantic | `SlideOutline` | `QuizOutline` |
52
+ | Runner | `iter_education_pptx` | `iter_quiz_maker` |
53
+ | Tool | `create_pptx` | `create_quiz` |
54
+ | Tab | `education_pptx.py` | `quiz_maker.py` |
55
+ | Studio API | `api_generate_slides` | `api_generate_quiz` |
56
+
57
+ Reuse [`_gather_lesson_source_context()`](libs/agent/src/agent/runner.py) for web/RAG grounding (same as slides).
58
+
59
+ ---
60
+
61
+ ## Part 1 — Skill + agent backend
62
+
63
+ ### 1.1 Skill definition
64
+
65
+ Create [`skills/quiz-maker/SKILL.md`](skills/quiz-maker/SKILL.md):
66
+
67
+ ```yaml
68
+ name: quiz-maker
69
+ description: Create a multiple-choice quiz from a topic and grade level
70
+ task: education
71
+ tools:
72
+ - create_quiz
73
+ model_hints:
74
+ - minicpm5-1b
75
+ ```
76
+
77
+ Optional [`skills/quiz-maker/references/mcq-format.md`](skills/quiz-maker/references/mcq-format.md): exactly 4 choices, one correct, short explanation.
78
+
79
+ ### 1.2 Models ([`models.py`](libs/agent/src/agent/models.py))
80
+
81
+ ```python
82
+ class QuizQuestion(BaseModel):
83
+ prompt: str
84
+ choices: list[str] = Field(min_length=4, max_length=4)
85
+ correct_index: int = Field(ge=0, le=3)
86
+ explanation: str = ""
87
+
88
+ class QuizOutline(BaseModel):
89
+ title: str
90
+ instructions: str = ""
91
+ questions: list[QuizQuestion] = Field(min_length=3, max_length=12)
92
+
93
+ class QuizMakerInput(BaseModel):
94
+ topic: str
95
+ grade: str
96
+ question_count: int = Field(ge=5, le=10, default=5)
97
+ # mirror EducationPptxInput source fields: source_mode, urls, session_id, doc_ids, ...
98
+ conversation_context: str = "" # schema-ready; UI deferred
99
+ ```
100
+
101
+ ### 1.3 Prompts ([`prompts.py`](libs/agent/src/agent/prompts.py))
102
+
103
+ Same retry/repair/fallback pattern as slides:
104
+
105
+ - `quiz_outline_system`, `quiz_outline_user`, `quiz_outline_repair`
106
+ - `fallback_quiz(topic, grade, n)` — deterministic MCQ stub if model JSON fails
107
+ - `quiz_to_markdown(outline)` — Studio preview
108
+
109
+ ### 1.4 Export tool
110
+
111
+ New [`libs/agent/src/agent/tools/quiz.py`](libs/agent/src/agent/tools/quiz.py):
112
+
113
+ - `create_quiz_docx(outline)` — numbered questions, A–D choices; **answer key on final page**
114
+ - `create_quiz_html(outline)` — printable worksheet + collapsible answer key section
115
+ - Register `create_quiz` in [`tools_registry.py`](libs/agent/src/agent/tools_registry.py)
116
+
117
+ ### 1.5 Runner ([`runner.py`](libs/agent/src/agent/runner.py))
118
+
119
+ - `QUIZ_MAKER_SKILL = "quiz-maker"`
120
+ - `iter_quiz_maker()` — copy `_iter_education_pptx_steps` structure:
121
+ 1. load model
122
+ 2. `_gather_lesson_source_context()`
123
+ 3. `_generate_quiz_outline()`
124
+ 4. `create_quiz` tool
125
+ 5. markdown preview + trace
126
+
127
+ Add `QuizGenerationProgress` labels in [`progress.py`](libs/agent/src/agent/progress.py) (small duplicate OK).
128
+
129
+ ### 1.6 Tests
130
+
131
+ `libs/agent/tests/`:
132
+
133
+ - JSON parse/repair for quiz outline
134
+ - `fallback_quiz` smoke
135
+ - docx/html file creation (temp dir)
136
+
137
+ ---
138
+
139
+ ## Part 2 — Classic tab
140
+
141
+ New [`apps/gradio-space/src/gradio_space/tabs/quiz_maker.py`](apps/gradio-space/src/gradio_space/tabs/quiz_maker.py):
142
+
143
+ - Inputs: topic, grade, question count (5–10), source mode (reuse `SOURCE_MODES` from [`education_pptx.py`](apps/gradio-space/src/gradio_space/tabs/education_pptx.py))
144
+ - Discover URLs / doc scope — copy Slides column patterns
145
+ - Outputs: markdown preview, DOCX + HTML downloads, Agent trace accordion
146
+
147
+ Wire in [`app.py`](apps/gradio-space/src/gradio_space/app.py) as **Quiz maker** tab after Lesson slides.
148
+
149
+ ---
150
+
151
+ ## Part 3 — Studio Quiz view
152
+
153
+ ### 3.1 API ([`api/studio.py`](apps/gradio-space/src/gradio_space/api/studio.py))
154
+
155
+ ```python
156
+ @server.api(name="generate_quiz")
157
+ def api_generate_quiz(topic, grade, question_count, session_id, source_mode, ...)
158
+ ```
159
+
160
+ Return: `outline_md`, `preview_html`, `downloads: {docx, html}`, `trace_json`, `status`.
161
+
162
+ ### 3.2 UI ([`index.html`](apps/gradio-space/static/studio/index.html), [`studio.js`](apps/gradio-space/static/studio/studio.js), [`studio.css`](apps/gradio-space/static/studio/studio.css))
163
+
164
+ - Sidebar nav: **Quiz** (`data-view="quiz"`, icon `quiz`)
165
+ - Single-column workspace (like Language lessons):
166
+ - Topic, grade, question count
167
+ - Source mode + doc scope rail (shared with Slides when session active)
168
+ - **Generate quiz** + progress steps
169
+ - Preview pane (HTML worksheet)
170
+ - Download row: DOCX + HTML
171
+ - Agent trace collapsible
172
+
173
+ ### 3.3 Teaching-loop shortcuts (wahou)
174
+
175
+ After slides generate successfully, show subtle CTA on Slides view:
176
+
177
+ - **Create quiz on this topic** → switches to Quiz view, pre-fills topic/grade/session scope
178
+
179
+ Optional v1.1 (not blocking): **Generate quiz from chat** — same `conversation_helpers.py` as slides plan.
180
+
181
+ ---
182
+
183
+ ## Part 4 — Docs
184
+
185
+ Update [`apps/gradio-space/README.md`](apps/gradio-space/README.md):
186
+
187
+ - New API: `generate_quiz`
188
+ - Demo step: Research → Slides → Quiz on same topic
189
+ - Classic tab list
190
+
191
+ ---
192
+
193
+ ## Risks
194
+
195
+ | Risk | Mitigation |
196
+ |------|------------|
197
+ | Small model invalid JSON | repair + retry + `fallback_quiz` (same as slides) |
198
+ | Weak distractors | `mcq-format.md` reference + grade-appropriate prompt |
199
+ | Scope creep (interactive quiz UI) | v1 = printable exports only; no in-app grading |
200
+
201
+ ---
202
+
203
+ ## Files
204
+
205
+ **Create:** `skills/quiz-maker/SKILL.md`, `libs/agent/src/agent/tools/quiz.py`, `tabs/quiz_maker.py`, agent tests
206
+
207
+ **Modify:** `models.py`, `prompts.py`, `runner.py`, `tools_registry.py`, `progress.py`, `api/studio.py`, `app.py`, Studio static assets, README
208
+
209
+ **Depends on:** `conversation_helpers.py` only if quiz-from-chat shortcut ships in same sprint (optional)
210
+
211
+ ## Estimated effort
212
+
213
+ | Block | Time |
214
+ |-------|------|
215
+ | Skill + models + prompts + tool | 3–4h |
216
+ | Runner + tests | 2–3h |
217
+ | Classic tab | 1–2h |
218
+ | Studio Quiz view + API | 3–4h |
219
+ | Teaching-loop CTA + docs | 1h |
220
+ | **Total** | **~1.5 days** |
221
+
222
+ Can parallelize with slides-from-chat after `conversation_helpers.py` lands.
.cursor/plans/slides_from_chat_presenter_4cff567a.plan.md ADDED
@@ -0,0 +1,197 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ name: Slides from chat presenter
3
+ overview: "Sprint 1 (wow/teaching loop): bridge every Studio chat surface into the existing slide pipeline, then add an in-browser presenter mode so generated decks feel like a finished lesson — not just a download."
4
+ todos:
5
+ - id: conv-helper
6
+ content: Add conversation_helpers.py + EducationPptxInput.conversation_context + prompt/runner wiring
7
+ status: completed
8
+ - id: slides-from-chat-api
9
+ content: Extend generate_lesson_slides + api_generate_slides_from_conversation with shared finalizer
10
+ status: completed
11
+ - id: studio-slide-buttons
12
+ content: Add Generate-slides-from-chat on Research, Language lessons, Chat; extract renderSlideGenerationResult()
13
+ status: completed
14
+ - id: presenter-mode
15
+ content: Fullscreen presenter overlay (gallery/HTML source, keyboard nav, Present toolbar button)
16
+ status: completed
17
+ - id: docs-demo
18
+ content: Update apps/gradio-space/README.md demo script + bump STUDIO_ASSET_VERSION
19
+ status: completed
20
+ isProject: false
21
+ ---
22
+
23
+ # Slides from chat + presenter mode
24
+
25
+ ## Why this sprint
26
+
27
+ Last sprint shipped **demo polish** ([`last_sprint_demo_polish_2f148411.plan.md`](.cursor/plans/last_sprint_demo_polish_2f148411.plan.md)) — Chat labels, honest loading hints, export clarity. The deferred backend work lives in [`slides_from_chat_+_quiz_f53701a3.plan.md`](.cursor/plans/slides_from_chat_+_quiz_f53701a3.plan.md) **Part A only**.
28
+
29
+ **Wahou moment:** A teacher researches a topic → asks questions with citations → taps **Generate slides from this chat** → previews in **presenter mode** (fullscreen, arrow keys) → downloads PPTX. One continuous story on `/`.
30
+
31
+ ```mermaid
32
+ flowchart LR
33
+ Research[Research chat] --> Btn[Generate slides from chat]
34
+ Lessons[Language lessons] --> Btn
35
+ Chat[Chat] --> Btn
36
+ Btn --> API[generate_slides_from_conversation]
37
+ API --> Runner[iter_education_pptx]
38
+ Runner --> Canvas[Hero canvas + gallery]
39
+ Canvas --> Present[Presenter mode]
40
+ Present --> Export[PPTX / DOCX / HTML]
41
+ ```
42
+
43
+ ## What already exists (reuse, do not rebuild)
44
+
45
+ | Piece | Location |
46
+ |-------|----------|
47
+ | Slide agent loop | [`libs/agent/src/agent/runner.py`](libs/agent/src/agent/runner.py) `iter_education_pptx` |
48
+ | RAG/source grounding | [`education_pptx.py`](apps/gradio-space/src/gradio_space/tabs/education_pptx.py) + `source_mode` |
49
+ | Studio generate + render | [`studio.js`](apps/gradio-space/static/studio/studio.js) `generateSlides()`, canvas/gallery/downloads |
50
+ | Static HTML deck cards | [`libs/agent/src/agent/preview.py`](libs/agent/src/agent/preview.py) `.lesson-deck` |
51
+ | PNG thumbnail strip | `gallery_html` in slide API response |
52
+
53
+ **Gap:** No `conversation_context` on [`EducationPptxInput`](libs/agent/src/agent/models.py). No `generate_slides_from_conversation` API. No chat → Slides buttons. No presenter UI (gallery opens raw PNG in new tab; canvas is vertically stacked scroll).
54
+
55
+ ---
56
+
57
+ ## Part 1 — Backend: conversation → outline
58
+
59
+ ### 1.1 `conversation_helpers.py`
60
+
61
+ Create [`apps/gradio-space/src/gradio_space/conversation_helpers.py`](apps/gradio-space/src/gradio_space/conversation_helpers.py):
62
+
63
+ - `format_conversation_context(history, history_kind)` normalizes three Studio shapes:
64
+ - `research`: `list[{role, content}]`
65
+ - `gradio` / `voice` / `debug`: `list[[user, assistant]]` or dict variants
66
+ - Truncate to ~6–8k chars (keep **recent** turns)
67
+ - Return `(conversation_text, derived_topic)` — `derived_topic` = first non-empty user message
68
+
69
+ Unit tests: one case per `history_kind`.
70
+
71
+ ### 1.2 Agent prompt extension (minimal)
72
+
73
+ In [`models.py`](libs/agent/src/agent/models.py):
74
+
75
+ ```python
76
+ conversation_context: str = ""
77
+ ```
78
+
79
+ In [`prompts.py`](libs/agent/src/agent/prompts.py) `education_outline_user`: when set, append instruction to base outline on transcript facts (separate block from RAG `source_context`).
80
+
81
+ Forward through [`_generate_outline`](libs/agent/src/agent/runner.py).
82
+
83
+ ### 1.3 Tab + API
84
+
85
+ [`generate_lesson_slides`](apps/gradio-space/src/gradio_space/tabs/education_pptx.py):
86
+
87
+ - Add `conversation_context: str = ""`, `conversation_topic: str = ""`
88
+ - Topic = `conversation_topic or resolve_topic(...)` when context non-empty
89
+ - Trace note: conversation char count
90
+
91
+ [`api/studio.py`](apps/gradio-space/src/gradio_space/api/studio.py):
92
+
93
+ ```python
94
+ @server.api(name="generate_slides_from_conversation")
95
+ def api_generate_slides_from_conversation(history, history_kind, topic, grade, slide_count, ...)
96
+ ```
97
+
98
+ Delegate to shared finalizer with `api_generate_slides` (extract `_finalize_slide_result()` if duplicated).
99
+
100
+ ---
101
+
102
+ ## Part 2 — Studio UI: buttons on all chat surfaces
103
+
104
+ ### 2.1 Buttons ([`index.html`](apps/gradio-space/static/studio/index.html))
105
+
106
+ Secondary actions below send on each chat panel:
107
+
108
+ | Panel | ID | Label |
109
+ |-------|-----|-------|
110
+ | Research | `#btn-research-to-slides` | Generate slides from chat |
111
+ | Language lessons | `#btn-lessons-to-slides` | Generate slides from chat |
112
+ | Chat | `#btn-chat-to-slides` | Generate slides from chat |
113
+
114
+ Disable when history empty (update in each `render*Chat()`).
115
+
116
+ ### 2.2 JS flow ([`studio.js`](apps/gradio-space/static/studio/studio.js))
117
+
118
+ ```javascript
119
+ async function generateSlidesFromConversation(kind) { ... }
120
+ ```
121
+
122
+ - `setWorkspaceView("slides")` — user sees hero canvas fill
123
+ - Reuse `startProgressPanel` / `SLIDE_PIPELINE_STEPS`
124
+ - **Refactor:** extract `renderSlideGenerationResult(data)` from `generateSlides()` — both paths call it
125
+ - Pre-fill topic from workspace; API falls back to `derived_topic`
126
+ - RAG/source controls on Slides column still apply (conversation + indexed docs)
127
+
128
+ ### 2.3 Classic parity (optional, ~1h)
129
+
130
+ Same params on [`tabs/chat.py`](apps/gradio-space/src/gradio_space/tabs/chat.py) and language-lesson tab — not blocking Studio demo.
131
+
132
+ ---
133
+
134
+ ## Part 3 — Presenter mode (frontend-only wow)
135
+
136
+ No new LLM calls. Wrap existing `canvas_html` / gallery images.
137
+
138
+ ### 3.1 Presenter overlay
139
+
140
+ Add to Slides view ([`index.html`](apps/gradio-space/static/studio/index.html) + [`studio.css`](apps/gradio-space/static/studio/studio.css)):
141
+
142
+ - **Present** button in slide toolbar (enabled after generation)
143
+ - Fullscreen overlay: one slide at a time, 16:9 card centered
144
+ - Controls: Prev / Next, slide counter `3 / 8`, Esc to exit, `←` `→` keyboard
145
+ - Optional: speaker notes drawer (data already in outline HTML if exposed; else parse from `.lesson-slide` DOM)
146
+
147
+ ### 3.2 Data source priority
148
+
149
+ 1. If `data.gallery` PNG paths exist → use `<img>` per slide (cleanest presenter)
150
+ 2. Else parse `.lesson-slide` nodes from injected `canvas_html`
151
+ 3. Title slide = index 0
152
+
153
+ ### 3.3 UX polish (wahou details)
154
+
155
+ - Animate slide transition (fade 150ms)
156
+ - Topbar **Present** icon (`present_to_all`) mirrors sidebar Slides nav
157
+ - After **Generate slides from chat**, auto-scroll to canvas + pulse Present button once
158
+ - Bump `STUDIO_ASSET_VERSION` in [`server.py`](apps/gradio-space/src/gradio_space/server.py)
159
+
160
+ ---
161
+
162
+ ## Demo script (2 min add-on)
163
+
164
+ 1. Research ingest on "photosynthesis" → ask 2 RAG questions with citations
165
+ 2. **Generate slides from chat** → 3 slides on GPU
166
+ 3. Click **Present** → arrow through deck
167
+ 4. Download PPTX + expand Agent trace
168
+
169
+ ---
170
+
171
+ ## Risks
172
+
173
+ | Risk | Mitigation |
174
+ |------|------------|
175
+ | Long chat blows context | Truncate in `format_conversation_context` |
176
+ | CPU still slow | Keep 3-slide demo tip; presenter works on cached result |
177
+ | Gallery vs HTML mismatch | Prefer PNG gallery for presenter when available |
178
+
179
+ ---
180
+
181
+ ## Files
182
+
183
+ **Create:** `conversation_helpers.py`, tests in `apps/gradio-space/tests/`
184
+
185
+ **Modify:** `models.py`, `prompts.py`, `runner.py`, `education_pptx.py`, `api/studio.py`, `index.html`, `studio.js`, `studio.css`, `server.py`, `apps/gradio-space/README.md`
186
+
187
+ **Explicitly out of scope:** Quiz (separate plan), mindmap (sprint 2)
188
+
189
+ ## Estimated effort
190
+
191
+ | Block | Time |
192
+ |-------|------|
193
+ | Backend conversation path | 2–3h |
194
+ | Studio buttons + shared render | 2h |
195
+ | Presenter overlay | 3–4h |
196
+ | Tests + demo polish | 1–2h |
197
+ | **Total** | **~1 day** |
apps/gradio-space/README.md CHANGED
@@ -30,6 +30,7 @@ This package uses **Gradio 6 Server mode** (`gradio.Server`):
30
  - `list_sessions`, `list_documents`, `session_memory`
31
  - `discover_sources`, `auto_search_ingest`, `ingest_sources`, `ingest_url`, `ingest_files`
32
  - `research_chat`, `generate_slides` (supports `source_mode`: none / web / rag)
 
33
 
34
  **Voice & coach**
35
 
@@ -55,7 +56,15 @@ Set `ALLOW_MODEL_SWITCH=true` in `.env` (see [USAGE.md](../../USAGE.md)). The Se
55
  | `minicpm-v-4.6-gguf` | llama.cpp (Llama Champion track) |
56
  | `minicpm5-1b` | transformers |
57
 
58
- ## Demo script (judges) — Language lessons + Cohere stack
 
 
 
 
 
 
 
 
59
 
60
  **Badge line:** Cohere Labs — Transcribe + Tiny Aya on one local Language lessons page.
61
 
@@ -64,7 +73,7 @@ Set `ALLOW_MODEL_SWITCH=true` in `.env` (see [USAGE.md](../../USAGE.md)). The Se
64
  3. Switch to **Spanish**, type a follow-up (text in, text + audio out with **Auto-speak replies** on)
65
  4. Select **Other (text only)** → enter `hi` → show Tiny Aya Fire-quality written lesson (text only banner)
66
  5. Toggle **Use indexed sources** after ingesting one PDF in **Research**
67
- 6. Optional: **Generate Slides** from the Slides tab; **Classic UI** (`/classic`) for EchoCoach pitch metrics
68
 
69
  Space secrets for GPU demo:
70
 
 
30
  - `list_sessions`, `list_documents`, `session_memory`
31
  - `discover_sources`, `auto_search_ingest`, `ingest_sources`, `ingest_url`, `ingest_files`
32
  - `research_chat`, `generate_slides` (supports `source_mode`: none / web / rag)
33
+ - `generate_slides_from_conversation` — build a deck from Research, Language lessons, or Chat history
34
 
35
  **Voice & coach**
36
 
 
56
  | `minicpm-v-4.6-gguf` | llama.cpp (Llama Champion track) |
57
  | `minicpm5-1b` | transformers |
58
 
59
+ ## Demo script (judges) — teaching loop
60
+
61
+ 1. Open `/` — **Small Model Finetuning** project workspace
62
+ 2. **Research** — ingest a PDF or URL on your topic → ask 2 RAG questions with citations
63
+ 3. Tap **Generate slides from chat** → switch to **Slides** → preview deck → **Present** (fullscreen, arrow keys)
64
+ 4. Download **PPTX** and expand **Agent trace**
65
+ 5. Optional: **Language lessons** → French voice turn → **Slides from chat** on the same topic
66
+
67
+ ### Language lessons + Cohere stack (voice demo)
68
 
69
  **Badge line:** Cohere Labs — Transcribe + Tiny Aya on one local Language lessons page.
70
 
 
73
  3. Switch to **Spanish**, type a follow-up (text in, text + audio out with **Auto-speak replies** on)
74
  4. Select **Other (text only)** → enter `hi` → show Tiny Aya Fire-quality written lesson (text only banner)
75
  5. Toggle **Use indexed sources** after ingesting one PDF in **Research**
76
+ 6. Optional: **Classic UI** (`/classic`) for EchoCoach pitch metrics
77
 
78
  Space secrets for GPU demo:
79
 
apps/gradio-space/src/gradio_space/api/studio.py CHANGED
@@ -37,6 +37,7 @@ from gradio_space.research_helpers import (
37
  resolve_doc_ids,
38
  resolve_session,
39
  )
 
40
  from gradio_space.tabs.education_pptx import SOURCE_MODES, SEARCH_WORKFLOWS, generate_lesson_slides
41
  from gradio_space.tabs.research_mind import (
42
  ask_question,
@@ -508,71 +509,13 @@ def api_debug_chat(
508
  )
509
 
510
 
511
- def api_generate_slides(
 
 
512
  topic: str,
513
- grade: str = "6",
514
- slide_count: int = 5,
515
- session_id: str = "",
516
- use_rag: bool = True,
517
- doc_ids: list[str] | None = None,
518
- source_mode: str = "",
519
- search_workflow: str = "two_step",
520
- urls_text: str = "",
521
- selected_urls: list[str] | None = None,
522
- file_paths: list[str] | None = None,
523
  ) -> dict[str, Any]:
524
- rag_docs = doc_ids or []
525
- sid = (session_id or "").strip()
526
- if not (source_mode or "").strip() and use_rag and not sid:
527
- sid = _pick_session(topic)
528
-
529
- source_label, workflow_label, effective_sid, effective_docs = _resolve_source_labels(
530
- source_mode,
531
- search_workflow,
532
- use_rag,
533
- sid,
534
- rag_docs,
535
- )
536
-
537
- rag_notice = ""
538
- if (source_mode or "").strip().lower() == "rag" or (
539
- not (source_mode or "").strip() and use_rag
540
- ):
541
- has_sources = _session_has_rag_sources(sid, rag_docs)
542
- if use_rag and not has_sources and source_label == _SOURCE_LABELS["rag"]:
543
- rag_notice = (
544
- "Cross-Reference Sources is on, but this session has no indexed documents — "
545
- "generated from model knowledge only. Ingest sources in Step 1 to enable RAG."
546
- )
547
- source_label = _SOURCE_LABELS["none"]
548
- effective_sid = ""
549
- effective_docs = []
550
-
551
- upload_files = file_paths if file_paths else None
552
-
553
- gen = generate_lesson_slides(
554
- topic,
555
- grade,
556
- int(slide_count),
557
- source_label,
558
- workflow_label,
559
- urls_text or "",
560
- selected_urls or [],
561
- upload_files,
562
- effective_sid,
563
- effective_docs,
564
- topic,
565
- effective_sid,
566
- effective_docs,
567
- _NoopProgress(),
568
- skip_preview_images=False,
569
- )
570
- last: tuple | None = None
571
- for item in gen:
572
- last = item
573
- if last is None:
574
- return err("Generation failed before producing output.")
575
-
576
  (
577
  outline_md,
578
  preview_html,
@@ -622,6 +565,158 @@ def api_generate_slides(
622
  )
623
 
624
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
625
  def api_teacher_voice_turn(
626
  message: str,
627
  mode: TeacherVoiceMode = "lesson",
@@ -1099,6 +1194,38 @@ def register_studio_apis(server: gr.Server) -> None:
1099
  file_paths,
1100
  )
1101
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1102
  @server.api(name="language_lesson_turn")
1103
  def _language_lesson_turn(
1104
  message: str = "",
 
37
  resolve_doc_ids,
38
  resolve_session,
39
  )
40
+ from gradio_space.conversation_helpers import format_conversation_context
41
  from gradio_space.tabs.education_pptx import SOURCE_MODES, SEARCH_WORKFLOWS, generate_lesson_slides
42
  from gradio_space.tabs.research_mind import (
43
  ask_question,
 
509
  )
510
 
511
 
512
+ def _build_slide_api_response(
513
+ last: tuple,
514
+ *,
515
  topic: str,
516
+ sid: str,
517
+ rag_notice: str = "",
 
 
 
 
 
 
 
 
518
  ) -> dict[str, Any]:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
519
  (
520
  outline_md,
521
  preview_html,
 
565
  )
566
 
567
 
568
+ def _run_slide_generation(**kwargs) -> dict[str, Any]:
569
+ topic = kwargs.pop("topic")
570
+ sid = kwargs.pop("sid", "")
571
+ rag_notice = kwargs.pop("rag_notice", "")
572
+
573
+ gen = generate_lesson_slides(topic, **kwargs)
574
+ last: tuple | None = None
575
+ for item in gen:
576
+ last = item
577
+ if last is None:
578
+ return err("Generation failed before producing output.")
579
+ return _build_slide_api_response(last, topic=topic, sid=sid, rag_notice=rag_notice)
580
+
581
+
582
+ def api_generate_slides(
583
+ topic: str,
584
+ grade: str = "6",
585
+ slide_count: int = 5,
586
+ session_id: str = "",
587
+ use_rag: bool = True,
588
+ doc_ids: list[str] | None = None,
589
+ source_mode: str = "",
590
+ search_workflow: str = "two_step",
591
+ urls_text: str = "",
592
+ selected_urls: list[str] | None = None,
593
+ file_paths: list[str] | None = None,
594
+ ) -> dict[str, Any]:
595
+ rag_docs = doc_ids or []
596
+ sid = (session_id or "").strip()
597
+ if not (source_mode or "").strip() and use_rag and not sid:
598
+ sid = _pick_session(topic)
599
+
600
+ source_label, workflow_label, effective_sid, effective_docs = _resolve_source_labels(
601
+ source_mode,
602
+ search_workflow,
603
+ use_rag,
604
+ sid,
605
+ rag_docs,
606
+ )
607
+
608
+ rag_notice = ""
609
+ if (source_mode or "").strip().lower() == "rag" or (
610
+ not (source_mode or "").strip() and use_rag
611
+ ):
612
+ has_sources = _session_has_rag_sources(sid, rag_docs)
613
+ if use_rag and not has_sources and source_label == _SOURCE_LABELS["rag"]:
614
+ rag_notice = (
615
+ "Cross-Reference Sources is on, but this session has no indexed documents — "
616
+ "generated from model knowledge only. Ingest sources in Step 1 to enable RAG."
617
+ )
618
+ source_label = _SOURCE_LABELS["none"]
619
+ effective_sid = ""
620
+ effective_docs = []
621
+
622
+ upload_files = file_paths if file_paths else None
623
+
624
+ return _run_slide_generation(
625
+ topic=topic,
626
+ sid=sid,
627
+ rag_notice=rag_notice,
628
+ grade=grade,
629
+ slide_count=int(slide_count),
630
+ source_mode_label=source_label,
631
+ search_workflow_label=workflow_label,
632
+ urls_text=urls_text or "",
633
+ selected_urls=selected_urls or [],
634
+ upload_files=upload_files,
635
+ session_id=effective_sid,
636
+ doc_ids=effective_docs,
637
+ workspace_topic=topic,
638
+ workspace_session=effective_sid,
639
+ workspace_doc_ids=effective_docs,
640
+ progress=_NoopProgress(),
641
+ skip_preview_images=False,
642
+ )
643
+
644
+
645
+ def api_generate_slides_from_conversation(
646
+ history: list | None,
647
+ history_kind: str,
648
+ topic: str,
649
+ grade: str = "6",
650
+ slide_count: int = 5,
651
+ session_id: str = "",
652
+ use_rag: bool = True,
653
+ doc_ids: list[str] | None = None,
654
+ source_mode: str = "",
655
+ search_workflow: str = "two_step",
656
+ urls_text: str = "",
657
+ selected_urls: list[str] | None = None,
658
+ file_paths: list[str] | None = None,
659
+ ) -> dict[str, Any]:
660
+ conversation_text, derived_topic = format_conversation_context(history, history_kind)
661
+ if not conversation_text.strip():
662
+ return err("Start a conversation first.")
663
+
664
+ effective_topic = (topic or "").strip() or derived_topic
665
+ if not effective_topic:
666
+ return err("Enter a topic or chat about a lesson first.")
667
+
668
+ rag_docs = doc_ids or []
669
+ sid = (session_id or "").strip()
670
+ if not (source_mode or "").strip() and use_rag and not sid:
671
+ sid = _pick_session(effective_topic)
672
+
673
+ source_label, workflow_label, effective_sid, effective_docs = _resolve_source_labels(
674
+ source_mode,
675
+ search_workflow,
676
+ use_rag,
677
+ sid,
678
+ rag_docs,
679
+ )
680
+
681
+ rag_notice = ""
682
+ if (source_mode or "").strip().lower() == "rag" or (
683
+ not (source_mode or "").strip() and use_rag
684
+ ):
685
+ has_sources = _session_has_rag_sources(sid, rag_docs)
686
+ if use_rag and not has_sources and source_label == _SOURCE_LABELS["rag"]:
687
+ rag_notice = (
688
+ "Cross-Reference Sources is on, but this session has no indexed documents — "
689
+ "generated from model knowledge only. Ingest sources in Step 1 to enable RAG."
690
+ )
691
+ source_label = _SOURCE_LABELS["none"]
692
+ effective_sid = ""
693
+ effective_docs = []
694
+
695
+ upload_files = file_paths if file_paths else None
696
+
697
+ return _run_slide_generation(
698
+ topic=effective_topic,
699
+ sid=sid,
700
+ rag_notice=rag_notice,
701
+ grade=grade,
702
+ slide_count=int(slide_count),
703
+ source_mode_label=source_label,
704
+ search_workflow_label=workflow_label,
705
+ urls_text=urls_text or "",
706
+ selected_urls=selected_urls or [],
707
+ upload_files=upload_files,
708
+ session_id=effective_sid,
709
+ doc_ids=effective_docs,
710
+ workspace_topic=effective_topic,
711
+ workspace_session=effective_sid,
712
+ workspace_doc_ids=effective_docs,
713
+ progress=_NoopProgress(),
714
+ skip_preview_images=False,
715
+ conversation_context=conversation_text,
716
+ conversation_topic=derived_topic,
717
+ )
718
+
719
+
720
  def api_teacher_voice_turn(
721
  message: str,
722
  mode: TeacherVoiceMode = "lesson",
 
1194
  file_paths,
1195
  )
1196
 
1197
+ @server.api(name="generate_slides_from_conversation")
1198
+ def _generate_slides_from_conversation(
1199
+ history: list | None,
1200
+ history_kind: str = "gradio",
1201
+ topic: str = "",
1202
+ grade: str = "6",
1203
+ slide_count: int = 5,
1204
+ session_id: str = "",
1205
+ use_rag: bool = True,
1206
+ doc_ids: list[str] | None = None,
1207
+ source_mode: str = "",
1208
+ search_workflow: str = "two_step",
1209
+ urls_text: str = "",
1210
+ selected_urls: list[str] | None = None,
1211
+ file_paths: list[str] | None = None,
1212
+ ) -> dict[str, Any]:
1213
+ return api_generate_slides_from_conversation(
1214
+ history,
1215
+ history_kind,
1216
+ topic,
1217
+ grade,
1218
+ slide_count,
1219
+ session_id,
1220
+ use_rag,
1221
+ doc_ids,
1222
+ source_mode,
1223
+ search_workflow,
1224
+ urls_text,
1225
+ selected_urls,
1226
+ file_paths,
1227
+ )
1228
+
1229
  @server.api(name="language_lesson_turn")
1230
  def _language_lesson_turn(
1231
  message: str = "",
apps/gradio-space/src/gradio_space/conversation_helpers.py ADDED
@@ -0,0 +1,108 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ MAX_CONVERSATION_CHARS = 8000
4
+
5
+
6
+ def _strip_message_text(content: object) -> str:
7
+ if content is None:
8
+ return ""
9
+ if isinstance(content, str):
10
+ return content.strip()
11
+ if isinstance(content, dict):
12
+ for key in ("text", "message", "content"):
13
+ val = content.get(key)
14
+ if isinstance(val, str) and val.strip():
15
+ return val.strip()
16
+ return ""
17
+ if isinstance(content, list):
18
+ parts: list[str] = []
19
+ for item in content:
20
+ if isinstance(item, dict) and item.get("type") == "text":
21
+ text = str(item.get("text") or "").strip()
22
+ if text:
23
+ parts.append(text)
24
+ elif isinstance(item, str) and item.strip():
25
+ parts.append(item.strip())
26
+ return "\n".join(parts).strip()
27
+ return str(content).strip()
28
+
29
+
30
+ def _append_turn(lines: list[str], role: str, content: str) -> None:
31
+ text = content.strip()
32
+ if not text:
33
+ return
34
+ label = "User" if role == "user" else "Assistant"
35
+ lines.append(f"{label}: {text}")
36
+
37
+
38
+ def _iter_turns(history: list, history_kind: str) -> list[tuple[str, str]]:
39
+ turns: list[tuple[str, str]] = []
40
+ kind = (history_kind or "gradio").strip().lower()
41
+
42
+ if kind == "research":
43
+ for msg in history or []:
44
+ if not isinstance(msg, dict):
45
+ continue
46
+ role = str(msg.get("role") or "").strip().lower()
47
+ if role not in {"user", "assistant"}:
48
+ continue
49
+ turns.append((role, _strip_message_text(msg.get("content"))))
50
+ return turns
51
+
52
+ for item in history or []:
53
+ if isinstance(item, dict) and item.get("role"):
54
+ role = str(item.get("role") or "").strip().lower()
55
+ if role not in {"user", "assistant"}:
56
+ continue
57
+ turns.append((role, _strip_message_text(item.get("content"))))
58
+ continue
59
+ if isinstance(item, (list, tuple)) and len(item) >= 2:
60
+ user_text = _strip_message_text(item[0])
61
+ assistant_text = _strip_message_text(item[1])
62
+ if user_text:
63
+ turns.append(("user", user_text))
64
+ if assistant_text:
65
+ turns.append(("assistant", assistant_text))
66
+
67
+ return turns
68
+
69
+
70
+ def format_conversation_context(
71
+ history: list | None,
72
+ history_kind: str = "gradio",
73
+ ) -> tuple[str, str]:
74
+ """Normalize chat history into transcript text and a derived topic."""
75
+ turns = _iter_turns(history or [], history_kind)
76
+ if not turns:
77
+ return "", ""
78
+
79
+ lines: list[str] = []
80
+ derived_topic = ""
81
+ for role, content in turns:
82
+ if role == "user" and not derived_topic and content.strip():
83
+ derived_topic = content.strip()[:200]
84
+ _append_turn(lines, role, content)
85
+
86
+ if not lines:
87
+ return "", derived_topic
88
+
89
+ full = "\n\n".join(lines)
90
+ if len(full) <= MAX_CONVERSATION_CHARS:
91
+ return full, derived_topic
92
+
93
+ # Keep the most recent turns within the char budget.
94
+ kept: list[str] = []
95
+ total = 0
96
+ for line in reversed(lines):
97
+ extra = len(line) + (2 if kept else 0)
98
+ if total + extra > MAX_CONVERSATION_CHARS and kept:
99
+ break
100
+ kept.insert(0, line)
101
+ total += extra
102
+
103
+ truncated = "\n\n".join(kept)
104
+ if len(kept) < len(lines):
105
+ truncated = (
106
+ "[Earlier conversation truncated for length.]\n\n" + truncated
107
+ )
108
+ return truncated, derived_topic
apps/gradio-space/src/gradio_space/server.py CHANGED
@@ -23,7 +23,7 @@ from gradio_space.ui.theme import get_theme, load_css
23
  _PKG_ROOT = Path(__file__).resolve().parent
24
  _APP_ROOT = _PKG_ROOT.parents[1]
25
  _STATIC_DIR = _APP_ROOT / "static" / "studio"
26
- _STUDIO_ASSET_VERSION = "20260615b"
27
  _STUDIO_INDEX_HTML = _STATIC_DIR / "index.html"
28
 
29
 
 
23
  _PKG_ROOT = Path(__file__).resolve().parent
24
  _APP_ROOT = _PKG_ROOT.parents[1]
25
  _STATIC_DIR = _APP_ROOT / "static" / "studio"
26
+ _STUDIO_ASSET_VERSION = "20260615c"
27
  _STUDIO_INDEX_HTML = _STATIC_DIR / "index.html"
28
 
29
 
apps/gradio-space/src/gradio_space/tabs/education_pptx.py CHANGED
@@ -228,8 +228,13 @@ def generate_lesson_slides(
228
  progress: gr.Progress = gr.Progress(),
229
  *,
230
  skip_preview_images: bool = False,
 
 
231
  ):
232
- topic = resolve_topic(topic, workspace_topic)
 
 
 
233
  session_id = resolve_session(session_id, workspace_session)
234
  doc_ids = resolve_doc_ids(doc_ids, workspace_doc_ids)
235
  slide_progress = SlideGenerationProgress(
@@ -271,6 +276,7 @@ def generate_lesson_slides(
271
  files=files,
272
  session_id=session_id or None,
273
  doc_ids=doc_ids or [],
 
274
  progress=slide_progress,
275
  skip_preview_images=skip_preview_images,
276
  ):
 
228
  progress: gr.Progress = gr.Progress(),
229
  *,
230
  skip_preview_images: bool = False,
231
+ conversation_context: str = "",
232
+ conversation_topic: str = "",
233
  ):
234
+ if (conversation_context or "").strip():
235
+ topic = (conversation_topic or topic).strip() or resolve_topic(topic, workspace_topic)
236
+ else:
237
+ topic = resolve_topic(topic, workspace_topic)
238
  session_id = resolve_session(session_id, workspace_session)
239
  doc_ids = resolve_doc_ids(doc_ids, workspace_doc_ids)
240
  slide_progress = SlideGenerationProgress(
 
276
  files=files,
277
  session_id=session_id or None,
278
  doc_ids=doc_ids or [],
279
+ conversation_context=conversation_context,
280
  progress=slide_progress,
281
  skip_preview_images=skip_preview_images,
282
  ):
apps/gradio-space/static/studio/index.html CHANGED
@@ -182,6 +182,10 @@
182
  <span class="material-symbols-outlined">chat</span>
183
  Ask
184
  </button>
 
 
 
 
185
  <p id="research-chat-status" class="status-text"></p>
186
  <details class="studio-debug-trace" id="research-trace-details">
187
  <summary>Agent trace</summary>
@@ -267,6 +271,10 @@
267
  <span class="material-symbols-outlined">auto_awesome</span>
268
  Generate Slides
269
  </button>
 
 
 
 
270
  </div>
271
  <p class="status-text slide-demo-tip">Demo tip: 3 slides on GPU hardware for a quick run.</p>
272
  <p id="generate-status" class="status-text">Ready to generate.</p>
@@ -398,6 +406,10 @@
398
  <p id="lessons-record-status" class="status-text lessons-record-status"></p>
399
  <div class="lessons-send-row">
400
  <button type="button" id="btn-lessons-send" class="btn btn-primary">Send</button>
 
 
 
 
401
  <label class="toggle-row lessons-auto-speak">
402
  <span>Auto-speak replies</span>
403
  <input id="lessons-auto-speak" type="checkbox" checked />
@@ -454,6 +466,10 @@
454
  <textarea id="debug-message" class="input" rows="2" placeholder="Hello, model…"></textarea>
455
  </label>
456
  <button type="button" id="btn-debug-send" class="btn btn-primary btn-block">Send</button>
 
 
 
 
457
  </div>
458
  <details class="studio-debug-trace" id="debug-trace-details">
459
  <summary>Agent trace</summary>
@@ -495,6 +511,27 @@
495
  </aside>
496
  </div>
497
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
498
  <script type="module" src="/static/studio/studio.js?v={{STUDIO_ASSET_VERSION}}"></script>
499
  </body>
500
  </html>
 
182
  <span class="material-symbols-outlined">chat</span>
183
  Ask
184
  </button>
185
+ <button type="button" id="btn-research-to-slides" class="btn btn-secondary btn-block" disabled>
186
+ <span class="material-symbols-outlined">present_to_all</span>
187
+ Generate slides from chat
188
+ </button>
189
  <p id="research-chat-status" class="status-text"></p>
190
  <details class="studio-debug-trace" id="research-trace-details">
191
  <summary>Agent trace</summary>
 
271
  <span class="material-symbols-outlined">auto_awesome</span>
272
  Generate Slides
273
  </button>
274
+ <button type="button" id="btn-present" class="btn btn-secondary" disabled title="Present fullscreen">
275
+ <span class="material-symbols-outlined">slideshow</span>
276
+ Present
277
+ </button>
278
  </div>
279
  <p class="status-text slide-demo-tip">Demo tip: 3 slides on GPU hardware for a quick run.</p>
280
  <p id="generate-status" class="status-text">Ready to generate.</p>
 
406
  <p id="lessons-record-status" class="status-text lessons-record-status"></p>
407
  <div class="lessons-send-row">
408
  <button type="button" id="btn-lessons-send" class="btn btn-primary">Send</button>
409
+ <button type="button" id="btn-lessons-to-slides" class="btn btn-secondary" disabled>
410
+ <span class="material-symbols-outlined">present_to_all</span>
411
+ Slides from chat
412
+ </button>
413
  <label class="toggle-row lessons-auto-speak">
414
  <span>Auto-speak replies</span>
415
  <input id="lessons-auto-speak" type="checkbox" checked />
 
466
  <textarea id="debug-message" class="input" rows="2" placeholder="Hello, model…"></textarea>
467
  </label>
468
  <button type="button" id="btn-debug-send" class="btn btn-primary btn-block">Send</button>
469
+ <button type="button" id="btn-chat-to-slides" class="btn btn-secondary btn-block" disabled>
470
+ <span class="material-symbols-outlined">present_to_all</span>
471
+ Generate slides from chat
472
+ </button>
473
  </div>
474
  <details class="studio-debug-trace" id="debug-trace-details">
475
  <summary>Agent trace</summary>
 
511
  </aside>
512
  </div>
513
 
514
+ <div id="presenter-overlay" class="presenter-overlay hidden" aria-hidden="true" role="dialog" aria-label="Slide presenter">
515
+ <div class="presenter-backdrop" id="presenter-backdrop"></div>
516
+ <div class="presenter-shell">
517
+ <div class="presenter-toolbar">
518
+ <span id="presenter-counter" class="presenter-counter">1 / 1</span>
519
+ <div class="presenter-toolbar-actions">
520
+ <button type="button" id="btn-presenter-prev" class="btn btn-ghost btn-icon material-symbols-outlined" aria-label="Previous slide">chevron_left</button>
521
+ <button type="button" id="btn-presenter-next" class="btn btn-ghost btn-icon material-symbols-outlined" aria-label="Next slide">chevron_right</button>
522
+ <button type="button" id="btn-presenter-close" class="btn btn-ghost btn-icon material-symbols-outlined" aria-label="Exit presenter">close</button>
523
+ </div>
524
+ </div>
525
+ <div class="presenter-stage">
526
+ <div id="presenter-slide" class="presenter-slide presenter-fade"></div>
527
+ </div>
528
+ <details class="presenter-notes" id="presenter-notes-details">
529
+ <summary>Speaker notes</summary>
530
+ <p id="presenter-notes" class="presenter-notes-text"></p>
531
+ </details>
532
+ </div>
533
+ </div>
534
+
535
  <script type="module" src="/static/studio/studio.js?v={{STUDIO_ASSET_VERSION}}"></script>
536
  </body>
537
  </html>
apps/gradio-space/static/studio/studio.css CHANGED
@@ -434,7 +434,115 @@ body.sidebar-open {
434
  .field { display: flex; flex-direction: column; gap: 0.35rem; font-size: 0.82rem; color: var(--secondary); }
435
  .field-wide { grid-column: 1 / -1; }
436
 
437
- .controls-actions { display: flex; justify-content: flex-end; margin-top: 0.75rem; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
438
 
439
  .slide-demo-tip {
440
  margin-top: 0.5rem;
 
434
  .field { display: flex; flex-direction: column; gap: 0.35rem; font-size: 0.82rem; color: var(--secondary); }
435
  .field-wide { grid-column: 1 / -1; }
436
 
437
+ .controls-actions { display: flex; justify-content: flex-end; gap: 0.5rem; margin-top: 0.75rem; flex-wrap: wrap; }
438
+
439
+ .btn-present-pulse {
440
+ animation: present-pulse 1.2s ease-in-out 2;
441
+ }
442
+
443
+ @keyframes present-pulse {
444
+ 0%, 100% { box-shadow: 0 0 0 0 rgba(26, 115, 232, 0.45); }
445
+ 50% { box-shadow: 0 0 0 6px rgba(26, 115, 232, 0); }
446
+ }
447
+
448
+ .presenter-overlay {
449
+ position: fixed;
450
+ inset: 0;
451
+ z-index: 2000;
452
+ display: flex;
453
+ align-items: center;
454
+ justify-content: center;
455
+ }
456
+
457
+ .presenter-overlay.hidden {
458
+ display: none;
459
+ }
460
+
461
+ .presenter-backdrop {
462
+ position: absolute;
463
+ inset: 0;
464
+ background: rgba(10, 12, 16, 0.92);
465
+ }
466
+
467
+ .presenter-shell {
468
+ position: relative;
469
+ z-index: 1;
470
+ width: min(96vw, 1100px);
471
+ max-height: 96vh;
472
+ display: flex;
473
+ flex-direction: column;
474
+ gap: 0.75rem;
475
+ }
476
+
477
+ .presenter-toolbar {
478
+ display: flex;
479
+ align-items: center;
480
+ justify-content: space-between;
481
+ color: #f5f7fa;
482
+ }
483
+
484
+ .presenter-counter {
485
+ font-size: 0.9rem;
486
+ letter-spacing: 0.04em;
487
+ }
488
+
489
+ .presenter-toolbar-actions {
490
+ display: flex;
491
+ gap: 0.25rem;
492
+ }
493
+
494
+ .presenter-stage {
495
+ aspect-ratio: 16 / 9;
496
+ background: #1a1d21;
497
+ border-radius: 12px;
498
+ overflow: hidden;
499
+ box-shadow: 0 24px 64px rgba(0, 0, 0, 0.45);
500
+ }
501
+
502
+ .presenter-slide {
503
+ width: 100%;
504
+ height: 100%;
505
+ display: flex;
506
+ align-items: center;
507
+ justify-content: center;
508
+ padding: 1.5rem;
509
+ box-sizing: border-box;
510
+ overflow: auto;
511
+ }
512
+
513
+ .presenter-slide img {
514
+ max-width: 100%;
515
+ max-height: 100%;
516
+ object-fit: contain;
517
+ border-radius: 8px;
518
+ }
519
+
520
+ .presenter-slide .lesson-slide {
521
+ width: 100%;
522
+ max-width: 920px;
523
+ margin: 0 auto;
524
+ }
525
+
526
+ .presenter-fade {
527
+ animation: presenter-fade 150ms ease;
528
+ }
529
+
530
+ @keyframes presenter-fade {
531
+ from { opacity: 0.35; }
532
+ to { opacity: 1; }
533
+ }
534
+
535
+ .presenter-notes {
536
+ color: #d8dee6;
537
+ font-size: 0.85rem;
538
+ max-width: 920px;
539
+ margin: 0 auto;
540
+ }
541
+
542
+ .presenter-notes-text {
543
+ margin: 0.35rem 0 0;
544
+ line-height: 1.45;
545
+ }
546
 
547
  .slide-demo-tip {
548
  margin-top: 0.5rem;
apps/gradio-space/static/studio/studio.js CHANGED
@@ -76,6 +76,9 @@ const state = {
76
  pendingLessonsAudioPath: null,
77
  holdMicActive: false,
78
  useBrowserMic: true,
 
 
 
79
  };
80
 
81
  function effectiveTopic(local) {
@@ -187,10 +190,246 @@ function updateResearchDocCount(count) {
187
  }
188
 
189
  function openResearchView() {
190
- document.querySelector('.nav-item[data-view="research"]')?.click();
191
  window.setTimeout(() => $("#research-question")?.focus(), 80);
192
  }
193
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
194
  function getSelectedDiscoveredUrls(listId = "#url-choices-list") {
195
  const boxes = document.querySelectorAll(`${listId} input[type=checkbox]:checked`);
196
  return [...boxes].map((el) => el.value);
@@ -404,6 +643,7 @@ function renderLessonsChat() {
404
  if (!state.history.length) {
405
  container.innerHTML =
406
  '<p class="research-chat-empty">Choose a language, then type, speak, or upload audio to start your lesson.</p>';
 
407
  return;
408
  }
409
  const parts = [];
@@ -432,6 +672,7 @@ function renderLessonsChat() {
432
  }
433
  container.innerHTML = parts.join("");
434
  container.scrollTop = container.scrollHeight;
 
435
  }
436
 
437
  function renderLessonsUrlChoices(urls, selected) {
@@ -599,6 +840,7 @@ function renderResearchChat() {
599
  if (!state.researchChatHistory.length) {
600
  container.innerHTML =
601
  '<p class="research-chat-empty">Ingest sources, then ask questions — answers include citations from your library.</p>';
 
602
  return;
603
  }
604
  container.innerHTML = state.researchChatHistory
@@ -609,6 +851,7 @@ function renderResearchChat() {
609
  })
610
  .join("");
611
  container.scrollTop = container.scrollHeight;
 
612
  }
613
 
614
  function renderDebugChat() {
@@ -616,6 +859,7 @@ function renderDebugChat() {
616
  if (!state.debugChatHistory.length) {
617
  container.innerHTML =
618
  '<p class="research-chat-empty">Ask the local model — turn on RAG to ground answers in your library.</p>';
 
619
  return;
620
  }
621
  container.innerHTML = state.debugChatHistory
@@ -624,6 +868,7 @@ function renderDebugChat() {
624
  })
625
  .join("");
626
  container.scrollTop = container.scrollHeight;
 
627
  }
628
 
629
  function updateResearchRagBadge() {
@@ -1215,6 +1460,7 @@ async function initWorkspace() {
1215
  syncLessonsModeUi();
1216
  renderLessonsChat();
1217
  await refreshDebugDocuments();
 
1218
  const recStatus = await callApi("recording_status", []);
1219
  state.useBrowserMic = !recStatus.backend || /unavailable|no capture/i.test(recStatus.message || "");
1220
  syncLayoutOffsets();
@@ -1230,104 +1476,80 @@ async function ingestFiles(files) {
1230
  }
1231
 
1232
  async function generateSlides() {
1233
- const topic = effectiveTopic($("#lesson-topic").value);
1234
- const grade = $("#lesson-grade").value;
1235
- const slideCount = Number($("#slide-count").value);
1236
- const useRag = Boolean($("#lessons-use-rag")?.checked);
1237
- const docIds = effectiveDocIds([]);
1238
- const sourceMode = $("#slide-source-mode")?.value || "";
1239
- const searchWorkflow = $("#slide-search-workflow")?.value || "two_step";
1240
- const urlsText = $("#slide-urls-text")?.value.trim() || "";
1241
- const selectedUrls = getSelectedDiscoveredUrls("#slide-url-choices-list");
1242
 
1243
  await withRegionLoading(
1244
  $("#slide-canvas"),
1245
  "Generating slides…",
1246
  async () => {
1247
- const filePaths = [];
1248
- const slideFiles = $("#slide-source-files")?.files;
1249
- if (slideFiles?.length) {
1250
- for (const file of slideFiles) {
1251
- filePaths.push(await uploadFile(file));
1252
- }
1253
- }
1254
-
1255
- startProgressPanel();
1256
- const waitTimer = advanceProgressWhileWaiting();
1257
  let data;
1258
  try {
1259
- data = await callApi("generate_slides", [
1260
- topic,
1261
- grade,
1262
- slideCount,
1263
- state.workspaceSessionId,
1264
- useRag,
1265
- docIds,
1266
- sourceMode,
1267
- searchWorkflow,
1268
- urlsText,
1269
- selectedUrls,
1270
- filePaths,
1271
  ]);
1272
  } catch (_err) {
1273
  $("#progress-eta").textContent = "Failed";
1274
  throw _err;
1275
- } finally {
1276
- clearInterval(waitTimer);
1277
- if (state.progressTimer) {
1278
- clearInterval(state.progressTimer);
1279
- state.progressTimer = null;
1280
- }
1281
  }
1282
 
1283
- finishProgressPanel(data);
1284
- $("#generate-status").textContent = stripMd(data.status || "Slides generated.");
1285
- const canvasHtml =
1286
- data.canvas_html ||
1287
- (data.preview_html ? `<div class="studio-canvas-inner">${data.preview_html}</div>` : "");
1288
- $("#slide-canvas-content").innerHTML =
1289
- canvasHtml || '<div class="studio-canvas-empty"><p>Preview unavailable.</p></div>';
1290
-
1291
- const galleryEl = $("#slide-gallery");
1292
- if (data.gallery_html) {
1293
- galleryEl.innerHTML = data.gallery_html;
1294
- galleryEl.classList.remove("hidden");
1295
- } else if (data.gallery?.length) {
1296
- galleryEl.innerHTML = data.gallery
1297
- .map(
1298
- (path, i) =>
1299
- `<a class="studio-gallery-item" href="${fileUrl(path)}" target="_blank" rel="noopener"><img src="${fileUrl(path)}" alt="Slide ${i + 1}" loading="lazy" /></a>`
1300
- )
1301
- .join("");
1302
- galleryEl.classList.remove("hidden");
1303
- } else {
1304
- galleryEl.classList.add("hidden");
1305
- galleryEl.innerHTML = "";
1306
- }
1307
 
1308
- state.downloads = data.downloads;
1309
- const dl = $("#downloads");
1310
- if (data.downloads?.pptx) {
1311
- dl.classList.remove("hidden");
1312
- dl.innerHTML = `
1313
- <a href="${fileUrl(data.downloads.pptx)}" download>PPTX</a>
1314
- <a href="${fileUrl(data.downloads.docx)}" download>DOCX</a>
1315
- <a href="${fileUrl(data.downloads.html)}" download>HTML</a>`;
1316
- $("#btn-export").disabled = false;
1317
- const exportBtn = $("#btn-export");
1318
- if (exportBtn) exportBtn.textContent = "Download PPTX";
1319
- syncLayoutOffsets();
1320
- }
1321
 
1322
- const outlineDetails = $("#slide-outline-details");
1323
- const outlineEl = $("#slide-outline");
1324
- if (data.outline_md) {
1325
- outlineEl.innerHTML = renderMarkdownLite(data.outline_md);
1326
- outlineDetails?.classList.remove("hidden");
1327
- } else {
1328
- outlineEl.innerHTML = "";
1329
- outlineDetails?.classList.add("hidden");
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1330
  }
 
 
 
1331
  },
1332
  {
1333
  overlayEl: $("#canvas-overlay"),
@@ -1622,6 +1844,34 @@ function bindUi() {
1622
  });
1623
 
1624
  $("#btn-generate").addEventListener("click", () => generateSlides().catch(() => {}));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1625
 
1626
  $("#btn-lessons-send")?.addEventListener("click", () => sendLessonsTurn().catch(() => {}));
1627
  $("#lessons-message")?.addEventListener("keydown", (e) => {
 
76
  pendingLessonsAudioPath: null,
77
  holdMicActive: false,
78
  useBrowserMic: true,
79
+ presenterSlides: [],
80
+ presenterIndex: 0,
81
+ fromConversation: false,
82
  };
83
 
84
  function effectiveTopic(local) {
 
190
  }
191
 
192
  function openResearchView() {
193
+ setWorkspaceView("research");
194
  window.setTimeout(() => $("#research-question")?.focus(), 80);
195
  }
196
 
197
+ function setWorkspaceView(view) {
198
+ const btn = document.querySelector(`.nav-item[data-view="${view}"]`);
199
+ if (btn) btn.click();
200
+ }
201
+
202
+ function hasChatHistory(kind) {
203
+ if (kind === "research") return state.researchChatHistory.length > 0;
204
+ if (kind === "voice") return state.history.length > 0;
205
+ if (kind === "debug") return state.debugChatHistory.length > 0;
206
+ return false;
207
+ }
208
+
209
+ function syncChatToSlidesButtons() {
210
+ const researchBtn = $("#btn-research-to-slides");
211
+ const lessonsBtn = $("#btn-lessons-to-slides");
212
+ const chatBtn = $("#btn-chat-to-slides");
213
+ if (researchBtn) researchBtn.disabled = !hasChatHistory("research");
214
+ if (lessonsBtn) lessonsBtn.disabled = !hasChatHistory("voice");
215
+ if (chatBtn) chatBtn.disabled = !hasChatHistory("debug");
216
+ }
217
+
218
+ function pickHistory(kind) {
219
+ if (kind === "research") {
220
+ return { history: state.researchChatHistory, historyKind: "research" };
221
+ }
222
+ if (kind === "voice") {
223
+ return { history: state.history, historyKind: "voice" };
224
+ }
225
+ return { history: state.debugChatHistory, historyKind: "debug" };
226
+ }
227
+
228
+ function buildPresenterSlidesFromData(data) {
229
+ const slides = [];
230
+ if (data.gallery?.length) {
231
+ for (const path of data.gallery) {
232
+ slides.push({ type: "image", src: fileUrl(path), notes: "" });
233
+ }
234
+ return slides;
235
+ }
236
+
237
+ const canvasHost = document.createElement("div");
238
+ const canvasHtml =
239
+ data.canvas_html ||
240
+ (data.preview_html ? `<div class="studio-canvas-inner">${data.preview_html}</div>` : "");
241
+ canvasHost.innerHTML = canvasHtml || "";
242
+ const cards = canvasHost.querySelectorAll(".lesson-slide");
243
+ cards.forEach((card) => {
244
+ const noteEl = card.querySelector(".speaker-note");
245
+ const notes = noteEl ? noteEl.textContent.replace(/^Teacher note:\s*/i, "").trim() : "";
246
+ slides.push({ type: "html", html: card.outerHTML, notes });
247
+ });
248
+ return slides;
249
+ }
250
+
251
+ function setPresenterEnabled(enabled) {
252
+ const presentBtn = $("#btn-present");
253
+ if (presentBtn) presentBtn.disabled = !enabled;
254
+ }
255
+
256
+ function renderPresenterSlide() {
257
+ const slideEl = $("#presenter-slide");
258
+ const counterEl = $("#presenter-counter");
259
+ const notesEl = $("#presenter-notes");
260
+ const slides = state.presenterSlides;
261
+ if (!slideEl || !slides.length) return;
262
+
263
+ const index = Math.max(0, Math.min(state.presenterIndex, slides.length - 1));
264
+ state.presenterIndex = index;
265
+ const slide = slides[index];
266
+ slideEl.classList.remove("presenter-fade");
267
+ void slideEl.offsetWidth;
268
+ slideEl.classList.add("presenter-fade");
269
+
270
+ if (slide.type === "image") {
271
+ slideEl.innerHTML = `<img src="${slide.src}" alt="Slide ${index + 1}" />`;
272
+ } else {
273
+ slideEl.innerHTML = slide.html || "";
274
+ }
275
+
276
+ if (counterEl) counterEl.textContent = `${index + 1} / ${slides.length}`;
277
+ if (notesEl) {
278
+ notesEl.textContent = slide.notes || "No speaker notes for this slide.";
279
+ }
280
+ }
281
+
282
+ function openPresenter() {
283
+ if (!state.presenterSlides.length) return;
284
+ const overlay = $("#presenter-overlay");
285
+ if (!overlay) return;
286
+ state.presenterIndex = 0;
287
+ renderPresenterSlide();
288
+ overlay.classList.remove("hidden");
289
+ overlay.setAttribute("aria-hidden", "false");
290
+ }
291
+
292
+ function closePresenter() {
293
+ const overlay = $("#presenter-overlay");
294
+ if (!overlay) return;
295
+ overlay.classList.add("hidden");
296
+ overlay.setAttribute("aria-hidden", "true");
297
+ }
298
+
299
+ function presenterNext() {
300
+ if (!state.presenterSlides.length) return;
301
+ if (state.presenterIndex < state.presenterSlides.length - 1) {
302
+ state.presenterIndex += 1;
303
+ renderPresenterSlide();
304
+ }
305
+ }
306
+
307
+ function presenterPrev() {
308
+ if (!state.presenterSlides.length) return;
309
+ if (state.presenterIndex > 0) {
310
+ state.presenterIndex -= 1;
311
+ renderPresenterSlide();
312
+ }
313
+ }
314
+
315
+ function pulsePresentButton() {
316
+ const btn = $("#btn-present");
317
+ if (!btn) return;
318
+ btn.classList.remove("btn-present-pulse");
319
+ void btn.offsetWidth;
320
+ btn.classList.add("btn-present-pulse");
321
+ window.setTimeout(() => btn.classList.remove("btn-present-pulse"), 2600);
322
+ }
323
+
324
+ function renderSlideGenerationResult(data, { scrollToCanvas = false, pulsePresent = false } = {}) {
325
+ finishProgressPanel(data);
326
+ $("#generate-status").textContent = stripMd(data.status || "Slides generated.");
327
+ const canvasHtml =
328
+ data.canvas_html ||
329
+ (data.preview_html ? `<div class="studio-canvas-inner">${data.preview_html}</div>` : "");
330
+ $("#slide-canvas-content").innerHTML =
331
+ canvasHtml || '<div class="studio-canvas-empty"><p>Preview unavailable.</p></div>';
332
+
333
+ const galleryEl = $("#slide-gallery");
334
+ if (data.gallery_html) {
335
+ galleryEl.innerHTML = data.gallery_html;
336
+ galleryEl.classList.remove("hidden");
337
+ } else if (data.gallery?.length) {
338
+ galleryEl.innerHTML = data.gallery
339
+ .map(
340
+ (path, i) =>
341
+ `<a class="studio-gallery-item" href="${fileUrl(path)}" target="_blank" rel="noopener"><img src="${fileUrl(path)}" alt="Slide ${i + 1}" loading="lazy" /></a>`
342
+ )
343
+ .join("");
344
+ galleryEl.classList.remove("hidden");
345
+ } else {
346
+ galleryEl.classList.add("hidden");
347
+ galleryEl.innerHTML = "";
348
+ }
349
+
350
+ state.downloads = data.downloads;
351
+ state.presenterSlides = buildPresenterSlidesFromData(data);
352
+ setPresenterEnabled(state.presenterSlides.length > 0);
353
+
354
+ const dl = $("#downloads");
355
+ if (data.downloads?.pptx) {
356
+ dl.classList.remove("hidden");
357
+ dl.innerHTML = `
358
+ <a href="${fileUrl(data.downloads.pptx)}" download>PPTX</a>
359
+ <a href="${fileUrl(data.downloads.docx)}" download>DOCX</a>
360
+ <a href="${fileUrl(data.downloads.html)}" download>HTML</a>`;
361
+ $("#btn-export").disabled = false;
362
+ const exportBtn = $("#btn-export");
363
+ if (exportBtn) exportBtn.textContent = "Download PPTX";
364
+ syncLayoutOffsets();
365
+ }
366
+
367
+ const outlineDetails = $("#slide-outline-details");
368
+ const outlineEl = $("#slide-outline");
369
+ if (data.outline_md) {
370
+ outlineEl.innerHTML = renderMarkdownLite(data.outline_md);
371
+ outlineDetails?.classList.remove("hidden");
372
+ } else {
373
+ outlineEl.innerHTML = "";
374
+ outlineDetails?.classList.add("hidden");
375
+ }
376
+
377
+ setTracePanel("#slides-trace-panel", data);
378
+
379
+ if (scrollToCanvas) {
380
+ $("#slide-canvas")?.scrollIntoView({ behavior: "smooth", block: "nearest" });
381
+ }
382
+ if (pulsePresent && state.presenterSlides.length) {
383
+ pulsePresentButton();
384
+ }
385
+ }
386
+
387
+ async function collectSlideGenerationParams() {
388
+ const topic = effectiveTopic($("#lesson-topic").value);
389
+ const grade = $("#lesson-grade").value;
390
+ const slideCount = Number($("#slide-count").value);
391
+ const useRag = Boolean($("#lessons-use-rag")?.checked);
392
+ const docIds = effectiveDocIds([]);
393
+ const sourceMode = $("#slide-source-mode")?.value || "";
394
+ const searchWorkflow = $("#slide-search-workflow")?.value || "two_step";
395
+ const urlsText = $("#slide-urls-text")?.value.trim() || "";
396
+ const selectedUrls = getSelectedDiscoveredUrls("#slide-url-choices-list");
397
+ const filePaths = [];
398
+ const slideFiles = $("#slide-source-files")?.files;
399
+ if (slideFiles?.length) {
400
+ for (const file of slideFiles) {
401
+ filePaths.push(await uploadFile(file));
402
+ }
403
+ }
404
+ return {
405
+ topic,
406
+ grade,
407
+ slideCount,
408
+ sessionId: state.workspaceSessionId,
409
+ useRag,
410
+ docIds,
411
+ sourceMode,
412
+ searchWorkflow,
413
+ urlsText,
414
+ selectedUrls,
415
+ filePaths,
416
+ };
417
+ }
418
+
419
+ async function runSlideGenerationApi(apiName, apiArgs) {
420
+ startProgressPanel();
421
+ const waitTimer = advanceProgressWhileWaiting();
422
+ try {
423
+ return await callApi(apiName, apiArgs);
424
+ } finally {
425
+ clearInterval(waitTimer);
426
+ if (state.progressTimer) {
427
+ clearInterval(state.progressTimer);
428
+ state.progressTimer = null;
429
+ }
430
+ }
431
+ }
432
+
433
  function getSelectedDiscoveredUrls(listId = "#url-choices-list") {
434
  const boxes = document.querySelectorAll(`${listId} input[type=checkbox]:checked`);
435
  return [...boxes].map((el) => el.value);
 
643
  if (!state.history.length) {
644
  container.innerHTML =
645
  '<p class="research-chat-empty">Choose a language, then type, speak, or upload audio to start your lesson.</p>';
646
+ syncChatToSlidesButtons();
647
  return;
648
  }
649
  const parts = [];
 
672
  }
673
  container.innerHTML = parts.join("");
674
  container.scrollTop = container.scrollHeight;
675
+ syncChatToSlidesButtons();
676
  }
677
 
678
  function renderLessonsUrlChoices(urls, selected) {
 
840
  if (!state.researchChatHistory.length) {
841
  container.innerHTML =
842
  '<p class="research-chat-empty">Ingest sources, then ask questions — answers include citations from your library.</p>';
843
+ syncChatToSlidesButtons();
844
  return;
845
  }
846
  container.innerHTML = state.researchChatHistory
 
851
  })
852
  .join("");
853
  container.scrollTop = container.scrollHeight;
854
+ syncChatToSlidesButtons();
855
  }
856
 
857
  function renderDebugChat() {
 
859
  if (!state.debugChatHistory.length) {
860
  container.innerHTML =
861
  '<p class="research-chat-empty">Ask the local model — turn on RAG to ground answers in your library.</p>';
862
+ syncChatToSlidesButtons();
863
  return;
864
  }
865
  container.innerHTML = state.debugChatHistory
 
868
  })
869
  .join("");
870
  container.scrollTop = container.scrollHeight;
871
+ syncChatToSlidesButtons();
872
  }
873
 
874
  function updateResearchRagBadge() {
 
1460
  syncLessonsModeUi();
1461
  renderLessonsChat();
1462
  await refreshDebugDocuments();
1463
+ syncChatToSlidesButtons();
1464
  const recStatus = await callApi("recording_status", []);
1465
  state.useBrowserMic = !recStatus.backend || /unavailable|no capture/i.test(recStatus.message || "");
1466
  syncLayoutOffsets();
 
1476
  }
1477
 
1478
  async function generateSlides() {
1479
+ const params = await collectSlideGenerationParams();
 
 
 
 
 
 
 
 
1480
 
1481
  await withRegionLoading(
1482
  $("#slide-canvas"),
1483
  "Generating slides…",
1484
  async () => {
 
 
 
 
 
 
 
 
 
 
1485
  let data;
1486
  try {
1487
+ data = await runSlideGenerationApi("generate_slides", [
1488
+ params.topic,
1489
+ params.grade,
1490
+ params.slideCount,
1491
+ params.sessionId,
1492
+ params.useRag,
1493
+ params.docIds,
1494
+ params.sourceMode,
1495
+ params.searchWorkflow,
1496
+ params.urlsText,
1497
+ params.selectedUrls,
1498
+ params.filePaths,
1499
  ]);
1500
  } catch (_err) {
1501
  $("#progress-eta").textContent = "Failed";
1502
  throw _err;
 
 
 
 
 
 
1503
  }
1504
 
1505
+ state.fromConversation = false;
1506
+ renderSlideGenerationResult(data);
1507
+ },
1508
+ {
1509
+ overlayEl: $("#canvas-overlay"),
1510
+ hint: "First run may take several minutes on CPU; use GPU Space or fewer slides for a quick demo.",
1511
+ }
1512
+ );
1513
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1514
 
1515
+ async function generateSlidesFromConversation(kind) {
1516
+ const { history, historyKind } = pickHistory(kind);
1517
+ if (!history?.length) {
1518
+ showError("Start a conversation first.");
1519
+ return;
1520
+ }
 
 
 
 
 
 
 
1521
 
1522
+ const params = await collectSlideGenerationParams();
1523
+ setWorkspaceView("slides");
1524
+
1525
+ await withRegionLoading(
1526
+ $("#slide-canvas"),
1527
+ "Generating slides from chat…",
1528
+ async () => {
1529
+ let data;
1530
+ try {
1531
+ data = await runSlideGenerationApi("generate_slides_from_conversation", [
1532
+ history,
1533
+ historyKind,
1534
+ params.topic,
1535
+ params.grade,
1536
+ params.slideCount,
1537
+ params.sessionId,
1538
+ params.useRag,
1539
+ params.docIds,
1540
+ params.sourceMode,
1541
+ params.searchWorkflow,
1542
+ params.urlsText,
1543
+ params.selectedUrls,
1544
+ params.filePaths,
1545
+ ]);
1546
+ } catch (_err) {
1547
+ $("#progress-eta").textContent = "Failed";
1548
+ throw _err;
1549
  }
1550
+
1551
+ state.fromConversation = true;
1552
+ renderSlideGenerationResult(data, { scrollToCanvas: true, pulsePresent: true });
1553
  },
1554
  {
1555
  overlayEl: $("#canvas-overlay"),
 
1844
  });
1845
 
1846
  $("#btn-generate").addEventListener("click", () => generateSlides().catch(() => {}));
1847
+ $("#btn-present")?.addEventListener("click", () => openPresenter());
1848
+ $("#btn-research-to-slides")?.addEventListener("click", () =>
1849
+ generateSlidesFromConversation("research").catch(() => {})
1850
+ );
1851
+ $("#btn-lessons-to-slides")?.addEventListener("click", () =>
1852
+ generateSlidesFromConversation("voice").catch(() => {})
1853
+ );
1854
+ $("#btn-chat-to-slides")?.addEventListener("click", () =>
1855
+ generateSlidesFromConversation("debug").catch(() => {})
1856
+ );
1857
+ $("#btn-presenter-close")?.addEventListener("click", closePresenter);
1858
+ $("#btn-presenter-backdrop")?.addEventListener("click", closePresenter);
1859
+ $("#btn-presenter-prev")?.addEventListener("click", presenterPrev);
1860
+ $("#btn-presenter-next")?.addEventListener("click", presenterNext);
1861
+ document.addEventListener("keydown", (e) => {
1862
+ const overlay = $("#presenter-overlay");
1863
+ if (!overlay || overlay.classList.contains("hidden")) return;
1864
+ if (e.key === "Escape") {
1865
+ e.preventDefault();
1866
+ closePresenter();
1867
+ } else if (e.key === "ArrowRight") {
1868
+ e.preventDefault();
1869
+ presenterNext();
1870
+ } else if (e.key === "ArrowLeft") {
1871
+ e.preventDefault();
1872
+ presenterPrev();
1873
+ }
1874
+ });
1875
 
1876
  $("#btn-lessons-send")?.addEventListener("click", () => sendLessonsTurn().catch(() => {}));
1877
  $("#lessons-message")?.addEventListener("keydown", (e) => {
apps/gradio-space/tests/test_conversation_helpers.py ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from gradio_space.conversation_helpers import format_conversation_context
2
+
3
+
4
+ def test_format_research_history():
5
+ history = [
6
+ {"role": "user", "content": "What is photosynthesis?"},
7
+ {"role": "assistant", "content": "Plants convert light into energy."},
8
+ ]
9
+ text, topic = format_conversation_context(history, "research")
10
+ assert "User: What is photosynthesis?" in text
11
+ assert "Assistant: Plants convert light" in text
12
+ assert topic == "What is photosynthesis?"
13
+
14
+
15
+ def test_format_gradio_history():
16
+ history = [["Hello", "Hi there"], ["Explain mitosis", "Cells divide."]]
17
+ text, topic = format_conversation_context(history, "gradio")
18
+ assert "User: Hello" in text
19
+ assert "Assistant: Hi there" in text
20
+ assert "User: Explain mitosis" in text
21
+ assert topic == "Hello"
22
+
23
+
24
+ def test_format_voice_role_objects():
25
+ history = [
26
+ {"role": "user", "content": "Teach fractions"},
27
+ {"role": "assistant", "content": "Fractions are parts of a whole."},
28
+ ]
29
+ text, topic = format_conversation_context(history, "voice")
30
+ assert "User: Teach fractions" in text
31
+ assert topic == "Teach fractions"
32
+
33
+
34
+ def test_format_debug_pairs():
35
+ history = [["Debug question", "Debug answer"]]
36
+ text, topic = format_conversation_context(history, "debug")
37
+ assert "User: Debug question" in text
38
+ assert topic == "Debug question"
39
+
40
+
41
+ def test_truncates_long_history():
42
+ long_user = "x" * 5000
43
+ history = [
44
+ {"role": "user", "content": long_user},
45
+ {"role": "assistant", "content": "old reply"},
46
+ {"role": "user", "content": "recent question"},
47
+ {"role": "assistant", "content": "recent answer"},
48
+ ]
49
+ text, _topic = format_conversation_context(history, "research")
50
+ assert len(text) <= 8200
51
+ assert "recent question" in text
52
+ assert "truncated" in text.lower() or "recent answer" in text
apps/gradio-space/tests/test_model_loading.py CHANGED
@@ -20,7 +20,7 @@ models:
20
  label: Beta GGUF
21
  backend: llama_cpp
22
  model_repo: openbmb/MiniCPM-V-4.6-gguf
23
- model_file: MiniCPM-V-4.6-Q4_K_M.gguf
24
  multimodal: true
25
  """
26
  )
 
20
  label: Beta GGUF
21
  backend: llama_cpp
22
  model_repo: openbmb/MiniCPM-V-4.6-gguf
23
+ model_file: MiniCPM-V-4_6-Q4_K_M.gguf
24
  multimodal: true
25
  """
26
  )
libs/agent/src/agent/models.py CHANGED
@@ -27,6 +27,7 @@ class EducationPptxInput(BaseModel):
27
  files: list[Path] = Field(default_factory=list)
28
  session_id: str | None = None
29
  doc_ids: list[str] = Field(default_factory=list)
 
30
 
31
 
32
  class Citation(BaseModel):
 
27
  files: list[Path] = Field(default_factory=list)
28
  session_id: str | None = None
29
  doc_ids: list[str] = Field(default_factory=list)
30
+ conversation_context: str = ""
31
 
32
 
33
  class Citation(BaseModel):
libs/agent/src/agent/prompts.py CHANGED
@@ -53,6 +53,12 @@ def education_outline_user(req: EducationPptxInput, *, source_context: str = "")
53
  "Do not invent citations in the JSON output.\n\n"
54
  f"{source_context}\n"
55
  )
 
 
 
 
 
 
56
  return base + "\nReturn JSON only."
57
 
58
 
 
53
  "Do not invent citations in the JSON output.\n\n"
54
  f"{source_context}\n"
55
  )
56
+ if req.conversation_context.strip():
57
+ base += (
58
+ "\nBase the slide outline on this conversation transcript. "
59
+ "Prefer topics and facts discussed over general knowledge.\n\n"
60
+ f"{req.conversation_context.strip()}\n"
61
+ )
62
  return base + "\nReturn JSON only."
63
 
64
 
libs/agent/src/agent/runner.py CHANGED
@@ -83,6 +83,7 @@ class AgentRunner:
83
  files: list[Path] | None = None,
84
  session_id: str | None = None,
85
  doc_ids: list[str] | None = None,
 
86
  progress: SlideGenerationProgress | None = None,
87
  skip_preview_images: bool = False,
88
  ) -> AgentResult:
@@ -99,6 +100,7 @@ class AgentRunner:
99
  files=files,
100
  session_id=session_id,
101
  doc_ids=doc_ids,
 
102
  progress=progress,
103
  skip_preview_images=skip_preview_images,
104
  ):
@@ -122,6 +124,7 @@ class AgentRunner:
122
  files: list[Path] | None = None,
123
  session_id: str | None = None,
124
  doc_ids: list[str] | None = None,
 
125
  progress: SlideGenerationProgress | None = None,
126
  skip_preview_images: bool = False,
127
  ) -> Iterator[SlideGenerationProgress | AgentResult]:
@@ -136,6 +139,7 @@ class AgentRunner:
136
  files=files or [],
137
  session_id=session_id or None,
138
  doc_ids=doc_ids or [],
 
139
  )
140
 
141
  trace = TraceRecorder(
@@ -173,6 +177,11 @@ class AgentRunner:
173
  progress: SlideGenerationProgress | None,
174
  skip_preview_images: bool,
175
  ) -> Iterator[SlideGenerationProgress | AgentResult]:
 
 
 
 
 
176
  if progress is not None:
177
  progress.begin("load_model", "Load language model")
178
  yield progress
 
83
  files: list[Path] | None = None,
84
  session_id: str | None = None,
85
  doc_ids: list[str] | None = None,
86
+ conversation_context: str = "",
87
  progress: SlideGenerationProgress | None = None,
88
  skip_preview_images: bool = False,
89
  ) -> AgentResult:
 
100
  files=files,
101
  session_id=session_id,
102
  doc_ids=doc_ids,
103
+ conversation_context=conversation_context,
104
  progress=progress,
105
  skip_preview_images=skip_preview_images,
106
  ):
 
124
  files: list[Path] | None = None,
125
  session_id: str | None = None,
126
  doc_ids: list[str] | None = None,
127
+ conversation_context: str = "",
128
  progress: SlideGenerationProgress | None = None,
129
  skip_preview_images: bool = False,
130
  ) -> Iterator[SlideGenerationProgress | AgentResult]:
 
139
  files=files or [],
140
  session_id=session_id or None,
141
  doc_ids=doc_ids or [],
142
+ conversation_context=(conversation_context or "").strip(),
143
  )
144
 
145
  trace = TraceRecorder(
 
177
  progress: SlideGenerationProgress | None,
178
  skip_preview_images: bool,
179
  ) -> Iterator[SlideGenerationProgress | AgentResult]:
180
+ if req.conversation_context.strip():
181
+ trace.log_note(
182
+ "Conversation grounding",
183
+ chars=len(req.conversation_context.strip()),
184
+ )
185
  if progress is not None:
186
  progress.begin("load_model", "Load language model")
187
  yield progress
libs/agent/tests/test_education_sources.py CHANGED
@@ -85,6 +85,18 @@ def test_education_outline_user_includes_source_context():
85
  assert "chlorophyll" in user
86
 
87
 
 
 
 
 
 
 
 
 
 
 
 
 
88
  def test_none_mode_skips_source_summary(research_env):
89
  runner = AgentRunner()
90
  result = runner.run_education_pptx(
 
85
  assert "chlorophyll" in user
86
 
87
 
88
+ def test_education_outline_user_includes_conversation_context():
89
+ req = EducationPptxInput(
90
+ topic="Photosynthesis",
91
+ grade="6",
92
+ slide_count=3,
93
+ conversation_context="User: What is photosynthesis?\n\nAssistant: Plants use sunlight.",
94
+ )
95
+ user = education_outline_user(req)
96
+ assert "conversation transcript" in user
97
+ assert "What is photosynthesis?" in user
98
+
99
+
100
  def test_none_mode_skips_source_summary(research_env):
101
  runner = AgentRunner()
102
  result = runner.run_education_pptx(
libs/inference/tests/test_config.py CHANGED
@@ -114,7 +114,7 @@ def test_minicpm_v_gguf_preset_from_repo(monkeypatch):
114
  assert model.backend == "llama_cpp"
115
  assert model.multimodal is True
116
  assert model.model_repo == "openbmb/MiniCPM-V-4.6-gguf"
117
- assert model.model_file == "MiniCPM-V-4.6-Q4_K_M.gguf"
118
 
119
 
120
  def test_resolve_relative_model_path(tmp_path, monkeypatch):
 
114
  assert model.backend == "llama_cpp"
115
  assert model.multimodal is True
116
  assert model.model_repo == "openbmb/MiniCPM-V-4.6-gguf"
117
+ assert model.model_file == "MiniCPM-V-4_6-Q4_K_M.gguf"
118
 
119
 
120
  def test_resolve_relative_model_path(tmp_path, monkeypatch):
models.yaml CHANGED
@@ -20,7 +20,7 @@ models:
20
  label: MiniCPM-V 4.6 (GGUF / llama.cpp)
21
  backend: llama_cpp
22
  model_repo: openbmb/MiniCPM-V-4.6-gguf
23
- model_file: MiniCPM-V-4.6-Q4_K_M.gguf
24
  multimodal: true
25
  n_ctx: 8192
26
  n_gpu_layers: 0
 
20
  label: MiniCPM-V 4.6 (GGUF / llama.cpp)
21
  backend: llama_cpp
22
  model_repo: openbmb/MiniCPM-V-4.6-gguf
23
+ model_file: MiniCPM-V-4_6-Q4_K_M.gguf
24
  multimodal: true
25
  n_ctx: 8192
26
  n_gpu_layers: 0
research/modal/_common.py CHANGED
@@ -324,8 +324,12 @@ def evaluate_gate(
324
  cand_score = _score(cand_tasks, task)
325
  base_score = _score(base_tasks, task)
326
 
 
 
 
 
327
  if goals.get("min_score") is not None:
328
- ok = cand_score is not None and cand_score >= goals["min_score"]
329
  checks.append({"check": f"{task} >= {goals['min_score']}", "value": cand_score, "ok": ok})
330
  passed = passed and ok
331
 
@@ -335,7 +339,7 @@ def evaluate_gate(
335
  if (cand_score is not None and base_score is not None)
336
  else None
337
  )
338
- ok = delta is not None and delta >= goals["min_improve"]
339
  checks.append(
340
  {"check": f"{task} improve >= {goals['min_improve']}", "value": delta, "ok": ok}
341
  )
@@ -346,7 +350,7 @@ def evaluate_gate(
346
  g_cand = _score(cand_tasks, g_task)
347
  g_base = _score(base_tasks, g_task)
348
  regress = g_base - g_cand if (g_cand is not None and g_base is not None) else None
349
- ok = regress is not None and regress <= guard["max_regress"]
350
  checks.append(
351
  {"check": f"{g_task} regress <= {guard['max_regress']}", "value": regress, "ok": ok}
352
  )
 
324
  cand_score = _score(cand_tasks, task)
325
  base_score = _score(base_tasks, task)
326
 
327
+ # Tolerance so a score landing exactly on a threshold (e.g. a clean +0.02
328
+ # improvement stored as 0.0199999996) is not rejected by float epsilon.
329
+ eps = 1e-9
330
+
331
  if goals.get("min_score") is not None:
332
+ ok = cand_score is not None and cand_score >= goals["min_score"] - eps
333
  checks.append({"check": f"{task} >= {goals['min_score']}", "value": cand_score, "ok": ok})
334
  passed = passed and ok
335
 
 
339
  if (cand_score is not None and base_score is not None)
340
  else None
341
  )
342
+ ok = delta is not None and delta >= goals["min_improve"] - eps
343
  checks.append(
344
  {"check": f"{task} improve >= {goals['min_improve']}", "value": delta, "ok": ok}
345
  )
 
350
  g_cand = _score(cand_tasks, g_task)
351
  g_base = _score(base_tasks, g_task)
352
  regress = g_base - g_cand if (g_cand is not None and g_base is not None) else None
353
+ ok = regress is not None and regress <= guard["max_regress"] + eps
354
  checks.append(
355
  {"check": f"{g_task} regress <= {guard['max_regress']}", "value": regress, "ok": ok}
356
  )