Spaces:
Sleeping
Sleeping
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 +207 -0
- .cursor/plans/modal_lora_training_loop_1a8a7b4b.plan.md +285 -0
- .cursor/plans/quiz_maker_skill_52f29d14.plan.md +222 -0
- .cursor/plans/slides_from_chat_presenter_4cff567a.plan.md +197 -0
- apps/gradio-space/README.md +11 -2
- apps/gradio-space/src/gradio_space/api/studio.py +190 -63
- apps/gradio-space/src/gradio_space/conversation_helpers.py +108 -0
- apps/gradio-space/src/gradio_space/server.py +1 -1
- apps/gradio-space/src/gradio_space/tabs/education_pptx.py +7 -1
- apps/gradio-space/static/studio/index.html +37 -0
- apps/gradio-space/static/studio/studio.css +109 -1
- apps/gradio-space/static/studio/studio.js +333 -83
- apps/gradio-space/tests/test_conversation_helpers.py +52 -0
- apps/gradio-space/tests/test_model_loading.py +1 -1
- libs/agent/src/agent/models.py +1 -0
- libs/agent/src/agent/prompts.py +6 -0
- libs/agent/src/agent/runner.py +9 -0
- libs/agent/tests/test_education_sources.py +12 -0
- libs/inference/tests/test_config.py +1 -1
- models.yaml +1 -1
- research/modal/_common.py +7 -3
.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) —
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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: **
|
| 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
|
|
|
|
|
|
|
| 512 |
topic: str,
|
| 513 |
-
|
| 514 |
-
|
| 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 = "
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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
|
| 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
|
| 1260 |
-
topic,
|
| 1261 |
-
grade,
|
| 1262 |
-
slideCount,
|
| 1263 |
-
|
| 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 |
-
|
| 1284 |
-
|
| 1285 |
-
|
| 1286 |
-
|
| 1287 |
-
|
| 1288 |
-
|
| 1289 |
-
|
| 1290 |
-
|
| 1291 |
-
|
| 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 |
-
|
| 1309 |
-
|
| 1310 |
-
|
| 1311 |
-
|
| 1312 |
-
|
| 1313 |
-
|
| 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 |
-
|
| 1323 |
-
|
| 1324 |
-
|
| 1325 |
-
|
| 1326 |
-
|
| 1327 |
-
|
| 1328 |
-
|
| 1329 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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-
|
| 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-
|
| 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-
|
| 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 |
)
|