Spaces:
Sleeping
Sleeping
MSG commited on
Commit ·
7a28b9f
1
Parent(s): 29e2c18
Feat/enhance UI and monorepo (#9)
Browse files* init plan redesign
* select model and setting
* echo coach wip
* ui education slide pptx
* research mind UI and UX
* echo coach wip
* ui teacher ux
* fix chat attributes
* wip teacher
* research web helpers
* selectors docs css
* css
* helpers wip
* wip research
* teacher voice
* teacher voice wip
* rag teacher scope helpers
* output response thinking and system prompts
* teacher voice and answer wip
- .cursor/plans/gradio_ui_ux_redesign_384ea89c.plan.md +334 -0
- apps/gradio-space/src/gradio_space/app.py +26 -19
- apps/gradio-space/src/gradio_space/model_loading.py +15 -0
- apps/gradio-space/src/gradio_space/research_helpers.py +53 -9
- apps/gradio-space/src/gradio_space/tabs/chat.py +60 -31
- apps/gradio-space/src/gradio_space/tabs/echo_coach.py +137 -152
- apps/gradio-space/src/gradio_space/tabs/education_pptx.py +87 -69
- apps/gradio-space/src/gradio_space/tabs/research_mind.py +274 -157
- apps/gradio-space/src/gradio_space/tabs/teacher_voice.py +474 -182
- apps/gradio-space/src/gradio_space/ui/__init__.py +18 -0
- apps/gradio-space/src/gradio_space/ui/components.py +349 -0
- apps/gradio-space/src/gradio_space/ui/settings_panel.py +75 -0
- apps/gradio-space/src/gradio_space/ui/styles.css +511 -0
- apps/gradio-space/src/gradio_space/ui/theme.py +38 -0
- libs/agent/src/agent/runner.py +3 -5
- libs/agent/src/agent/tools/research_tools.py +3 -8
- libs/echocoach/src/echocoach/prompts.py +4 -2
- libs/echocoach/src/echocoach/teacher_voice.py +327 -73
- libs/echocoach/src/echocoach/voiceout.py +2 -0
- libs/echocoach/tests/test_teacher_voice.py +114 -1
- libs/inference/src/inference/response_clean.py +156 -13
- libs/inference/tests/test_response_clean.py +73 -1
- libs/researchmind/src/researchmind/scope.py +47 -0
- libs/researchmind/tests/test_scope.py +37 -0
.cursor/plans/gradio_ui_ux_redesign_384ea89c.plan.md
ADDED
|
@@ -0,0 +1,334 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
name: Gradio UI UX Redesign
|
| 3 |
+
overview: Redesign the Build Small Hackathon Gradio app with a compact global shell (settings drawer for model/config), a shared visual system, and per-tab step-based flows that highlight the primary user path while tucking dev details into Advanced sections.
|
| 4 |
+
todos:
|
| 5 |
+
- id: shell-theme
|
| 6 |
+
content: Add theme.py, styles.css, settings_panel.py; refactor app.py header into compact brand + Settings accordion
|
| 7 |
+
status: completed
|
| 8 |
+
- id: shared-components
|
| 9 |
+
content: "Create ui/components.py: step indicator HTML, unified recording block, session picker, Advanced accordion helper, gr.Progress wrappers"
|
| 10 |
+
status: completed
|
| 11 |
+
- id: voice-tabs
|
| 12 |
+
content: Refactor EchoCoach + TeacherVoice to use shared recording, step strips, Advanced panels; promote TeacherVoice RAG checkbox
|
| 13 |
+
status: completed
|
| 14 |
+
- id: lesson-slides
|
| 15 |
+
content: "Redesign education_pptx.py as wizard: source mode radio, optional sources accordion, move trace to Advanced"
|
| 16 |
+
status: completed
|
| 17 |
+
- id: researchmind
|
| 18 |
+
content: Two-column ResearchMind layout; split Discover vs Auto-ingest buttons; compact memory; optional citation display in chat
|
| 19 |
+
status: completed
|
| 20 |
+
- id: chat-polish
|
| 21 |
+
content: Group Chat (debug) RAG controls; dev tab styling; optional RAG trace surfacing
|
| 22 |
+
status: completed
|
| 23 |
+
isProject: false
|
| 24 |
+
---
|
| 25 |
+
|
| 26 |
+
# Gradio App UI/UX Redesign
|
| 27 |
+
|
| 28 |
+
## Current problems (from screenshots + code audit)
|
| 29 |
+
|
| 30 |
+
| Issue | Where | Root cause |
|
| 31 |
+
|-------|-------|------------|
|
| 32 |
+
| Wall of config at top | [`app.py`](apps/gradio-space/src/gradio_space/app.py) L31-42 | Global `gr.Markdown` dumps model key, backend, presets path |
|
| 33 |
+
| Repeated model status | Every tab calls `model_status()` | No shared settings; same info 5× |
|
| 34 |
+
| No visual hierarchy | All tabs | Default Gradio 5 theme, no `css=` / `theme=` |
|
| 35 |
+
| Unclear user path | Lesson slides, ResearchMind, voice tabs | Many controls visible at once; instructions as markdown paragraphs |
|
| 36 |
+
| Dev noise in main flow | Trace JSON, file paths, ASR presets | Exposed inline instead of Advanced |
|
| 37 |
+
| Fragmented voice UX | EchoCoach + TeacherVoice | Dual recording paths, duplicate copy, inconsistent max seconds (30 vs 15) |
|
| 38 |
+
|
| 39 |
+
**Recommendation:** Stay on **Gradio Blocks** as the server and layout engine. Use **Gradio theme + global CSS** for 80% of polish, **`gr.HTML`** for step indicators and mode cards, and **collapsed Accordions** for Advanced/Debug — no separate HTML app unless a specific widget proves impossible in Gradio (unlikely for this scope).
|
| 40 |
+
|
| 41 |
+
---
|
| 42 |
+
|
| 43 |
+
## Target information architecture
|
| 44 |
+
|
| 45 |
+
```mermaid
|
| 46 |
+
flowchart TB
|
| 47 |
+
subgraph shell [App shell]
|
| 48 |
+
Header["Compact header: Build Small · tagline"]
|
| 49 |
+
Settings["Settings button → drawer"]
|
| 50 |
+
Tabs["5 tabs unchanged in name order"]
|
| 51 |
+
end
|
| 52 |
+
|
| 53 |
+
subgraph settings [Settings drawer contents]
|
| 54 |
+
ModelSelect["Model preset dropdown"]
|
| 55 |
+
ModelStatus["Load status + GPU"]
|
| 56 |
+
VoiceConfig["Voice stack summary"]
|
| 57 |
+
Paths["Presets / data paths"]
|
| 58 |
+
Warmup["Reload model button"]
|
| 59 |
+
end
|
| 60 |
+
|
| 61 |
+
Header --> Tabs
|
| 62 |
+
Settings --> ModelSelect
|
| 63 |
+
Settings --> ModelStatus
|
| 64 |
+
```
|
| 65 |
+
|
| 66 |
+
**Tab bar (keep order, improve labels/icons via CSS):**
|
| 67 |
+
|
| 68 |
+
1. **Lesson slides** — Create teaching decks
|
| 69 |
+
2. **ResearchMind** — Build a source library + ask questions
|
| 70 |
+
3. **EchoCoach** — Analyze a recorded pitch
|
| 71 |
+
4. **TeacherVoice** — Talk to a local teacher
|
| 72 |
+
5. **Chat (debug)** — Plain model + optional RAG test
|
| 73 |
+
|
| 74 |
+
For hackathon jury: polish the first four tabs heavily; style Chat (debug) with a subtle “dev” badge but keep it in the bar.
|
| 75 |
+
|
| 76 |
+
---
|
| 77 |
+
|
| 78 |
+
## Phase 1 — Global shell + design system
|
| 79 |
+
|
| 80 |
+
### New files
|
| 81 |
+
|
| 82 |
+
- [`apps/gradio-space/src/gradio_space/ui/theme.py`](apps/gradio-space/src/gradio_space/ui/theme.py) — `get_theme()` (Gradio `Soft` or custom primary color aligned with hackathon orange)
|
| 83 |
+
- [`apps/gradio-space/src/gradio_space/ui/styles.css`](apps/gradio-space/src/gradio_space/ui/styles.css) — app-wide rules: compact header, step pills, `.advanced-panel`, tab subtitle styling
|
| 84 |
+
- [`apps/gradio-space/src/gradio_space/ui/settings_panel.py`](apps/gradio-space/src/gradio_space/ui/settings_panel.py) — reusable settings accordion
|
| 85 |
+
- [`apps/gradio-space/src/gradio_space/ui/components.py`](apps/gradio-space/src/gradio_space/ui/components.py) — step indicator HTML, recording widget, session picker
|
| 86 |
+
|
| 87 |
+
### Refactor [`app.py`](apps/gradio-space/src/gradio_space/app.py)
|
| 88 |
+
|
| 89 |
+
Replace the large markdown header with:
|
| 90 |
+
|
| 91 |
+
```python
|
| 92 |
+
with gr.Blocks(title="Build Small", theme=get_theme(), css=load_css()) as demo:
|
| 93 |
+
with gr.Row(elem_classes=["app-header"]):
|
| 94 |
+
gr.HTML('<div class="brand">...</div>') # title + one-line tagline
|
| 95 |
+
settings_btn = gr.Button("Settings", size="sm", variant="secondary")
|
| 96 |
+
with gr.Accordion("Settings", open=False, visible=True) as settings_acc:
|
| 97 |
+
build_settings_panel() # model dropdown, status, paths, voice summary
|
| 98 |
+
with gr.Tabs():
|
| 99 |
+
...
|
| 100 |
+
```
|
| 101 |
+
|
| 102 |
+
**Settings panel contents:**
|
| 103 |
+
- **Model preset** — always show dropdown when `allow_model_switch`, else read-only badge with active preset
|
| 104 |
+
- **Status** — single `model_status()` + device hint (moved from per-tab)
|
| 105 |
+
- **Voice stack** — read-only summary from `get_echo_coach_config()` (ASR/TTS presets path, not raw env vars)
|
| 106 |
+
- **Paths** — presets file, ResearchMind data dir (collapsed sub-section)
|
| 107 |
+
- **Actions** — “Reload model” (calls existing `ensure_model_loaded` / `reset_backend`)
|
| 108 |
+
|
| 109 |
+
Wire `settings_btn.click` → toggle accordion open state.
|
| 110 |
+
|
| 111 |
+
Remove per-tab `gr.Markdown(model_status(...))` calls once centralized.
|
| 112 |
+
|
| 113 |
+
---
|
| 114 |
+
|
| 115 |
+
## Phase 2 — Shared UX patterns
|
| 116 |
+
|
| 117 |
+
### A. Step indicator (`gr.HTML`)
|
| 118 |
+
|
| 119 |
+
Reusable 3–4 step strip rendered as HTML/CSS (not Gradio-native, but lightweight):
|
| 120 |
+
|
| 121 |
+
```
|
| 122 |
+
[1 Topic] → [2 Sources] → [3 Generate] → [4 Preview]
|
| 123 |
+
```
|
| 124 |
+
|
| 125 |
+
Active step highlighted; future steps muted. Update via small Python helper returning HTML string on state changes.
|
| 126 |
+
|
| 127 |
+
### B. Unified recording block (`components.py`)
|
| 128 |
+
|
| 129 |
+
Extract duplicated logic from [`echo_coach.py`](apps/gradio-space/src/gradio_space/tabs/echo_coach.py) and [`teacher_voice.py`](apps/gradio-space/src/gradio_space/tabs/teacher_voice.py):
|
| 130 |
+
|
| 131 |
+
- **Primary path:** browser mic on `gr.Audio` (label: “Record or upload”)
|
| 132 |
+
- **Secondary:** accordion “Server microphone (Linux)” with Start/Stop — collapsed by default unless `recording_backend_status()` reports server mic as only option
|
| 133 |
+
- **One status line** instead of two markdown blocks
|
| 134 |
+
- **Advanced accordion:** language, ASR preset, max seconds
|
| 135 |
+
|
| 136 |
+
Align max turn length: use `_config.max_seconds` everywhere; TeacherVoice caps via backend, not a separate 15s UI default unless intentional (document in Advanced).
|
| 137 |
+
|
| 138 |
+
### C. Session + doc scope (`components.py`)
|
| 139 |
+
|
| 140 |
+
Shared widget used by ResearchMind, Lesson slides (RAG mode), TeacherVoice (RAG), Chat:
|
| 141 |
+
|
| 142 |
+
- Session dropdown + compact refresh icon button (not full-width button)
|
| 143 |
+
- Doc checkboxes inside accordion “Limit to documents”
|
| 144 |
+
|
| 145 |
+
### D. Advanced / Debug panel (every feature tab)
|
| 146 |
+
|
| 147 |
+
Standard accordion at bottom:
|
| 148 |
+
|
| 149 |
+
```
|
| 150 |
+
▸ Advanced & debug
|
| 151 |
+
- Agent trace (JSON)
|
| 152 |
+
- Trace summary
|
| 153 |
+
- Export paths
|
| 154 |
+
```
|
| 155 |
+
|
| 156 |
+
Hidden by default; satisfies jury “show capability” without cluttering main flow.
|
| 157 |
+
|
| 158 |
+
### E. Loading feedback
|
| 159 |
+
|
| 160 |
+
Add `gr.Progress()` to long handlers:
|
| 161 |
+
- `generate_lesson_slides`
|
| 162 |
+
- `discover_sources` / `ingest_selected`
|
| 163 |
+
- `analyze_pitch` / `send_turn`
|
| 164 |
+
- `ask_question`
|
| 165 |
+
|
| 166 |
+
Show staged labels: “Loading model…”, “Searching…”, “Generating slides…”, etc.
|
| 167 |
+
|
| 168 |
+
---
|
| 169 |
+
|
| 170 |
+
## Phase 3 — Per-tab redesigns
|
| 171 |
+
|
| 172 |
+
### Lesson slides ([`education_pptx.py`](apps/gradio-space/src/gradio_space/tabs/education_pptx.py))
|
| 173 |
+
|
| 174 |
+
**User story:** *Topic + grade → (optional sources) → Generate → Preview & download*
|
| 175 |
+
|
| 176 |
+
```mermaid
|
| 177 |
+
flowchart LR
|
| 178 |
+
S1["Step 1: Lesson details"] --> S2["Step 2: Sources optional"]
|
| 179 |
+
S2 --> S3["Step 3: Generate"]
|
| 180 |
+
S3 --> S4["Step 4: Preview and export"]
|
| 181 |
+
```
|
| 182 |
+
|
| 183 |
+
| Zone | Content |
|
| 184 |
+
|------|---------|
|
| 185 |
+
| Hero | One sentence + step indicator |
|
| 186 |
+
| Step 1 row | Topic, grade, slide count (always visible) |
|
| 187 |
+
| Step 2 accordion | “Add research sources (optional)” — source mode as **radio** (None / Web / RAG), not nested dropdowns |
|
| 188 |
+
| Web sub-flow | If two-step: show Discover → URL checkboxes; if auto: hide Discover, label Generate as “Search web & generate” |
|
| 189 |
+
| Primary CTA | Full-width **Generate lesson slides** |
|
| 190 |
+
| Results | Tabs: **Preview** (default) \| Outline; download row below |
|
| 191 |
+
| Footer | Google Docs tip in collapsed “Export help” |
|
| 192 |
+
| Advanced | trace JSON, trace summary |
|
| 193 |
+
|
| 194 |
+
Remove tab-level model status markdown. Move Google Docs paragraph to accordion.
|
| 195 |
+
|
| 196 |
+
---
|
| 197 |
+
|
| 198 |
+
### ResearchMind ([`research_mind.py`](apps/gradio-space/src/gradio_space/tabs/research_mind.py))
|
| 199 |
+
|
| 200 |
+
**User story:** *Add sources to a session → Ask questions about them*
|
| 201 |
+
|
| 202 |
+
Restructure from “3 inner tabs + chat below fold” to **two-column layout**:
|
| 203 |
+
|
| 204 |
+
```
|
| 205 |
+
┌─────────────────────────────┬──────────────────────────┐
|
| 206 |
+
│ BUILD LIBRARY (left) │ ASK (right) │
|
| 207 |
+
│ Session picker │ Chatbot (sticky height) │
|
| 208 |
+
│ Ingest mode radio │ Question input │
|
| 209 |
+
│ Topic / URLs / Upload │ Doc scope (accordion) │
|
| 210 |
+
│ [Discover] [Ingest] │ Citations hint in reply │
|
| 211 |
+
│ Status │ │
|
| 212 |
+
│ Memory summary (compact) │ │
|
| 213 |
+
└─────────────────────────────┴──────────────────────────┘
|
| 214 |
+
```
|
| 215 |
+
|
| 216 |
+
Key UX fixes:
|
| 217 |
+
- Split **Discover sources** vs **Auto search & ingest** into **two distinct buttons** (no shared button + mode dropdown confusion)
|
| 218 |
+
- Keep Memory/Trace as **tabs inside left column** or accordions, not top-level competing tabs
|
| 219 |
+
- Show **citation snippet** under assistant messages (parse from trace or extend `run_research_question` to return formatted citations markdown ��� small backend tweak in [`research_helpers.py`](apps/gradio-space/src/gradio_space/research_helpers.py))
|
| 220 |
+
- Remove memory store path from main view → Settings panel
|
| 221 |
+
- Clear question box after successful Ask
|
| 222 |
+
|
| 223 |
+
---
|
| 224 |
+
|
| 225 |
+
### EchoCoach ([`echo_coach.py`](apps/gradio-space/src/gradio_space/tabs/echo_coach.py))
|
| 226 |
+
|
| 227 |
+
**User story:** *Record pitch → Analyze → Read feedback + hear VoiceOut*
|
| 228 |
+
|
| 229 |
+
```mermaid
|
| 230 |
+
flowchart LR
|
| 231 |
+
R["Record or upload"] --> A["Analyze pitch"]
|
| 232 |
+
A --> O["Results: transcript, report, charts, audio"]
|
| 233 |
+
```
|
| 234 |
+
|
| 235 |
+
| Zone | Content |
|
| 236 |
+
|------|---------|
|
| 237 |
+
| Step strip | Record → Analyze → Results |
|
| 238 |
+
| Left (narrow) | Recording block + **Analyze pitch** (primary, large) |
|
| 239 |
+
| Right (wide) | Empty state: “Record up to 30s and click Analyze” until results |
|
| 240 |
+
| Results layout | Transcript HTML top → Coach report → Charts row → VoiceOut player |
|
| 241 |
+
| Cross-link | One line: “Want live tips? → TeacherVoice (Pitch practice)” |
|
| 242 |
+
| Advanced | language, ASR preset, VoiceOut checkbox, trace |
|
| 243 |
+
|
| 244 |
+
Replace opening markdown wall with 2-line subtitle + step indicator. Move localhost/Cursor mic warning to tooltip-style callout (`gr.Info` or small HTML banner, dismissible via accordion “Recording help”).
|
| 245 |
+
|
| 246 |
+
---
|
| 247 |
+
|
| 248 |
+
### TeacherVoice ([`teacher_voice.py`](apps/gradio-space/src/gradio_space/tabs/teacher_voice.py))
|
| 249 |
+
|
| 250 |
+
**User story:** *Pick mode → Record turn → Send → Hear reply → Continue*
|
| 251 |
+
|
| 252 |
+
| Zone | Content |
|
| 253 |
+
|------|---------|
|
| 254 |
+
| Mode selector | **Three mode cards** via `gr.Radio` styled as cards (Explain / Lesson coach / Pitch practice) — show topic field only for Explain + Lesson |
|
| 255 |
+
| Step strip | Mode → Record → Send → Listen |
|
| 256 |
+
| Left | Recording block + **Send turn** (primary) + Clear |
|
| 257 |
+
| Right | Chatbot + autoplay VoiceOut (hide redundant Speak buttons in Advanced unless autoplay fails) |
|
| 258 |
+
| RAG | Promote to visible checkbox “Use my ResearchMind sources” with inline session picker (not buried accordion) when mode supports RAG |
|
| 259 |
+
| Advanced | ASR, trace, Speak buttons, omni status |
|
| 260 |
+
|
| 261 |
+
Clarify turn flow in UI copy: numbered pills update on each action (idle → recording → ready to send → thinking → reply).
|
| 262 |
+
|
| 263 |
+
---
|
| 264 |
+
|
| 265 |
+
### Chat (debug) ([`chat.py`](apps/gradio-space/src/gradio_space/tabs/chat.py))
|
| 266 |
+
|
| 267 |
+
Minimal polish (per your choice to keep tab):
|
| 268 |
+
- Add subtle `gr.Markdown("*Developer surface — test raw model + RAG*")` with CSS class `.dev-tab`
|
| 269 |
+
- Group RAG controls in one bordered `gr.Group`
|
| 270 |
+
- Optionally surface trace when RAG is on (currently discarded in `rag_aware_chat`) — small enhancement for jury demo
|
| 271 |
+
|
| 272 |
+
---
|
| 273 |
+
|
| 274 |
+
## Phase 4 — Visual design tokens
|
| 275 |
+
|
| 276 |
+
Light theme, education-friendly, consistent with existing lesson deck serif preview:
|
| 277 |
+
|
| 278 |
+
| Token | Value | Usage |
|
| 279 |
+
|-------|-------|-------|
|
| 280 |
+
| Primary | `#e86c00` (hackathon orange) | CTAs, active step |
|
| 281 |
+
| Surface | `#fafafa` | Panel backgrounds |
|
| 282 |
+
| Text muted | `#666` | Subtitles, Advanced labels |
|
| 283 |
+
| Font UI | system sans | Gradio controls |
|
| 284 |
+
| Font content | Georgia (already in preview) | Slide preview only |
|
| 285 |
+
|
| 286 |
+
Apply via `gr.themes.Soft(primary_hue="orange", ...)` + CSS overrides for header height, button sizing, and step pills.
|
| 287 |
+
|
| 288 |
+
---
|
| 289 |
+
|
| 290 |
+
## Implementation order (recommended)
|
| 291 |
+
|
| 292 |
+
1. **Shell + theme + settings panel** — immediate visual win, removes duplicate headers
|
| 293 |
+
2. **Shared components** (recording, session picker, advanced accordion, progress)
|
| 294 |
+
3. **EchoCoach + TeacherVoice** — highest confusion today; shared recording widget
|
| 295 |
+
4. **Lesson slides** — wizard + source mode simplification
|
| 296 |
+
5. **ResearchMind** — two-column layout + split discover buttons
|
| 297 |
+
6. **Chat debug** — light grouping + optional RAG trace
|
| 298 |
+
|
| 299 |
+
Each phase is independently shippable; tabs keep working between phases.
|
| 300 |
+
|
| 301 |
+
---
|
| 302 |
+
|
| 303 |
+
## What we are NOT doing (scope guard)
|
| 304 |
+
|
| 305 |
+
- No rewrite to a separate FastAPI + React frontend (Gradio remains the server)
|
| 306 |
+
- No real-time duplex TeacherVoice (backend limitation; UI will set expectations clearly)
|
| 307 |
+
- No redesign of slide HTML generator in [`preview.py`](libs/agent/src/agent/preview.py) beyond minor spacing tweaks
|
| 308 |
+
- No new features (lesson ↔ TeacherVoice link, etc.) unless trivial during layout refactor
|
| 309 |
+
|
| 310 |
+
---
|
| 311 |
+
|
| 312 |
+
## Files touched (summary)
|
| 313 |
+
|
| 314 |
+
| File | Change |
|
| 315 |
+
|------|--------|
|
| 316 |
+
| [`app.py`](apps/gradio-space/src/gradio_space/app.py) | Theme, CSS, compact header, settings accordion |
|
| 317 |
+
| `ui/theme.py`, `ui/styles.css`, `ui/settings_panel.py`, `ui/components.py` | **New** shared UI layer |
|
| 318 |
+
| [`tabs/education_pptx.py`](apps/gradio-space/src/gradio_space/tabs/education_pptx.py) | Wizard layout, source radio, Advanced panel |
|
| 319 |
+
| [`tabs/research_mind.py`](apps/gradio-space/src/gradio_space/tabs/research_mind.py) | Two-column layout, split buttons |
|
| 320 |
+
| [`tabs/echo_coach.py`](apps/gradio-space/src/gradio_space/tabs/echo_coach.py) | Step flow, shared recording |
|
| 321 |
+
| [`tabs/teacher_voice.py`](apps/gradio-space/src/gradio_space/tabs/teacher_voice.py) | Mode cards, promoted RAG |
|
| 322 |
+
| [`tabs/chat.py`](apps/gradio-space/src/gradio_space/tabs/chat.py) | Dev styling, grouped RAG |
|
| 323 |
+
| [`research_helpers.py`](apps/gradio-space/src/gradio_space/research_helpers.py) | Optional citation formatting for chat |
|
| 324 |
+
| [`model_loading.py`](apps/gradio-space/src/gradio_space/model_loading.py) | Settings-panel reload hook |
|
| 325 |
+
|
| 326 |
+
---
|
| 327 |
+
|
| 328 |
+
## Success criteria (for hackathon demo)
|
| 329 |
+
|
| 330 |
+
- First screen shows **product name + tabs**, not YAML paths
|
| 331 |
+
- Each tab has an obvious **1-2-3 path** visible without scrolling past config
|
| 332 |
+
- Model/settings accessible in **one place** (Settings)
|
| 333 |
+
- Long operations show **progress**, not frozen UI
|
| 334 |
+
- Jury can expand **Advanced** to see traces, ASR, and paths on demand
|
apps/gradio-space/src/gradio_space/app.py
CHANGED
|
@@ -14,32 +14,37 @@ from gradio_space.tabs.education_pptx import gradio_allowed_paths
|
|
| 14 |
from gradio_space.tabs.echo_coach import echo_coach_allowed_paths
|
| 15 |
from gradio_space.tabs.research_mind import researchmind_allowed_paths
|
| 16 |
from gradio_space.tabs.teacher_voice import teacher_voice_allowed_paths
|
| 17 |
-
from
|
| 18 |
-
|
| 19 |
-
_app_config = get_app_config()
|
| 20 |
|
| 21 |
|
| 22 |
def build_demo() -> gr.Blocks:
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
f"""
|
| 33 |
-
# Lesson Agent + ResearchMind + EchoCoach + TeacherVoice
|
| 34 |
|
| 35 |
-
|
| 36 |
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
|
| 41 |
-
|
| 42 |
-
|
|
|
|
|
|
|
| 43 |
)
|
| 44 |
|
| 45 |
with gr.Tabs():
|
|
@@ -69,6 +74,8 @@ def main() -> None:
|
|
| 69 |
demo.launch(
|
| 70 |
server_name=server_name,
|
| 71 |
server_port=port,
|
|
|
|
|
|
|
| 72 |
allowed_paths=[
|
| 73 |
*gradio_allowed_paths(),
|
| 74 |
*researchmind_allowed_paths(),
|
|
|
|
| 14 |
from gradio_space.tabs.echo_coach import echo_coach_allowed_paths
|
| 15 |
from gradio_space.tabs.research_mind import researchmind_allowed_paths
|
| 16 |
from gradio_space.tabs.teacher_voice import teacher_voice_allowed_paths
|
| 17 |
+
from gradio_space.ui.settings_panel import build_settings_panel
|
| 18 |
+
from gradio_space.ui.theme import get_theme, load_css
|
|
|
|
| 19 |
|
| 20 |
|
| 21 |
def build_demo() -> gr.Blocks:
|
| 22 |
+
with gr.Blocks(title="Build Small — Lesson Agent") as demo:
|
| 23 |
+
with gr.Row(elem_classes=["app-header"]):
|
| 24 |
+
gr.HTML(
|
| 25 |
+
"""
|
| 26 |
+
<div class="brand-block">
|
| 27 |
+
<h1>Build Small</h1>
|
| 28 |
+
<p>Local lesson slides, research, voice coaching — offline on small models.
|
| 29 |
+
<a href="https://huggingface.co/build-small-hackathon" target="_blank">Hackathon</a></p>
|
| 30 |
+
</div>
|
| 31 |
+
"""
|
| 32 |
+
)
|
| 33 |
+
settings_toggle = gr.Button("⚙ Settings", size="sm", variant="secondary")
|
| 34 |
|
| 35 |
+
with gr.Accordion("Settings", open=False, elem_id="settings-panel") as settings_acc:
|
| 36 |
+
build_settings_panel()
|
|
|
|
|
|
|
| 37 |
|
| 38 |
+
settings_open = gr.State(False)
|
| 39 |
|
| 40 |
+
def _toggle_settings(is_open: bool) -> tuple[bool, dict]:
|
| 41 |
+
new_open = not is_open
|
| 42 |
+
return new_open, gr.update(open=new_open)
|
| 43 |
|
| 44 |
+
settings_toggle.click(
|
| 45 |
+
fn=_toggle_settings,
|
| 46 |
+
inputs=[settings_open],
|
| 47 |
+
outputs=[settings_open, settings_acc],
|
| 48 |
)
|
| 49 |
|
| 50 |
with gr.Tabs():
|
|
|
|
| 74 |
demo.launch(
|
| 75 |
server_name=server_name,
|
| 76 |
server_port=port,
|
| 77 |
+
theme=get_theme(),
|
| 78 |
+
css=load_css(),
|
| 79 |
allowed_paths=[
|
| 80 |
*gradio_allowed_paths(),
|
| 81 |
*researchmind_allowed_paths(),
|
apps/gradio-space/src/gradio_space/model_loading.py
CHANGED
|
@@ -74,6 +74,21 @@ def warmup(model_key: str | None = None) -> str:
|
|
| 74 |
)
|
| 75 |
|
| 76 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 77 |
def preload_active_model() -> str:
|
| 78 |
"""Load the active preset at startup so the first request is fast."""
|
| 79 |
key = get_active_model_key()
|
|
|
|
| 74 |
)
|
| 75 |
|
| 76 |
|
| 77 |
+
def reload_model(model_key: str) -> str:
|
| 78 |
+
"""Clear cached backend and reload weights for settings panel."""
|
| 79 |
+
global _current_model_key
|
| 80 |
+
|
| 81 |
+
key = model_key or _app_config.active_model
|
| 82 |
+
reset_backend()
|
| 83 |
+
_current_model_key = None
|
| 84 |
+
_load_state.pop(key, None)
|
| 85 |
+
_load_errors.pop(key, None)
|
| 86 |
+
error = ensure_model_loaded(key)
|
| 87 |
+
if error:
|
| 88 |
+
return error
|
| 89 |
+
return warmup(key)
|
| 90 |
+
|
| 91 |
+
|
| 92 |
def preload_active_model() -> str:
|
| 93 |
"""Load the active preset at startup so the first request is fast."""
|
| 94 |
key = get_active_model_key()
|
apps/gradio-space/src/gradio_space/research_helpers.py
CHANGED
|
@@ -25,6 +25,10 @@ def list_session_choices() -> list[tuple[str, str]]:
|
|
| 25 |
def refresh_sessions(current: str):
|
| 26 |
choices = list_session_choices()
|
| 27 |
values = [c[1] for c in choices]
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
value = current if current in values else ""
|
| 29 |
return gr.update(choices=choices, value=value)
|
| 30 |
|
|
@@ -62,6 +66,24 @@ def load_trace_json(trace_path: str) -> str:
|
|
| 62 |
return trace_path
|
| 63 |
|
| 64 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
def trace_summary_markdown(trace_path: str) -> str:
|
| 66 |
raw = load_trace_json(trace_path)
|
| 67 |
if not raw or not raw.strip().startswith("{"):
|
|
@@ -130,6 +152,27 @@ def merge_lesson_urls(pasted: str, selected: list[str] | None) -> list[str]:
|
|
| 130 |
return list(dict.fromkeys([*direct, *(selected or [])]))
|
| 131 |
|
| 132 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 133 |
def rag_scope_hint(session_id: str, doc_ids: list[str] | None) -> str:
|
| 134 |
if doc_ids:
|
| 135 |
return f"RAG scope: **{len(doc_ids)}** selected document(s)."
|
|
@@ -155,18 +198,15 @@ def run_research_question(
|
|
| 155 |
if not question.strip():
|
| 156 |
return "Enter a question.", "", ""
|
| 157 |
|
| 158 |
-
sid = session_id
|
| 159 |
-
if not sid:
|
| 160 |
-
sid = IngestPipeline().store.create_session().id
|
| 161 |
-
|
| 162 |
runner = AgentRunner()
|
| 163 |
result = runner.run_researchmind_chat(
|
| 164 |
question=question,
|
| 165 |
-
session_id=
|
| 166 |
doc_ids=doc_ids or None,
|
| 167 |
model_key=key,
|
| 168 |
backend=get_backend(key),
|
| 169 |
)
|
|
|
|
| 170 |
trace_json = json.dumps(
|
| 171 |
{
|
| 172 |
"trace_path": result.trace_path,
|
|
@@ -192,14 +232,18 @@ def rag_aware_chat(
|
|
| 192 |
use_rag: bool,
|
| 193 |
session_id: str,
|
| 194 |
doc_ids: list[str] | None,
|
| 195 |
-
) -> str:
|
|
|
|
| 196 |
if not use_rag:
|
| 197 |
-
return chat(message, history, model_key)
|
| 198 |
|
| 199 |
-
answer,
|
| 200 |
message,
|
| 201 |
session_id=session_id,
|
| 202 |
doc_ids=doc_ids,
|
| 203 |
model_key=model_key,
|
| 204 |
)
|
| 205 |
-
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
def refresh_sessions(current: str):
|
| 26 |
choices = list_session_choices()
|
| 27 |
values = [c[1] for c in choices]
|
| 28 |
+
if current and current not in values:
|
| 29 |
+
# New session may be selected before choices refresh (e.g. after discover).
|
| 30 |
+
choices.append((f"Session ({current})", current))
|
| 31 |
+
values.append(current)
|
| 32 |
value = current if current in values else ""
|
| 33 |
return gr.update(choices=choices, value=value)
|
| 34 |
|
|
|
|
| 66 |
return trace_path
|
| 67 |
|
| 68 |
|
| 69 |
+
def trace_as_dict(value: str | dict | None) -> dict:
|
| 70 |
+
"""Normalize trace payloads for gr.JSON (dict only, never invalid strings)."""
|
| 71 |
+
if value is None:
|
| 72 |
+
return {}
|
| 73 |
+
if isinstance(value, dict):
|
| 74 |
+
return value
|
| 75 |
+
text = str(value).strip()
|
| 76 |
+
if not text:
|
| 77 |
+
return {}
|
| 78 |
+
if text.startswith("{"):
|
| 79 |
+
try:
|
| 80 |
+
parsed = json.loads(text)
|
| 81 |
+
except json.JSONDecodeError:
|
| 82 |
+
return {"error": text[:2000]}
|
| 83 |
+
return parsed if isinstance(parsed, dict) else {"data": parsed}
|
| 84 |
+
return {"message": text[:2000]}
|
| 85 |
+
|
| 86 |
+
|
| 87 |
def trace_summary_markdown(trace_path: str) -> str:
|
| 88 |
raw = load_trace_json(trace_path)
|
| 89 |
if not raw or not raw.strip().startswith("{"):
|
|
|
|
| 152 |
return list(dict.fromkeys([*direct, *(selected or [])]))
|
| 153 |
|
| 154 |
|
| 155 |
+
def format_citations_markdown(trace_json: str) -> str:
|
| 156 |
+
"""Extract citation lines from RAG trace JSON for chat display."""
|
| 157 |
+
if not trace_json or not trace_json.strip().startswith("{"):
|
| 158 |
+
return ""
|
| 159 |
+
try:
|
| 160 |
+
data = json.loads(trace_json)
|
| 161 |
+
except json.JSONDecodeError:
|
| 162 |
+
return ""
|
| 163 |
+
citations = data.get("citations") or []
|
| 164 |
+
if not citations:
|
| 165 |
+
return ""
|
| 166 |
+
lines = ["", "---", "**Sources:**"]
|
| 167 |
+
for i, cite in enumerate(citations[:5], start=1):
|
| 168 |
+
title = cite.get("title") or cite.get("uri") or "Source"
|
| 169 |
+
uri = cite.get("uri") or ""
|
| 170 |
+
lines.append(f"{i}. [{title}]({uri})" if uri else f"{i}. {title}")
|
| 171 |
+
if len(citations) > 5:
|
| 172 |
+
lines.append(f"_…and {len(citations) - 5} more (see Advanced trace)._")
|
| 173 |
+
return "\n".join(lines)
|
| 174 |
+
|
| 175 |
+
|
| 176 |
def rag_scope_hint(session_id: str, doc_ids: list[str] | None) -> str:
|
| 177 |
if doc_ids:
|
| 178 |
return f"RAG scope: **{len(doc_ids)}** selected document(s)."
|
|
|
|
| 198 |
if not question.strip():
|
| 199 |
return "Enter a question.", "", ""
|
| 200 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 201 |
runner = AgentRunner()
|
| 202 |
result = runner.run_researchmind_chat(
|
| 203 |
question=question,
|
| 204 |
+
session_id=session_id or "",
|
| 205 |
doc_ids=doc_ids or None,
|
| 206 |
model_key=key,
|
| 207 |
backend=get_backend(key),
|
| 208 |
)
|
| 209 |
+
sid = session_id or result.session_id
|
| 210 |
trace_json = json.dumps(
|
| 211 |
{
|
| 212 |
"trace_path": result.trace_path,
|
|
|
|
| 232 |
use_rag: bool,
|
| 233 |
session_id: str,
|
| 234 |
doc_ids: list[str] | None,
|
| 235 |
+
) -> tuple[str, str, str]:
|
| 236 |
+
"""Returns (reply, trace_json, trace_summary) for debug chat."""
|
| 237 |
if not use_rag:
|
| 238 |
+
return chat(message, history, model_key), "", ""
|
| 239 |
|
| 240 |
+
answer, trace_json, trace_summary = run_research_question(
|
| 241 |
message,
|
| 242 |
session_id=session_id,
|
| 243 |
doc_ids=doc_ids,
|
| 244 |
model_key=model_key,
|
| 245 |
)
|
| 246 |
+
citations = format_citations_markdown(trace_json)
|
| 247 |
+
if citations:
|
| 248 |
+
answer = f"{answer}\n{citations}"
|
| 249 |
+
return answer, trace_json, trace_summary
|
apps/gradio-space/src/gradio_space/tabs/chat.py
CHANGED
|
@@ -1,6 +1,5 @@
|
|
| 1 |
import gradio as gr
|
| 2 |
|
| 3 |
-
from gradio_space.model_loading import model_status
|
| 4 |
from gradio_space.research_helpers import (
|
| 5 |
list_session_choices,
|
| 6 |
rag_aware_chat,
|
|
@@ -8,63 +7,91 @@ from gradio_space.research_helpers import (
|
|
| 8 |
refresh_doc_choices,
|
| 9 |
refresh_sessions,
|
| 10 |
)
|
|
|
|
| 11 |
from inference.config import get_app_config
|
| 12 |
|
| 13 |
_app_config = get_app_config()
|
| 14 |
|
| 15 |
|
| 16 |
def build_chat_tab() -> None:
|
| 17 |
-
|
| 18 |
-
""
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
""
|
| 23 |
)
|
| 24 |
|
| 25 |
model_key = _app_config.active_model
|
| 26 |
|
| 27 |
-
with gr.
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
label="
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
)
|
| 35 |
-
|
| 36 |
|
| 37 |
-
|
| 38 |
-
label="Documents to search (empty = all docs in session, or entire corpus if no session)",
|
| 39 |
-
choices=[],
|
| 40 |
-
value=[],
|
| 41 |
-
)
|
| 42 |
-
rag_hint = gr.Markdown(value=rag_scope_hint("", []))
|
| 43 |
|
| 44 |
if _app_config.allow_model_switch and len(_app_config.models) > 1:
|
| 45 |
model_dropdown = gr.Dropdown(
|
| 46 |
choices=_app_config.model_choices(),
|
| 47 |
value=_app_config.active_model,
|
| 48 |
-
label="Model preset",
|
| 49 |
)
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
additional_inputs=[model_dropdown, use_rag, session_dd, doc_dd],
|
| 55 |
examples=[
|
| 56 |
-
[
|
| 57 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
],
|
| 59 |
)
|
| 60 |
else:
|
| 61 |
-
status = gr.Markdown(model_status(model_key))
|
| 62 |
|
| 63 |
def _chat(message, history, use_rag_flag, sid, docs):
|
| 64 |
-
|
|
|
|
|
|
|
|
|
|
| 65 |
|
| 66 |
-
gr.ChatInterface(
|
| 67 |
fn=_chat,
|
|
|
|
| 68 |
additional_inputs=[use_rag, session_dd, doc_dd],
|
| 69 |
examples=[
|
| 70 |
["What do my ingested sources say about AI agents?", True, "", []],
|
|
@@ -72,6 +99,8 @@ Test the active local model. Enable **ResearchMind RAG** to answer from ingested
|
|
| 72 |
],
|
| 73 |
)
|
| 74 |
|
|
|
|
|
|
|
| 75 |
def _update_hint(sid: str, docs: list[str] | None, rag_on: bool) -> str:
|
| 76 |
if not rag_on:
|
| 77 |
return "_Plain chat — model only, no document retrieval._"
|
|
|
|
| 1 |
import gradio as gr
|
| 2 |
|
|
|
|
| 3 |
from gradio_space.research_helpers import (
|
| 4 |
list_session_choices,
|
| 5 |
rag_aware_chat,
|
|
|
|
| 7 |
refresh_doc_choices,
|
| 8 |
refresh_sessions,
|
| 9 |
)
|
| 10 |
+
from gradio_space.ui.components import build_advanced_panel, DOC_CHOICE_LIST_CLASSES, tab_hero
|
| 11 |
from inference.config import get_app_config
|
| 12 |
|
| 13 |
_app_config = get_app_config()
|
| 14 |
|
| 15 |
|
| 16 |
def build_chat_tab() -> None:
|
| 17 |
+
tab_hero(
|
| 18 |
+
"Test the active local model with optional ResearchMind RAG.",
|
| 19 |
+
)
|
| 20 |
+
gr.HTML(
|
| 21 |
+
'<span class="dev-tab-badge">Developer</span> '
|
| 22 |
+
"Plain chat or corpus-grounded answers — traces appear in Advanced when RAG is on."
|
| 23 |
)
|
| 24 |
|
| 25 |
model_key = _app_config.active_model
|
| 26 |
|
| 27 |
+
with gr.Group():
|
| 28 |
+
gr.Markdown("#### RAG scope")
|
| 29 |
+
with gr.Row():
|
| 30 |
+
use_rag = gr.Checkbox(label="Use ResearchMind RAG", value=False)
|
| 31 |
+
session_dd = gr.Dropdown(
|
| 32 |
+
label="Session",
|
| 33 |
+
choices=list_session_choices(),
|
| 34 |
+
value="",
|
| 35 |
+
interactive=True,
|
| 36 |
+
scale=3,
|
| 37 |
+
)
|
| 38 |
+
refresh_sessions_btn = gr.Button("↻", size="sm", scale=0, min_width=40)
|
| 39 |
+
|
| 40 |
+
doc_dd = gr.CheckboxGroup(
|
| 41 |
+
label="Documents to search (empty = all docs in session, or entire corpus if no session)",
|
| 42 |
+
choices=[],
|
| 43 |
+
value=[],
|
| 44 |
+
elem_classes=DOC_CHOICE_LIST_CLASSES,
|
| 45 |
)
|
| 46 |
+
rag_hint = gr.Markdown(value=rag_scope_hint("", []))
|
| 47 |
|
| 48 |
+
advanced = build_advanced_panel()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
|
| 50 |
if _app_config.allow_model_switch and len(_app_config.models) > 1:
|
| 51 |
model_dropdown = gr.Dropdown(
|
| 52 |
choices=_app_config.model_choices(),
|
| 53 |
value=_app_config.active_model,
|
| 54 |
+
label="Model preset (debug override)",
|
| 55 |
)
|
| 56 |
+
|
| 57 |
+
def _chat(message, history, mkey, use_rag_flag, sid, docs):
|
| 58 |
+
reply, trace_json, trace_summary = rag_aware_chat(
|
| 59 |
+
message, history, mkey, use_rag_flag, sid, docs
|
| 60 |
+
)
|
| 61 |
+
return reply, trace_json, trace_summary
|
| 62 |
+
|
| 63 |
+
chat_iface = gr.ChatInterface(
|
| 64 |
+
fn=_chat,
|
| 65 |
+
additional_outputs=[advanced.trace_box, advanced.trace_summary],
|
| 66 |
additional_inputs=[model_dropdown, use_rag, session_dd, doc_dd],
|
| 67 |
examples=[
|
| 68 |
+
[
|
| 69 |
+
"What do my ingested sources say about AI agents?",
|
| 70 |
+
_app_config.active_model,
|
| 71 |
+
True,
|
| 72 |
+
"",
|
| 73 |
+
[],
|
| 74 |
+
],
|
| 75 |
+
[
|
| 76 |
+
"Hello! What can you help me with?",
|
| 77 |
+
_app_config.active_model,
|
| 78 |
+
False,
|
| 79 |
+
"",
|
| 80 |
+
[],
|
| 81 |
+
],
|
| 82 |
],
|
| 83 |
)
|
| 84 |
else:
|
|
|
|
| 85 |
|
| 86 |
def _chat(message, history, use_rag_flag, sid, docs):
|
| 87 |
+
reply, trace_json, trace_summary = rag_aware_chat(
|
| 88 |
+
message, history, model_key, use_rag_flag, sid, docs
|
| 89 |
+
)
|
| 90 |
+
return reply, trace_json, trace_summary
|
| 91 |
|
| 92 |
+
chat_iface = gr.ChatInterface(
|
| 93 |
fn=_chat,
|
| 94 |
+
additional_outputs=[advanced.trace_box, advanced.trace_summary],
|
| 95 |
additional_inputs=[use_rag, session_dd, doc_dd],
|
| 96 |
examples=[
|
| 97 |
["What do my ingested sources say about AI agents?", True, "", []],
|
|
|
|
| 99 |
],
|
| 100 |
)
|
| 101 |
|
| 102 |
+
_ = chat_iface # keep reference for linter
|
| 103 |
+
|
| 104 |
def _update_hint(sid: str, docs: list[str] | None, rag_on: bool) -> str:
|
| 105 |
if not rag_on:
|
| 106 |
return "_Plain chat — model only, no document retrieval._"
|
apps/gradio-space/src/gradio_space/tabs/echo_coach.py
CHANGED
|
@@ -6,15 +6,13 @@ import gradio as gr
|
|
| 6 |
|
| 7 |
from echocoach.config import get_echo_coach_config
|
| 8 |
from echocoach.pipeline import run_echo_coach
|
| 9 |
-
from
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
stop_server_recording,
|
| 16 |
)
|
| 17 |
-
from gradio_space.model_loading import ensure_model_loaded, get_active_model_key, model_status
|
| 18 |
from inference.factory import get_backend
|
| 19 |
|
| 20 |
_config = get_echo_coach_config()
|
|
@@ -28,66 +26,29 @@ _SAMPLE_AUDIO = (
|
|
| 28 |
)
|
| 29 |
|
| 30 |
|
| 31 |
-
def
|
| 32 |
-
|
| 33 |
-
message,
|
| 34 |
-
|
| 35 |
-
"",
|
| 36 |
-
None,
|
| 37 |
-
None,
|
| 38 |
-
None,
|
| 39 |
-
message,
|
| 40 |
-
{},
|
| 41 |
)
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
def ui_start_recording(max_seconds: int) -> tuple[str, dict, dict]:
|
| 45 |
-
try:
|
| 46 |
-
start_server_recording(int(max_seconds))
|
| 47 |
-
except ServerRecordingError as exc:
|
| 48 |
-
return (
|
| 49 |
-
str(exc),
|
| 50 |
-
gr.update(interactive=True),
|
| 51 |
-
gr.update(interactive=False),
|
| 52 |
-
)
|
| 53 |
return (
|
| 54 |
-
|
| 55 |
-
f"Recording… speak now, then click **Stop recording** "
|
| 56 |
-
f"(auto-stops after {int(max_seconds)}s)."
|
| 57 |
-
),
|
| 58 |
-
gr.update(interactive=False),
|
| 59 |
-
gr.update(interactive=True),
|
| 60 |
)
|
| 61 |
|
| 62 |
|
| 63 |
-
def
|
| 64 |
-
try:
|
| 65 |
-
elapsed = recording_elapsed_seconds()
|
| 66 |
-
path = stop_server_recording()
|
| 67 |
-
warning = recording_level_warning(path)
|
| 68 |
-
except ServerRecordingError as exc:
|
| 69 |
-
return (
|
| 70 |
-
None,
|
| 71 |
-
str(exc),
|
| 72 |
-
gr.update(interactive=True),
|
| 73 |
-
gr.update(interactive=False),
|
| 74 |
-
)
|
| 75 |
-
except Exception as exc: # noqa: BLE001 — surface unexpected recorder errors
|
| 76 |
-
return (
|
| 77 |
-
None,
|
| 78 |
-
f"Recording failed: {exc}",
|
| 79 |
-
gr.update(interactive=True),
|
| 80 |
-
gr.update(interactive=False),
|
| 81 |
-
)
|
| 82 |
-
|
| 83 |
-
status = f"Recording saved ({elapsed:.1f}s) → `{path}`. Click **Analyze pitch**."
|
| 84 |
-
if warning:
|
| 85 |
-
status += f" Warning: {warning}"
|
| 86 |
return (
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
gr.update(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 91 |
)
|
| 92 |
|
| 93 |
|
|
@@ -97,7 +58,10 @@ def load_sample_pitch() -> tuple[str | None, str]:
|
|
| 97 |
None,
|
| 98 |
f"Sample clip missing at `{_SAMPLE_AUDIO}`. Run `uv run python libs/echocoach/tests/make_fixture.py`.",
|
| 99 |
)
|
| 100 |
-
return
|
|
|
|
|
|
|
|
|
|
| 101 |
|
| 102 |
|
| 103 |
def analyze_pitch(
|
|
@@ -105,7 +69,9 @@ def analyze_pitch(
|
|
| 105 |
language: str,
|
| 106 |
asr_preset: str,
|
| 107 |
speak_rewrite: bool,
|
|
|
|
| 108 |
) -> tuple:
|
|
|
|
| 109 |
model_key = get_active_model_key()
|
| 110 |
load_error = ensure_model_loaded(model_key)
|
| 111 |
if load_error:
|
|
@@ -115,6 +81,7 @@ def analyze_pitch(
|
|
| 115 |
return _error_outputs("Record or upload a pitch (up to 30 seconds), then click **Analyze pitch**.")
|
| 116 |
|
| 117 |
try:
|
|
|
|
| 118 |
result = run_echo_coach(
|
| 119 |
audio_path,
|
| 120 |
language=language,
|
|
@@ -122,22 +89,29 @@ def analyze_pitch(
|
|
| 122 |
backend=get_backend(model_key),
|
| 123 |
speak_rewrite=speak_rewrite,
|
| 124 |
)
|
| 125 |
-
except Exception as exc: # noqa: BLE001
|
| 126 |
return _error_outputs(f"EchoCoach failed: {exc}")
|
| 127 |
|
| 128 |
-
|
|
|
|
| 129 |
if result.voiceout_warning:
|
| 130 |
-
status += f" VoiceOut: {result.voiceout_warning}"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 131 |
|
| 132 |
return (
|
| 133 |
status,
|
| 134 |
result.transcript_html,
|
| 135 |
result.report_markdown,
|
| 136 |
-
result.filler_chart_path,
|
| 137 |
-
result.pace_chart_path,
|
| 138 |
-
result.voiceout_path,
|
| 139 |
f"Trace saved: `{result.trace_path}`",
|
| 140 |
result.trace,
|
|
|
|
|
|
|
| 141 |
)
|
| 142 |
|
| 143 |
|
|
@@ -146,98 +120,107 @@ def build_echo_coach_tab() -> None:
|
|
| 146 |
asr_choices = _config.asr_choices()
|
| 147 |
default_lang = lang_choices[0][1] if lang_choices else "en"
|
| 148 |
default_asr = _config.asr_preset
|
| 149 |
-
mic_status = recording_backend_status()
|
| 150 |
-
|
| 151 |
-
gr.Markdown(
|
| 152 |
-
f"""
|
| 153 |
-
Record up to **{_config.max_seconds} seconds**, then get local feedback: transcript with **filler highlights**,
|
| 154 |
-
**pace score**, coach **rewrite**, and **VoiceOut** audio — all on-device.
|
| 155 |
-
|
| 156 |
-
- **ASR:** configurable (`voice_models.yaml`) — Cohere Transcribe 2B or Whisper.cpp
|
| 157 |
-
- **Coach:** text LLM preset (`ACTIVE_MODEL` / `ECHOCOACH_COACH_MODEL`)
|
| 158 |
-
- **TTS:** Piper VoiceOut (optional; install `echocoach[piper]`)
|
| 159 |
-
|
| 160 |
-
**Browser mic:** open **http://localhost:7860** in Chrome or Firefox (not Cursor's preview) and allow microphone access.
|
| 161 |
-
If the mic icon fails, use **Start / Stop recording** below or **Upload** a `.wav` / `.mp3`.
|
| 162 |
|
| 163 |
-
|
| 164 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 165 |
)
|
| 166 |
|
| 167 |
-
with gr.Row():
|
| 168 |
-
with gr.Column(scale=1):
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
|
|
|
| 181 |
)
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
audio_in = gr.Audio(
|
| 187 |
-
label="Your pitch (browser mic or upload)",
|
| 188 |
-
sources=["upload", "microphone"],
|
| 189 |
-
type="filepath",
|
| 190 |
-
format="wav",
|
| 191 |
-
)
|
| 192 |
-
language = gr.Dropdown(
|
| 193 |
-
label="Language",
|
| 194 |
-
choices=lang_choices,
|
| 195 |
-
value=default_lang,
|
| 196 |
)
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 201 |
)
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 205 |
)
|
| 206 |
-
analyze_btn = gr.Button("Analyze pitch", variant="primary")
|
| 207 |
-
status = gr.Textbox(label="Status", interactive=False, lines=3)
|
| 208 |
-
coach_status = gr.Markdown(model_status(get_active_model_key()))
|
| 209 |
-
|
| 210 |
-
with gr.Column(scale=2):
|
| 211 |
-
transcript_html = gr.HTML(label="Transcript")
|
| 212 |
-
report_md = gr.Markdown(label="Coach report")
|
| 213 |
-
with gr.Row():
|
| 214 |
-
filler_chart = gr.Image(label="Filler words", type="filepath")
|
| 215 |
-
pace_chart = gr.Image(label="Pace timeline", type="filepath")
|
| 216 |
-
voiceout = gr.Audio(label="VoiceOut", type="filepath")
|
| 217 |
-
trace_note = gr.Markdown()
|
| 218 |
-
trace_json = gr.JSON(label="Trace")
|
| 219 |
-
|
| 220 |
-
record_start_btn.click(
|
| 221 |
-
ui_start_recording,
|
| 222 |
-
inputs=[record_seconds],
|
| 223 |
-
outputs=[status, record_start_btn, record_stop_btn],
|
| 224 |
-
)
|
| 225 |
-
record_stop_btn.click(
|
| 226 |
-
ui_stop_recording,
|
| 227 |
-
outputs=[audio_in, status, record_start_btn, record_stop_btn],
|
| 228 |
-
).then(
|
| 229 |
-
lambda: recording_backend_status(),
|
| 230 |
-
outputs=[record_status_md],
|
| 231 |
-
)
|
| 232 |
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 237 |
|
| 238 |
analyze_btn.click(
|
| 239 |
analyze_pitch,
|
| 240 |
-
inputs=[
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 241 |
outputs=[
|
| 242 |
status,
|
| 243 |
transcript_html,
|
|
@@ -245,8 +228,10 @@ For conversational pitch tips, try the **TeacherVoice** tab (Pitch practice mode
|
|
| 245 |
filler_chart,
|
| 246 |
pace_chart,
|
| 247 |
voiceout,
|
| 248 |
-
|
| 249 |
-
|
|
|
|
|
|
|
| 250 |
],
|
| 251 |
)
|
| 252 |
|
|
|
|
| 6 |
|
| 7 |
from echocoach.config import get_echo_coach_config
|
| 8 |
from echocoach.pipeline import run_echo_coach
|
| 9 |
+
from gradio_space.model_loading import ensure_model_loaded, get_active_model_key
|
| 10 |
+
from gradio_space.ui.components import (
|
| 11 |
+
build_advanced_panel,
|
| 12 |
+
build_recording_block,
|
| 13 |
+
empty_state,
|
| 14 |
+
wire_recording_handlers,
|
|
|
|
| 15 |
)
|
|
|
|
| 16 |
from inference.factory import get_backend
|
| 17 |
|
| 18 |
_config = get_echo_coach_config()
|
|
|
|
| 26 |
)
|
| 27 |
|
| 28 |
|
| 29 |
+
def _error_html(message: str) -> str:
|
| 30 |
+
safe = (
|
| 31 |
+
message.replace("&", "&")
|
| 32 |
+
.replace("<", "<")
|
| 33 |
+
.replace(">", ">")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
return (
|
| 36 |
+
f'<div class="form-error">{safe}</div>'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
)
|
| 38 |
|
| 39 |
|
| 40 |
+
def _error_outputs(message: str) -> tuple:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
return (
|
| 42 |
+
message,
|
| 43 |
+
_error_html(message),
|
| 44 |
+
"",
|
| 45 |
+
gr.update(value=None, visible=False),
|
| 46 |
+
gr.update(value=None, visible=False),
|
| 47 |
+
gr.update(value=None, visible=False),
|
| 48 |
+
f"Trace: {message}",
|
| 49 |
+
{},
|
| 50 |
+
gr.update(visible=False),
|
| 51 |
+
gr.update(visible=True),
|
| 52 |
)
|
| 53 |
|
| 54 |
|
|
|
|
| 58 |
None,
|
| 59 |
f"Sample clip missing at `{_SAMPLE_AUDIO}`. Run `uv run python libs/echocoach/tests/make_fixture.py`.",
|
| 60 |
)
|
| 61 |
+
return (
|
| 62 |
+
gr.update(value=str(_SAMPLE_AUDIO)),
|
| 63 |
+
"Sample clip loaded — click **Analyze pitch** when ready.",
|
| 64 |
+
)
|
| 65 |
|
| 66 |
|
| 67 |
def analyze_pitch(
|
|
|
|
| 69 |
language: str,
|
| 70 |
asr_preset: str,
|
| 71 |
speak_rewrite: bool,
|
| 72 |
+
progress: gr.Progress = gr.Progress(),
|
| 73 |
) -> tuple:
|
| 74 |
+
progress(0, desc="Loading model…")
|
| 75 |
model_key = get_active_model_key()
|
| 76 |
load_error = ensure_model_loaded(model_key)
|
| 77 |
if load_error:
|
|
|
|
| 81 |
return _error_outputs("Record or upload a pitch (up to 30 seconds), then click **Analyze pitch**.")
|
| 82 |
|
| 83 |
try:
|
| 84 |
+
progress(0.2, desc="Transcribing & analyzing…")
|
| 85 |
result = run_echo_coach(
|
| 86 |
audio_path,
|
| 87 |
language=language,
|
|
|
|
| 89 |
backend=get_backend(model_key),
|
| 90 |
speak_rewrite=speak_rewrite,
|
| 91 |
)
|
| 92 |
+
except Exception as exc: # noqa: BLE001
|
| 93 |
return _error_outputs(f"EchoCoach failed: {exc}")
|
| 94 |
|
| 95 |
+
progress(1.0, desc="Done")
|
| 96 |
+
status = "**Analysis complete.** Review transcript, charts, and VoiceOut on the right."
|
| 97 |
if result.voiceout_warning:
|
| 98 |
+
status += f" VoiceOut note: {result.voiceout_warning}"
|
| 99 |
+
|
| 100 |
+
has_filler = bool(result.filler_chart_path)
|
| 101 |
+
has_pace = bool(result.pace_chart_path)
|
| 102 |
+
has_voiceout = bool(result.voiceout_path)
|
| 103 |
|
| 104 |
return (
|
| 105 |
status,
|
| 106 |
result.transcript_html,
|
| 107 |
result.report_markdown,
|
| 108 |
+
gr.update(value=result.filler_chart_path, visible=has_filler),
|
| 109 |
+
gr.update(value=result.pace_chart_path, visible=has_pace),
|
| 110 |
+
gr.update(value=result.voiceout_path, visible=has_voiceout),
|
| 111 |
f"Trace saved: `{result.trace_path}`",
|
| 112 |
result.trace,
|
| 113 |
+
gr.update(visible=False),
|
| 114 |
+
gr.update(visible=True),
|
| 115 |
)
|
| 116 |
|
| 117 |
|
|
|
|
| 120 |
asr_choices = _config.asr_choices()
|
| 121 |
default_lang = lang_choices[0][1] if lang_choices else "en"
|
| 122 |
default_asr = _config.asr_preset
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 123 |
|
| 124 |
+
gr.Markdown("### EchoCoach", elem_classes=["form-tab-heading"])
|
| 125 |
+
gr.HTML(
|
| 126 |
+
'<p class="tab-subtitle">'
|
| 127 |
+
"Record a short pitch and get transcript, pace analysis, filler highlights, and spoken feedback."
|
| 128 |
+
"</p>"
|
| 129 |
+
)
|
| 130 |
+
gr.HTML(
|
| 131 |
+
'<p class="cross-link">Want live coaching? Try '
|
| 132 |
+
"<strong>TeacherVoice → Pitch practice</strong>.</p>"
|
| 133 |
)
|
| 134 |
|
| 135 |
+
with gr.Row(elem_classes=["ec-workflow-columns"]):
|
| 136 |
+
with gr.Column(scale=1, elem_classes=["ec-input-col"]):
|
| 137 |
+
gr.HTML('<p class="form-section-label">Step 1 · Record your pitch</p>')
|
| 138 |
+
|
| 139 |
+
with gr.Column(elem_classes=["form-primary"]):
|
| 140 |
+
rec = build_recording_block(
|
| 141 |
+
max_seconds=_config.max_seconds,
|
| 142 |
+
default_seconds=min(30, _config.max_seconds),
|
| 143 |
+
lang_choices=lang_choices,
|
| 144 |
+
asr_choices=asr_choices,
|
| 145 |
+
default_lang=default_lang,
|
| 146 |
+
default_asr=default_asr,
|
| 147 |
+
audio_label="Your pitch (mic or upload, up to 30s)",
|
| 148 |
+
include_sample=True,
|
| 149 |
+
compact=True,
|
| 150 |
)
|
| 151 |
+
|
| 152 |
+
status = gr.Markdown(
|
| 153 |
+
value="_Record or upload audio, then analyze._",
|
| 154 |
+
elem_classes=["form-status"],
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 155 |
)
|
| 156 |
+
rec.status = status
|
| 157 |
+
|
| 158 |
+
with gr.Accordion(
|
| 159 |
+
"VoiceOut options",
|
| 160 |
+
open=False,
|
| 161 |
+
elem_classes=["form-optional-accordion"],
|
| 162 |
+
):
|
| 163 |
+
speak_rewrite = gr.Checkbox(
|
| 164 |
+
label="Speak full rewrite (otherwise summary + tip)",
|
| 165 |
+
value=False,
|
| 166 |
+
)
|
| 167 |
+
|
| 168 |
+
with gr.Row(elem_classes=["form-cta-row"]):
|
| 169 |
+
analyze_btn = gr.Button(
|
| 170 |
+
"Analyze pitch",
|
| 171 |
+
variant="primary",
|
| 172 |
+
elem_classes=["primary-cta"],
|
| 173 |
+
)
|
| 174 |
+
|
| 175 |
+
wire_recording_handlers(
|
| 176 |
+
rec,
|
| 177 |
+
stop_next_action="Click **Analyze pitch**.",
|
| 178 |
+
status_output=status,
|
| 179 |
+
sample_loader=load_sample_pitch,
|
| 180 |
)
|
| 181 |
+
|
| 182 |
+
advanced = build_advanced_panel(use_json=True)
|
| 183 |
+
|
| 184 |
+
with gr.Column(scale=2, elem_classes=["ec-results-col"]):
|
| 185 |
+
gr.HTML('<p class="form-section-label">Step 2 · Review feedback</p>')
|
| 186 |
+
|
| 187 |
+
results_empty = gr.HTML(
|
| 188 |
+
value=empty_state(
|
| 189 |
+
"Your transcript, pace charts, filler highlights, and VoiceOut audio "
|
| 190 |
+
"will appear here after you analyze a recording."
|
| 191 |
+
)
|
| 192 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 193 |
|
| 194 |
+
with gr.Column(visible=False) as results_panel:
|
| 195 |
+
report_md = gr.Markdown(
|
| 196 |
+
label="Coach summary",
|
| 197 |
+
elem_classes=["ec-coach-report"],
|
| 198 |
+
)
|
| 199 |
+
transcript_html = gr.HTML(
|
| 200 |
+
label="Transcript",
|
| 201 |
+
elem_classes=["ec-transcript"],
|
| 202 |
+
)
|
| 203 |
+
with gr.Row(elem_classes=["ec-charts-row"]):
|
| 204 |
+
filler_chart = gr.Image(
|
| 205 |
+
label="Filler words",
|
| 206 |
+
type="filepath",
|
| 207 |
+
visible=False,
|
| 208 |
+
)
|
| 209 |
+
pace_chart = gr.Image(
|
| 210 |
+
label="Pace timeline",
|
| 211 |
+
type="filepath",
|
| 212 |
+
visible=False,
|
| 213 |
+
)
|
| 214 |
+
voiceout = gr.Audio(label="VoiceOut feedback", type="filepath", visible=False)
|
| 215 |
|
| 216 |
analyze_btn.click(
|
| 217 |
analyze_pitch,
|
| 218 |
+
inputs=[
|
| 219 |
+
rec.audio_in,
|
| 220 |
+
rec.language,
|
| 221 |
+
rec.asr_preset,
|
| 222 |
+
speak_rewrite,
|
| 223 |
+
],
|
| 224 |
outputs=[
|
| 225 |
status,
|
| 226 |
transcript_html,
|
|
|
|
| 228 |
filler_chart,
|
| 229 |
pace_chart,
|
| 230 |
voiceout,
|
| 231 |
+
advanced.trace_summary,
|
| 232 |
+
advanced.trace_box,
|
| 233 |
+
results_empty,
|
| 234 |
+
results_panel,
|
| 235 |
],
|
| 236 |
)
|
| 237 |
|
apps/gradio-space/src/gradio_space/tabs/education_pptx.py
CHANGED
|
@@ -4,13 +4,14 @@ import gradio as gr
|
|
| 4 |
|
| 5 |
from agent.runner import AgentRunner
|
| 6 |
from agent.tools.pptx import get_outputs_dir
|
| 7 |
-
from gradio_space.model_loading import ensure_model_loaded, get_active_model_key
|
| 8 |
from gradio_space.research_helpers import (
|
| 9 |
list_session_choices,
|
| 10 |
merge_lesson_urls,
|
| 11 |
refresh_doc_choices,
|
| 12 |
refresh_sessions,
|
| 13 |
)
|
|
|
|
| 14 |
from inference.factory import get_backend
|
| 15 |
from researchmind.config import get_config
|
| 16 |
|
|
@@ -21,7 +22,7 @@ SOURCE_MODES = [
|
|
| 21 |
]
|
| 22 |
|
| 23 |
SEARCH_WORKFLOWS = [
|
| 24 |
-
("Two-step
|
| 25 |
("Auto search & ingest", "auto"),
|
| 26 |
]
|
| 27 |
|
|
@@ -70,6 +71,7 @@ def update_source_visibility(source_mode_label: str, search_workflow_label: str)
|
|
| 70 |
is_rag = mode == "rag"
|
| 71 |
is_sources = is_web or is_rag
|
| 72 |
is_two_step = is_web and workflow == "two_step"
|
|
|
|
| 73 |
return (
|
| 74 |
gr.update(visible=is_web),
|
| 75 |
gr.update(visible=is_two_step),
|
|
@@ -78,13 +80,20 @@ def update_source_visibility(source_mode_label: str, search_workflow_label: str)
|
|
| 78 |
gr.update(visible=is_sources),
|
| 79 |
gr.update(visible=is_rag),
|
| 80 |
gr.update(visible=is_rag),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 81 |
)
|
| 82 |
|
| 83 |
|
| 84 |
def discover_lesson_sources(
|
| 85 |
topic: str,
|
| 86 |
session_id: str,
|
|
|
|
| 87 |
) -> tuple[str, object, object]:
|
|
|
|
| 88 |
model_key = get_active_model_key()
|
| 89 |
load_error = ensure_model_loaded(model_key)
|
| 90 |
if load_error:
|
|
@@ -114,6 +123,7 @@ def discover_lesson_sources(
|
|
| 114 |
f"Found **{len(choices)}** verified URL(s). Select sources, then click "
|
| 115 |
"**Generate lesson slides**."
|
| 116 |
)
|
|
|
|
| 117 |
return (
|
| 118 |
summary,
|
| 119 |
gr.update(choices=choices, value=choices),
|
|
@@ -135,7 +145,9 @@ def generate_lesson_slides(
|
|
| 135 |
upload_files: list[str] | None,
|
| 136 |
session_id: str,
|
| 137 |
doc_ids: list[str] | None,
|
|
|
|
| 138 |
) -> tuple[str, str, list[str], str | None, str | None, str | None, str, str, str]:
|
|
|
|
| 139 |
model_key = get_active_model_key()
|
| 140 |
load_error = ensure_model_loaded(model_key)
|
| 141 |
if load_error:
|
|
@@ -151,6 +163,7 @@ def generate_lesson_slides(
|
|
| 151 |
files = [Path(p) for p in (upload_files or [])]
|
| 152 |
|
| 153 |
try:
|
|
|
|
| 154 |
runner = AgentRunner()
|
| 155 |
result = runner.run_education_pptx(
|
| 156 |
topic=topic,
|
|
@@ -165,10 +178,11 @@ def generate_lesson_slides(
|
|
| 165 |
session_id=session_id or None,
|
| 166 |
doc_ids=doc_ids or [],
|
| 167 |
)
|
| 168 |
-
except Exception as exc: # noqa: BLE001
|
| 169 |
message = f"Agent error: {exc}"
|
| 170 |
return _empty_outputs(message)
|
| 171 |
|
|
|
|
| 172 |
gallery = [str(Path(p).resolve()) for p in result.preview_images]
|
| 173 |
trace_summary = (
|
| 174 |
f"Run `{result.trace.run_id}` · skill `{result.trace.skill}` · "
|
|
@@ -190,85 +204,93 @@ def generate_lesson_slides(
|
|
| 190 |
|
| 191 |
|
| 192 |
def build_education_pptx_tab() -> None:
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
"""
|
| 197 |
-
### Lesson slide builder
|
| 198 |
-
|
| 199 |
-
Enter a topic and grade level. A **local small model** drafts the outline;
|
| 200 |
-
optionally ground it with **web search** or **RAG** from indexed sources.
|
| 201 |
-
"""
|
| 202 |
)
|
| 203 |
-
gr.Markdown(model_status(model_key))
|
| 204 |
|
| 205 |
-
with gr.
|
| 206 |
topic = gr.Textbox(
|
| 207 |
-
label="
|
| 208 |
-
placeholder="e.g. Photosynthesis, Fractions, The water cycle",
|
|
|
|
|
|
|
|
|
|
| 209 |
)
|
|
|
|
|
|
|
| 210 |
grade = gr.Dropdown(
|
| 211 |
-
label="Grade
|
| 212 |
choices=["K", "1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12", "Adult"],
|
| 213 |
value="6",
|
|
|
|
|
|
|
| 214 |
)
|
| 215 |
slide_count = gr.Slider(
|
| 216 |
minimum=3,
|
| 217 |
maximum=8,
|
| 218 |
step=1,
|
| 219 |
value=5,
|
| 220 |
-
label="
|
|
|
|
| 221 |
)
|
| 222 |
|
| 223 |
-
gr.
|
| 224 |
-
|
| 225 |
-
source_mode = gr.Dropdown(
|
| 226 |
label="Source mode",
|
| 227 |
choices=[m[0] for m in SOURCE_MODES],
|
| 228 |
value=SOURCE_MODES[0][0],
|
| 229 |
)
|
| 230 |
-
search_workflow = gr.
|
| 231 |
-
label="
|
| 232 |
choices=[m[0] for m in SEARCH_WORKFLOWS],
|
| 233 |
value=SEARCH_WORKFLOWS[0][0],
|
| 234 |
visible=False,
|
| 235 |
)
|
| 236 |
-
|
| 237 |
-
with gr.Row():
|
| 238 |
discover_btn = gr.Button("Discover sources", variant="secondary", visible=False)
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 243 |
visible=False,
|
| 244 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 245 |
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
lines=3,
|
| 254 |
-
placeholder="https://en.wikipedia.org/wiki/...",
|
| 255 |
-
visible=False,
|
| 256 |
-
)
|
| 257 |
-
upload_files = gr.File(
|
| 258 |
-
label="Upload PDF or DOCX",
|
| 259 |
-
file_count="multiple",
|
| 260 |
-
file_types=[".pdf", ".docx"],
|
| 261 |
-
visible=False,
|
| 262 |
-
)
|
| 263 |
-
doc_dd = gr.CheckboxGroup(
|
| 264 |
-
label="Documents in session (RAG scope)",
|
| 265 |
-
choices=[],
|
| 266 |
-
value=[],
|
| 267 |
-
visible=False,
|
| 268 |
-
)
|
| 269 |
|
| 270 |
-
|
| 271 |
-
source_status = gr.Markdown(value="_No sources gathered yet._")
|
| 272 |
|
| 273 |
with gr.Tabs():
|
| 274 |
with gr.Tab("Slide preview"):
|
|
@@ -294,23 +316,16 @@ optionally ground it with **web search** or **RAG** from indexed sources.
|
|
| 294 |
interactive=False,
|
| 295 |
)
|
| 296 |
|
| 297 |
-
gr.
|
| 298 |
-
|
| 299 |
-
|
|
|
|
| 300 |
then choose **Open with → Google Docs**. You can also upload the `.html` file via
|
| 301 |
**Google Docs → File → Open → Upload**.
|
| 302 |
"""
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
trace_box = gr.Textbox(
|
| 306 |
-
label="Agent trace (JSON)",
|
| 307 |
-
lines=12,
|
| 308 |
-
max_lines=20,
|
| 309 |
-
interactive=False,
|
| 310 |
-
)
|
| 311 |
|
| 312 |
-
|
| 313 |
-
trace_summary = gr.Markdown()
|
| 314 |
|
| 315 |
source_controls = [
|
| 316 |
search_workflow,
|
|
@@ -319,7 +334,9 @@ then choose **Open with → Google Docs**. You can also upload the `.html` file
|
|
| 319 |
urls_text,
|
| 320 |
upload_files,
|
| 321 |
session_dd,
|
|
|
|
| 322 |
doc_dd,
|
|
|
|
| 323 |
]
|
| 324 |
|
| 325 |
def _refresh_visibility(mode_label: str, workflow_label: str):
|
|
@@ -336,6 +353,7 @@ then choose **Open with → Google Docs**. You can also upload the `.html` file
|
|
| 336 |
outputs=source_controls,
|
| 337 |
)
|
| 338 |
|
|
|
|
| 339 |
session_dd.change(
|
| 340 |
fn=refresh_doc_choices,
|
| 341 |
inputs=[session_dd, doc_dd],
|
|
@@ -369,8 +387,8 @@ then choose **Open with → Google Docs**. You can also upload the `.html` file
|
|
| 369 |
pptx_file,
|
| 370 |
docx_file,
|
| 371 |
html_file,
|
| 372 |
-
trace_summary,
|
| 373 |
-
trace_box,
|
| 374 |
source_status,
|
| 375 |
],
|
| 376 |
)
|
|
|
|
| 4 |
|
| 5 |
from agent.runner import AgentRunner
|
| 6 |
from agent.tools.pptx import get_outputs_dir
|
| 7 |
+
from gradio_space.model_loading import ensure_model_loaded, get_active_model_key
|
| 8 |
from gradio_space.research_helpers import (
|
| 9 |
list_session_choices,
|
| 10 |
merge_lesson_urls,
|
| 11 |
refresh_doc_choices,
|
| 12 |
refresh_sessions,
|
| 13 |
)
|
| 14 |
+
from gradio_space.ui.components import build_advanced_panel, DOC_CHOICE_LIST_CLASSES
|
| 15 |
from inference.factory import get_backend
|
| 16 |
from researchmind.config import get_config
|
| 17 |
|
|
|
|
| 22 |
]
|
| 23 |
|
| 24 |
SEARCH_WORKFLOWS = [
|
| 25 |
+
("Two-step (discover & confirm)", "two_step"),
|
| 26 |
("Auto search & ingest", "auto"),
|
| 27 |
]
|
| 28 |
|
|
|
|
| 71 |
is_rag = mode == "rag"
|
| 72 |
is_sources = is_web or is_rag
|
| 73 |
is_two_step = is_web and workflow == "two_step"
|
| 74 |
+
is_auto = is_web and workflow == "auto"
|
| 75 |
return (
|
| 76 |
gr.update(visible=is_web),
|
| 77 |
gr.update(visible=is_two_step),
|
|
|
|
| 80 |
gr.update(visible=is_sources),
|
| 81 |
gr.update(visible=is_rag),
|
| 82 |
gr.update(visible=is_rag),
|
| 83 |
+
gr.update(visible=is_rag),
|
| 84 |
+
gr.update(visible=is_rag),
|
| 85 |
+
gr.update(
|
| 86 |
+
value="Search web & generate" if is_auto else "Generate lesson slides",
|
| 87 |
+
),
|
| 88 |
)
|
| 89 |
|
| 90 |
|
| 91 |
def discover_lesson_sources(
|
| 92 |
topic: str,
|
| 93 |
session_id: str,
|
| 94 |
+
progress: gr.Progress = gr.Progress(),
|
| 95 |
) -> tuple[str, object, object]:
|
| 96 |
+
progress(0, desc="Discovering sources…")
|
| 97 |
model_key = get_active_model_key()
|
| 98 |
load_error = ensure_model_loaded(model_key)
|
| 99 |
if load_error:
|
|
|
|
| 123 |
f"Found **{len(choices)}** verified URL(s). Select sources, then click "
|
| 124 |
"**Generate lesson slides**."
|
| 125 |
)
|
| 126 |
+
progress(1.0, desc="Done")
|
| 127 |
return (
|
| 128 |
summary,
|
| 129 |
gr.update(choices=choices, value=choices),
|
|
|
|
| 145 |
upload_files: list[str] | None,
|
| 146 |
session_id: str,
|
| 147 |
doc_ids: list[str] | None,
|
| 148 |
+
progress: gr.Progress = gr.Progress(),
|
| 149 |
) -> tuple[str, str, list[str], str | None, str | None, str | None, str, str, str]:
|
| 150 |
+
progress(0, desc="Loading model…")
|
| 151 |
model_key = get_active_model_key()
|
| 152 |
load_error = ensure_model_loaded(model_key)
|
| 153 |
if load_error:
|
|
|
|
| 163 |
files = [Path(p) for p in (upload_files or [])]
|
| 164 |
|
| 165 |
try:
|
| 166 |
+
progress(0.1, desc="Generating lesson slides…")
|
| 167 |
runner = AgentRunner()
|
| 168 |
result = runner.run_education_pptx(
|
| 169 |
topic=topic,
|
|
|
|
| 178 |
session_id=session_id or None,
|
| 179 |
doc_ids=doc_ids or [],
|
| 180 |
)
|
| 181 |
+
except Exception as exc: # noqa: BLE001
|
| 182 |
message = f"Agent error: {exc}"
|
| 183 |
return _empty_outputs(message)
|
| 184 |
|
| 185 |
+
progress(1.0, desc="Done")
|
| 186 |
gallery = [str(Path(p).resolve()) for p in result.preview_images]
|
| 187 |
trace_summary = (
|
| 188 |
f"Run `{result.trace.run_id}` · skill `{result.trace.skill}` · "
|
|
|
|
| 204 |
|
| 205 |
|
| 206 |
def build_education_pptx_tab() -> None:
|
| 207 |
+
gr.Markdown("### Create lesson slides", elem_classes=["lesson-tab-heading"])
|
| 208 |
+
gr.HTML(
|
| 209 |
+
'<p class="tab-subtitle">Enter your topic below, adjust grade and length if needed, then generate.</p>'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 210 |
)
|
|
|
|
| 211 |
|
| 212 |
+
with gr.Column(elem_classes=["lesson-form-primary"]):
|
| 213 |
topic = gr.Textbox(
|
| 214 |
+
label="What are you teaching?",
|
| 215 |
+
placeholder="e.g. Photosynthesis, Fractions, The water cycle, AI agents…",
|
| 216 |
+
lines=2,
|
| 217 |
+
max_lines=3,
|
| 218 |
+
elem_classes=["lesson-topic-input"],
|
| 219 |
)
|
| 220 |
+
|
| 221 |
+
with gr.Row(elem_classes=["lesson-form-secondary"]):
|
| 222 |
grade = gr.Dropdown(
|
| 223 |
+
label="Grade",
|
| 224 |
choices=["K", "1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12", "Adult"],
|
| 225 |
value="6",
|
| 226 |
+
scale=1,
|
| 227 |
+
min_width=100,
|
| 228 |
)
|
| 229 |
slide_count = gr.Slider(
|
| 230 |
minimum=3,
|
| 231 |
maximum=8,
|
| 232 |
step=1,
|
| 233 |
value=5,
|
| 234 |
+
label="Slides",
|
| 235 |
+
scale=2,
|
| 236 |
)
|
| 237 |
|
| 238 |
+
with gr.Accordion("Research sources (optional)", open=False, elem_classes=["lesson-optional-accordion"]):
|
| 239 |
+
source_mode = gr.Radio(
|
|
|
|
| 240 |
label="Source mode",
|
| 241 |
choices=[m[0] for m in SOURCE_MODES],
|
| 242 |
value=SOURCE_MODES[0][0],
|
| 243 |
)
|
| 244 |
+
search_workflow = gr.Radio(
|
| 245 |
+
label="Web search workflow",
|
| 246 |
choices=[m[0] for m in SEARCH_WORKFLOWS],
|
| 247 |
value=SEARCH_WORKFLOWS[0][0],
|
| 248 |
visible=False,
|
| 249 |
)
|
|
|
|
|
|
|
| 250 |
discover_btn = gr.Button("Discover sources", variant="secondary", visible=False)
|
| 251 |
+
with gr.Row():
|
| 252 |
+
session_dd = gr.Dropdown(
|
| 253 |
+
label="ResearchMind session",
|
| 254 |
+
choices=list_session_choices(),
|
| 255 |
+
value="",
|
| 256 |
+
visible=False,
|
| 257 |
+
)
|
| 258 |
+
refresh_sess_btn = gr.Button("↻", size="sm", visible=False, min_width=40)
|
| 259 |
+
url_choices = gr.CheckboxGroup(
|
| 260 |
+
label="Suggested URLs to use",
|
| 261 |
+
choices=[],
|
| 262 |
+
visible=False,
|
| 263 |
+
elem_classes=DOC_CHOICE_LIST_CLASSES,
|
| 264 |
+
)
|
| 265 |
+
urls_text = gr.Textbox(
|
| 266 |
+
label="URLs (one per line, optional)",
|
| 267 |
+
lines=3,
|
| 268 |
+
placeholder="https://en.wikipedia.org/wiki/...",
|
| 269 |
+
visible=False,
|
| 270 |
+
)
|
| 271 |
+
upload_files = gr.File(
|
| 272 |
+
label="Upload PDF or DOCX",
|
| 273 |
+
file_count="multiple",
|
| 274 |
+
file_types=[".pdf", ".docx"],
|
| 275 |
visible=False,
|
| 276 |
)
|
| 277 |
+
doc_dd = gr.CheckboxGroup(
|
| 278 |
+
label="Documents in session (RAG scope)",
|
| 279 |
+
choices=[],
|
| 280 |
+
value=[],
|
| 281 |
+
visible=False,
|
| 282 |
+
elem_classes=DOC_CHOICE_LIST_CLASSES,
|
| 283 |
+
)
|
| 284 |
|
| 285 |
+
with gr.Row(elem_classes=["lesson-generate-row"]):
|
| 286 |
+
generate_btn = gr.Button(
|
| 287 |
+
"Generate lesson slides",
|
| 288 |
+
variant="primary",
|
| 289 |
+
elem_classes=["primary-cta"],
|
| 290 |
+
scale=1,
|
| 291 |
+
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 292 |
|
| 293 |
+
source_status = gr.Markdown(value="_Ready to generate._", elem_classes=["lesson-status"])
|
|
|
|
| 294 |
|
| 295 |
with gr.Tabs():
|
| 296 |
with gr.Tab("Slide preview"):
|
|
|
|
| 316 |
interactive=False,
|
| 317 |
)
|
| 318 |
|
| 319 |
+
with gr.Accordion("Export help — open in Google Docs", open=False):
|
| 320 |
+
gr.Markdown(
|
| 321 |
+
"""
|
| 322 |
+
Download the `.docx` file, upload it to [Google Drive](https://drive.google.com),
|
| 323 |
then choose **Open with → Google Docs**. You can also upload the `.html` file via
|
| 324 |
**Google Docs → File → Open → Upload**.
|
| 325 |
"""
|
| 326 |
+
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 327 |
|
| 328 |
+
advanced = build_advanced_panel()
|
|
|
|
| 329 |
|
| 330 |
source_controls = [
|
| 331 |
search_workflow,
|
|
|
|
| 334 |
urls_text,
|
| 335 |
upload_files,
|
| 336 |
session_dd,
|
| 337 |
+
refresh_sess_btn,
|
| 338 |
doc_dd,
|
| 339 |
+
generate_btn,
|
| 340 |
]
|
| 341 |
|
| 342 |
def _refresh_visibility(mode_label: str, workflow_label: str):
|
|
|
|
| 353 |
outputs=source_controls,
|
| 354 |
)
|
| 355 |
|
| 356 |
+
refresh_sess_btn.click(fn=refresh_sessions, inputs=[session_dd], outputs=[session_dd])
|
| 357 |
session_dd.change(
|
| 358 |
fn=refresh_doc_choices,
|
| 359 |
inputs=[session_dd, doc_dd],
|
|
|
|
| 387 |
pptx_file,
|
| 388 |
docx_file,
|
| 389 |
html_file,
|
| 390 |
+
advanced.trace_summary,
|
| 391 |
+
advanced.trace_box,
|
| 392 |
source_status,
|
| 393 |
],
|
| 394 |
)
|
apps/gradio-space/src/gradio_space/tabs/research_mind.py
CHANGED
|
@@ -6,86 +6,69 @@ from pathlib import Path
|
|
| 6 |
import gradio as gr
|
| 7 |
|
| 8 |
from agent.runner import AgentRunner
|
| 9 |
-
from gradio_space.model_loading import ensure_model_loaded, get_active_model_key
|
| 10 |
from gradio_space.research_helpers import (
|
|
|
|
| 11 |
format_ingest_status,
|
| 12 |
list_session_choices,
|
| 13 |
load_trace_json,
|
| 14 |
memory_summary,
|
|
|
|
| 15 |
rag_scope_hint,
|
| 16 |
refresh_doc_choices,
|
| 17 |
refresh_sessions,
|
| 18 |
run_research_question,
|
| 19 |
trace_summary_markdown,
|
| 20 |
)
|
|
|
|
| 21 |
from inference.factory import get_backend
|
| 22 |
-
from researchmind.config import get_config
|
| 23 |
-
from researchmind.ingest import IngestPipeline
|
| 24 |
|
| 25 |
logger = logging.getLogger(__name__)
|
| 26 |
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
|
|
|
| 31 |
|
| 32 |
|
| 33 |
def discover_sources(
|
| 34 |
topic: str,
|
| 35 |
-
ingest_mode: str,
|
| 36 |
session_id: str,
|
| 37 |
-
|
|
|
|
|
|
|
| 38 |
model_key = get_active_model_key()
|
| 39 |
load_error = ensure_model_loaded(model_key)
|
| 40 |
if load_error:
|
| 41 |
return (
|
| 42 |
load_error,
|
| 43 |
-
gr.update(choices=[], value=[]),
|
| 44 |
session_id,
|
| 45 |
load_error,
|
| 46 |
load_error,
|
| 47 |
memory_summary(session_id),
|
| 48 |
refresh_doc_choices(session_id, []),
|
|
|
|
| 49 |
)
|
| 50 |
|
| 51 |
-
|
| 52 |
-
|
| 53 |
return (
|
| 54 |
-
|
| 55 |
-
gr.update(choices=[], value=[]),
|
| 56 |
session_id,
|
| 57 |
-
|
| 58 |
-
|
| 59 |
memory_summary(session_id),
|
| 60 |
refresh_doc_choices(session_id, []),
|
|
|
|
| 61 |
)
|
| 62 |
|
| 63 |
-
auto_search = ingest_mode == "auto"
|
| 64 |
try:
|
| 65 |
runner = AgentRunner()
|
| 66 |
-
if auto_search:
|
| 67 |
-
result = runner.run_researchmind_ingest(
|
| 68 |
-
topic=topic,
|
| 69 |
-
urls=[],
|
| 70 |
-
files=[],
|
| 71 |
-
auto_search=True,
|
| 72 |
-
session_id=session_id or None,
|
| 73 |
-
model_key=model_key,
|
| 74 |
-
backend=get_backend(model_key),
|
| 75 |
-
)
|
| 76 |
-
trace_json = load_trace_json(result.trace_path)
|
| 77 |
-
return (
|
| 78 |
-
format_ingest_status(result),
|
| 79 |
-
gr.update(choices=[], value=[]),
|
| 80 |
-
result.session_id,
|
| 81 |
-
trace_summary_markdown(result.trace_path),
|
| 82 |
-
trace_json,
|
| 83 |
-
memory_summary(result.session_id),
|
| 84 |
-
refresh_doc_choices(result.session_id, []),
|
| 85 |
-
)
|
| 86 |
-
|
| 87 |
discover = runner.run_researchmind_discover(
|
| 88 |
-
topic=topic,
|
| 89 |
auto_search=False,
|
| 90 |
session_id=session_id or None,
|
| 91 |
model_key=model_key,
|
|
@@ -95,83 +78,171 @@ def discover_sources(
|
|
| 95 |
if not choices:
|
| 96 |
summary = (
|
| 97 |
"No verified URLs found. Try a more specific topic, paste URLs manually, "
|
| 98 |
-
"or
|
| 99 |
)
|
| 100 |
else:
|
| 101 |
summary = (
|
| 102 |
-
f"Found **{len(choices)} verified URL(s)
|
| 103 |
-
|
| 104 |
)
|
| 105 |
trace_json = load_trace_json(discover.trace_path)
|
|
|
|
| 106 |
return (
|
| 107 |
summary,
|
| 108 |
-
gr.update(choices=choices, value=choices),
|
| 109 |
-
discover.session_id,
|
| 110 |
trace_summary_markdown(discover.trace_path),
|
| 111 |
trace_json,
|
| 112 |
memory_summary(discover.session_id),
|
| 113 |
refresh_doc_choices(discover.session_id, []),
|
|
|
|
| 114 |
)
|
| 115 |
except Exception as exc: # noqa: BLE001
|
| 116 |
msg = f"Discover error: {exc}"
|
| 117 |
return (
|
| 118 |
msg,
|
| 119 |
-
gr.update(choices=[], value=[]),
|
| 120 |
session_id,
|
| 121 |
msg,
|
| 122 |
msg,
|
| 123 |
memory_summary(session_id),
|
| 124 |
refresh_doc_choices(session_id, []),
|
|
|
|
| 125 |
)
|
| 126 |
|
| 127 |
|
| 128 |
-
def
|
| 129 |
topic: str,
|
| 130 |
-
urls_text: str,
|
| 131 |
-
selected_urls: list[str],
|
| 132 |
-
upload_files: list[str] | None,
|
| 133 |
session_id: str,
|
| 134 |
-
|
|
|
|
|
|
|
| 135 |
model_key = get_active_model_key()
|
| 136 |
load_error = ensure_model_loaded(model_key)
|
| 137 |
if load_error:
|
| 138 |
return (
|
| 139 |
load_error,
|
| 140 |
-
|
|
|
|
| 141 |
load_error,
|
| 142 |
load_error,
|
| 143 |
-
|
| 144 |
refresh_doc_choices(session_id, []),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 145 |
)
|
| 146 |
|
| 147 |
-
direct_urls =
|
| 148 |
all_urls = list(dict.fromkeys([*direct_urls, *(selected_urls or [])]))
|
| 149 |
files = [Path(p) for p in (upload_files or [])]
|
| 150 |
|
| 151 |
if not all_urls and not files:
|
| 152 |
-
msg = "
|
| 153 |
return (
|
| 154 |
msg,
|
| 155 |
-
memory_summary(
|
| 156 |
msg,
|
| 157 |
msg,
|
| 158 |
-
refresh_sessions(
|
| 159 |
-
refresh_doc_choices(
|
| 160 |
)
|
| 161 |
|
| 162 |
try:
|
| 163 |
logger.info("Ingesting %d URL(s) and %d file(s)", len(all_urls), len(files))
|
| 164 |
runner = AgentRunner()
|
| 165 |
result = runner.run_researchmind_ingest(
|
| 166 |
-
topic=topic or
|
| 167 |
urls=all_urls,
|
| 168 |
files=files,
|
| 169 |
auto_search=False,
|
| 170 |
-
session_id=
|
| 171 |
model_key=model_key,
|
| 172 |
backend=get_backend(model_key),
|
| 173 |
)
|
| 174 |
trace_json = load_trace_json(result.trace_path)
|
|
|
|
| 175 |
return (
|
| 176 |
format_ingest_status(result),
|
| 177 |
memory_summary(result.session_id),
|
|
@@ -185,11 +256,11 @@ def ingest_selected(
|
|
| 185 |
msg = f"**Ingest error:** {exc}"
|
| 186 |
return (
|
| 187 |
msg,
|
| 188 |
-
memory_summary(
|
| 189 |
msg,
|
| 190 |
msg,
|
| 191 |
-
refresh_sessions(
|
| 192 |
-
refresh_doc_choices(
|
| 193 |
)
|
| 194 |
|
| 195 |
|
|
@@ -198,116 +269,148 @@ def ask_question(
|
|
| 198 |
session_id: str,
|
| 199 |
doc_ids: list[str] | None,
|
| 200 |
chat_history: list[dict],
|
| 201 |
-
|
|
|
|
| 202 |
if not question.strip():
|
| 203 |
-
return chat_history or [], "Enter a question.", "", rag_scope_hint(session_id, doc_ids)
|
| 204 |
|
| 205 |
try:
|
|
|
|
| 206 |
answer, trace_json, trace_summary = run_research_question(
|
| 207 |
question,
|
| 208 |
session_id=session_id,
|
| 209 |
doc_ids=doc_ids,
|
| 210 |
)
|
|
|
|
|
|
|
|
|
|
| 211 |
history = list(chat_history or [])
|
| 212 |
history.append({"role": "user", "content": question})
|
| 213 |
history.append({"role": "assistant", "content": answer})
|
| 214 |
-
|
|
|
|
| 215 |
except Exception as exc: # noqa: BLE001
|
| 216 |
logger.exception("Research chat failed")
|
| 217 |
history = list(chat_history or [])
|
| 218 |
history.append({"role": "user", "content": question})
|
| 219 |
err = f"Chat error: {exc}"
|
| 220 |
history.append({"role": "assistant", "content": err})
|
| 221 |
-
return history, err, err, rag_scope_hint(session_id, doc_ids)
|
| 222 |
|
| 223 |
|
| 224 |
def build_research_mind_tab() -> None:
|
| 225 |
-
"
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
"""
|
| 231 |
-
### ResearchMind
|
| 232 |
-
|
| 233 |
-
Scrape sources once, index into **MemRAG** (local SQLite + embeddings), then ask questions **offline** with citations.
|
| 234 |
-
"""
|
| 235 |
)
|
| 236 |
-
gr.Markdown(model_status(model_key))
|
| 237 |
-
gr.Markdown(f"Memory store: `{cfg.data_dir.resolve()}`")
|
| 238 |
|
| 239 |
-
with gr.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 240 |
session_dd = gr.Dropdown(
|
| 241 |
label="Session",
|
| 242 |
choices=list_session_choices(),
|
| 243 |
value="",
|
| 244 |
-
|
| 245 |
)
|
| 246 |
-
refresh_btn = gr.Button("
|
| 247 |
-
|
| 248 |
-
with gr.
|
| 249 |
-
with gr.
|
| 250 |
-
gr.
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
)
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
|
|
|
| 261 |
)
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 266 |
)
|
| 267 |
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
placeholder="https://en.wikipedia.org/wiki/...",
|
| 272 |
)
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 277 |
)
|
| 278 |
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
label="Question",
|
| 308 |
-
placeholder="What do these sources say about AI agents?",
|
| 309 |
-
)
|
| 310 |
-
ask_btn = gr.Button("Ask", variant="primary")
|
| 311 |
|
| 312 |
refresh_btn.click(fn=refresh_sessions, inputs=[session_dd], outputs=[session_dd])
|
| 313 |
refresh_memory_btn.click(fn=memory_summary, inputs=[session_dd], outputs=[memory_md])
|
|
@@ -323,43 +426,57 @@ Scrape sources once, index into **MemRAG** (local SQLite + embeddings), then ask
|
|
| 323 |
)
|
| 324 |
doc_dd.change(fn=rag_scope_hint, inputs=[session_dd, doc_dd], outputs=[rag_hint])
|
| 325 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 326 |
discover_btn.click(
|
| 327 |
-
fn=
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
session_dd,
|
| 337 |
-
trace_summary,
|
| 338 |
-
trace_box,
|
| 339 |
-
memory_md,
|
| 340 |
-
doc_dd,
|
| 341 |
-
],
|
| 342 |
)
|
| 343 |
|
| 344 |
ingest_btn.click(
|
| 345 |
fn=ingest_selected,
|
| 346 |
inputs=[topic, urls_text, url_choices, upload_files, session_dd],
|
| 347 |
-
outputs=[
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 348 |
)
|
| 349 |
|
| 350 |
ask_btn.click(
|
| 351 |
fn=ask_question,
|
| 352 |
inputs=[question, session_dd, doc_dd, chatbot],
|
| 353 |
-
outputs=[chatbot, trace_box, trace_summary, rag_hint],
|
| 354 |
)
|
| 355 |
question.submit(
|
| 356 |
fn=ask_question,
|
| 357 |
inputs=[question, session_dd, doc_dd, chatbot],
|
| 358 |
-
outputs=[chatbot, trace_box, trace_summary, rag_hint],
|
| 359 |
)
|
| 360 |
|
| 361 |
|
| 362 |
def researchmind_allowed_paths() -> list[str]:
|
|
|
|
|
|
|
| 363 |
cfg = get_config()
|
| 364 |
root = cfg.data_dir.resolve()
|
| 365 |
root.mkdir(parents=True, exist_ok=True)
|
|
|
|
| 6 |
import gradio as gr
|
| 7 |
|
| 8 |
from agent.runner import AgentRunner
|
| 9 |
+
from gradio_space.model_loading import ensure_model_loaded, get_active_model_key
|
| 10 |
from gradio_space.research_helpers import (
|
| 11 |
+
format_citations_markdown,
|
| 12 |
format_ingest_status,
|
| 13 |
list_session_choices,
|
| 14 |
load_trace_json,
|
| 15 |
memory_summary,
|
| 16 |
+
parse_urls_text,
|
| 17 |
rag_scope_hint,
|
| 18 |
refresh_doc_choices,
|
| 19 |
refresh_sessions,
|
| 20 |
run_research_question,
|
| 21 |
trace_summary_markdown,
|
| 22 |
)
|
| 23 |
+
from gradio_space.ui.components import build_advanced_panel, DOC_CHOICE_LIST_CLASSES
|
| 24 |
from inference.factory import get_backend
|
|
|
|
|
|
|
| 25 |
|
| 26 |
logger = logging.getLogger(__name__)
|
| 27 |
|
| 28 |
+
|
| 29 |
+
def _require_topic(topic: str | None) -> str | None:
|
| 30 |
+
if not (topic or "").strip():
|
| 31 |
+
return "Enter a research topic first — it names your session and guides web search."
|
| 32 |
+
return None
|
| 33 |
|
| 34 |
|
| 35 |
def discover_sources(
|
| 36 |
topic: str,
|
|
|
|
| 37 |
session_id: str,
|
| 38 |
+
progress: gr.Progress = gr.Progress(),
|
| 39 |
+
) -> tuple[str, object, str, str, str, str, object, object]:
|
| 40 |
+
progress(0, desc="Searching web…")
|
| 41 |
model_key = get_active_model_key()
|
| 42 |
load_error = ensure_model_loaded(model_key)
|
| 43 |
if load_error:
|
| 44 |
return (
|
| 45 |
load_error,
|
| 46 |
+
gr.update(choices=[], value=[], visible=False),
|
| 47 |
session_id,
|
| 48 |
load_error,
|
| 49 |
load_error,
|
| 50 |
memory_summary(session_id),
|
| 51 |
refresh_doc_choices(session_id, []),
|
| 52 |
+
gr.update(visible=False),
|
| 53 |
)
|
| 54 |
|
| 55 |
+
topic_error = _require_topic(topic)
|
| 56 |
+
if topic_error:
|
| 57 |
return (
|
| 58 |
+
topic_error,
|
| 59 |
+
gr.update(choices=[], value=[], visible=False),
|
| 60 |
session_id,
|
| 61 |
+
topic_error,
|
| 62 |
+
topic_error,
|
| 63 |
memory_summary(session_id),
|
| 64 |
refresh_doc_choices(session_id, []),
|
| 65 |
+
gr.update(visible=False),
|
| 66 |
)
|
| 67 |
|
|
|
|
| 68 |
try:
|
| 69 |
runner = AgentRunner()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 70 |
discover = runner.run_researchmind_discover(
|
| 71 |
+
topic=topic.strip(),
|
| 72 |
auto_search=False,
|
| 73 |
session_id=session_id or None,
|
| 74 |
model_key=model_key,
|
|
|
|
| 78 |
if not choices:
|
| 79 |
summary = (
|
| 80 |
"No verified URLs found. Try a more specific topic, paste URLs manually, "
|
| 81 |
+
"or use **Auto-ingest from web**."
|
| 82 |
)
|
| 83 |
else:
|
| 84 |
summary = (
|
| 85 |
+
f"Found **{len(choices)}** verified URL(s). Review the list, then click "
|
| 86 |
+
"**Ingest selected sources**."
|
| 87 |
)
|
| 88 |
trace_json = load_trace_json(discover.trace_path)
|
| 89 |
+
progress(1.0, desc="Done")
|
| 90 |
return (
|
| 91 |
summary,
|
| 92 |
+
gr.update(choices=choices, value=choices, visible=bool(choices)),
|
| 93 |
+
refresh_sessions(discover.session_id),
|
| 94 |
trace_summary_markdown(discover.trace_path),
|
| 95 |
trace_json,
|
| 96 |
memory_summary(discover.session_id),
|
| 97 |
refresh_doc_choices(discover.session_id, []),
|
| 98 |
+
gr.update(visible=bool(choices)),
|
| 99 |
)
|
| 100 |
except Exception as exc: # noqa: BLE001
|
| 101 |
msg = f"Discover error: {exc}"
|
| 102 |
return (
|
| 103 |
msg,
|
| 104 |
+
gr.update(choices=[], value=[], visible=False),
|
| 105 |
session_id,
|
| 106 |
msg,
|
| 107 |
msg,
|
| 108 |
memory_summary(session_id),
|
| 109 |
refresh_doc_choices(session_id, []),
|
| 110 |
+
gr.update(visible=False),
|
| 111 |
)
|
| 112 |
|
| 113 |
|
| 114 |
+
def auto_search_ingest(
|
| 115 |
topic: str,
|
|
|
|
|
|
|
|
|
|
| 116 |
session_id: str,
|
| 117 |
+
progress: gr.Progress = gr.Progress(),
|
| 118 |
+
) -> tuple[str, object, str, str, str, str, object, object]:
|
| 119 |
+
progress(0, desc="Auto search & ingest…")
|
| 120 |
model_key = get_active_model_key()
|
| 121 |
load_error = ensure_model_loaded(model_key)
|
| 122 |
if load_error:
|
| 123 |
return (
|
| 124 |
load_error,
|
| 125 |
+
gr.update(choices=[], value=[], visible=False),
|
| 126 |
+
session_id,
|
| 127 |
load_error,
|
| 128 |
load_error,
|
| 129 |
+
memory_summary(session_id),
|
| 130 |
refresh_doc_choices(session_id, []),
|
| 131 |
+
gr.update(visible=False),
|
| 132 |
+
)
|
| 133 |
+
|
| 134 |
+
topic_error = _require_topic(topic)
|
| 135 |
+
if topic_error:
|
| 136 |
+
return (
|
| 137 |
+
topic_error,
|
| 138 |
+
gr.update(choices=[], value=[], visible=False),
|
| 139 |
+
session_id,
|
| 140 |
+
topic_error,
|
| 141 |
+
topic_error,
|
| 142 |
+
memory_summary(session_id),
|
| 143 |
+
refresh_doc_choices(session_id, []),
|
| 144 |
+
gr.update(visible=False),
|
| 145 |
+
)
|
| 146 |
+
|
| 147 |
+
try:
|
| 148 |
+
runner = AgentRunner()
|
| 149 |
+
result = runner.run_researchmind_ingest(
|
| 150 |
+
topic=topic.strip(),
|
| 151 |
+
urls=[],
|
| 152 |
+
files=[],
|
| 153 |
+
auto_search=True,
|
| 154 |
+
session_id=session_id or None,
|
| 155 |
+
model_key=model_key,
|
| 156 |
+
backend=get_backend(model_key),
|
| 157 |
+
)
|
| 158 |
+
trace_json = load_trace_json(result.trace_path)
|
| 159 |
+
progress(1.0, desc="Done")
|
| 160 |
+
return (
|
| 161 |
+
format_ingest_status(result),
|
| 162 |
+
gr.update(choices=[], value=[], visible=False),
|
| 163 |
+
refresh_sessions(result.session_id),
|
| 164 |
+
trace_summary_markdown(result.trace_path),
|
| 165 |
+
trace_json,
|
| 166 |
+
memory_summary(result.session_id),
|
| 167 |
+
refresh_doc_choices(result.session_id, []),
|
| 168 |
+
gr.update(visible=False),
|
| 169 |
+
)
|
| 170 |
+
except Exception as exc: # noqa: BLE001
|
| 171 |
+
msg = f"Auto ingest error: {exc}"
|
| 172 |
+
return (
|
| 173 |
+
msg,
|
| 174 |
+
gr.update(choices=[], value=[], visible=False),
|
| 175 |
+
session_id,
|
| 176 |
+
msg,
|
| 177 |
+
msg,
|
| 178 |
+
memory_summary(session_id),
|
| 179 |
+
refresh_doc_choices(session_id, []),
|
| 180 |
+
gr.update(visible=False),
|
| 181 |
+
)
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
def ingest_selected(
|
| 185 |
+
topic: str | None,
|
| 186 |
+
urls_text: str | None,
|
| 187 |
+
selected_urls: list[str] | None,
|
| 188 |
+
upload_files: list[str] | None,
|
| 189 |
+
session_id: str | None,
|
| 190 |
+
progress: gr.Progress = gr.Progress(),
|
| 191 |
+
) -> tuple[str, str, str, str, object, object]:
|
| 192 |
+
progress(0, desc="Ingesting sources…")
|
| 193 |
+
sid = session_id or ""
|
| 194 |
+
model_key = get_active_model_key()
|
| 195 |
+
load_error = ensure_model_loaded(model_key)
|
| 196 |
+
if load_error:
|
| 197 |
+
return (
|
| 198 |
+
load_error,
|
| 199 |
+
memory_summary(sid),
|
| 200 |
+
load_error,
|
| 201 |
+
load_error,
|
| 202 |
+
refresh_sessions(sid),
|
| 203 |
+
refresh_doc_choices(sid, []),
|
| 204 |
+
)
|
| 205 |
+
|
| 206 |
+
topic_error = _require_topic(topic)
|
| 207 |
+
if topic_error:
|
| 208 |
+
return (
|
| 209 |
+
topic_error,
|
| 210 |
+
memory_summary(sid),
|
| 211 |
+
topic_error,
|
| 212 |
+
topic_error,
|
| 213 |
+
refresh_sessions(sid),
|
| 214 |
+
refresh_doc_choices(sid, []),
|
| 215 |
)
|
| 216 |
|
| 217 |
+
direct_urls = parse_urls_text(urls_text or "")
|
| 218 |
all_urls = list(dict.fromkeys([*direct_urls, *(selected_urls or [])]))
|
| 219 |
files = [Path(p) for p in (upload_files or [])]
|
| 220 |
|
| 221 |
if not all_urls and not files:
|
| 222 |
+
msg = "Add URLs, select suggested sources, or upload a file — then ingest."
|
| 223 |
return (
|
| 224 |
msg,
|
| 225 |
+
memory_summary(sid),
|
| 226 |
msg,
|
| 227 |
msg,
|
| 228 |
+
refresh_sessions(sid),
|
| 229 |
+
refresh_doc_choices(sid, []),
|
| 230 |
)
|
| 231 |
|
| 232 |
try:
|
| 233 |
logger.info("Ingesting %d URL(s) and %d file(s)", len(all_urls), len(files))
|
| 234 |
runner = AgentRunner()
|
| 235 |
result = runner.run_researchmind_ingest(
|
| 236 |
+
topic=(topic or "").strip(),
|
| 237 |
urls=all_urls,
|
| 238 |
files=files,
|
| 239 |
auto_search=False,
|
| 240 |
+
session_id=sid or None,
|
| 241 |
model_key=model_key,
|
| 242 |
backend=get_backend(model_key),
|
| 243 |
)
|
| 244 |
trace_json = load_trace_json(result.trace_path)
|
| 245 |
+
progress(1.0, desc="Done")
|
| 246 |
return (
|
| 247 |
format_ingest_status(result),
|
| 248 |
memory_summary(result.session_id),
|
|
|
|
| 256 |
msg = f"**Ingest error:** {exc}"
|
| 257 |
return (
|
| 258 |
msg,
|
| 259 |
+
memory_summary(sid),
|
| 260 |
msg,
|
| 261 |
msg,
|
| 262 |
+
refresh_sessions(sid),
|
| 263 |
+
refresh_doc_choices(sid, []),
|
| 264 |
)
|
| 265 |
|
| 266 |
|
|
|
|
| 269 |
session_id: str,
|
| 270 |
doc_ids: list[str] | None,
|
| 271 |
chat_history: list[dict],
|
| 272 |
+
progress: gr.Progress = gr.Progress(),
|
| 273 |
+
) -> tuple[list[dict], str, str, str, str]:
|
| 274 |
if not question.strip():
|
| 275 |
+
return chat_history or [], "Enter a question.", "", rag_scope_hint(session_id, doc_ids), question
|
| 276 |
|
| 277 |
try:
|
| 278 |
+
progress(0, desc="Searching corpus…")
|
| 279 |
answer, trace_json, trace_summary = run_research_question(
|
| 280 |
question,
|
| 281 |
session_id=session_id,
|
| 282 |
doc_ids=doc_ids,
|
| 283 |
)
|
| 284 |
+
citations = format_citations_markdown(trace_json)
|
| 285 |
+
if citations:
|
| 286 |
+
answer = f"{answer}\n{citations}"
|
| 287 |
history = list(chat_history or [])
|
| 288 |
history.append({"role": "user", "content": question})
|
| 289 |
history.append({"role": "assistant", "content": answer})
|
| 290 |
+
progress(1.0, desc="Done")
|
| 291 |
+
return history, trace_json, trace_summary, rag_scope_hint(session_id, doc_ids), ""
|
| 292 |
except Exception as exc: # noqa: BLE001
|
| 293 |
logger.exception("Research chat failed")
|
| 294 |
history = list(chat_history or [])
|
| 295 |
history.append({"role": "user", "content": question})
|
| 296 |
err = f"Chat error: {exc}"
|
| 297 |
history.append({"role": "assistant", "content": err})
|
| 298 |
+
return history, err, err, rag_scope_hint(session_id, doc_ids), question
|
| 299 |
|
| 300 |
|
| 301 |
def build_research_mind_tab() -> None:
|
| 302 |
+
gr.Markdown("### ResearchMind", elem_classes=["form-tab-heading"])
|
| 303 |
+
gr.HTML(
|
| 304 |
+
'<p class="tab-subtitle">'
|
| 305 |
+
"Start with a topic, add sources to your library, then ask questions with citations."
|
| 306 |
+
"</p>"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 307 |
)
|
|
|
|
|
|
|
| 308 |
|
| 309 |
+
with gr.Column(elem_classes=["form-primary"]):
|
| 310 |
+
topic = gr.Textbox(
|
| 311 |
+
label="What are you researching?",
|
| 312 |
+
placeholder="e.g. AI agents, Photosynthesis, American Revolution…",
|
| 313 |
+
lines=2,
|
| 314 |
+
max_lines=3,
|
| 315 |
+
elem_classes=["form-topic-input"],
|
| 316 |
+
)
|
| 317 |
+
|
| 318 |
+
with gr.Row(elem_classes=["form-secondary"]):
|
| 319 |
session_dd = gr.Dropdown(
|
| 320 |
label="Session",
|
| 321 |
choices=list_session_choices(),
|
| 322 |
value="",
|
| 323 |
+
scale=4,
|
| 324 |
)
|
| 325 |
+
refresh_btn = gr.Button("↻", size="sm", scale=0, min_width=40)
|
| 326 |
+
|
| 327 |
+
with gr.Row(elem_classes=["rm-workflow-columns"]):
|
| 328 |
+
with gr.Column(scale=1, elem_classes=["rm-ingest-col"]):
|
| 329 |
+
gr.HTML('<p class="form-section-label">Step 1 · Add sources</p>')
|
| 330 |
+
|
| 331 |
+
with gr.Row(elem_classes=["rm-action-row"]):
|
| 332 |
+
discover_btn = gr.Button("Discover on web", variant="secondary", size="sm")
|
| 333 |
+
auto_btn = gr.Button("Auto-ingest from web", variant="secondary", size="sm")
|
| 334 |
+
|
| 335 |
+
with gr.Accordion("Suggested URLs from web search", open=True, visible=False) as urls_acc:
|
| 336 |
+
url_choices = gr.CheckboxGroup(
|
| 337 |
+
label="Select sources to ingest",
|
| 338 |
+
choices=[],
|
| 339 |
+
value=[],
|
| 340 |
+
elem_classes=DOC_CHOICE_LIST_CLASSES,
|
| 341 |
)
|
| 342 |
+
|
| 343 |
+
with gr.Accordion(
|
| 344 |
+
"Paste URLs or upload files",
|
| 345 |
+
open=False,
|
| 346 |
+
elem_classes=["form-optional-accordion"],
|
| 347 |
+
):
|
| 348 |
+
urls_text = gr.Textbox(
|
| 349 |
+
label="URLs (one per line)",
|
| 350 |
+
lines=3,
|
| 351 |
+
placeholder="https://en.wikipedia.org/wiki/...",
|
| 352 |
+
)
|
| 353 |
+
upload_files = gr.File(
|
| 354 |
+
label="Upload PDF or DOCX",
|
| 355 |
+
file_count="multiple",
|
| 356 |
+
file_types=[".pdf", ".docx"],
|
| 357 |
+
)
|
| 358 |
+
|
| 359 |
+
with gr.Row(elem_classes=["form-cta-row"]):
|
| 360 |
+
ingest_btn = gr.Button(
|
| 361 |
+
"Ingest selected sources",
|
| 362 |
+
variant="primary",
|
| 363 |
+
elem_classes=["primary-cta"],
|
| 364 |
)
|
| 365 |
|
| 366 |
+
ingest_status = gr.Markdown(
|
| 367 |
+
value="_Enter a topic, then discover or paste sources to ingest._",
|
| 368 |
+
elem_classes=["form-status"],
|
|
|
|
| 369 |
)
|
| 370 |
+
|
| 371 |
+
with gr.Accordion("Indexed documents", open=False):
|
| 372 |
+
memory_md = gr.Markdown(value=memory_summary(""))
|
| 373 |
+
refresh_memory_btn = gr.Button("Refresh", size="sm")
|
| 374 |
+
|
| 375 |
+
advanced = build_advanced_panel()
|
| 376 |
+
|
| 377 |
+
with gr.Column(scale=1, elem_classes=["rm-ask-col"]):
|
| 378 |
+
gr.HTML('<p class="form-section-label">Step 2 · Ask questions</p>')
|
| 379 |
+
|
| 380 |
+
chatbot = gr.Chatbot(
|
| 381 |
+
label="Answers",
|
| 382 |
+
height=320,
|
| 383 |
+
placeholder="Ask a question after ingesting sources — answers include citations.",
|
| 384 |
)
|
| 385 |
|
| 386 |
+
with gr.Column(elem_classes=["form-primary"]):
|
| 387 |
+
question = gr.Textbox(
|
| 388 |
+
label="Your question",
|
| 389 |
+
placeholder="What do these sources say about AI agents?",
|
| 390 |
+
lines=2,
|
| 391 |
+
max_lines=4,
|
| 392 |
+
elem_classes=["form-ask-input"],
|
| 393 |
+
)
|
| 394 |
+
|
| 395 |
+
with gr.Accordion(
|
| 396 |
+
"Limit to specific documents",
|
| 397 |
+
open=False,
|
| 398 |
+
elem_classes=["form-optional-accordion"],
|
| 399 |
+
):
|
| 400 |
+
doc_dd = gr.CheckboxGroup(
|
| 401 |
+
label="Documents (empty = all in session)",
|
| 402 |
+
choices=[],
|
| 403 |
+
value=[],
|
| 404 |
+
elem_classes=DOC_CHOICE_LIST_CLASSES,
|
| 405 |
+
)
|
| 406 |
+
|
| 407 |
+
rag_hint = gr.Markdown(
|
| 408 |
+
value=rag_scope_hint("", []),
|
| 409 |
+
elem_classes=["form-status"],
|
| 410 |
+
)
|
| 411 |
+
|
| 412 |
+
with gr.Row(elem_classes=["form-cta-row"]):
|
| 413 |
+
ask_btn = gr.Button("Ask", variant="primary", elem_classes=["primary-cta"])
|
|
|
|
|
|
|
|
|
|
|
|
|
| 414 |
|
| 415 |
refresh_btn.click(fn=refresh_sessions, inputs=[session_dd], outputs=[session_dd])
|
| 416 |
refresh_memory_btn.click(fn=memory_summary, inputs=[session_dd], outputs=[memory_md])
|
|
|
|
| 426 |
)
|
| 427 |
doc_dd.change(fn=rag_scope_hint, inputs=[session_dd, doc_dd], outputs=[rag_hint])
|
| 428 |
|
| 429 |
+
discover_outputs = [
|
| 430 |
+
ingest_status,
|
| 431 |
+
url_choices,
|
| 432 |
+
session_dd,
|
| 433 |
+
advanced.trace_summary,
|
| 434 |
+
advanced.trace_box,
|
| 435 |
+
memory_md,
|
| 436 |
+
doc_dd,
|
| 437 |
+
urls_acc,
|
| 438 |
+
]
|
| 439 |
+
|
| 440 |
discover_btn.click(
|
| 441 |
+
fn=discover_sources,
|
| 442 |
+
inputs=[topic, session_dd],
|
| 443 |
+
outputs=discover_outputs,
|
| 444 |
+
)
|
| 445 |
+
|
| 446 |
+
auto_btn.click(
|
| 447 |
+
fn=auto_search_ingest,
|
| 448 |
+
inputs=[topic, session_dd],
|
| 449 |
+
outputs=discover_outputs,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 450 |
)
|
| 451 |
|
| 452 |
ingest_btn.click(
|
| 453 |
fn=ingest_selected,
|
| 454 |
inputs=[topic, urls_text, url_choices, upload_files, session_dd],
|
| 455 |
+
outputs=[
|
| 456 |
+
ingest_status,
|
| 457 |
+
memory_md,
|
| 458 |
+
advanced.trace_box,
|
| 459 |
+
advanced.trace_summary,
|
| 460 |
+
session_dd,
|
| 461 |
+
doc_dd,
|
| 462 |
+
],
|
| 463 |
)
|
| 464 |
|
| 465 |
ask_btn.click(
|
| 466 |
fn=ask_question,
|
| 467 |
inputs=[question, session_dd, doc_dd, chatbot],
|
| 468 |
+
outputs=[chatbot, advanced.trace_box, advanced.trace_summary, rag_hint, question],
|
| 469 |
)
|
| 470 |
question.submit(
|
| 471 |
fn=ask_question,
|
| 472 |
inputs=[question, session_dd, doc_dd, chatbot],
|
| 473 |
+
outputs=[chatbot, advanced.trace_box, advanced.trace_summary, rag_hint, question],
|
| 474 |
)
|
| 475 |
|
| 476 |
|
| 477 |
def researchmind_allowed_paths() -> list[str]:
|
| 478 |
+
from researchmind.config import get_config
|
| 479 |
+
|
| 480 |
cfg = get_config()
|
| 481 |
root = cfg.data_dir.resolve()
|
| 482 |
root.mkdir(parents=True, exist_ok=True)
|
apps/gradio-space/src/gradio_space/tabs/teacher_voice.py
CHANGED
|
@@ -3,95 +3,84 @@ from __future__ import annotations
|
|
| 3 |
import gradio as gr
|
| 4 |
|
| 5 |
from echocoach.config import get_echo_coach_config
|
|
|
|
| 6 |
from echocoach.prompts import MODE_LABELS, TeacherVoiceMode
|
| 7 |
-
from echocoach.
|
| 8 |
-
|
| 9 |
-
recording_backend_status,
|
| 10 |
-
recording_elapsed_seconds,
|
| 11 |
-
recording_level_warning,
|
| 12 |
-
start_server_recording,
|
| 13 |
-
stop_server_recording,
|
| 14 |
-
)
|
| 15 |
-
from echocoach.teacher_voice import RAG_MODES, run_teacher_voice_turn
|
| 16 |
-
from gradio_space.model_loading import ensure_model_loaded, get_active_model_key, model_status
|
| 17 |
from gradio_space.research_helpers import (
|
| 18 |
-
list_doc_choices,
|
| 19 |
list_session_choices,
|
|
|
|
| 20 |
rag_scope_hint,
|
| 21 |
refresh_doc_choices,
|
| 22 |
refresh_sessions,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
)
|
| 24 |
-
from echocoach.omni import omni_status_message
|
| 25 |
from gradio_space.voice_helpers import speak_last_assistant_reply
|
| 26 |
from inference.factory import get_backend
|
| 27 |
|
| 28 |
_config = get_echo_coach_config()
|
| 29 |
_TURN_MAX = min(15, _config.max_seconds)
|
| 30 |
_MODE_CHOICES = [(label, key) for key, label in MODE_LABELS.items()]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
|
| 32 |
|
| 33 |
def _empty_turn() -> tuple:
|
| 34 |
return (
|
| 35 |
[],
|
| 36 |
-
|
| 37 |
-
"Start recording, speak your question, stop, then click **Send turn**.",
|
| 38 |
"",
|
| 39 |
{},
|
|
|
|
| 40 |
)
|
| 41 |
|
| 42 |
|
| 43 |
-
def
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
return (
|
| 48 |
-
str(exc),
|
| 49 |
-
gr.update(interactive=True),
|
| 50 |
-
gr.update(interactive=False),
|
| 51 |
-
)
|
| 52 |
-
return (
|
| 53 |
-
(
|
| 54 |
-
f"Recording… speak now, then click **Stop recording** "
|
| 55 |
-
f"(auto-stops after {int(max_seconds)}s)."
|
| 56 |
-
),
|
| 57 |
-
gr.update(interactive=False),
|
| 58 |
-
gr.update(interactive=True),
|
| 59 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
|
| 61 |
-
|
| 62 |
-
def ui_stop_recording() -> tuple[str | None, str, dict, dict]:
|
| 63 |
-
try:
|
| 64 |
-
elapsed = recording_elapsed_seconds()
|
| 65 |
-
path = stop_server_recording()
|
| 66 |
-
warning = recording_level_warning(path)
|
| 67 |
-
except ServerRecordingError as exc:
|
| 68 |
-
return (
|
| 69 |
-
None,
|
| 70 |
-
str(exc),
|
| 71 |
-
gr.update(interactive=True),
|
| 72 |
-
gr.update(interactive=False),
|
| 73 |
-
)
|
| 74 |
-
except Exception as exc: # noqa: BLE001
|
| 75 |
-
return (
|
| 76 |
-
None,
|
| 77 |
-
f"Recording failed: {exc}",
|
| 78 |
-
gr.update(interactive=True),
|
| 79 |
-
gr.update(interactive=False),
|
| 80 |
-
)
|
| 81 |
-
|
| 82 |
-
status = f"Recording saved ({elapsed:.1f}s). Click **Send turn** to talk to TeacherVoice."
|
| 83 |
-
if warning:
|
| 84 |
-
status += f" Warning: {warning}"
|
| 85 |
return (
|
| 86 |
-
|
| 87 |
status,
|
| 88 |
-
|
| 89 |
-
|
|
|
|
| 90 |
)
|
| 91 |
|
| 92 |
|
| 93 |
-
def
|
| 94 |
-
return
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
|
| 96 |
|
| 97 |
def send_turn(
|
|
@@ -104,62 +93,93 @@ def send_turn(
|
|
| 104 |
use_rag: bool,
|
| 105 |
session_id: str,
|
| 106 |
doc_ids: list[str] | None,
|
|
|
|
| 107 |
) -> tuple:
|
|
|
|
| 108 |
model_key = get_active_model_key()
|
| 109 |
load_error = ensure_model_loaded(model_key)
|
| 110 |
if load_error:
|
| 111 |
-
return (
|
| 112 |
-
history,
|
| 113 |
-
None,
|
| 114 |
-
load_error,
|
| 115 |
-
"",
|
| 116 |
-
{},
|
| 117 |
-
)
|
| 118 |
|
| 119 |
if not audio_path:
|
| 120 |
return (
|
| 121 |
-
history,
|
| 122 |
-
|
| 123 |
-
"Record or upload audio, then click **Send turn**.",
|
| 124 |
"",
|
| 125 |
{},
|
|
|
|
| 126 |
)
|
| 127 |
|
| 128 |
try:
|
|
|
|
| 129 |
result = run_teacher_voice_turn(
|
| 130 |
audio_path,
|
| 131 |
history,
|
| 132 |
mode=mode,
|
| 133 |
language=language,
|
| 134 |
-
topic=topic or None,
|
| 135 |
asr_preset=asr_preset,
|
|
|
|
| 136 |
backend=get_backend(model_key),
|
| 137 |
use_rag=use_rag and mode in RAG_MODES,
|
| 138 |
-
session_id=session_id,
|
| 139 |
-
doc_ids=doc_ids,
|
| 140 |
max_turn_seconds=_TURN_MAX,
|
| 141 |
)
|
| 142 |
except Exception as exc: # noqa: BLE001
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 143 |
return (
|
| 144 |
-
history,
|
| 145 |
-
|
| 146 |
-
f"TeacherVoice failed: {exc}",
|
| 147 |
"",
|
| 148 |
{},
|
|
|
|
| 149 |
)
|
| 150 |
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 154 |
|
| 155 |
-
|
| 156 |
-
return (
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
result.trace,
|
| 162 |
-
)
|
| 163 |
|
| 164 |
|
| 165 |
def _format_speak_status(status: str) -> str:
|
|
@@ -178,144 +198,416 @@ def speak_quick_reply(history: list, language: str) -> tuple[str | None, str, st
|
|
| 178 |
return playback, status, _format_speak_status(status)
|
| 179 |
|
| 180 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 181 |
def build_teacher_voice_tab() -> None:
|
| 182 |
lang_choices = _config.language_choices()
|
| 183 |
asr_choices = _config.asr_choices()
|
| 184 |
default_lang = lang_choices[0][1] if lang_choices else "en"
|
| 185 |
default_asr = _config.asr_preset
|
| 186 |
-
mic_status = recording_backend_status()
|
| 187 |
-
|
| 188 |
omni_note = omni_status_message()
|
| 189 |
-
gr.Markdown(
|
| 190 |
-
f"""
|
| 191 |
-
**TeacherVoice** — turn-based voice conversation with a local teacher (not full duplex).
|
| 192 |
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 200 |
)
|
| 201 |
|
| 202 |
-
with gr.Row():
|
| 203 |
-
with gr.Column(scale=1):
|
| 204 |
-
|
| 205 |
-
|
|
|
|
|
|
|
| 206 |
choices=_MODE_CHOICES,
|
| 207 |
value="explain",
|
|
|
|
| 208 |
)
|
|
|
|
| 209 |
topic_tb = gr.Textbox(
|
| 210 |
-
label="
|
| 211 |
-
placeholder="e.g. Photosynthesis for
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 212 |
)
|
| 213 |
-
|
| 214 |
-
with gr.
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 221 |
)
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
label="Your turn (browser mic or upload)",
|
| 227 |
-
sources=["upload", "microphone"],
|
| 228 |
-
type="filepath",
|
| 229 |
-
format="wav",
|
| 230 |
)
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
with gr.
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
choices=list_session_choices(),
|
| 238 |
-
value="",
|
| 239 |
)
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
status = gr.Textbox(label="Status", interactive=False, lines=3)
|
| 247 |
-
coach_status = gr.Markdown(model_status(get_active_model_key()))
|
| 248 |
-
|
| 249 |
-
with gr.Column(scale=2):
|
| 250 |
-
chatbot = gr.Chatbot(label="Conversation", height=360)
|
| 251 |
-
with gr.Row():
|
| 252 |
-
speak_full_btn = gr.Button("Speak last reply", variant="secondary")
|
| 253 |
-
speak_quick_btn = gr.Button("Speak first sentence", variant="secondary")
|
| 254 |
-
speak_status = gr.Markdown(
|
| 255 |
-
value="_Use **Speak** buttons to hear the latest teacher reply._"
|
| 256 |
)
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 261 |
)
|
| 262 |
-
trace_note = gr.Markdown()
|
| 263 |
-
trace_json = gr.JSON(label="Trace")
|
| 264 |
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
inputs=[
|
| 268 |
-
outputs=[
|
| 269 |
-
)
|
| 270 |
-
record_stop_btn.click(
|
| 271 |
-
ui_stop_recording,
|
| 272 |
-
outputs=[audio_in, status, record_start_btn, record_stop_btn],
|
| 273 |
).then(
|
| 274 |
-
|
| 275 |
-
|
|
|
|
| 276 |
)
|
| 277 |
|
| 278 |
refresh_sessions_btn.click(fn=refresh_sessions, inputs=[session_dd], outputs=[session_dd])
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
outputs=[doc_dd],
|
| 283 |
-
)
|
| 284 |
for trigger in (use_rag, session_dd, doc_dd):
|
| 285 |
trigger.change(
|
| 286 |
-
fn=
|
| 287 |
-
rag_scope_hint(sid, docs) if rag_on else "_RAG off — model knowledge only._"
|
| 288 |
-
),
|
| 289 |
inputs=[use_rag, session_dd, doc_dd],
|
| 290 |
outputs=[rag_hint],
|
| 291 |
)
|
| 292 |
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 303 |
session_dd,
|
| 304 |
doc_dd,
|
| 305 |
],
|
| 306 |
-
|
|
|
|
|
|
|
|
|
|
| 307 |
)
|
| 308 |
|
| 309 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 310 |
|
| 311 |
speak_full_btn.click(
|
| 312 |
speak_full_reply,
|
| 313 |
-
inputs=[chatbot, language],
|
| 314 |
outputs=[voiceout, status, speak_status],
|
| 315 |
)
|
| 316 |
speak_quick_btn.click(
|
| 317 |
speak_quick_reply,
|
| 318 |
-
inputs=[chatbot, language],
|
| 319 |
outputs=[voiceout, status, speak_status],
|
| 320 |
)
|
| 321 |
|
|
|
|
| 3 |
import gradio as gr
|
| 4 |
|
| 5 |
from echocoach.config import get_echo_coach_config
|
| 6 |
+
from echocoach.omni import omni_status_message
|
| 7 |
from echocoach.prompts import MODE_LABELS, TeacherVoiceMode
|
| 8 |
+
from echocoach.teacher_voice import RAG_MODES, run_teacher_voice_text_turn, run_teacher_voice_turn
|
| 9 |
+
from gradio_space.model_loading import ensure_model_loaded, get_active_model_key
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
from gradio_space.research_helpers import (
|
|
|
|
| 11 |
list_session_choices,
|
| 12 |
+
memory_summary,
|
| 13 |
rag_scope_hint,
|
| 14 |
refresh_doc_choices,
|
| 15 |
refresh_sessions,
|
| 16 |
+
trace_as_dict,
|
| 17 |
+
)
|
| 18 |
+
from gradio_space.tabs.research_mind import (
|
| 19 |
+
auto_search_ingest,
|
| 20 |
+
discover_sources,
|
| 21 |
+
ingest_selected,
|
| 22 |
+
)
|
| 23 |
+
from gradio_space.ui.components import (
|
| 24 |
+
build_advanced_panel,
|
| 25 |
+
build_recording_block,
|
| 26 |
+
DOC_CHOICE_LIST_CLASSES,
|
| 27 |
+
wire_recording_handlers,
|
| 28 |
)
|
|
|
|
| 29 |
from gradio_space.voice_helpers import speak_last_assistant_reply
|
| 30 |
from inference.factory import get_backend
|
| 31 |
|
| 32 |
_config = get_echo_coach_config()
|
| 33 |
_TURN_MAX = min(15, _config.max_seconds)
|
| 34 |
_MODE_CHOICES = [(label, key) for key, label in MODE_LABELS.items()]
|
| 35 |
+
_THINK_OPEN = "<" + "think" + ">"
|
| 36 |
+
_THINK_CLOSE = "</" + "think" + ">"
|
| 37 |
+
_REASONING_TAGS = [
|
| 38 |
+
(_THINK_OPEN, _THINK_CLOSE),
|
| 39 |
+
("<think>", "</think>"),
|
| 40 |
+
("<thinking>", "</thinking>"),
|
| 41 |
+
]
|
| 42 |
|
| 43 |
|
| 44 |
def _empty_turn() -> tuple:
|
| 45 |
return (
|
| 46 |
[],
|
| 47 |
+
"_Type a message or record audio, then send._",
|
|
|
|
| 48 |
"",
|
| 49 |
{},
|
| 50 |
+
"",
|
| 51 |
)
|
| 52 |
|
| 53 |
|
| 54 |
+
def _turn_result(result) -> tuple:
|
| 55 |
+
status = (
|
| 56 |
+
f"**Turn complete** — you sent {len(result.user_text)} chars, "
|
| 57 |
+
f"teacher replied with {len(result.assistant_text)} chars."
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
)
|
| 59 |
+
if result.rag_status:
|
| 60 |
+
status += f"\n\n{result.rag_status}"
|
| 61 |
+
if result.voiceout_warning:
|
| 62 |
+
first_line = result.voiceout_warning.split("\n", 1)[0].strip()
|
| 63 |
+
if len(first_line) > 120:
|
| 64 |
+
first_line = first_line[:117] + "…"
|
| 65 |
+
status += f" VoiceOut note: {first_line} _(details in Advanced)_"
|
| 66 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 67 |
return (
|
| 68 |
+
result.history,
|
| 69 |
status,
|
| 70 |
+
f"Trace saved: `{result.trace_path}`",
|
| 71 |
+
trace_as_dict(result.trace),
|
| 72 |
+
"",
|
| 73 |
)
|
| 74 |
|
| 75 |
|
| 76 |
+
def _turn_error(history: list | None, message: str) -> tuple:
|
| 77 |
+
return (
|
| 78 |
+
history or [],
|
| 79 |
+
f"**TeacherVoice failed:** {message}",
|
| 80 |
+
"",
|
| 81 |
+
{},
|
| 82 |
+
gr.update(),
|
| 83 |
+
)
|
| 84 |
|
| 85 |
|
| 86 |
def send_turn(
|
|
|
|
| 93 |
use_rag: bool,
|
| 94 |
session_id: str,
|
| 95 |
doc_ids: list[str] | None,
|
| 96 |
+
progress: gr.Progress = gr.Progress(),
|
| 97 |
) -> tuple:
|
| 98 |
+
progress(0, desc="Loading model…")
|
| 99 |
model_key = get_active_model_key()
|
| 100 |
load_error = ensure_model_loaded(model_key)
|
| 101 |
if load_error:
|
| 102 |
+
return _turn_error(history, load_error)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 103 |
|
| 104 |
if not audio_path:
|
| 105 |
return (
|
| 106 |
+
history or [],
|
| 107 |
+
"_Record or upload audio, then click **Send voice turn**._",
|
|
|
|
| 108 |
"",
|
| 109 |
{},
|
| 110 |
+
gr.update(),
|
| 111 |
)
|
| 112 |
|
| 113 |
try:
|
| 114 |
+
progress(0.15, desc="Listening…")
|
| 115 |
result = run_teacher_voice_turn(
|
| 116 |
audio_path,
|
| 117 |
history,
|
| 118 |
mode=mode,
|
| 119 |
language=language,
|
|
|
|
| 120 |
asr_preset=asr_preset,
|
| 121 |
+
topic=topic.strip() or None,
|
| 122 |
backend=get_backend(model_key),
|
| 123 |
use_rag=use_rag and mode in RAG_MODES,
|
| 124 |
+
session_id=session_id or None,
|
| 125 |
+
doc_ids=doc_ids or None,
|
| 126 |
max_turn_seconds=_TURN_MAX,
|
| 127 |
)
|
| 128 |
except Exception as exc: # noqa: BLE001
|
| 129 |
+
return _turn_error(history, str(exc))
|
| 130 |
+
|
| 131 |
+
progress(1.0, desc="Done")
|
| 132 |
+
return _turn_result(result)
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
def send_text_turn(
|
| 136 |
+
message: str,
|
| 137 |
+
history: list,
|
| 138 |
+
mode: TeacherVoiceMode,
|
| 139 |
+
language: str,
|
| 140 |
+
topic: str,
|
| 141 |
+
use_rag: bool,
|
| 142 |
+
session_id: str,
|
| 143 |
+
doc_ids: list[str] | None,
|
| 144 |
+
progress: gr.Progress = gr.Progress(),
|
| 145 |
+
) -> tuple:
|
| 146 |
+
progress(0, desc="Loading model…")
|
| 147 |
+
model_key = get_active_model_key()
|
| 148 |
+
load_error = ensure_model_loaded(model_key)
|
| 149 |
+
if load_error:
|
| 150 |
+
return _turn_error(history, load_error)
|
| 151 |
+
|
| 152 |
+
if not message.strip():
|
| 153 |
return (
|
| 154 |
+
history or [],
|
| 155 |
+
"_Type your question above, then click **Send text turn**._",
|
|
|
|
| 156 |
"",
|
| 157 |
{},
|
| 158 |
+
gr.update(),
|
| 159 |
)
|
| 160 |
|
| 161 |
+
try:
|
| 162 |
+
progress(0.2, desc="Thinking…")
|
| 163 |
+
result = run_teacher_voice_text_turn(
|
| 164 |
+
message,
|
| 165 |
+
history,
|
| 166 |
+
mode=mode,
|
| 167 |
+
language=language,
|
| 168 |
+
topic=topic.strip() or None,
|
| 169 |
+
backend=get_backend(model_key),
|
| 170 |
+
use_rag=use_rag and mode in RAG_MODES,
|
| 171 |
+
session_id=session_id or None,
|
| 172 |
+
doc_ids=doc_ids or None,
|
| 173 |
+
)
|
| 174 |
+
except Exception as exc: # noqa: BLE001
|
| 175 |
+
return _turn_error(history, str(exc))
|
| 176 |
|
| 177 |
+
progress(1.0, desc="Done")
|
| 178 |
+
return _turn_result(result)
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
def clear_conversation() -> tuple:
|
| 182 |
+
return _empty_turn()
|
|
|
|
|
|
|
| 183 |
|
| 184 |
|
| 185 |
def _format_speak_status(status: str) -> str:
|
|
|
|
| 198 |
return playback, status, _format_speak_status(status)
|
| 199 |
|
| 200 |
|
| 201 |
+
def _update_rag_hint(rag_on: bool, sid: str, docs: list[str] | None) -> str:
|
| 202 |
+
if not rag_on:
|
| 203 |
+
return (
|
| 204 |
+
"_Using model knowledge only. Use **Discover** or **Auto-ingest** below, "
|
| 205 |
+
"then check **Answer from my indexed sources**._"
|
| 206 |
+
)
|
| 207 |
+
return rag_scope_hint(sid, docs)
|
| 208 |
+
|
| 209 |
+
|
| 210 |
+
def _ingest_succeeded(status: str) -> bool:
|
| 211 |
+
text = (status or "").lower()
|
| 212 |
+
return not any(
|
| 213 |
+
marker in text
|
| 214 |
+
for marker in (
|
| 215 |
+
"error",
|
| 216 |
+
"enter a research topic",
|
| 217 |
+
"add urls",
|
| 218 |
+
"no verified urls found",
|
| 219 |
+
)
|
| 220 |
+
)
|
| 221 |
+
|
| 222 |
+
|
| 223 |
+
def _enable_rag_after_ingest(
|
| 224 |
+
status: str,
|
| 225 |
+
session_id: str,
|
| 226 |
+
doc_ids: list[str] | None,
|
| 227 |
+
) -> tuple[dict, str]:
|
| 228 |
+
if _ingest_succeeded(status):
|
| 229 |
+
return gr.update(value=True), _update_rag_hint(True, session_id, doc_ids)
|
| 230 |
+
return gr.update(), _update_rag_hint(False, session_id, doc_ids)
|
| 231 |
+
|
| 232 |
+
|
| 233 |
+
def _discover_for_json(topic: str, session_id: str, progress: gr.Progress = gr.Progress()):
|
| 234 |
+
results = list(discover_sources(topic, session_id, progress))
|
| 235 |
+
results[4] = trace_as_dict(results[4])
|
| 236 |
+
return tuple(results)
|
| 237 |
+
|
| 238 |
+
|
| 239 |
+
def _auto_ingest_for_json(topic: str, session_id: str, progress: gr.Progress = gr.Progress()):
|
| 240 |
+
results = list(auto_search_ingest(topic, session_id, progress))
|
| 241 |
+
results[4] = trace_as_dict(results[4])
|
| 242 |
+
return tuple(results)
|
| 243 |
+
|
| 244 |
+
|
| 245 |
+
def _ingest_for_json(
|
| 246 |
+
topic: str,
|
| 247 |
+
urls_text: str,
|
| 248 |
+
selected_urls: list[str],
|
| 249 |
+
upload_files: list[str] | None,
|
| 250 |
+
session_id: str,
|
| 251 |
+
progress: gr.Progress = gr.Progress(),
|
| 252 |
+
):
|
| 253 |
+
results = list(
|
| 254 |
+
ingest_selected(topic, urls_text, selected_urls, upload_files, session_id, progress)
|
| 255 |
+
)
|
| 256 |
+
results[2] = trace_as_dict(results[2])
|
| 257 |
+
return tuple(results)
|
| 258 |
+
|
| 259 |
+
|
| 260 |
+
def _on_mode_change(mode: str) -> tuple:
|
| 261 |
+
topic_mode = mode in ("explain", "lesson")
|
| 262 |
+
rag_mode = mode in RAG_MODES
|
| 263 |
+
if mode == "lesson":
|
| 264 |
+
topic_up = gr.update(
|
| 265 |
+
visible=topic_mode,
|
| 266 |
+
label="Focus topic",
|
| 267 |
+
placeholder="e.g. Photosynthesis for grade 6 — for web search and lesson context",
|
| 268 |
+
)
|
| 269 |
+
message_up = gr.update(
|
| 270 |
+
label="Your message",
|
| 271 |
+
placeholder="e.g. What are the main steps of photosynthesis?",
|
| 272 |
+
)
|
| 273 |
+
elif mode == "explain":
|
| 274 |
+
topic_up = gr.update(
|
| 275 |
+
visible=topic_mode,
|
| 276 |
+
label="Focus topic",
|
| 277 |
+
placeholder="e.g. Photosynthesis — for web search and lesson context",
|
| 278 |
+
)
|
| 279 |
+
message_up = gr.update(
|
| 280 |
+
label="Your message",
|
| 281 |
+
placeholder="e.g. How does photosynthesis work?",
|
| 282 |
+
)
|
| 283 |
+
else:
|
| 284 |
+
topic_up = gr.update(visible=False, value="")
|
| 285 |
+
message_up = gr.update(
|
| 286 |
+
label="Your message",
|
| 287 |
+
placeholder="e.g. Here is my opening line — how can I improve it?",
|
| 288 |
+
)
|
| 289 |
+
rag_acc = gr.update(visible=rag_mode)
|
| 290 |
+
use_rag = gr.update(value=False) if not rag_mode else gr.update()
|
| 291 |
+
return topic_up, message_up, rag_acc, use_rag
|
| 292 |
+
|
| 293 |
+
|
| 294 |
def build_teacher_voice_tab() -> None:
|
| 295 |
lang_choices = _config.language_choices()
|
| 296 |
asr_choices = _config.asr_choices()
|
| 297 |
default_lang = lang_choices[0][1] if lang_choices else "en"
|
| 298 |
default_asr = _config.asr_preset
|
|
|
|
|
|
|
| 299 |
omni_note = omni_status_message()
|
|
|
|
|
|
|
|
|
|
| 300 |
|
| 301 |
+
gr.Markdown("### TeacherVoice", elem_classes=["form-tab-heading"])
|
| 302 |
+
gr.HTML(
|
| 303 |
+
'<p class="tab-subtitle">'
|
| 304 |
+
"Pick a mode, type a question or record audio, and hear a spoken reply from your local teacher."
|
| 305 |
+
"</p>"
|
| 306 |
+
)
|
| 307 |
+
if omni_note:
|
| 308 |
+
gr.Markdown(omni_note, elem_classes=["form-status"])
|
| 309 |
+
gr.HTML(
|
| 310 |
+
'<p class="cross-link">Want charts and filler analysis? Use '
|
| 311 |
+
"<strong>EchoCoach</strong> for pitch feedback.</p>"
|
| 312 |
)
|
| 313 |
|
| 314 |
+
with gr.Row(elem_classes=["tv-workflow-columns"]):
|
| 315 |
+
with gr.Column(scale=1, elem_classes=["tv-input-col"]):
|
| 316 |
+
gr.HTML('<p class="form-section-label">Step 1 · Choose mode & speak</p>')
|
| 317 |
+
|
| 318 |
+
mode_dd = gr.Radio(
|
| 319 |
+
label="How do you want to practice?",
|
| 320 |
choices=_MODE_CHOICES,
|
| 321 |
value="explain",
|
| 322 |
+
elem_classes=["mode-cards"],
|
| 323 |
)
|
| 324 |
+
|
| 325 |
topic_tb = gr.Textbox(
|
| 326 |
+
label="Focus topic",
|
| 327 |
+
placeholder="e.g. Photosynthesis — used for web search and lesson context",
|
| 328 |
+
lines=1,
|
| 329 |
+
max_lines=2,
|
| 330 |
+
elem_classes=["form-secondary"],
|
| 331 |
+
)
|
| 332 |
+
|
| 333 |
+
with gr.Accordion(
|
| 334 |
+
"ResearchMind sources (optional)",
|
| 335 |
+
open=False,
|
| 336 |
+
visible=True,
|
| 337 |
+
elem_classes=["form-optional-accordion"],
|
| 338 |
+
) as rag_acc:
|
| 339 |
+
gr.Markdown(
|
| 340 |
+
"Set **Focus topic** above, then discover or ingest sources. "
|
| 341 |
+
"Enable RAG to ground answers in your library.",
|
| 342 |
+
elem_classes=["form-status"],
|
| 343 |
+
)
|
| 344 |
+
|
| 345 |
+
with gr.Row(elem_classes=["rm-action-row"]):
|
| 346 |
+
discover_btn = gr.Button("Discover on web", variant="secondary", size="sm")
|
| 347 |
+
auto_btn = gr.Button("Auto-ingest from web", variant="secondary", size="sm")
|
| 348 |
+
|
| 349 |
+
with gr.Accordion(
|
| 350 |
+
"Suggested URLs from web search",
|
| 351 |
+
open=True,
|
| 352 |
+
visible=False,
|
| 353 |
+
) as urls_acc:
|
| 354 |
+
url_choices = gr.CheckboxGroup(
|
| 355 |
+
label="Select sources to ingest",
|
| 356 |
+
choices=[],
|
| 357 |
+
value=[],
|
| 358 |
+
elem_classes=DOC_CHOICE_LIST_CLASSES,
|
| 359 |
+
)
|
| 360 |
+
|
| 361 |
+
with gr.Accordion(
|
| 362 |
+
"Paste URLs or upload files",
|
| 363 |
+
open=False,
|
| 364 |
+
elem_classes=["form-optional-accordion"],
|
| 365 |
+
):
|
| 366 |
+
urls_text = gr.Textbox(
|
| 367 |
+
label="URLs (one per line)",
|
| 368 |
+
lines=3,
|
| 369 |
+
placeholder="https://en.wikipedia.org/wiki/...",
|
| 370 |
+
)
|
| 371 |
+
upload_files = gr.File(
|
| 372 |
+
label="Upload PDF or DOCX",
|
| 373 |
+
file_count="multiple",
|
| 374 |
+
file_types=[".pdf", ".docx"],
|
| 375 |
+
)
|
| 376 |
+
|
| 377 |
+
ingest_btn = gr.Button(
|
| 378 |
+
"Ingest selected sources",
|
| 379 |
+
variant="secondary",
|
| 380 |
+
size="sm",
|
| 381 |
+
)
|
| 382 |
+
|
| 383 |
+
ingest_status = gr.Markdown(
|
| 384 |
+
value="_Set focus topic, then discover or auto-ingest sources._",
|
| 385 |
+
elem_classes=["form-status"],
|
| 386 |
+
)
|
| 387 |
+
|
| 388 |
+
use_rag = gr.Checkbox(
|
| 389 |
+
label="Answer from my indexed sources (with citations)",
|
| 390 |
+
value=False,
|
| 391 |
+
)
|
| 392 |
+
with gr.Row(elem_classes=["form-secondary"]):
|
| 393 |
+
session_dd = gr.Dropdown(
|
| 394 |
+
label="Session",
|
| 395 |
+
choices=list_session_choices(),
|
| 396 |
+
value="",
|
| 397 |
+
scale=4,
|
| 398 |
+
)
|
| 399 |
+
refresh_sessions_btn = gr.Button("↻", size="sm", scale=0, min_width=40)
|
| 400 |
+
doc_dd = gr.CheckboxGroup(
|
| 401 |
+
label="Documents (empty = all in session)",
|
| 402 |
+
choices=[],
|
| 403 |
+
value=[],
|
| 404 |
+
elem_classes=DOC_CHOICE_LIST_CLASSES,
|
| 405 |
+
)
|
| 406 |
+
rag_hint = gr.Markdown(
|
| 407 |
+
value=_update_rag_hint(False, "", []),
|
| 408 |
+
elem_classes=["form-status"],
|
| 409 |
+
)
|
| 410 |
+
|
| 411 |
+
with gr.Accordion("Indexed in this session", open=False):
|
| 412 |
+
indexed_md = gr.Markdown(value=memory_summary(""))
|
| 413 |
+
refresh_indexed_btn = gr.Button("Refresh", size="sm")
|
| 414 |
+
|
| 415 |
+
message_tb = gr.Textbox(
|
| 416 |
+
label="Your message",
|
| 417 |
+
placeholder="e.g. How does photosynthesis work?",
|
| 418 |
+
lines=3,
|
| 419 |
+
max_lines=6,
|
| 420 |
+
elem_classes=["form-ask-input"],
|
| 421 |
)
|
| 422 |
+
|
| 423 |
+
with gr.Row(elem_classes=["form-cta-row"]):
|
| 424 |
+
send_text_btn = gr.Button(
|
| 425 |
+
"Send text turn",
|
| 426 |
+
variant="primary",
|
| 427 |
+
elem_classes=["primary-cta"],
|
| 428 |
+
)
|
| 429 |
+
|
| 430 |
+
gr.HTML('<p class="tv-or-divider">— or record your voice —</p>')
|
| 431 |
+
|
| 432 |
+
with gr.Column(elem_classes=["form-primary"]):
|
| 433 |
+
rec = build_recording_block(
|
| 434 |
+
max_seconds=_TURN_MAX,
|
| 435 |
+
default_seconds=_TURN_MAX,
|
| 436 |
+
lang_choices=lang_choices,
|
| 437 |
+
asr_choices=asr_choices,
|
| 438 |
+
default_lang=default_lang,
|
| 439 |
+
default_asr=default_asr,
|
| 440 |
+
audio_label="Your turn (mic or upload, up to 15s)",
|
| 441 |
+
compact=True,
|
| 442 |
)
|
| 443 |
+
|
| 444 |
+
status = gr.Markdown(
|
| 445 |
+
value="_Type a message or record audio, then send._",
|
| 446 |
+
elem_classes=["form-status"],
|
|
|
|
|
|
|
|
|
|
|
|
|
| 447 |
)
|
| 448 |
+
rec.status = status
|
| 449 |
+
|
| 450 |
+
with gr.Row(elem_classes=["form-cta-row"]):
|
| 451 |
+
send_voice_btn = gr.Button(
|
| 452 |
+
"Send voice turn",
|
| 453 |
+
variant="secondary",
|
|
|
|
|
|
|
| 454 |
)
|
| 455 |
+
clear_btn = gr.Button("Clear conversation", variant="secondary", size="sm")
|
| 456 |
+
|
| 457 |
+
wire_recording_handlers(
|
| 458 |
+
rec,
|
| 459 |
+
stop_next_action="Click **Send voice turn**.",
|
| 460 |
+
status_output=status,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 461 |
)
|
| 462 |
+
|
| 463 |
+
with gr.Accordion(
|
| 464 |
+
"Replay teacher audio",
|
| 465 |
+
open=False,
|
| 466 |
+
elem_classes=["form-optional-accordion"],
|
| 467 |
+
):
|
| 468 |
+
with gr.Row(elem_classes=["tv-replay-row"]):
|
| 469 |
+
speak_full_btn = gr.Button("Speak full reply", variant="secondary", size="sm")
|
| 470 |
+
speak_quick_btn = gr.Button("Speak first sentence", variant="secondary", size="sm")
|
| 471 |
+
voiceout = gr.Audio(
|
| 472 |
+
label="Replay audio",
|
| 473 |
+
type="filepath",
|
| 474 |
+
visible=False,
|
| 475 |
+
)
|
| 476 |
+
speak_status = gr.Markdown(
|
| 477 |
+
value="_Each reply includes an audio player in the chat. Use replay to regenerate speech._",
|
| 478 |
+
elem_classes=["form-status"],
|
| 479 |
+
)
|
| 480 |
+
|
| 481 |
+
advanced = build_advanced_panel(use_json=True)
|
| 482 |
+
|
| 483 |
+
with gr.Column(scale=2, elem_classes=["tv-results-col"]):
|
| 484 |
+
gr.HTML('<p class="form-section-label">Step 2 · Conversation</p>')
|
| 485 |
+
|
| 486 |
+
chatbot = gr.Chatbot(
|
| 487 |
+
label="Conversation",
|
| 488 |
+
height=360,
|
| 489 |
+
reasoning_tags=_REASONING_TAGS,
|
| 490 |
+
placeholder=(
|
| 491 |
+
"Your back-and-forth with the teacher will show here. "
|
| 492 |
+
"Type a message or record audio on the left, then send a turn."
|
| 493 |
+
),
|
| 494 |
)
|
|
|
|
|
|
|
| 495 |
|
| 496 |
+
mode_dd.change(
|
| 497 |
+
fn=_on_mode_change,
|
| 498 |
+
inputs=[mode_dd],
|
| 499 |
+
outputs=[topic_tb, message_tb, rag_acc, use_rag],
|
|
|
|
|
|
|
|
|
|
|
|
|
| 500 |
).then(
|
| 501 |
+
fn=_update_rag_hint,
|
| 502 |
+
inputs=[use_rag, session_dd, doc_dd],
|
| 503 |
+
outputs=[rag_hint],
|
| 504 |
)
|
| 505 |
|
| 506 |
refresh_sessions_btn.click(fn=refresh_sessions, inputs=[session_dd], outputs=[session_dd])
|
| 507 |
+
refresh_indexed_btn.click(fn=memory_summary, inputs=[session_dd], outputs=[indexed_md])
|
| 508 |
+
session_dd.change(fn=memory_summary, inputs=[session_dd], outputs=[indexed_md])
|
| 509 |
+
session_dd.change(fn=refresh_doc_choices, inputs=[session_dd, doc_dd], outputs=[doc_dd])
|
|
|
|
|
|
|
| 510 |
for trigger in (use_rag, session_dd, doc_dd):
|
| 511 |
trigger.change(
|
| 512 |
+
fn=_update_rag_hint,
|
|
|
|
|
|
|
| 513 |
inputs=[use_rag, session_dd, doc_dd],
|
| 514 |
outputs=[rag_hint],
|
| 515 |
)
|
| 516 |
|
| 517 |
+
discover_outputs = [
|
| 518 |
+
ingest_status,
|
| 519 |
+
url_choices,
|
| 520 |
+
session_dd,
|
| 521 |
+
advanced.trace_summary,
|
| 522 |
+
advanced.trace_box,
|
| 523 |
+
indexed_md,
|
| 524 |
+
doc_dd,
|
| 525 |
+
urls_acc,
|
| 526 |
+
]
|
| 527 |
+
|
| 528 |
+
discover_btn.click(
|
| 529 |
+
fn=_discover_for_json,
|
| 530 |
+
inputs=[topic_tb, session_dd],
|
| 531 |
+
outputs=discover_outputs,
|
| 532 |
+
).then(
|
| 533 |
+
fn=_update_rag_hint,
|
| 534 |
+
inputs=[use_rag, session_dd, doc_dd],
|
| 535 |
+
outputs=[rag_hint],
|
| 536 |
+
)
|
| 537 |
+
|
| 538 |
+
auto_btn.click(
|
| 539 |
+
fn=_auto_ingest_for_json,
|
| 540 |
+
inputs=[topic_tb, session_dd],
|
| 541 |
+
outputs=discover_outputs,
|
| 542 |
+
).then(
|
| 543 |
+
fn=_enable_rag_after_ingest,
|
| 544 |
+
inputs=[ingest_status, session_dd, doc_dd],
|
| 545 |
+
outputs=[use_rag, rag_hint],
|
| 546 |
+
)
|
| 547 |
+
|
| 548 |
+
ingest_btn.click(
|
| 549 |
+
fn=_ingest_for_json,
|
| 550 |
+
inputs=[topic_tb, urls_text, url_choices, upload_files, session_dd],
|
| 551 |
+
outputs=[
|
| 552 |
+
ingest_status,
|
| 553 |
+
indexed_md,
|
| 554 |
+
advanced.trace_box,
|
| 555 |
+
advanced.trace_summary,
|
| 556 |
session_dd,
|
| 557 |
doc_dd,
|
| 558 |
],
|
| 559 |
+
).then(
|
| 560 |
+
fn=_enable_rag_after_ingest,
|
| 561 |
+
inputs=[ingest_status, session_dd, doc_dd],
|
| 562 |
+
outputs=[use_rag, rag_hint],
|
| 563 |
)
|
| 564 |
|
| 565 |
+
turn_outputs = [
|
| 566 |
+
chatbot,
|
| 567 |
+
status,
|
| 568 |
+
advanced.trace_summary,
|
| 569 |
+
advanced.trace_box,
|
| 570 |
+
message_tb,
|
| 571 |
+
]
|
| 572 |
+
|
| 573 |
+
text_turn_inputs = [
|
| 574 |
+
message_tb,
|
| 575 |
+
chatbot,
|
| 576 |
+
mode_dd,
|
| 577 |
+
rec.language,
|
| 578 |
+
topic_tb,
|
| 579 |
+
use_rag,
|
| 580 |
+
session_dd,
|
| 581 |
+
doc_dd,
|
| 582 |
+
]
|
| 583 |
+
|
| 584 |
+
voice_turn_inputs = [
|
| 585 |
+
rec.audio_in,
|
| 586 |
+
chatbot,
|
| 587 |
+
mode_dd,
|
| 588 |
+
rec.language,
|
| 589 |
+
rec.asr_preset,
|
| 590 |
+
topic_tb,
|
| 591 |
+
use_rag,
|
| 592 |
+
session_dd,
|
| 593 |
+
doc_dd,
|
| 594 |
+
]
|
| 595 |
+
|
| 596 |
+
send_text_btn.click(send_text_turn, inputs=text_turn_inputs, outputs=turn_outputs)
|
| 597 |
+
message_tb.submit(send_text_turn, inputs=text_turn_inputs, outputs=turn_outputs)
|
| 598 |
+
|
| 599 |
+
send_voice_btn.click(send_turn, inputs=voice_turn_inputs, outputs=turn_outputs)
|
| 600 |
+
|
| 601 |
+
clear_btn.click(clear_conversation, outputs=turn_outputs)
|
| 602 |
|
| 603 |
speak_full_btn.click(
|
| 604 |
speak_full_reply,
|
| 605 |
+
inputs=[chatbot, rec.language],
|
| 606 |
outputs=[voiceout, status, speak_status],
|
| 607 |
)
|
| 608 |
speak_quick_btn.click(
|
| 609 |
speak_quick_reply,
|
| 610 |
+
inputs=[chatbot, rec.language],
|
| 611 |
outputs=[voiceout, status, speak_status],
|
| 612 |
)
|
| 613 |
|
apps/gradio-space/src/gradio_space/ui/__init__.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from gradio_space.ui.components import (
|
| 2 |
+
build_advanced_panel,
|
| 3 |
+
build_session_picker,
|
| 4 |
+
build_step_indicator,
|
| 5 |
+
wire_recording_handlers,
|
| 6 |
+
)
|
| 7 |
+
from gradio_space.ui.settings_panel import build_settings_panel
|
| 8 |
+
from gradio_space.ui.theme import get_theme, load_css
|
| 9 |
+
|
| 10 |
+
__all__ = [
|
| 11 |
+
"build_advanced_panel",
|
| 12 |
+
"build_session_picker",
|
| 13 |
+
"build_settings_panel",
|
| 14 |
+
"build_step_indicator",
|
| 15 |
+
"get_theme",
|
| 16 |
+
"load_css",
|
| 17 |
+
"wire_recording_handlers",
|
| 18 |
+
]
|
apps/gradio-space/src/gradio_space/ui/components.py
ADDED
|
@@ -0,0 +1,349 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from dataclasses import dataclass
|
| 4 |
+
from typing import Callable
|
| 5 |
+
|
| 6 |
+
import gradio as gr
|
| 7 |
+
|
| 8 |
+
from echocoach.recording import (
|
| 9 |
+
ServerRecordingError,
|
| 10 |
+
recording_backend_status,
|
| 11 |
+
recording_elapsed_seconds,
|
| 12 |
+
recording_level_warning,
|
| 13 |
+
start_server_recording,
|
| 14 |
+
stop_server_recording,
|
| 15 |
+
)
|
| 16 |
+
from gradio_space.research_helpers import (
|
| 17 |
+
list_session_choices,
|
| 18 |
+
rag_scope_hint,
|
| 19 |
+
refresh_doc_choices,
|
| 20 |
+
refresh_sessions,
|
| 21 |
+
)
|
| 22 |
+
|
| 23 |
+
# Shared elem_classes for document / URL CheckboxGroup rows (see styles.css).
|
| 24 |
+
DOC_CHOICE_LIST_CLASSES = ["doc-choice-list"]
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def build_step_indicator(steps: list[str], active_index: int = 0) -> str:
|
| 28 |
+
"""Render a horizontal step strip as HTML."""
|
| 29 |
+
parts: list[str] = ['<div class="step-strip">']
|
| 30 |
+
for i, label in enumerate(steps):
|
| 31 |
+
if i > 0:
|
| 32 |
+
parts.append('<span class="step-arrow">→</span>')
|
| 33 |
+
if i < active_index:
|
| 34 |
+
state = "done"
|
| 35 |
+
elif i == active_index:
|
| 36 |
+
state = "active"
|
| 37 |
+
else:
|
| 38 |
+
state = ""
|
| 39 |
+
cls = f"step-pill {state}".strip()
|
| 40 |
+
parts.append(
|
| 41 |
+
f'<span class="{cls}"><span class="num">{i + 1}</span>{label}</span>'
|
| 42 |
+
)
|
| 43 |
+
parts.append("</div>")
|
| 44 |
+
return "".join(parts)
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def tab_hero(subtitle: str, steps: list[str] | None = None, active_step: int = 0) -> gr.HTML:
|
| 48 |
+
html = f'<p class="tab-subtitle">{subtitle}</p>'
|
| 49 |
+
if steps:
|
| 50 |
+
html += build_step_indicator(steps, active_step)
|
| 51 |
+
return gr.HTML(html)
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
@dataclass
|
| 55 |
+
class SessionPickerWidgets:
|
| 56 |
+
session_dd: gr.Dropdown
|
| 57 |
+
refresh_btn: gr.Button
|
| 58 |
+
doc_dd: gr.CheckboxGroup | None = None
|
| 59 |
+
rag_hint: gr.Markdown | None = None
|
| 60 |
+
|
| 61 |
+
def wire(
|
| 62 |
+
self,
|
| 63 |
+
*,
|
| 64 |
+
on_session_change: Callable | None = None,
|
| 65 |
+
extra_session_outputs: list | None = None,
|
| 66 |
+
) -> None:
|
| 67 |
+
session_outputs = list(extra_session_outputs or [])
|
| 68 |
+
if self.doc_dd is not None:
|
| 69 |
+
self.session_dd.change(
|
| 70 |
+
fn=refresh_doc_choices,
|
| 71 |
+
inputs=[self.session_dd, self.doc_dd],
|
| 72 |
+
outputs=[self.doc_dd],
|
| 73 |
+
)
|
| 74 |
+
if on_session_change is not None:
|
| 75 |
+
self.session_dd.change(
|
| 76 |
+
fn=on_session_change,
|
| 77 |
+
inputs=[self.session_dd],
|
| 78 |
+
outputs=session_outputs,
|
| 79 |
+
)
|
| 80 |
+
self.refresh_btn.click(
|
| 81 |
+
fn=refresh_sessions,
|
| 82 |
+
inputs=[self.session_dd],
|
| 83 |
+
outputs=[self.session_dd],
|
| 84 |
+
)
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def build_session_picker(
|
| 88 |
+
*,
|
| 89 |
+
include_docs: bool = False,
|
| 90 |
+
doc_label: str = "Documents (empty = all in session)",
|
| 91 |
+
session_label: str = "Session",
|
| 92 |
+
) -> SessionPickerWidgets:
|
| 93 |
+
with gr.Row():
|
| 94 |
+
session_dd = gr.Dropdown(
|
| 95 |
+
label=session_label,
|
| 96 |
+
choices=list_session_choices(),
|
| 97 |
+
value="",
|
| 98 |
+
interactive=True,
|
| 99 |
+
scale=4,
|
| 100 |
+
)
|
| 101 |
+
refresh_btn = gr.Button("↻", size="sm", scale=0, min_width=40)
|
| 102 |
+
|
| 103 |
+
doc_dd = None
|
| 104 |
+
rag_hint = None
|
| 105 |
+
if include_docs:
|
| 106 |
+
with gr.Accordion("Limit to documents", open=False):
|
| 107 |
+
doc_dd = gr.CheckboxGroup(
|
| 108 |
+
label=doc_label,
|
| 109 |
+
choices=[],
|
| 110 |
+
value=[],
|
| 111 |
+
elem_classes=DOC_CHOICE_LIST_CLASSES,
|
| 112 |
+
)
|
| 113 |
+
rag_hint = gr.Markdown(value=rag_scope_hint("", []))
|
| 114 |
+
doc_dd.change(
|
| 115 |
+
fn=rag_scope_hint,
|
| 116 |
+
inputs=[session_dd, doc_dd],
|
| 117 |
+
outputs=[rag_hint],
|
| 118 |
+
)
|
| 119 |
+
session_dd.change(
|
| 120 |
+
fn=rag_scope_hint,
|
| 121 |
+
inputs=[session_dd, doc_dd],
|
| 122 |
+
outputs=[rag_hint],
|
| 123 |
+
)
|
| 124 |
+
|
| 125 |
+
return SessionPickerWidgets(
|
| 126 |
+
session_dd=session_dd,
|
| 127 |
+
refresh_btn=refresh_btn,
|
| 128 |
+
doc_dd=doc_dd,
|
| 129 |
+
rag_hint=rag_hint,
|
| 130 |
+
)
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
@dataclass
|
| 134 |
+
class RecordingWidgets:
|
| 135 |
+
record_status_md: gr.Markdown
|
| 136 |
+
audio_in: gr.Audio
|
| 137 |
+
record_start_btn: gr.Button
|
| 138 |
+
record_stop_btn: gr.Button
|
| 139 |
+
record_seconds: gr.Slider
|
| 140 |
+
sample_btn: gr.Button | None = None
|
| 141 |
+
language: gr.Dropdown | None = None
|
| 142 |
+
asr_preset: gr.Dropdown | None = None
|
| 143 |
+
|
| 144 |
+
status: gr.Textbox | gr.Markdown | None = None
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
def build_recording_block(
|
| 148 |
+
*,
|
| 149 |
+
max_seconds: int,
|
| 150 |
+
default_seconds: int | None = None,
|
| 151 |
+
lang_choices: list[tuple[str, str]],
|
| 152 |
+
asr_choices: list[tuple[str, str]],
|
| 153 |
+
default_lang: str,
|
| 154 |
+
default_asr: str,
|
| 155 |
+
audio_label: str = "Record or upload",
|
| 156 |
+
include_sample: bool = False,
|
| 157 |
+
server_mic_open: bool = False,
|
| 158 |
+
advanced_open: bool = False,
|
| 159 |
+
compact: bool = False,
|
| 160 |
+
audio_elem_classes: list[str] | None = None,
|
| 161 |
+
) -> RecordingWidgets:
|
| 162 |
+
mic_status = recording_backend_status()
|
| 163 |
+
slider_value = default_seconds or min(30, max_seconds)
|
| 164 |
+
sample_btn: gr.Button | None = None
|
| 165 |
+
|
| 166 |
+
if compact:
|
| 167 |
+
record_status_md = gr.Markdown(mic_status, elem_classes=["form-status", "ec-mic-hint"])
|
| 168 |
+
audio_classes = ["ec-audio-primary", *(audio_elem_classes or [])]
|
| 169 |
+
audio_in = gr.Audio(
|
| 170 |
+
label=audio_label,
|
| 171 |
+
sources=["upload", "microphone"],
|
| 172 |
+
type="filepath",
|
| 173 |
+
format="wav",
|
| 174 |
+
elem_classes=audio_classes,
|
| 175 |
+
)
|
| 176 |
+
with gr.Row(elem_classes=["ec-record-row"]):
|
| 177 |
+
record_start_btn = gr.Button("Start recording", variant="secondary", size="sm")
|
| 178 |
+
record_stop_btn = gr.Button("Stop recording", variant="stop", size="sm", interactive=False)
|
| 179 |
+
if include_sample:
|
| 180 |
+
sample_btn = gr.Button("Try sample clip", variant="secondary", size="sm")
|
| 181 |
+
with gr.Accordion(
|
| 182 |
+
"Recording options",
|
| 183 |
+
open=False,
|
| 184 |
+
elem_classes=["form-optional-accordion"],
|
| 185 |
+
):
|
| 186 |
+
gr.Markdown(
|
| 187 |
+
"Open **http://localhost:7860** in Chrome or Firefox (not Cursor's preview) "
|
| 188 |
+
"and allow microphone access. On Linux you can also use **Start recording** "
|
| 189 |
+
"for server-side capture. Use **Upload** if the browser mic fails."
|
| 190 |
+
)
|
| 191 |
+
record_seconds = gr.Slider(
|
| 192 |
+
label="Max length (seconds)",
|
| 193 |
+
minimum=3,
|
| 194 |
+
maximum=max_seconds,
|
| 195 |
+
value=slider_value,
|
| 196 |
+
step=1,
|
| 197 |
+
)
|
| 198 |
+
language = gr.Dropdown(label="Language", choices=lang_choices, value=default_lang)
|
| 199 |
+
asr_preset = gr.Dropdown(label="ASR preset", choices=asr_choices, value=default_asr)
|
| 200 |
+
else:
|
| 201 |
+
record_status_md = gr.Markdown(mic_status)
|
| 202 |
+
with gr.Accordion("Recording help", open=False):
|
| 203 |
+
gr.Markdown(
|
| 204 |
+
"Open **http://localhost:7860** in Chrome or Firefox (not Cursor's preview) "
|
| 205 |
+
"and allow microphone access. Use **Upload** if the browser mic fails."
|
| 206 |
+
)
|
| 207 |
+
audio_in = gr.Audio(
|
| 208 |
+
label=audio_label,
|
| 209 |
+
sources=["upload", "microphone"],
|
| 210 |
+
type="filepath",
|
| 211 |
+
format="wav",
|
| 212 |
+
elem_classes=audio_elem_classes or None,
|
| 213 |
+
)
|
| 214 |
+
with gr.Accordion("Server microphone (Linux)", open=server_mic_open):
|
| 215 |
+
record_seconds = gr.Slider(
|
| 216 |
+
label="Max length (seconds)",
|
| 217 |
+
minimum=3,
|
| 218 |
+
maximum=max_seconds,
|
| 219 |
+
value=slider_value,
|
| 220 |
+
step=1,
|
| 221 |
+
)
|
| 222 |
+
with gr.Row():
|
| 223 |
+
record_start_btn = gr.Button("Start recording", variant="secondary")
|
| 224 |
+
record_stop_btn = gr.Button("Stop recording", variant="stop", interactive=False)
|
| 225 |
+
if include_sample:
|
| 226 |
+
sample_btn = gr.Button("Load sample clip", variant="secondary")
|
| 227 |
+
language = None
|
| 228 |
+
asr_preset = None
|
| 229 |
+
with gr.Accordion("Voice settings", open=advanced_open):
|
| 230 |
+
language = gr.Dropdown(label="Language", choices=lang_choices, value=default_lang)
|
| 231 |
+
asr_preset = gr.Dropdown(label="ASR preset", choices=asr_choices, value=default_asr)
|
| 232 |
+
|
| 233 |
+
return RecordingWidgets(
|
| 234 |
+
record_status_md=record_status_md,
|
| 235 |
+
audio_in=audio_in,
|
| 236 |
+
record_start_btn=record_start_btn,
|
| 237 |
+
record_stop_btn=record_stop_btn,
|
| 238 |
+
record_seconds=record_seconds,
|
| 239 |
+
sample_btn=sample_btn,
|
| 240 |
+
language=language,
|
| 241 |
+
asr_preset=asr_preset,
|
| 242 |
+
)
|
| 243 |
+
|
| 244 |
+
|
| 245 |
+
def ui_start_recording(max_seconds: int) -> tuple[str, dict, dict]:
|
| 246 |
+
try:
|
| 247 |
+
start_server_recording(int(max_seconds))
|
| 248 |
+
except ServerRecordingError as exc:
|
| 249 |
+
return (
|
| 250 |
+
str(exc),
|
| 251 |
+
gr.update(interactive=True),
|
| 252 |
+
gr.update(interactive=False),
|
| 253 |
+
)
|
| 254 |
+
return (
|
| 255 |
+
(
|
| 256 |
+
f"Recording… speak now, then click **Stop recording** "
|
| 257 |
+
f"(auto-stops after {int(max_seconds)}s)."
|
| 258 |
+
),
|
| 259 |
+
gr.update(interactive=False),
|
| 260 |
+
gr.update(interactive=True),
|
| 261 |
+
)
|
| 262 |
+
|
| 263 |
+
|
| 264 |
+
def ui_stop_recording(*, next_action: str) -> tuple[str | None, str, dict, dict]:
|
| 265 |
+
try:
|
| 266 |
+
elapsed = recording_elapsed_seconds()
|
| 267 |
+
path = stop_server_recording()
|
| 268 |
+
warning = recording_level_warning(path)
|
| 269 |
+
except ServerRecordingError as exc:
|
| 270 |
+
return (
|
| 271 |
+
None,
|
| 272 |
+
str(exc),
|
| 273 |
+
gr.update(interactive=True),
|
| 274 |
+
gr.update(interactive=False),
|
| 275 |
+
)
|
| 276 |
+
except Exception as exc: # noqa: BLE001
|
| 277 |
+
return (
|
| 278 |
+
None,
|
| 279 |
+
f"Recording failed: {exc}",
|
| 280 |
+
gr.update(interactive=True),
|
| 281 |
+
gr.update(interactive=False),
|
| 282 |
+
)
|
| 283 |
+
|
| 284 |
+
status = f"Recording saved ({elapsed:.1f}s). {next_action}"
|
| 285 |
+
if warning:
|
| 286 |
+
status += f" Warning: {warning}"
|
| 287 |
+
return (
|
| 288 |
+
gr.update(value=str(path)),
|
| 289 |
+
status,
|
| 290 |
+
gr.update(interactive=True),
|
| 291 |
+
gr.update(interactive=False),
|
| 292 |
+
)
|
| 293 |
+
|
| 294 |
+
|
| 295 |
+
def wire_recording_handlers(
|
| 296 |
+
rec: RecordingWidgets,
|
| 297 |
+
*,
|
| 298 |
+
stop_next_action: str,
|
| 299 |
+
status_output: gr.Textbox | gr.Markdown | None = None,
|
| 300 |
+
sample_loader: Callable[[], tuple] | None = None,
|
| 301 |
+
) -> None:
|
| 302 |
+
status_out = status_output or rec.status
|
| 303 |
+
if status_out is None:
|
| 304 |
+
raise ValueError("wire_recording_handlers requires status_output or rec.status")
|
| 305 |
+
|
| 306 |
+
rec.record_start_btn.click(
|
| 307 |
+
ui_start_recording,
|
| 308 |
+
inputs=[rec.record_seconds],
|
| 309 |
+
outputs=[status_out, rec.record_start_btn, rec.record_stop_btn],
|
| 310 |
+
)
|
| 311 |
+
rec.record_stop_btn.click(
|
| 312 |
+
lambda: ui_stop_recording(next_action=stop_next_action),
|
| 313 |
+
outputs=[rec.audio_in, status_out, rec.record_start_btn, rec.record_stop_btn],
|
| 314 |
+
).then(
|
| 315 |
+
lambda: recording_backend_status(),
|
| 316 |
+
outputs=[rec.record_status_md],
|
| 317 |
+
)
|
| 318 |
+
|
| 319 |
+
if rec.sample_btn is not None and sample_loader is not None:
|
| 320 |
+
rec.sample_btn.click(sample_loader, outputs=[rec.audio_in, status_out])
|
| 321 |
+
|
| 322 |
+
|
| 323 |
+
@dataclass
|
| 324 |
+
class AdvancedPanelWidgets:
|
| 325 |
+
trace_summary: gr.Markdown
|
| 326 |
+
trace_box: gr.Textbox | gr.JSON
|
| 327 |
+
|
| 328 |
+
|
| 329 |
+
def build_advanced_panel(
|
| 330 |
+
*,
|
| 331 |
+
use_json: bool = False,
|
| 332 |
+
trace_lines: int = 12,
|
| 333 |
+
) -> AdvancedPanelWidgets:
|
| 334 |
+
with gr.Accordion("Advanced & debug", open=False):
|
| 335 |
+
trace_summary = gr.Markdown()
|
| 336 |
+
if use_json:
|
| 337 |
+
trace_box = gr.JSON(label="Trace")
|
| 338 |
+
else:
|
| 339 |
+
trace_box = gr.Textbox(
|
| 340 |
+
label="Agent trace (JSON)",
|
| 341 |
+
lines=trace_lines,
|
| 342 |
+
max_lines=20,
|
| 343 |
+
interactive=False,
|
| 344 |
+
)
|
| 345 |
+
return AdvancedPanelWidgets(trace_summary=trace_summary, trace_box=trace_box)
|
| 346 |
+
|
| 347 |
+
|
| 348 |
+
def empty_state(message: str) -> str:
|
| 349 |
+
return f'<div class="empty-state">{message}</div>'
|
apps/gradio-space/src/gradio_space/ui/settings_panel.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import gradio as gr
|
| 4 |
+
|
| 5 |
+
from echocoach.config import get_echo_coach_config
|
| 6 |
+
from gradio_space.model_loading import model_status, reload_model
|
| 7 |
+
from inference.config import get_app_config
|
| 8 |
+
from researchmind.config import get_config as get_research_config
|
| 9 |
+
|
| 10 |
+
_app_config = get_app_config()
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def _voice_stack_summary() -> str:
|
| 14 |
+
cfg = get_echo_coach_config()
|
| 15 |
+
asr = cfg.get_asr()
|
| 16 |
+
tts = cfg.get_tts()
|
| 17 |
+
lines = [
|
| 18 |
+
f"- **ASR:** {asr.label} (`{cfg.asr_preset}`)",
|
| 19 |
+
f"- **TTS:** {tts.label} (`{cfg.tts_preset}`)",
|
| 20 |
+
f"- **Coach model:** `{cfg.coach_model}`",
|
| 21 |
+
f"- **Max recording:** {cfg.max_seconds}s",
|
| 22 |
+
]
|
| 23 |
+
if cfg.presets_path:
|
| 24 |
+
lines.append(f"- Voice presets: `{cfg.presets_path}`")
|
| 25 |
+
return "\n".join(lines)
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def _paths_summary() -> str:
|
| 29 |
+
rm = get_research_config()
|
| 30 |
+
lines = []
|
| 31 |
+
if _app_config.presets_path:
|
| 32 |
+
lines.append(f"- **Model presets:** `{_app_config.presets_path}`")
|
| 33 |
+
else:
|
| 34 |
+
lines.append("- **Model presets:** built-in defaults")
|
| 35 |
+
lines.append(f"- **ResearchMind store:** `{rm.data_dir.resolve()}`")
|
| 36 |
+
return "\n".join(lines)
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def build_settings_panel() -> tuple[gr.Dropdown | None, gr.Markdown, gr.Button]:
|
| 40 |
+
"""Build settings accordion contents. Returns (model_dropdown or None, status_md, reload_btn)."""
|
| 41 |
+
model_dropdown: gr.Dropdown | None = None
|
| 42 |
+
|
| 43 |
+
if _app_config.allow_model_switch and len(_app_config.models) > 1:
|
| 44 |
+
model_dropdown = gr.Dropdown(
|
| 45 |
+
choices=_app_config.model_choices(),
|
| 46 |
+
value=_app_config.active_model,
|
| 47 |
+
label="Model preset",
|
| 48 |
+
)
|
| 49 |
+
else:
|
| 50 |
+
active = _app_config.active
|
| 51 |
+
gr.Markdown(
|
| 52 |
+
f"**Active model:** `{active.key}` — {active.label} \n"
|
| 53 |
+
f"**Backend:** `{active.backend}`"
|
| 54 |
+
)
|
| 55 |
+
|
| 56 |
+
status_md = gr.Markdown(value=model_status(_app_config.active_model))
|
| 57 |
+
gr.Markdown("#### Voice stack")
|
| 58 |
+
gr.Markdown(_voice_stack_summary())
|
| 59 |
+
with gr.Accordion("Paths & files", open=False):
|
| 60 |
+
gr.Markdown(_paths_summary())
|
| 61 |
+
|
| 62 |
+
reload_btn = gr.Button("Reload model", variant="secondary", size="sm")
|
| 63 |
+
|
| 64 |
+
if model_dropdown is not None:
|
| 65 |
+
model_dropdown.change(fn=model_status, inputs=model_dropdown, outputs=status_md)
|
| 66 |
+
|
| 67 |
+
if model_dropdown is not None:
|
| 68 |
+
reload_btn.click(fn=reload_model, inputs=[model_dropdown], outputs=status_md)
|
| 69 |
+
else:
|
| 70 |
+
reload_btn.click(
|
| 71 |
+
fn=lambda: reload_model(_app_config.active_model),
|
| 72 |
+
outputs=status_md,
|
| 73 |
+
)
|
| 74 |
+
|
| 75 |
+
return model_dropdown, status_md, reload_btn
|
apps/gradio-space/src/gradio_space/ui/styles.css
ADDED
|
@@ -0,0 +1,511 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* Build Small — global UI polish */
|
| 2 |
+
|
| 3 |
+
.app-header {
|
| 4 |
+
align-items: center !important;
|
| 5 |
+
justify-content: space-between !important;
|
| 6 |
+
margin-bottom: 0.25rem !important;
|
| 7 |
+
padding: 0.5rem 0 !important;
|
| 8 |
+
}
|
| 9 |
+
|
| 10 |
+
.brand-block h1 {
|
| 11 |
+
font-size: 1.35rem;
|
| 12 |
+
font-weight: 700;
|
| 13 |
+
margin: 0;
|
| 14 |
+
line-height: 1.2;
|
| 15 |
+
color: #1a1a1a;
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
.brand-block p {
|
| 19 |
+
margin: 0.15rem 0 0;
|
| 20 |
+
font-size: 0.875rem;
|
| 21 |
+
color: #666;
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
.brand-block a {
|
| 25 |
+
color: #374151;
|
| 26 |
+
text-decoration: none;
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
.brand-block a:hover {
|
| 30 |
+
text-decoration: underline;
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
.tab-subtitle {
|
| 34 |
+
color: #666;
|
| 35 |
+
font-size: 0.9rem;
|
| 36 |
+
margin: 0 0 0.75rem 0 !important;
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
.dev-tab-badge {
|
| 40 |
+
display: inline-block;
|
| 41 |
+
font-size: 0.7rem;
|
| 42 |
+
font-weight: 600;
|
| 43 |
+
text-transform: uppercase;
|
| 44 |
+
letter-spacing: 0.04em;
|
| 45 |
+
color: #666;
|
| 46 |
+
background: #f0f0f0;
|
| 47 |
+
border: 1px solid #ddd;
|
| 48 |
+
border-radius: 4px;
|
| 49 |
+
padding: 2px 8px;
|
| 50 |
+
margin-left: 0.5rem;
|
| 51 |
+
vertical-align: middle;
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
/* Step indicator pills */
|
| 55 |
+
.step-strip {
|
| 56 |
+
display: flex;
|
| 57 |
+
flex-wrap: wrap;
|
| 58 |
+
gap: 0.35rem;
|
| 59 |
+
align-items: center;
|
| 60 |
+
margin: 0.5rem 0 1rem;
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
.step-pill {
|
| 64 |
+
display: inline-flex;
|
| 65 |
+
align-items: center;
|
| 66 |
+
gap: 0.35rem;
|
| 67 |
+
padding: 0.35rem 0.75rem;
|
| 68 |
+
border-radius: 999px;
|
| 69 |
+
font-size: 0.8rem;
|
| 70 |
+
font-weight: 500;
|
| 71 |
+
border: 1px solid #ddd;
|
| 72 |
+
background: #fafafa;
|
| 73 |
+
color: #888;
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
.step-pill.active {
|
| 77 |
+
background: #f3f4f6;
|
| 78 |
+
border-color: #9ca3af;
|
| 79 |
+
color: #374151;
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
.step-pill.done {
|
| 83 |
+
background: #f9fafb;
|
| 84 |
+
border-color: #e5e7eb;
|
| 85 |
+
color: #6b7280;
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
.step-pill .num {
|
| 89 |
+
display: inline-flex;
|
| 90 |
+
width: 1.25rem;
|
| 91 |
+
height: 1.25rem;
|
| 92 |
+
align-items: center;
|
| 93 |
+
justify-content: center;
|
| 94 |
+
border-radius: 50%;
|
| 95 |
+
font-size: 0.7rem;
|
| 96 |
+
font-weight: 700;
|
| 97 |
+
background: #e5e7eb;
|
| 98 |
+
color: #4b5563;
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
.step-pill.active .num {
|
| 102 |
+
background: #6b7280;
|
| 103 |
+
color: white;
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
.step-pill.done .num {
|
| 107 |
+
background: #d1d5db;
|
| 108 |
+
color: #374151;
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
.step-arrow {
|
| 112 |
+
color: #ccc;
|
| 113 |
+
font-size: 0.75rem;
|
| 114 |
+
user-select: none;
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
/* Section panels */
|
| 118 |
+
.panel-card {
|
| 119 |
+
border: 1px solid #e8e8e8;
|
| 120 |
+
border-radius: 8px;
|
| 121 |
+
padding: 0.75rem 1rem;
|
| 122 |
+
background: #fafafa;
|
| 123 |
+
margin-bottom: 0.75rem;
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
.panel-card h4 {
|
| 127 |
+
margin: 0 0 0.5rem;
|
| 128 |
+
font-size: 0.85rem;
|
| 129 |
+
font-weight: 600;
|
| 130 |
+
color: #444;
|
| 131 |
+
text-transform: uppercase;
|
| 132 |
+
letter-spacing: 0.03em;
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
.empty-state {
|
| 136 |
+
text-align: center;
|
| 137 |
+
padding: 2.5rem 1.5rem;
|
| 138 |
+
color: #6b7280;
|
| 139 |
+
font-size: 0.92rem;
|
| 140 |
+
line-height: 1.5;
|
| 141 |
+
border: 1px dashed #d1d5db;
|
| 142 |
+
border-radius: 10px;
|
| 143 |
+
background: #fff;
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
.primary-cta {
|
| 147 |
+
width: 100% !important;
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
.mode-cards label {
|
| 151 |
+
flex: 1;
|
| 152 |
+
font-size: 0.88rem !important;
|
| 153 |
+
font-weight: 500 !important;
|
| 154 |
+
padding: 0.65rem 0.75rem !important;
|
| 155 |
+
border-radius: 8px !important;
|
| 156 |
+
border: 1px solid #e5e7eb !important;
|
| 157 |
+
text-align: center;
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
.mode-cards label.selected,
|
| 161 |
+
.mode-cards input:checked + span,
|
| 162 |
+
.mode-cards .selected {
|
| 163 |
+
border-color: #9ca3af !important;
|
| 164 |
+
background: #f3f4f6 !important;
|
| 165 |
+
font-weight: 600 !important;
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
.cross-link {
|
| 169 |
+
font-size: 0.85rem;
|
| 170 |
+
color: #666;
|
| 171 |
+
margin: 0.5rem 0;
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
.cross-link strong {
|
| 175 |
+
color: #374151;
|
| 176 |
+
}
|
| 177 |
+
|
| 178 |
+
/* Only explicit primary CTAs get accent color */
|
| 179 |
+
button.primary-cta,
|
| 180 |
+
.primary-cta > button {
|
| 181 |
+
background: #e86c00 !important;
|
| 182 |
+
border-color: #cf6000 !important;
|
| 183 |
+
color: #fff !important;
|
| 184 |
+
}
|
| 185 |
+
|
| 186 |
+
button.primary-cta:hover,
|
| 187 |
+
.primary-cta > button:hover {
|
| 188 |
+
background: #cf6000 !important;
|
| 189 |
+
}
|
| 190 |
+
|
| 191 |
+
/* Neutralize Gradio orange on tabs, labels, sliders */
|
| 192 |
+
.gradio-container .tab-nav button.selected {
|
| 193 |
+
border-bottom-color: #374151 !important;
|
| 194 |
+
color: #111827 !important;
|
| 195 |
+
}
|
| 196 |
+
|
| 197 |
+
.gradio-container .tab-nav button {
|
| 198 |
+
color: #6b7280 !important;
|
| 199 |
+
}
|
| 200 |
+
|
| 201 |
+
.gradio-container label span,
|
| 202 |
+
.gradio-container .block > .wrap > label > span {
|
| 203 |
+
background: transparent !important;
|
| 204 |
+
color: #4b5563 !important;
|
| 205 |
+
font-weight: 500 !important;
|
| 206 |
+
padding-left: 0 !important;
|
| 207 |
+
}
|
| 208 |
+
|
| 209 |
+
.gradio-container input[type="range"] {
|
| 210 |
+
accent-color: #6b7280 !important;
|
| 211 |
+
}
|
| 212 |
+
|
| 213 |
+
/* ── Shared form patterns (Lesson slides, ResearchMind, …) ── */
|
| 214 |
+
.form-tab-heading,
|
| 215 |
+
.lesson-tab-heading {
|
| 216 |
+
margin: 0 0 0.25rem !important;
|
| 217 |
+
font-size: 1.15rem !important;
|
| 218 |
+
font-weight: 600 !important;
|
| 219 |
+
color: #111827 !important;
|
| 220 |
+
}
|
| 221 |
+
|
| 222 |
+
.form-primary,
|
| 223 |
+
.lesson-form-primary {
|
| 224 |
+
margin-bottom: 0.5rem !important;
|
| 225 |
+
}
|
| 226 |
+
|
| 227 |
+
.form-primary label > span,
|
| 228 |
+
.lesson-form-primary label > span {
|
| 229 |
+
font-size: 0.95rem !important;
|
| 230 |
+
font-weight: 600 !important;
|
| 231 |
+
color: #111827 !important;
|
| 232 |
+
}
|
| 233 |
+
|
| 234 |
+
.form-topic-input textarea,
|
| 235 |
+
.form-topic-input input,
|
| 236 |
+
.form-ask-input textarea,
|
| 237 |
+
.form-ask-input input,
|
| 238 |
+
.lesson-topic-input textarea,
|
| 239 |
+
.lesson-topic-input input {
|
| 240 |
+
font-size: 1.2rem !important;
|
| 241 |
+
line-height: 1.45 !important;
|
| 242 |
+
padding: 0.85rem 1rem !important;
|
| 243 |
+
min-height: 3rem !important;
|
| 244 |
+
border: 2px solid #d1d5db !important;
|
| 245 |
+
border-radius: 10px !important;
|
| 246 |
+
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04) !important;
|
| 247 |
+
}
|
| 248 |
+
|
| 249 |
+
.form-ask-input textarea,
|
| 250 |
+
.form-ask-input input {
|
| 251 |
+
font-size: 1.05rem !important;
|
| 252 |
+
min-height: 2.75rem !important;
|
| 253 |
+
}
|
| 254 |
+
|
| 255 |
+
.form-topic-input textarea:focus,
|
| 256 |
+
.form-topic-input input:focus,
|
| 257 |
+
.form-ask-input textarea:focus,
|
| 258 |
+
.form-ask-input input:focus,
|
| 259 |
+
.lesson-topic-input textarea:focus,
|
| 260 |
+
.lesson-topic-input input:focus {
|
| 261 |
+
border-color: #9ca3af !important;
|
| 262 |
+
outline: none !important;
|
| 263 |
+
box-shadow: 0 0 0 3px rgba(107, 114, 128, 0.15) !important;
|
| 264 |
+
}
|
| 265 |
+
|
| 266 |
+
.form-secondary,
|
| 267 |
+
.lesson-form-secondary {
|
| 268 |
+
max-width: 32rem;
|
| 269 |
+
margin-bottom: 0.5rem !important;
|
| 270 |
+
opacity: 0.92;
|
| 271 |
+
}
|
| 272 |
+
|
| 273 |
+
.form-secondary label > span,
|
| 274 |
+
.lesson-form-secondary label > span {
|
| 275 |
+
font-size: 0.78rem !important;
|
| 276 |
+
font-weight: 500 !important;
|
| 277 |
+
color: #6b7280 !important;
|
| 278 |
+
}
|
| 279 |
+
|
| 280 |
+
.form-secondary .wrap,
|
| 281 |
+
.form-secondary input,
|
| 282 |
+
.form-secondary select,
|
| 283 |
+
.lesson-form-secondary .wrap,
|
| 284 |
+
.lesson-form-secondary input,
|
| 285 |
+
.lesson-form-secondary select {
|
| 286 |
+
font-size: 0.875rem !important;
|
| 287 |
+
}
|
| 288 |
+
|
| 289 |
+
.form-optional-accordion > button,
|
| 290 |
+
.lesson-optional-accordion > button {
|
| 291 |
+
font-size: 0.82rem !important;
|
| 292 |
+
font-weight: 500 !important;
|
| 293 |
+
color: #6b7280 !important;
|
| 294 |
+
padding: 0.5rem 0.75rem !important;
|
| 295 |
+
}
|
| 296 |
+
|
| 297 |
+
.form-optional-accordion label > span,
|
| 298 |
+
.lesson-optional-accordion label > span {
|
| 299 |
+
font-size: 0.78rem !important;
|
| 300 |
+
color: #6b7280 !important;
|
| 301 |
+
}
|
| 302 |
+
|
| 303 |
+
.form-optional-accordion .wrap,
|
| 304 |
+
.lesson-optional-accordion .wrap {
|
| 305 |
+
font-size: 0.85rem !important;
|
| 306 |
+
}
|
| 307 |
+
|
| 308 |
+
.form-status,
|
| 309 |
+
.lesson-status {
|
| 310 |
+
font-size: 0.85rem !important;
|
| 311 |
+
color: #6b7280 !important;
|
| 312 |
+
margin: 0.35rem 0 !important;
|
| 313 |
+
}
|
| 314 |
+
|
| 315 |
+
.form-section-label {
|
| 316 |
+
font-size: 0.8rem;
|
| 317 |
+
font-weight: 600;
|
| 318 |
+
text-transform: uppercase;
|
| 319 |
+
letter-spacing: 0.04em;
|
| 320 |
+
color: #6b7280;
|
| 321 |
+
margin: 0 0 0.65rem;
|
| 322 |
+
}
|
| 323 |
+
|
| 324 |
+
.rm-workflow-columns {
|
| 325 |
+
gap: 1.25rem !important;
|
| 326 |
+
align-items: flex-start !important;
|
| 327 |
+
}
|
| 328 |
+
|
| 329 |
+
.rm-ingest-col,
|
| 330 |
+
.rm-ask-col {
|
| 331 |
+
border: 1px solid #e5e7eb;
|
| 332 |
+
border-radius: 12px;
|
| 333 |
+
padding: 1rem 1.1rem !important;
|
| 334 |
+
background: #fafafa;
|
| 335 |
+
}
|
| 336 |
+
|
| 337 |
+
.rm-ask-col .chatbot {
|
| 338 |
+
min-height: 280px;
|
| 339 |
+
}
|
| 340 |
+
|
| 341 |
+
.rm-action-row {
|
| 342 |
+
margin-top: 0.5rem !important;
|
| 343 |
+
gap: 0.5rem !important;
|
| 344 |
+
}
|
| 345 |
+
|
| 346 |
+
.rm-action-row button {
|
| 347 |
+
flex: 1;
|
| 348 |
+
}
|
| 349 |
+
|
| 350 |
+
.form-cta-row {
|
| 351 |
+
margin-top: 0.65rem !important;
|
| 352 |
+
margin-bottom: 0.25rem !important;
|
| 353 |
+
}
|
| 354 |
+
|
| 355 |
+
/* EchoCoach & TeacherVoice workflow columns */
|
| 356 |
+
.ec-workflow-columns,
|
| 357 |
+
.tv-workflow-columns {
|
| 358 |
+
gap: 1.25rem !important;
|
| 359 |
+
align-items: flex-start !important;
|
| 360 |
+
}
|
| 361 |
+
|
| 362 |
+
.ec-input-col,
|
| 363 |
+
.ec-results-col,
|
| 364 |
+
.tv-input-col,
|
| 365 |
+
.tv-results-col {
|
| 366 |
+
border: 1px solid #e5e7eb;
|
| 367 |
+
border-radius: 12px;
|
| 368 |
+
padding: 1rem 1.1rem !important;
|
| 369 |
+
background: #fafafa;
|
| 370 |
+
}
|
| 371 |
+
|
| 372 |
+
.tv-replay-row {
|
| 373 |
+
gap: 0.5rem !important;
|
| 374 |
+
}
|
| 375 |
+
|
| 376 |
+
.tv-replay-row button {
|
| 377 |
+
flex: 1;
|
| 378 |
+
}
|
| 379 |
+
|
| 380 |
+
.tv-or-divider {
|
| 381 |
+
text-align: center;
|
| 382 |
+
font-size: 0.78rem;
|
| 383 |
+
color: #9ca3af;
|
| 384 |
+
margin: 0.65rem 0 0.5rem;
|
| 385 |
+
letter-spacing: 0.02em;
|
| 386 |
+
}
|
| 387 |
+
|
| 388 |
+
.tv-results-col .chatbot {
|
| 389 |
+
min-height: 320px;
|
| 390 |
+
}
|
| 391 |
+
|
| 392 |
+
.ec-coach-report {
|
| 393 |
+
font-size: 0.95rem !important;
|
| 394 |
+
line-height: 1.55 !important;
|
| 395 |
+
color: #1f2937 !important;
|
| 396 |
+
}
|
| 397 |
+
|
| 398 |
+
.ec-coach-report h1,
|
| 399 |
+
.ec-coach-report h2,
|
| 400 |
+
.ec-coach-report h3 {
|
| 401 |
+
font-size: 1rem !important;
|
| 402 |
+
font-weight: 600 !important;
|
| 403 |
+
margin: 0.75rem 0 0.35rem !important;
|
| 404 |
+
color: #111827 !important;
|
| 405 |
+
}
|
| 406 |
+
|
| 407 |
+
.ec-transcript {
|
| 408 |
+
font-size: 0.92rem !important;
|
| 409 |
+
line-height: 1.6 !important;
|
| 410 |
+
color: #374151 !important;
|
| 411 |
+
}
|
| 412 |
+
|
| 413 |
+
.ec-transcript mark,
|
| 414 |
+
.ec-transcript .filler {
|
| 415 |
+
background: #fef3c7;
|
| 416 |
+
padding: 0 2px;
|
| 417 |
+
border-radius: 2px;
|
| 418 |
+
}
|
| 419 |
+
|
| 420 |
+
.ec-audio-primary .wrap,
|
| 421 |
+
.ec-audio-primary audio {
|
| 422 |
+
min-height: 5.5rem;
|
| 423 |
+
}
|
| 424 |
+
|
| 425 |
+
.ec-audio-primary label > span {
|
| 426 |
+
font-size: 0.95rem !important;
|
| 427 |
+
font-weight: 600 !important;
|
| 428 |
+
color: #111827 !important;
|
| 429 |
+
}
|
| 430 |
+
|
| 431 |
+
.ec-record-row {
|
| 432 |
+
margin-top: 0.5rem !important;
|
| 433 |
+
gap: 0.5rem !important;
|
| 434 |
+
flex-wrap: wrap !important;
|
| 435 |
+
}
|
| 436 |
+
|
| 437 |
+
.ec-record-row button {
|
| 438 |
+
flex: 1;
|
| 439 |
+
min-width: 7rem;
|
| 440 |
+
}
|
| 441 |
+
|
| 442 |
+
.ec-mic-hint {
|
| 443 |
+
margin-top: 0.25rem !important;
|
| 444 |
+
margin-bottom: 0.5rem !important;
|
| 445 |
+
}
|
| 446 |
+
|
| 447 |
+
.ec-charts-row {
|
| 448 |
+
gap: 0.75rem !important;
|
| 449 |
+
}
|
| 450 |
+
|
| 451 |
+
.ec-charts-row > div {
|
| 452 |
+
flex: 1;
|
| 453 |
+
min-width: 0;
|
| 454 |
+
}
|
| 455 |
+
|
| 456 |
+
.form-error {
|
| 457 |
+
padding: 12px;
|
| 458 |
+
border: 1px solid #fca5a5;
|
| 459 |
+
border-radius: 8px;
|
| 460 |
+
background: #fff5f5;
|
| 461 |
+
color: #8a1f1f;
|
| 462 |
+
font-size: 0.9rem;
|
| 463 |
+
}
|
| 464 |
+
|
| 465 |
+
/* Document / URL checkbox lists — light rows so titles stay readable when checked */
|
| 466 |
+
.doc-choice-list label {
|
| 467 |
+
background: #ffffff !important;
|
| 468 |
+
border: 1px solid #e5e7eb !important;
|
| 469 |
+
border-radius: 8px !important;
|
| 470 |
+
padding: 0.45rem 0.6rem !important;
|
| 471 |
+
margin: 0.2rem 0 !important;
|
| 472 |
+
color: #374151 !important;
|
| 473 |
+
font-size: 0.85rem !important;
|
| 474 |
+
line-height: 1.35 !important;
|
| 475 |
+
}
|
| 476 |
+
|
| 477 |
+
.doc-choice-list label span,
|
| 478 |
+
.doc-choice-list label p,
|
| 479 |
+
.doc-choice-list .label-text {
|
| 480 |
+
color: #374151 !important;
|
| 481 |
+
background: transparent !important;
|
| 482 |
+
font-weight: 400 !important;
|
| 483 |
+
}
|
| 484 |
+
|
| 485 |
+
.doc-choice-list label.selected,
|
| 486 |
+
.doc-choice-list label:has(input:checked) {
|
| 487 |
+
background: #f3f4f6 !important;
|
| 488 |
+
border-color: #9ca3af !important;
|
| 489 |
+
color: #111827 !important;
|
| 490 |
+
}
|
| 491 |
+
|
| 492 |
+
.doc-choice-list label.selected span,
|
| 493 |
+
.doc-choice-list label.selected p,
|
| 494 |
+
.doc-choice-list label.selected .label-text,
|
| 495 |
+
.doc-choice-list label:has(input:checked) span,
|
| 496 |
+
.doc-choice-list label:has(input:checked) p,
|
| 497 |
+
.doc-choice-list label:has(input:checked) .label-text {
|
| 498 |
+
color: #111827 !important;
|
| 499 |
+
font-weight: 500 !important;
|
| 500 |
+
}
|
| 501 |
+
|
| 502 |
+
/* Legacy lesson-only aliases */
|
| 503 |
+
.lesson-generate-row {
|
| 504 |
+
margin-top: 0.75rem !important;
|
| 505 |
+
margin-bottom: 0.5rem !important;
|
| 506 |
+
}
|
| 507 |
+
|
| 508 |
+
.settings-open-hint {
|
| 509 |
+
font-size: 0.8rem;
|
| 510 |
+
color: #888;
|
| 511 |
+
}
|
apps/gradio-space/src/gradio_space/ui/theme.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
|
| 5 |
+
import gradio as gr
|
| 6 |
+
|
| 7 |
+
_CSS_PATH = Path(__file__).resolve().parent / "styles.css"
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def get_theme() -> gr.Theme:
|
| 11 |
+
"""Neutral base theme — accent color only on explicit primary CTAs via CSS."""
|
| 12 |
+
return gr.themes.Soft(
|
| 13 |
+
primary_hue=gr.themes.colors.slate,
|
| 14 |
+
secondary_hue=gr.themes.colors.gray,
|
| 15 |
+
neutral_hue=gr.themes.colors.gray,
|
| 16 |
+
font=[gr.themes.GoogleFont("Inter"), "system-ui", "sans-serif"],
|
| 17 |
+
).set(
|
| 18 |
+
button_primary_background_fill="#374151",
|
| 19 |
+
button_primary_background_fill_hover="#1f2937",
|
| 20 |
+
button_primary_text_color="#ffffff",
|
| 21 |
+
button_secondary_background_fill="#f3f4f6",
|
| 22 |
+
button_secondary_background_fill_hover="#e5e7eb",
|
| 23 |
+
block_label_background_fill="transparent",
|
| 24 |
+
block_label_text_color="#4b5563",
|
| 25 |
+
block_label_text_weight="500",
|
| 26 |
+
block_title_text_weight="600",
|
| 27 |
+
block_title_text_color="#111827",
|
| 28 |
+
input_background_fill="#ffffff",
|
| 29 |
+
body_text_color="#374151",
|
| 30 |
+
border_color_primary="#e5e7eb",
|
| 31 |
+
checkbox_label_background_fill_selected="#f3f4f6",
|
| 32 |
+
checkbox_label_text_color_selected="#111827",
|
| 33 |
+
checkbox_label_border_color_selected="#9ca3af",
|
| 34 |
+
)
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def load_css() -> str:
|
| 38 |
+
return _CSS_PATH.read_text(encoding="utf-8")
|
libs/agent/src/agent/runner.py
CHANGED
|
@@ -454,12 +454,10 @@ class AgentRunner:
|
|
| 454 |
req: EducationPptxInput,
|
| 455 |
ingest: ResearchIngestResult | None,
|
| 456 |
) -> tuple[str | None, list[str] | None]:
|
|
|
|
|
|
|
| 457 |
doc_ids = AgentRunner._lesson_doc_ids(store, session_id, req, ingest)
|
| 458 |
-
|
| 459 |
-
return None, doc_ids
|
| 460 |
-
if session_id:
|
| 461 |
-
return session_id, None
|
| 462 |
-
return None, None
|
| 463 |
|
| 464 |
def run_researchmind_discover(
|
| 465 |
self,
|
|
|
|
| 454 |
req: EducationPptxInput,
|
| 455 |
ingest: ResearchIngestResult | None,
|
| 456 |
) -> tuple[str | None, list[str] | None]:
|
| 457 |
+
from researchmind.scope import resolve_retrieve_scope
|
| 458 |
+
|
| 459 |
doc_ids = AgentRunner._lesson_doc_ids(store, session_id, req, ingest)
|
| 460 |
+
return resolve_retrieve_scope(session_id, doc_ids or None)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 461 |
|
| 462 |
def run_researchmind_discover(
|
| 463 |
self,
|
libs/agent/src/agent/tools/research_tools.py
CHANGED
|
@@ -8,6 +8,7 @@ from researchmind.config import get_config
|
|
| 8 |
from researchmind.extract import ExtractedDocument
|
| 9 |
from researchmind.ingest import IngestPipeline
|
| 10 |
from researchmind.retrieve import retrieve
|
|
|
|
| 11 |
from researchmind.scrape_pdf import extract_pdf
|
| 12 |
from researchmind.scrape_web import fetch_and_extract
|
| 13 |
from researchmind.search_urls import search_urls
|
|
@@ -53,8 +54,7 @@ def tool_research_answer(
|
|
| 53 |
) -> tuple[str, list[Citation], str]:
|
| 54 |
cfg = get_config()
|
| 55 |
store = get_store()
|
| 56 |
-
scope_session = session_id
|
| 57 |
-
scope_docs = doc_ids if doc_ids else None
|
| 58 |
chunks = retrieve(
|
| 59 |
question,
|
| 60 |
store,
|
|
@@ -63,12 +63,7 @@ def tool_research_answer(
|
|
| 63 |
doc_ids=scope_docs,
|
| 64 |
)
|
| 65 |
if not chunks:
|
| 66 |
-
|
| 67 |
-
hint = "No chunks for the selected document(s). Try other sources or re-ingest."
|
| 68 |
-
elif session_id:
|
| 69 |
-
hint = "No indexed sources in this session yet. Ingest URLs or files first."
|
| 70 |
-
else:
|
| 71 |
-
hint = "No indexed sources yet. Ingest URLs or documents first."
|
| 72 |
return hint, [], ""
|
| 73 |
|
| 74 |
context, citations = format_context_block(chunks)
|
|
|
|
| 8 |
from researchmind.extract import ExtractedDocument
|
| 9 |
from researchmind.ingest import IngestPipeline
|
| 10 |
from researchmind.retrieve import retrieve
|
| 11 |
+
from researchmind.scope import rag_scope_warning, resolve_retrieve_scope
|
| 12 |
from researchmind.scrape_pdf import extract_pdf
|
| 13 |
from researchmind.scrape_web import fetch_and_extract
|
| 14 |
from researchmind.search_urls import search_urls
|
|
|
|
| 54 |
) -> tuple[str, list[Citation], str]:
|
| 55 |
cfg = get_config()
|
| 56 |
store = get_store()
|
| 57 |
+
scope_session, scope_docs = resolve_retrieve_scope(session_id, doc_ids)
|
|
|
|
| 58 |
chunks = retrieve(
|
| 59 |
question,
|
| 60 |
store,
|
|
|
|
| 63 |
doc_ids=scope_docs,
|
| 64 |
)
|
| 65 |
if not chunks:
|
| 66 |
+
hint = rag_scope_warning(session_id=session_id, doc_ids=doc_ids)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 67 |
return hint, [], ""
|
| 68 |
|
| 69 |
context, citations = format_context_block(chunks)
|
libs/echocoach/src/echocoach/prompts.py
CHANGED
|
@@ -13,13 +13,15 @@ MODE_LABELS: dict[TeacherVoiceMode, str] = {
|
|
| 13 |
}
|
| 14 |
|
| 15 |
EXPLAIN_SYSTEM = """You are TeacherVoice, a friendly tutor who explains ideas in plain language.
|
| 16 |
-
|
|
|
|
| 17 |
Use simple examples when helpful. If the student asks in another language, reply in that language.
|
| 18 |
When source excerpts are provided, ground your answer in them and cite with [1], [2], etc."""
|
| 19 |
|
| 20 |
LESSON_SYSTEM = """You are TeacherVoice, a lesson-planning coach for teachers and students.
|
|
|
|
|
|
|
| 21 |
Help outline and explain lesson content verbally: learning goals, key points, and a simple flow.
|
| 22 |
-
Keep each reply short (2-5 sentences) for voice playback.
|
| 23 |
If a lesson topic is set, stay focused on it. When source excerpts are provided, use them and cite [1], [2], etc."""
|
| 24 |
|
| 25 |
PITCH_SYSTEM = """You are TeacherVoice, a supportive public-speaking coach in a live conversation.
|
|
|
|
| 13 |
}
|
| 14 |
|
| 15 |
EXPLAIN_SYSTEM = """You are TeacherVoice, a friendly tutor who explains ideas in plain language.
|
| 16 |
+
Reply with ONLY the spoken answer (2-5 short sentences). Do not include planning, drafting,
|
| 17 |
+
numbered outlines, or phrases like "let me think" or "first I need to".
|
| 18 |
Use simple examples when helpful. If the student asks in another language, reply in that language.
|
| 19 |
When source excerpts are provided, ground your answer in them and cite with [1], [2], etc."""
|
| 20 |
|
| 21 |
LESSON_SYSTEM = """You are TeacherVoice, a lesson-planning coach for teachers and students.
|
| 22 |
+
Reply with ONLY the spoken answer (2-5 short sentences). Do not include planning, drafting,
|
| 23 |
+
or meta commentary about how you will answer.
|
| 24 |
Help outline and explain lesson content verbally: learning goals, key points, and a simple flow.
|
|
|
|
| 25 |
If a lesson topic is set, stay focused on it. When source excerpts are provided, use them and cite [1], [2], etc."""
|
| 26 |
|
| 27 |
PITCH_SYSTEM = """You are TeacherVoice, a supportive public-speaking coach in a live conversation.
|
libs/echocoach/src/echocoach/teacher_voice.py
CHANGED
|
@@ -7,13 +7,16 @@ from dataclasses import dataclass, field
|
|
| 7 |
from pathlib import Path
|
| 8 |
from typing import Any
|
| 9 |
|
|
|
|
| 10 |
from agent.trace import TraceRecorder
|
| 11 |
from inference.base import InferenceBackend
|
| 12 |
-
from inference.response_clean import
|
| 13 |
-
|
| 14 |
-
|
|
|
|
|
|
|
| 15 |
from researchmind.ingest import IngestPipeline
|
| 16 |
-
from researchmind.
|
| 17 |
|
| 18 |
from echocoach.asr.factory import get_asr_backend
|
| 19 |
from echocoach.audio_io import clamp_duration, load_audio_mono_16k, write_wav_temp
|
|
@@ -22,6 +25,10 @@ from echocoach.prompts import TeacherVoiceMode, system_prompt_for_mode, topic_co
|
|
| 22 |
from echocoach.voiceout import extract_message_text, strip_references_for_tts, synthesize_voice_reply
|
| 23 |
|
| 24 |
RAG_MODES: frozenset[TeacherVoiceMode] = frozenset({"explain", "lesson"})
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
|
| 26 |
|
| 27 |
@dataclass
|
|
@@ -41,22 +48,34 @@ class TeacherVoiceTurnResult:
|
|
| 41 |
voiceout_first_path: str | None
|
| 42 |
voiceout_warning: str | None
|
| 43 |
rag_references: str | None
|
|
|
|
| 44 |
trace_path: str
|
| 45 |
trace: dict[str, Any] = field(default_factory=dict)
|
| 46 |
|
| 47 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
def append_chat_turn(
|
| 49 |
history: list,
|
| 50 |
user_text: str,
|
| 51 |
assistant_text: str,
|
| 52 |
-
|
|
|
|
|
|
|
|
|
|
| 53 |
"""Append a turn in Gradio 5 messages format."""
|
| 54 |
-
updated: list[dict[str,
|
| 55 |
for item in history or []:
|
| 56 |
if isinstance(item, dict) and "role" in item and "content" in item:
|
| 57 |
-
updated.append(
|
| 58 |
-
{"role": str(item["role"]), "content": extract_message_text(item["content"])}
|
| 59 |
-
)
|
| 60 |
elif isinstance(item, (list, tuple)) and len(item) == 2:
|
| 61 |
user_msg, assistant_msg = item
|
| 62 |
updated.append({"role": "user", "content": extract_message_text(user_msg)})
|
|
@@ -65,23 +84,40 @@ def append_chat_turn(
|
|
| 65 |
{"role": "assistant", "content": extract_message_text(assistant_msg)}
|
| 66 |
)
|
| 67 |
updated.append({"role": "user", "content": user_text})
|
| 68 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
return updated
|
| 70 |
|
| 71 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 72 |
def history_to_messages(history: list) -> list[dict[str, str]]:
|
| 73 |
messages: list[dict[str, str]] = []
|
| 74 |
for item in history:
|
| 75 |
if isinstance(item, dict):
|
|
|
|
| 76 |
messages.append(
|
| 77 |
-
{"role":
|
| 78 |
)
|
| 79 |
else:
|
| 80 |
user_msg, assistant_msg = item
|
| 81 |
messages.append({"role": "user", "content": extract_message_text(user_msg)})
|
| 82 |
if assistant_msg:
|
| 83 |
messages.append(
|
| 84 |
-
{
|
|
|
|
|
|
|
|
|
|
| 85 |
)
|
| 86 |
return messages
|
| 87 |
|
|
@@ -92,10 +128,16 @@ def fetch_rag_context(
|
|
| 92 |
session_id: str,
|
| 93 |
doc_ids: list[str] | None,
|
| 94 |
) -> RagContext | None:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
store = IngestPipeline().store
|
| 96 |
cfg = get_researchmind_config()
|
| 97 |
-
scope_session = session_id
|
| 98 |
-
scope_docs = doc_ids if doc_ids else None
|
| 99 |
chunks = retrieve(
|
| 100 |
question,
|
| 101 |
store,
|
|
@@ -104,12 +146,7 @@ def fetch_rag_context(
|
|
| 104 |
doc_ids=scope_docs,
|
| 105 |
)
|
| 106 |
if not chunks:
|
| 107 |
-
|
| 108 |
-
warning = "No passages in selected documents for this question."
|
| 109 |
-
elif session_id:
|
| 110 |
-
warning = "No indexed sources in this session yet."
|
| 111 |
-
else:
|
| 112 |
-
warning = "No indexed sources in the corpus yet."
|
| 113 |
return RagContext(context_block="", references_markdown="", chunk_count=0, warning=warning)
|
| 114 |
|
| 115 |
context_block, citations = format_context_block(chunks)
|
|
@@ -121,6 +158,120 @@ def fetch_rag_context(
|
|
| 121 |
)
|
| 122 |
|
| 123 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 124 |
def build_teacher_messages(
|
| 125 |
*,
|
| 126 |
mode: TeacherVoiceMode,
|
|
@@ -143,11 +294,148 @@ def build_teacher_messages(
|
|
| 143 |
"Use these source excerpts as grounding. Cite with [1], [2], etc. when relevant.\n\n"
|
| 144 |
f"{rag.context_block}"
|
| 145 |
)
|
| 146 |
-
user_parts.append(user_text.strip())
|
| 147 |
messages.append({"role": "user", "content": "\n\n".join(user_parts)})
|
| 148 |
return messages
|
| 149 |
|
| 150 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 151 |
def run_teacher_voice_turn(
|
| 152 |
audio_path: str,
|
| 153 |
history: list,
|
|
@@ -218,7 +506,12 @@ def run_teacher_voice_turn(
|
|
| 218 |
)
|
| 219 |
if omni_wav_or_note and omni_user and omni_reply and Path(omni_wav_or_note).is_file():
|
| 220 |
trace.log_note("omni_turn", path=omni_wav_or_note)
|
| 221 |
-
new_history = append_chat_turn(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 222 |
trace_path = trace.save()
|
| 223 |
return TeacherVoiceTurnResult(
|
| 224 |
user_text=omni_user,
|
|
@@ -228,63 +521,24 @@ def run_teacher_voice_turn(
|
|
| 228 |
voiceout_first_path=omni_wav_or_note,
|
| 229 |
voiceout_warning=None,
|
| 230 |
rag_references=None,
|
|
|
|
| 231 |
trace_path=str(trace_path),
|
| 232 |
trace=trace.to_dict(),
|
| 233 |
)
|
| 234 |
if omni_wav_or_note:
|
| 235 |
trace.log_note("omni_fallback", message=omni_wav_or_note)
|
| 236 |
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
if not sid:
|
| 242 |
-
sid = IngestPipeline().store.create_session().id
|
| 243 |
-
rag = fetch_rag_context(user_text, session_id=sid, doc_ids=doc_ids)
|
| 244 |
-
if rag:
|
| 245 |
-
trace.log_note(
|
| 246 |
-
"rag_retrieve",
|
| 247 |
-
chunks=rag.chunk_count,
|
| 248 |
-
warning=rag.warning,
|
| 249 |
-
)
|
| 250 |
-
if rag.references_markdown:
|
| 251 |
-
rag_refs = rag.references_markdown
|
| 252 |
-
|
| 253 |
-
messages = build_teacher_messages(
|
| 254 |
mode=mode,
|
| 255 |
-
history=history,
|
| 256 |
-
user_text=user_text,
|
| 257 |
-
topic=topic,
|
| 258 |
-
rag=rag if rag and rag.context_block else None,
|
| 259 |
-
)
|
| 260 |
-
raw_reply = backend.chat(messages, max_tokens=512, temperature=0.5)
|
| 261 |
-
assistant_text = strip_reasoning_output(raw_reply).strip()
|
| 262 |
-
trace.log_llm(messages[-1]["content"], raw_reply)
|
| 263 |
-
|
| 264 |
-
if rag_refs:
|
| 265 |
-
assistant_text = f"{assistant_text}\n\n{rag_refs}"
|
| 266 |
-
|
| 267 |
-
voiceout_path, voiceout_first, voiceout_warning = synthesize_voice_reply(
|
| 268 |
-
strip_references_for_tts(assistant_text),
|
| 269 |
language=language,
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
new_history = append_chat_turn(history, user_text, assistant_text)
|
| 278 |
-
|
| 279 |
-
trace_path = trace.save()
|
| 280 |
-
return TeacherVoiceTurnResult(
|
| 281 |
-
user_text=user_text,
|
| 282 |
-
assistant_text=assistant_text,
|
| 283 |
-
history=new_history,
|
| 284 |
-
voiceout_path=voiceout_path,
|
| 285 |
-
voiceout_first_path=voiceout_first,
|
| 286 |
-
voiceout_warning=voiceout_warning,
|
| 287 |
-
rag_references=rag_refs,
|
| 288 |
-
trace_path=str(trace_path),
|
| 289 |
-
trace=trace.to_dict(),
|
| 290 |
)
|
|
|
|
| 7 |
from pathlib import Path
|
| 8 |
from typing import Any
|
| 9 |
|
| 10 |
+
from agent.runner import AgentRunner
|
| 11 |
from agent.trace import TraceRecorder
|
| 12 |
from inference.base import InferenceBackend
|
| 13 |
+
from inference.response_clean import (
|
| 14 |
+
needs_teacher_compaction,
|
| 15 |
+
prepare_display_reply,
|
| 16 |
+
strip_reasoning_output,
|
| 17 |
+
)
|
| 18 |
from researchmind.ingest import IngestPipeline
|
| 19 |
+
from researchmind.scope import retrieval_query
|
| 20 |
|
| 21 |
from echocoach.asr.factory import get_asr_backend
|
| 22 |
from echocoach.audio_io import clamp_duration, load_audio_mono_16k, write_wav_temp
|
|
|
|
| 25 |
from echocoach.voiceout import extract_message_text, strip_references_for_tts, synthesize_voice_reply
|
| 26 |
|
| 27 |
RAG_MODES: frozenset[TeacherVoiceMode] = frozenset({"explain", "lesson"})
|
| 28 |
+
_VOICE_USER_SUFFIX = (
|
| 29 |
+
"Reply now in 2-4 complete spoken sentences only. "
|
| 30 |
+
"No planning, outlines, sentence labels, or meta commentary."
|
| 31 |
+
)
|
| 32 |
|
| 33 |
|
| 34 |
@dataclass
|
|
|
|
| 48 |
voiceout_first_path: str | None
|
| 49 |
voiceout_warning: str | None
|
| 50 |
rag_references: str | None
|
| 51 |
+
rag_status: str | None
|
| 52 |
trace_path: str
|
| 53 |
trace: dict[str, Any] = field(default_factory=dict)
|
| 54 |
|
| 55 |
|
| 56 |
+
def _assistant_content_for_chat(
|
| 57 |
+
display_text: str,
|
| 58 |
+
*,
|
| 59 |
+
voice_path: str | None = None,
|
| 60 |
+
) -> str | list:
|
| 61 |
+
if voice_path:
|
| 62 |
+
return [display_text, {"path": voice_path}]
|
| 63 |
+
return display_text
|
| 64 |
+
|
| 65 |
+
|
| 66 |
def append_chat_turn(
|
| 67 |
history: list,
|
| 68 |
user_text: str,
|
| 69 |
assistant_text: str,
|
| 70 |
+
*,
|
| 71 |
+
assistant_display: str | None = None,
|
| 72 |
+
voice_path: str | None = None,
|
| 73 |
+
) -> list[dict[str, Any]]:
|
| 74 |
"""Append a turn in Gradio 5 messages format."""
|
| 75 |
+
updated: list[dict[str, Any]] = []
|
| 76 |
for item in history or []:
|
| 77 |
if isinstance(item, dict) and "role" in item and "content" in item:
|
| 78 |
+
updated.append({"role": str(item["role"]), "content": item["content"]})
|
|
|
|
|
|
|
| 79 |
elif isinstance(item, (list, tuple)) and len(item) == 2:
|
| 80 |
user_msg, assistant_msg = item
|
| 81 |
updated.append({"role": "user", "content": extract_message_text(user_msg)})
|
|
|
|
| 84 |
{"role": "assistant", "content": extract_message_text(assistant_msg)}
|
| 85 |
)
|
| 86 |
updated.append({"role": "user", "content": user_text})
|
| 87 |
+
display_text = assistant_display if assistant_display is not None else assistant_text
|
| 88 |
+
updated.append(
|
| 89 |
+
{
|
| 90 |
+
"role": "assistant",
|
| 91 |
+
"content": _assistant_content_for_chat(display_text, voice_path=voice_path),
|
| 92 |
+
}
|
| 93 |
+
)
|
| 94 |
return updated
|
| 95 |
|
| 96 |
|
| 97 |
+
def _message_text_for_llm(role: str, content: object) -> str:
|
| 98 |
+
text = extract_message_text(content)
|
| 99 |
+
if role == "assistant":
|
| 100 |
+
return strip_reasoning_output(text)
|
| 101 |
+
return text
|
| 102 |
+
|
| 103 |
+
|
| 104 |
def history_to_messages(history: list) -> list[dict[str, str]]:
|
| 105 |
messages: list[dict[str, str]] = []
|
| 106 |
for item in history:
|
| 107 |
if isinstance(item, dict):
|
| 108 |
+
role = str(item["role"])
|
| 109 |
messages.append(
|
| 110 |
+
{"role": role, "content": _message_text_for_llm(role, item["content"])}
|
| 111 |
)
|
| 112 |
else:
|
| 113 |
user_msg, assistant_msg = item
|
| 114 |
messages.append({"role": "user", "content": extract_message_text(user_msg)})
|
| 115 |
if assistant_msg:
|
| 116 |
messages.append(
|
| 117 |
+
{
|
| 118 |
+
"role": "assistant",
|
| 119 |
+
"content": strip_reasoning_output(extract_message_text(assistant_msg)),
|
| 120 |
+
}
|
| 121 |
)
|
| 122 |
return messages
|
| 123 |
|
|
|
|
| 128 |
session_id: str,
|
| 129 |
doc_ids: list[str] | None,
|
| 130 |
) -> RagContext | None:
|
| 131 |
+
"""Retrieve passages for diagnostics/tests. Production turns use AgentRunner."""
|
| 132 |
+
from researchmind.config import get_config as get_researchmind_config
|
| 133 |
+
from researchmind.ingest import IngestPipeline
|
| 134 |
+
from researchmind.citations import format_context_block, format_references
|
| 135 |
+
from researchmind.retrieve import retrieve
|
| 136 |
+
from researchmind.scope import rag_scope_warning, resolve_retrieve_scope
|
| 137 |
+
|
| 138 |
store = IngestPipeline().store
|
| 139 |
cfg = get_researchmind_config()
|
| 140 |
+
scope_session, scope_docs = resolve_retrieve_scope(session_id or None, doc_ids)
|
|
|
|
| 141 |
chunks = retrieve(
|
| 142 |
question,
|
| 143 |
store,
|
|
|
|
| 146 |
doc_ids=scope_docs,
|
| 147 |
)
|
| 148 |
if not chunks:
|
| 149 |
+
warning = rag_scope_warning(session_id=session_id or None, doc_ids=doc_ids)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 150 |
return RagContext(context_block="", references_markdown="", chunk_count=0, warning=warning)
|
| 151 |
|
| 152 |
context_block, citations = format_context_block(chunks)
|
|
|
|
| 158 |
)
|
| 159 |
|
| 160 |
|
| 161 |
+
def _rag_turn_via_agent(
|
| 162 |
+
user_text: str,
|
| 163 |
+
*,
|
| 164 |
+
topic: str | None,
|
| 165 |
+
session_id: str,
|
| 166 |
+
doc_ids: list[str] | None,
|
| 167 |
+
model_key: str,
|
| 168 |
+
backend: InferenceBackend,
|
| 169 |
+
trace: TraceRecorder,
|
| 170 |
+
) -> tuple[str, str | None, str | None, str]:
|
| 171 |
+
"""Grounded answer via ResearchMind harness. Returns text, refs, status, display."""
|
| 172 |
+
query = retrieval_query(user_text, topic=topic)
|
| 173 |
+
trace.log_note("rag_query", query=query, session_id=session_id or None, doc_ids=doc_ids or [])
|
| 174 |
+
|
| 175 |
+
result = AgentRunner().run_researchmind_chat(
|
| 176 |
+
question=query,
|
| 177 |
+
session_id=session_id or "",
|
| 178 |
+
doc_ids=doc_ids,
|
| 179 |
+
model_key=model_key,
|
| 180 |
+
backend=backend,
|
| 181 |
+
)
|
| 182 |
+
|
| 183 |
+
citation_count = len(result.citations)
|
| 184 |
+
if citation_count:
|
| 185 |
+
rag_status = (
|
| 186 |
+
f"Retrieved passages from **{citation_count}** source(s) "
|
| 187 |
+
f"for grounded answer."
|
| 188 |
+
)
|
| 189 |
+
else:
|
| 190 |
+
rag_status = (
|
| 191 |
+
"_No indexed passages matched this question — reply uses model guidance only._"
|
| 192 |
+
)
|
| 193 |
+
trace.log_note(
|
| 194 |
+
"rag_retrieve",
|
| 195 |
+
citations=citation_count,
|
| 196 |
+
session_id=session_id or None,
|
| 197 |
+
doc_ids=doc_ids or [],
|
| 198 |
+
research_trace=result.trace_path,
|
| 199 |
+
)
|
| 200 |
+
|
| 201 |
+
assistant_text = result.answer.strip()
|
| 202 |
+
display_reply = prepare_display_reply(assistant_text)
|
| 203 |
+
rag_refs = result.references_markdown or None
|
| 204 |
+
return assistant_text, rag_refs, rag_status, display_reply
|
| 205 |
+
|
| 206 |
+
|
| 207 |
+
def _indexed_scope_available(session_id: str, doc_ids: list[str] | None) -> bool:
|
| 208 |
+
store = IngestPipeline().store
|
| 209 |
+
if doc_ids:
|
| 210 |
+
return True
|
| 211 |
+
if session_id:
|
| 212 |
+
return bool(store.list_documents(session_id=session_id))
|
| 213 |
+
return bool(store.list_documents())
|
| 214 |
+
|
| 215 |
+
|
| 216 |
+
def _rag_off_status(session_id: str, doc_ids: list[str] | None) -> str | None:
|
| 217 |
+
if _indexed_scope_available(session_id, doc_ids):
|
| 218 |
+
return (
|
| 219 |
+
"_Sources are indexed but RAG is off — enable **Answer from my indexed sources** "
|
| 220 |
+
"for cited, source-grounded replies._"
|
| 221 |
+
)
|
| 222 |
+
return (
|
| 223 |
+
"_No sources used. Set a focus topic, **Discover/Auto-ingest** sources, then enable "
|
| 224 |
+
"**Answer from my indexed sources** for citations._"
|
| 225 |
+
)
|
| 226 |
+
|
| 227 |
+
|
| 228 |
+
def _compact_teacher_reply(
|
| 229 |
+
raw_reply: str,
|
| 230 |
+
*,
|
| 231 |
+
mode: TeacherVoiceMode,
|
| 232 |
+
backend: InferenceBackend,
|
| 233 |
+
trace: TraceRecorder,
|
| 234 |
+
) -> str:
|
| 235 |
+
seed = strip_reasoning_output(raw_reply).strip() or raw_reply.strip()[:1200]
|
| 236 |
+
messages = [
|
| 237 |
+
{
|
| 238 |
+
"role": "system",
|
| 239 |
+
"content": (
|
| 240 |
+
f"{system_prompt_for_mode(mode)}\n\n"
|
| 241 |
+
"Rewrite the draft below into ONLY 2-4 spoken sentences for voice playback. "
|
| 242 |
+
"Keep any [n] citations. No planning or labels."
|
| 243 |
+
),
|
| 244 |
+
},
|
| 245 |
+
{"role": "user", "content": seed},
|
| 246 |
+
]
|
| 247 |
+
compact_raw = backend.chat(messages, max_tokens=220, temperature=0.2)
|
| 248 |
+
trace.log_note("teacher_compact")
|
| 249 |
+
trace.log_llm(messages[-1]["content"], compact_raw)
|
| 250 |
+
compact = strip_reasoning_output(compact_raw).strip()
|
| 251 |
+
return compact or seed
|
| 252 |
+
|
| 253 |
+
|
| 254 |
+
def _finalize_non_rag_reply(
|
| 255 |
+
raw_reply: str,
|
| 256 |
+
*,
|
| 257 |
+
mode: TeacherVoiceMode,
|
| 258 |
+
backend: InferenceBackend,
|
| 259 |
+
trace: TraceRecorder,
|
| 260 |
+
) -> tuple[str, str]:
|
| 261 |
+
assistant_text = strip_reasoning_output(raw_reply).strip()
|
| 262 |
+
if needs_teacher_compaction(raw_reply) or not assistant_text:
|
| 263 |
+
assistant_text = _compact_teacher_reply(
|
| 264 |
+
raw_reply,
|
| 265 |
+
mode=mode,
|
| 266 |
+
backend=backend,
|
| 267 |
+
trace=trace,
|
| 268 |
+
)
|
| 269 |
+
display_reply = prepare_display_reply(raw_reply)
|
| 270 |
+
if needs_teacher_compaction(display_reply):
|
| 271 |
+
display_reply = prepare_display_reply(assistant_text)
|
| 272 |
+
return assistant_text, display_reply
|
| 273 |
+
|
| 274 |
+
|
| 275 |
def build_teacher_messages(
|
| 276 |
*,
|
| 277 |
mode: TeacherVoiceMode,
|
|
|
|
| 294 |
"Use these source excerpts as grounding. Cite with [1], [2], etc. when relevant.\n\n"
|
| 295 |
f"{rag.context_block}"
|
| 296 |
)
|
| 297 |
+
user_parts.append(f"{user_text.strip()}\n\n{_VOICE_USER_SUFFIX}")
|
| 298 |
messages.append({"role": "user", "content": "\n\n".join(user_parts)})
|
| 299 |
return messages
|
| 300 |
|
| 301 |
|
| 302 |
+
def _generate_teacher_reply(
|
| 303 |
+
user_text: str,
|
| 304 |
+
history: list,
|
| 305 |
+
*,
|
| 306 |
+
trace: TraceRecorder,
|
| 307 |
+
mode: TeacherVoiceMode,
|
| 308 |
+
language: str,
|
| 309 |
+
topic: str | None,
|
| 310 |
+
model_key: str,
|
| 311 |
+
backend: InferenceBackend,
|
| 312 |
+
use_rag: bool,
|
| 313 |
+
session_id: str,
|
| 314 |
+
doc_ids: list[str] | None,
|
| 315 |
+
tts_key: str,
|
| 316 |
+
) -> TeacherVoiceTurnResult:
|
| 317 |
+
rag_refs: str | None = None
|
| 318 |
+
rag_status: str | None = None
|
| 319 |
+
|
| 320 |
+
if use_rag and mode in RAG_MODES:
|
| 321 |
+
assistant_text, rag_refs, rag_status, display_reply = _rag_turn_via_agent(
|
| 322 |
+
user_text,
|
| 323 |
+
topic=topic,
|
| 324 |
+
session_id=session_id,
|
| 325 |
+
doc_ids=doc_ids,
|
| 326 |
+
model_key=model_key,
|
| 327 |
+
backend=backend,
|
| 328 |
+
trace=trace,
|
| 329 |
+
)
|
| 330 |
+
else:
|
| 331 |
+
messages = build_teacher_messages(
|
| 332 |
+
mode=mode,
|
| 333 |
+
history=history,
|
| 334 |
+
user_text=user_text,
|
| 335 |
+
topic=topic,
|
| 336 |
+
)
|
| 337 |
+
raw_reply = backend.chat(messages, max_tokens=256, temperature=0.2)
|
| 338 |
+
assistant_text, display_reply = _finalize_non_rag_reply(
|
| 339 |
+
raw_reply,
|
| 340 |
+
mode=mode,
|
| 341 |
+
backend=backend,
|
| 342 |
+
trace=trace,
|
| 343 |
+
)
|
| 344 |
+
trace.log_llm(messages[-1]["content"], raw_reply)
|
| 345 |
+
if mode in RAG_MODES:
|
| 346 |
+
rag_status = _rag_off_status(session_id, doc_ids)
|
| 347 |
+
|
| 348 |
+
voiceout_path, voiceout_first, voiceout_warning = synthesize_voice_reply(
|
| 349 |
+
strip_references_for_tts(assistant_text),
|
| 350 |
+
language=language,
|
| 351 |
+
tts_preset=tts_key,
|
| 352 |
+
chunk_first=True,
|
| 353 |
+
out_subdir="teacher_voice",
|
| 354 |
+
)
|
| 355 |
+
if voiceout_path:
|
| 356 |
+
trace.set_artifact(voiceout_path)
|
| 357 |
+
|
| 358 |
+
new_history = append_chat_turn(
|
| 359 |
+
history,
|
| 360 |
+
user_text,
|
| 361 |
+
assistant_text,
|
| 362 |
+
assistant_display=display_reply,
|
| 363 |
+
voice_path=voiceout_path,
|
| 364 |
+
)
|
| 365 |
+
|
| 366 |
+
trace_path = trace.save()
|
| 367 |
+
return TeacherVoiceTurnResult(
|
| 368 |
+
user_text=user_text,
|
| 369 |
+
assistant_text=assistant_text,
|
| 370 |
+
history=new_history,
|
| 371 |
+
voiceout_path=voiceout_path,
|
| 372 |
+
voiceout_first_path=voiceout_first,
|
| 373 |
+
voiceout_warning=voiceout_warning,
|
| 374 |
+
rag_references=rag_refs,
|
| 375 |
+
rag_status=rag_status,
|
| 376 |
+
trace_path=str(trace_path),
|
| 377 |
+
trace=trace.to_dict(),
|
| 378 |
+
)
|
| 379 |
+
|
| 380 |
+
|
| 381 |
+
def run_teacher_voice_text_turn(
|
| 382 |
+
user_text: str,
|
| 383 |
+
history: list,
|
| 384 |
+
*,
|
| 385 |
+
mode: TeacherVoiceMode = "explain",
|
| 386 |
+
language: str = "en",
|
| 387 |
+
topic: str | None = None,
|
| 388 |
+
tts_preset: str | None = None,
|
| 389 |
+
coach_model: str | None = None,
|
| 390 |
+
backend: InferenceBackend,
|
| 391 |
+
use_rag: bool = False,
|
| 392 |
+
session_id: str = "",
|
| 393 |
+
doc_ids: list[str] | None = None,
|
| 394 |
+
) -> TeacherVoiceTurnResult:
|
| 395 |
+
"""Process a typed user message (skips ASR)."""
|
| 396 |
+
user_text = user_text.strip()
|
| 397 |
+
if not user_text:
|
| 398 |
+
raise ValueError("Type a message to send.")
|
| 399 |
+
|
| 400 |
+
config = get_echo_coach_config()
|
| 401 |
+
tts_key = tts_preset or config.realtime_tts_preset or config.tts_preset
|
| 402 |
+
model_key = coach_model or config.coach_model
|
| 403 |
+
run_id = uuid.uuid4().hex[:12]
|
| 404 |
+
|
| 405 |
+
trace = TraceRecorder(
|
| 406 |
+
skill="teacher-voice",
|
| 407 |
+
model=model_key,
|
| 408 |
+
user_input={
|
| 409 |
+
"mode": mode,
|
| 410 |
+
"language": language,
|
| 411 |
+
"topic": topic,
|
| 412 |
+
"input_type": "text",
|
| 413 |
+
"user_text": user_text,
|
| 414 |
+
"tts_preset": tts_key,
|
| 415 |
+
"use_rag": use_rag,
|
| 416 |
+
"session_id": session_id,
|
| 417 |
+
"doc_ids": doc_ids or [],
|
| 418 |
+
},
|
| 419 |
+
run_id=run_id,
|
| 420 |
+
)
|
| 421 |
+
trace.log_note("text_input", chars=len(user_text))
|
| 422 |
+
|
| 423 |
+
return _generate_teacher_reply(
|
| 424 |
+
user_text,
|
| 425 |
+
history,
|
| 426 |
+
trace=trace,
|
| 427 |
+
mode=mode,
|
| 428 |
+
language=language,
|
| 429 |
+
topic=topic,
|
| 430 |
+
model_key=model_key,
|
| 431 |
+
backend=backend,
|
| 432 |
+
use_rag=use_rag,
|
| 433 |
+
session_id=session_id,
|
| 434 |
+
doc_ids=doc_ids,
|
| 435 |
+
tts_key=tts_key,
|
| 436 |
+
)
|
| 437 |
+
|
| 438 |
+
|
| 439 |
def run_teacher_voice_turn(
|
| 440 |
audio_path: str,
|
| 441 |
history: list,
|
|
|
|
| 506 |
)
|
| 507 |
if omni_wav_or_note and omni_user and omni_reply and Path(omni_wav_or_note).is_file():
|
| 508 |
trace.log_note("omni_turn", path=omni_wav_or_note)
|
| 509 |
+
new_history = append_chat_turn(
|
| 510 |
+
history,
|
| 511 |
+
omni_user,
|
| 512 |
+
omni_reply,
|
| 513 |
+
voice_path=omni_wav_or_note,
|
| 514 |
+
)
|
| 515 |
trace_path = trace.save()
|
| 516 |
return TeacherVoiceTurnResult(
|
| 517 |
user_text=omni_user,
|
|
|
|
| 521 |
voiceout_first_path=omni_wav_or_note,
|
| 522 |
voiceout_warning=None,
|
| 523 |
rag_references=None,
|
| 524 |
+
rag_status=None,
|
| 525 |
trace_path=str(trace_path),
|
| 526 |
trace=trace.to_dict(),
|
| 527 |
)
|
| 528 |
if omni_wav_or_note:
|
| 529 |
trace.log_note("omni_fallback", message=omni_wav_or_note)
|
| 530 |
|
| 531 |
+
return _generate_teacher_reply(
|
| 532 |
+
user_text,
|
| 533 |
+
history,
|
| 534 |
+
trace=trace,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 535 |
mode=mode,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 536 |
language=language,
|
| 537 |
+
topic=topic,
|
| 538 |
+
model_key=model_key,
|
| 539 |
+
backend=backend,
|
| 540 |
+
use_rag=use_rag,
|
| 541 |
+
session_id=session_id,
|
| 542 |
+
doc_ids=doc_ids,
|
| 543 |
+
tts_key=tts_key,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 544 |
)
|
libs/echocoach/src/echocoach/voiceout.py
CHANGED
|
@@ -29,6 +29,8 @@ def extract_message_text(content: object) -> str:
|
|
| 29 |
if isinstance(block, str):
|
| 30 |
text = block.strip()
|
| 31 |
elif isinstance(block, dict):
|
|
|
|
|
|
|
| 32 |
text = str(block.get("text") or block.get("content") or "").strip()
|
| 33 |
else:
|
| 34 |
text = str(block).strip()
|
|
|
|
| 29 |
if isinstance(block, str):
|
| 30 |
text = block.strip()
|
| 31 |
elif isinstance(block, dict):
|
| 32 |
+
if block.get("path") or block.get("file"):
|
| 33 |
+
continue
|
| 34 |
text = str(block.get("text") or block.get("content") or "").strip()
|
| 35 |
else:
|
| 36 |
text = str(block).strip()
|
libs/echocoach/tests/test_teacher_voice.py
CHANGED
|
@@ -21,6 +21,9 @@ from echocoach.voiceout import (
|
|
| 21 |
strip_references_for_tts,
|
| 22 |
)
|
| 23 |
|
|
|
|
|
|
|
|
|
|
| 24 |
|
| 25 |
class _MockBackend:
|
| 26 |
def load(self) -> None:
|
|
@@ -65,6 +68,36 @@ def test_append_chat_turn_migrates_legacy_tuples():
|
|
| 65 |
assert history[0] == {"role": "user", "content": "Old question"}
|
| 66 |
|
| 67 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
def test_history_to_messages_tuple_pairs():
|
| 69 |
history = [("Hi", "Hello"), ("What is AI?", "Machine learning.")]
|
| 70 |
messages = history_to_messages(history)
|
|
@@ -93,7 +126,8 @@ def test_build_teacher_messages_includes_topic_and_rag():
|
|
| 93 |
assert "lesson-planning" in messages[0]["content"]
|
| 94 |
assert "Photosynthesis" in messages[0]["content"]
|
| 95 |
assert "[1] Plants need light." in messages[-1]["content"]
|
| 96 |
-
assert
|
|
|
|
| 97 |
|
| 98 |
|
| 99 |
def test_pitch_mode_system_prompt():
|
|
@@ -151,6 +185,55 @@ def test_fetch_rag_context_empty_store_warns(research_env):
|
|
| 151 |
assert ctx.warning
|
| 152 |
|
| 153 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 154 |
@pytest.fixture
|
| 155 |
def research_env(tmp_path, monkeypatch):
|
| 156 |
from researchmind.config import ResearchMindConfig
|
|
@@ -168,6 +251,36 @@ def research_env(tmp_path, monkeypatch):
|
|
| 168 |
monkeypatch.setenv("AGENT_OUTPUTS_DIR", str(tmp_path / "outputs"))
|
| 169 |
|
| 170 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 171 |
def test_run_teacher_voice_turn_mock_asr(monkeypatch, tmp_path):
|
| 172 |
from echocoach.teacher_voice import run_teacher_voice_turn
|
| 173 |
|
|
|
|
| 21 |
strip_references_for_tts,
|
| 22 |
)
|
| 23 |
|
| 24 |
+
_THINK_OPEN = "<" + "think" + ">"
|
| 25 |
+
_THINK_CLOSE = "</" + "think" + ">"
|
| 26 |
+
|
| 27 |
|
| 28 |
class _MockBackend:
|
| 29 |
def load(self) -> None:
|
|
|
|
| 68 |
assert history[0] == {"role": "user", "content": "Old question"}
|
| 69 |
|
| 70 |
|
| 71 |
+
def test_append_chat_turn_attaches_voice_to_assistant_message(tmp_path):
|
| 72 |
+
wav = tmp_path / "reply.wav"
|
| 73 |
+
wav.write_bytes(b"RIFF")
|
| 74 |
+
|
| 75 |
+
history = append_chat_turn(
|
| 76 |
+
[],
|
| 77 |
+
"Hi",
|
| 78 |
+
"Hello",
|
| 79 |
+
assistant_display=f"{_THINK_OPEN}plan{_THINK_CLOSE}\n\nHello",
|
| 80 |
+
voice_path=str(wav),
|
| 81 |
+
)
|
| 82 |
+
assistant = history[-1]
|
| 83 |
+
assert assistant["role"] == "assistant"
|
| 84 |
+
assert isinstance(assistant["content"], list)
|
| 85 |
+
assert assistant["content"][0].startswith(_THINK_OPEN)
|
| 86 |
+
assert assistant["content"][1] == {"path": str(wav)}
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def test_history_to_messages_strips_assistant_reasoning():
|
| 90 |
+
history = [
|
| 91 |
+
{"role": "user", "content": "Hi"},
|
| 92 |
+
{
|
| 93 |
+
"role": "assistant",
|
| 94 |
+
"content": f"{_THINK_OPEN}planning{_THINK_CLOSE}\n\nHello there.",
|
| 95 |
+
},
|
| 96 |
+
]
|
| 97 |
+
messages = history_to_messages(history)
|
| 98 |
+
assert messages[-1]["content"] == "Hello there."
|
| 99 |
+
|
| 100 |
+
|
| 101 |
def test_history_to_messages_tuple_pairs():
|
| 102 |
history = [("Hi", "Hello"), ("What is AI?", "Machine learning.")]
|
| 103 |
messages = history_to_messages(history)
|
|
|
|
| 126 |
assert "lesson-planning" in messages[0]["content"]
|
| 127 |
assert "Photosynthesis" in messages[0]["content"]
|
| 128 |
assert "[1] Plants need light." in messages[-1]["content"]
|
| 129 |
+
assert "How do plants eat?" in messages[-1]["content"]
|
| 130 |
+
assert "Reply now in 2-4 complete spoken sentences only" in messages[-1]["content"]
|
| 131 |
|
| 132 |
|
| 133 |
def test_pitch_mode_system_prompt():
|
|
|
|
| 185 |
assert ctx.warning
|
| 186 |
|
| 187 |
|
| 188 |
+
def test_retrieval_query_exported():
|
| 189 |
+
from researchmind.scope import retrieval_query as rm_query
|
| 190 |
+
|
| 191 |
+
assert rm_query("step 2?", topic="Photosynthesis") == "Photosynthesis: step 2?"
|
| 192 |
+
|
| 193 |
+
|
| 194 |
+
def test_rag_turn_via_agent_mock(monkeypatch, tmp_path):
|
| 195 |
+
from agent.models import Citation, ResearchChatResult
|
| 196 |
+
from echocoach.teacher_voice import _rag_turn_via_agent
|
| 197 |
+
from agent.trace import TraceRecorder
|
| 198 |
+
|
| 199 |
+
result = ResearchChatResult(
|
| 200 |
+
answer="Plants use light [1].\n\n**References**\n[1] Bio",
|
| 201 |
+
citations=[
|
| 202 |
+
Citation(
|
| 203 |
+
index=1,
|
| 204 |
+
chunk_id="c1",
|
| 205 |
+
doc_title="Bio",
|
| 206 |
+
doc_uri="https://example.com",
|
| 207 |
+
excerpt="Plants use light.",
|
| 208 |
+
)
|
| 209 |
+
],
|
| 210 |
+
references_markdown="**References**\n[1] Bio",
|
| 211 |
+
session_id="",
|
| 212 |
+
trace_path=str(tmp_path / "trace.json"),
|
| 213 |
+
)
|
| 214 |
+
|
| 215 |
+
class _RunnerStub:
|
| 216 |
+
def run_researchmind_chat(self, **kwargs):
|
| 217 |
+
return result
|
| 218 |
+
|
| 219 |
+
monkeypatch.setattr("echocoach.teacher_voice.AgentRunner", _RunnerStub)
|
| 220 |
+
|
| 221 |
+
trace = TraceRecorder(skill="teacher-voice", model="test", user_input={})
|
| 222 |
+
text, refs, status, display = _rag_turn_via_agent(
|
| 223 |
+
"How do plants eat?",
|
| 224 |
+
topic="Photosynthesis",
|
| 225 |
+
session_id="",
|
| 226 |
+
doc_ids=None,
|
| 227 |
+
model_key="test",
|
| 228 |
+
backend=_MockBackend(),
|
| 229 |
+
trace=trace,
|
| 230 |
+
)
|
| 231 |
+
assert "Plants use light" in text
|
| 232 |
+
assert refs
|
| 233 |
+
assert "1" in status
|
| 234 |
+
assert display
|
| 235 |
+
|
| 236 |
+
|
| 237 |
@pytest.fixture
|
| 238 |
def research_env(tmp_path, monkeypatch):
|
| 239 |
from researchmind.config import ResearchMindConfig
|
|
|
|
| 251 |
monkeypatch.setenv("AGENT_OUTPUTS_DIR", str(tmp_path / "outputs"))
|
| 252 |
|
| 253 |
|
| 254 |
+
def test_run_teacher_voice_text_turn_mock(monkeypatch, tmp_path):
|
| 255 |
+
from echocoach.teacher_voice import run_teacher_voice_text_turn
|
| 256 |
+
|
| 257 |
+
class _Tts:
|
| 258 |
+
def synthesize(self, text, *, language, out_dir=None):
|
| 259 |
+
out = (out_dir or tmp_path) / "out.wav"
|
| 260 |
+
out.parent.mkdir(parents=True, exist_ok=True)
|
| 261 |
+
sf.write(out, np.zeros(8000, dtype=np.float32), 16_000)
|
| 262 |
+
return str(out), None
|
| 263 |
+
|
| 264 |
+
monkeypatch.setattr("echocoach.voiceout.get_tts_backend", lambda _: _Tts())
|
| 265 |
+
|
| 266 |
+
result = run_teacher_voice_text_turn(
|
| 267 |
+
"Tell me about plants.",
|
| 268 |
+
[],
|
| 269 |
+
mode="explain",
|
| 270 |
+
backend=_MockBackend(),
|
| 271 |
+
use_rag=False,
|
| 272 |
+
)
|
| 273 |
+
assert result.user_text == "Tell me about plants."
|
| 274 |
+
assert "sunlight" in result.assistant_text
|
| 275 |
+
assert len(result.history) == 2
|
| 276 |
+
assistant = result.history[-1]
|
| 277 |
+
assert assistant["role"] == "assistant"
|
| 278 |
+
assert isinstance(assistant["content"], list)
|
| 279 |
+
assert assistant["content"][0] == "Plants use sunlight to make food."
|
| 280 |
+
assert assistant["content"][1]["path"]
|
| 281 |
+
assert result.trace.get("skill") == "teacher-voice"
|
| 282 |
+
|
| 283 |
+
|
| 284 |
def test_run_teacher_voice_turn_mock_asr(monkeypatch, tmp_path):
|
| 285 |
from echocoach.teacher_voice import run_teacher_voice_turn
|
| 286 |
|
libs/inference/src/inference/response_clean.py
CHANGED
|
@@ -19,24 +19,48 @@ _THINK_BLOCKS = re.compile(
|
|
| 19 |
)
|
| 20 |
_MALFORMED_THINK_OPEN = re.compile(r"^think>\s*", re.IGNORECASE)
|
| 21 |
_ANSWER_SPLITS = [
|
| 22 |
-
re.compile(r"(?:Let's draft:|Draft:)\s*", re.IGNORECASE),
|
| 23 |
re.compile(r"\nSummary:\s*", re.IGNORECASE),
|
| 24 |
re.compile(r"\nAnswer:\s*", re.IGNORECASE),
|
|
|
|
|
|
|
| 25 |
re.compile(r"\n\n(?:In summary|To summarize)[,:]\s*", re.IGNORECASE),
|
| 26 |
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
_META_TAIL = re.compile(
|
| 28 |
r"\n\n(?:Now,|We need|Also,|But we|However,|The instruction|So we|"
|
| 29 |
-
r"That means|We must|We should|We have|We can)\b",
|
| 30 |
re.IGNORECASE,
|
| 31 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
_REASONING_OPENERS = (
|
| 33 |
"we need to",
|
| 34 |
"first,",
|
|
|
|
|
|
|
| 35 |
"the user",
|
| 36 |
"let me",
|
| 37 |
"okay,",
|
| 38 |
"now, let",
|
|
|
|
| 39 |
"i need to",
|
|
|
|
|
|
|
| 40 |
)
|
| 41 |
|
| 42 |
|
|
@@ -44,24 +68,139 @@ def _normalize_extracted(text: str) -> str:
|
|
| 44 |
cleaned = text.strip()
|
| 45 |
cleaned = re.sub(r"^Summary:\s*", "", cleaned, flags=re.IGNORECASE)
|
| 46 |
cleaned = re.sub(r"^Answer:\s*", "", cleaned, flags=re.IGNORECASE)
|
|
|
|
| 47 |
return cleaned.strip()
|
| 48 |
|
| 49 |
|
| 50 |
-
def
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
for pattern in _ANSWER_SPLITS:
|
| 52 |
match = pattern.search(text)
|
| 53 |
if not match:
|
| 54 |
continue
|
| 55 |
-
rest =
|
| 56 |
-
rest =
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
|
| 61 |
|
| 62 |
def looks_like_reasoning_only(text: str) -> bool:
|
| 63 |
-
sample = text[:
|
| 64 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
|
| 66 |
|
| 67 |
def strip_reasoning_output(text: str) -> str:
|
|
@@ -71,16 +210,20 @@ def strip_reasoning_output(text: str) -> str:
|
|
| 71 |
return ""
|
| 72 |
|
| 73 |
cleaned = _THINK_BLOCKS.sub("", cleaned).strip()
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
|
| 75 |
if _MALFORMED_THINK_OPEN.match(cleaned):
|
| 76 |
body = _MALFORMED_THINK_OPEN.sub("", cleaned, count=1).strip()
|
| 77 |
-
extracted =
|
| 78 |
if extracted:
|
| 79 |
return extracted
|
| 80 |
cleaned = body
|
| 81 |
|
| 82 |
-
if looks_like_reasoning_only(cleaned):
|
| 83 |
-
extracted =
|
| 84 |
if extracted:
|
| 85 |
return extracted
|
| 86 |
|
|
|
|
| 19 |
)
|
| 20 |
_MALFORMED_THINK_OPEN = re.compile(r"^think>\s*", re.IGNORECASE)
|
| 21 |
_ANSWER_SPLITS = [
|
| 22 |
+
re.compile(r"(?:Let's draft:|Let me draft:|Draft:)\s*", re.IGNORECASE),
|
| 23 |
re.compile(r"\nSummary:\s*", re.IGNORECASE),
|
| 24 |
re.compile(r"\nAnswer:\s*", re.IGNORECASE),
|
| 25 |
+
re.compile(r"\nFinal answer:\s*", re.IGNORECASE),
|
| 26 |
+
re.compile(r"\nLet me write:\s*", re.IGNORECASE),
|
| 27 |
re.compile(r"\n\n(?:In summary|To summarize)[,:]\s*", re.IGNORECASE),
|
| 28 |
]
|
| 29 |
+
_ANSWER_MARKER = re.compile(
|
| 30 |
+
r"(?:^|\n)(?:Final answer|Let me write|Let's draft|Let me draft|Answer|Summary|"
|
| 31 |
+
r"Now, write the response):\s*",
|
| 32 |
+
re.IGNORECASE | re.MULTILINE,
|
| 33 |
+
)
|
| 34 |
+
_SENTENCE_PART = re.compile(
|
| 35 |
+
r"Sentence\s+\d+:\s*(.+?)(?=\n(?:Sentence\s+\d+:|That's\b|I can\b|Let me\b|So,|\Z))",
|
| 36 |
+
re.IGNORECASE | re.DOTALL,
|
| 37 |
+
)
|
| 38 |
_META_TAIL = re.compile(
|
| 39 |
r"\n\n(?:Now,|We need|Also,|But we|However,|The instruction|So we|"
|
| 40 |
+
r"That means|We must|We should|We have|We can|Next,)\b",
|
| 41 |
re.IGNORECASE,
|
| 42 |
)
|
| 43 |
+
_META_AFTER_ANSWER = re.compile(
|
| 44 |
+
r"\n\n(?:That's about|That's two|I think it covers|I'll add|To be more precise|"
|
| 45 |
+
r"Let me write|Let me count|Let me draft|Let me check|I need to make sure|"
|
| 46 |
+
r"I can add|I can make|So, three|So, two).*",
|
| 47 |
+
re.DOTALL | re.IGNORECASE,
|
| 48 |
+
)
|
| 49 |
+
_COMPLETE_SENTENCE = re.compile(r"[.!?][\"')\]]*\s*$")
|
| 50 |
+
_LIST_OUTLINE = re.compile(r"^\d+\.\s", re.MULTILINE)
|
| 51 |
_REASONING_OPENERS = (
|
| 52 |
"we need to",
|
| 53 |
"first,",
|
| 54 |
+
"first, the",
|
| 55 |
+
"next,",
|
| 56 |
"the user",
|
| 57 |
"let me",
|
| 58 |
"okay,",
|
| 59 |
"now, let",
|
| 60 |
+
"now, write",
|
| 61 |
"i need to",
|
| 62 |
+
"i should",
|
| 63 |
+
"i recall",
|
| 64 |
)
|
| 65 |
|
| 66 |
|
|
|
|
| 68 |
cleaned = text.strip()
|
| 69 |
cleaned = re.sub(r"^Summary:\s*", "", cleaned, flags=re.IGNORECASE)
|
| 70 |
cleaned = re.sub(r"^Answer:\s*", "", cleaned, flags=re.IGNORECASE)
|
| 71 |
+
cleaned = re.sub(r"^Final answer:\s*", "", cleaned, flags=re.IGNORECASE)
|
| 72 |
return cleaned.strip()
|
| 73 |
|
| 74 |
|
| 75 |
+
def _clean_answer_candidate(text: str) -> str:
|
| 76 |
+
rest = _normalize_extracted(text)
|
| 77 |
+
rest = _META_TAIL.split(rest, maxsplit=1)[0].strip()
|
| 78 |
+
rest = _META_AFTER_ANSWER.split(rest, maxsplit=1)[0].strip()
|
| 79 |
+
return rest
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def _slice_until_next_marker(text: str, start: int) -> str:
|
| 83 |
+
rest = text[start:]
|
| 84 |
+
next_match = _ANSWER_MARKER.search(rest)
|
| 85 |
+
if next_match and next_match.start() > 0:
|
| 86 |
+
rest = rest[: next_match.start()]
|
| 87 |
+
return rest
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def _is_list_outline(text: str) -> bool:
|
| 91 |
+
lines = [line.strip() for line in text.splitlines() if line.strip()]
|
| 92 |
+
if len(lines) < 2:
|
| 93 |
+
return False
|
| 94 |
+
numbered = sum(1 for line in lines if _LIST_OUTLINE.match(line))
|
| 95 |
+
return numbered >= max(2, len(lines) // 2)
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def _extract_labeled_sentences(text: str) -> str | None:
|
| 99 |
+
parts: list[str] = []
|
| 100 |
+
for match in _SENTENCE_PART.finditer(text):
|
| 101 |
+
sentence = _clean_answer_candidate(match.group(1))
|
| 102 |
+
if not sentence:
|
| 103 |
+
continue
|
| 104 |
+
if sentence.lower().startswith(("that's ", "so, ", "i can ", "let me ")):
|
| 105 |
+
continue
|
| 106 |
+
parts.append(sentence)
|
| 107 |
+
if not parts:
|
| 108 |
+
return None
|
| 109 |
+
return " ".join(parts)
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
def _extract_answer_candidates(text: str) -> list[str]:
|
| 113 |
+
candidates: list[str] = []
|
| 114 |
+
for match in _ANSWER_MARKER.finditer(text):
|
| 115 |
+
rest = _clean_answer_candidate(_slice_until_next_marker(text, match.end()))
|
| 116 |
+
if len(rest) >= 20 and not _is_list_outline(rest):
|
| 117 |
+
candidates.append(rest)
|
| 118 |
for pattern in _ANSWER_SPLITS:
|
| 119 |
match = pattern.search(text)
|
| 120 |
if not match:
|
| 121 |
continue
|
| 122 |
+
rest = _clean_answer_candidate(_slice_until_next_marker(text, match.end()))
|
| 123 |
+
if len(rest) >= 20 and not _is_list_outline(rest):
|
| 124 |
+
candidates.append(rest)
|
| 125 |
+
return candidates
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
def _extract_best_answer(text: str) -> str | None:
|
| 129 |
+
labeled = _extract_labeled_sentences(text)
|
| 130 |
+
if labeled:
|
| 131 |
+
return labeled
|
| 132 |
+
|
| 133 |
+
candidates = _extract_answer_candidates(text)
|
| 134 |
+
if not candidates:
|
| 135 |
+
return None
|
| 136 |
+
complete = [c for c in candidates if _COMPLETE_SENTENCE.search(c)]
|
| 137 |
+
pool = complete or candidates
|
| 138 |
+
return max(pool, key=len)
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
def _extract_answer_from_reasoning(text: str) -> str | None:
|
| 142 |
+
return _extract_best_answer(text)
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
def _split_reasoning_and_answer(text: str) -> tuple[str | None, str]:
|
| 146 |
+
cleaned = text.strip()
|
| 147 |
+
if not cleaned:
|
| 148 |
+
return None, ""
|
| 149 |
+
|
| 150 |
+
final = _extract_best_answer(cleaned)
|
| 151 |
+
if final and final != cleaned:
|
| 152 |
+
idx = cleaned.find(final)
|
| 153 |
+
if idx > 0:
|
| 154 |
+
return cleaned[:idx].strip(), final
|
| 155 |
+
return None, final
|
| 156 |
+
|
| 157 |
+
if looks_like_reasoning_only(cleaned):
|
| 158 |
+
return cleaned, ""
|
| 159 |
+
|
| 160 |
+
return None, cleaned
|
| 161 |
|
| 162 |
|
| 163 |
def looks_like_reasoning_only(text: str) -> bool:
|
| 164 |
+
sample = text[:320].lower()
|
| 165 |
+
if any(sample.startswith(opener) for opener in _REASONING_OPENERS):
|
| 166 |
+
return True
|
| 167 |
+
return bool(_SENTENCE_PART.search(text) and len(text) > 120)
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
def needs_teacher_compaction(text: str) -> bool:
|
| 171 |
+
cleaned = text.strip()
|
| 172 |
+
if not cleaned:
|
| 173 |
+
return True
|
| 174 |
+
if looks_like_reasoning_only(cleaned):
|
| 175 |
+
return True
|
| 176 |
+
if _ANSWER_MARKER.search(cleaned) or _SENTENCE_PART.search(cleaned):
|
| 177 |
+
return True
|
| 178 |
+
return len(cleaned) > 420
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
def prepare_display_reply(text: str) -> str:
|
| 182 |
+
"""Normalize model output for chat UI while preserving thinking blocks."""
|
| 183 |
+
cleaned = text.strip()
|
| 184 |
+
if not cleaned:
|
| 185 |
+
return ""
|
| 186 |
+
|
| 187 |
+
if _THINK_BLOCKS.search(cleaned):
|
| 188 |
+
answer = _THINK_BLOCKS.sub("", cleaned).strip()
|
| 189 |
+
return answer or cleaned
|
| 190 |
+
|
| 191 |
+
if _MALFORMED_THINK_OPEN.match(cleaned):
|
| 192 |
+
body = _MALFORMED_THINK_OPEN.sub("", cleaned, count=1).strip()
|
| 193 |
+
reasoning, answer = _split_reasoning_and_answer(body)
|
| 194 |
+
if answer:
|
| 195 |
+
think_body = reasoning or body
|
| 196 |
+
return f"{_THINK_OPEN}\n{think_body}\n{_THINK_CLOSE}\n\n{answer}"
|
| 197 |
+
return f"{_THINK_OPEN}\n{body}\n{_THINK_CLOSE}"
|
| 198 |
+
|
| 199 |
+
reasoning, answer = _split_reasoning_and_answer(cleaned)
|
| 200 |
+
if reasoning and answer:
|
| 201 |
+
return f"{_THINK_OPEN}\n{reasoning}\n{_THINK_CLOSE}\n\n{answer}"
|
| 202 |
+
|
| 203 |
+
return cleaned
|
| 204 |
|
| 205 |
|
| 206 |
def strip_reasoning_output(text: str) -> str:
|
|
|
|
| 210 |
return ""
|
| 211 |
|
| 212 |
cleaned = _THINK_BLOCKS.sub("", cleaned).strip()
|
| 213 |
+
if cleaned and not _THINK_BLOCKS.search(text):
|
| 214 |
+
extracted = _extract_best_answer(cleaned)
|
| 215 |
+
if extracted:
|
| 216 |
+
return extracted
|
| 217 |
|
| 218 |
if _MALFORMED_THINK_OPEN.match(cleaned):
|
| 219 |
body = _MALFORMED_THINK_OPEN.sub("", cleaned, count=1).strip()
|
| 220 |
+
extracted = _extract_best_answer(body)
|
| 221 |
if extracted:
|
| 222 |
return extracted
|
| 223 |
cleaned = body
|
| 224 |
|
| 225 |
+
if looks_like_reasoning_only(cleaned) or _ANSWER_MARKER.search(cleaned) or _SENTENCE_PART.search(cleaned):
|
| 226 |
+
extracted = _extract_best_answer(cleaned)
|
| 227 |
if extracted:
|
| 228 |
return extracted
|
| 229 |
|
libs/inference/tests/test_response_clean.py
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
-
from inference.response_clean import strip_reasoning_output
|
| 4 |
|
| 5 |
_RT_OPEN = "<" + "redacted_thinking" + ">"
|
| 6 |
_RT_CLOSE = "</" + "redacted_thinking" + ">"
|
|
@@ -32,3 +32,75 @@ Summary: This review covers AI agent applications, evaluation, and future work [
|
|
| 32 |
def test_preserves_normal_answer():
|
| 33 |
text = "AI agents combine perception, planning, and action [1]."
|
| 34 |
assert strip_reasoning_output(text) == text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
+
from inference.response_clean import prepare_display_reply, strip_reasoning_output
|
| 4 |
|
| 5 |
_RT_OPEN = "<" + "redacted_thinking" + ">"
|
| 6 |
_RT_CLOSE = "</" + "redacted_thinking" + ">"
|
|
|
|
| 32 |
def test_preserves_normal_answer():
|
| 33 |
text = "AI agents combine perception, planning, and action [1]."
|
| 34 |
assert strip_reasoning_output(text) == text
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def test_extracts_final_answer_from_plain_chain_of_thought():
|
| 38 |
+
raw = """First, I need to explain finetuning in plain language. I should keep it concise.
|
| 39 |
+
|
| 40 |
+
Let me draft:
|
| 41 |
+
1. Finetuning adjusts a model for a task.
|
| 42 |
+
2. Best practices include good data.
|
| 43 |
+
|
| 44 |
+
Final answer:
|
| 45 |
+
|
| 46 |
+
Finetuning small model adjusts a model to improve its performance on a specific task.
|
| 47 |
+
For example, fine-tuning a language model can enhance its ability to understand complex queries.
|
| 48 |
+
Best practices include using diverse and high-quality data.
|
| 49 |
+
|
| 50 |
+
That's about 3 sentences. I think it covers it.
|
| 51 |
+
|
| 52 |
+
Let me write:
|
| 53 |
+
|
| 54 |
+
Finetuning small model involves training the model with additional data to specialize in a task.
|
| 55 |
+
For instance, fine-tuning a computer vision model can improve its object"""
|
| 56 |
+
out = strip_reasoning_output(raw)
|
| 57 |
+
assert out.startswith("Finetuning small model adjusts")
|
| 58 |
+
assert "First, I need" not in out
|
| 59 |
+
assert "Let me draft" not in out
|
| 60 |
+
assert "That's about 3 sentences" not in out
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def test_prepare_display_reply_collapses_plain_chain_of_thought():
|
| 64 |
+
raw = """First, I need to plan the answer.
|
| 65 |
+
|
| 66 |
+
Final answer:
|
| 67 |
+
|
| 68 |
+
Finetuning teaches a small model to specialize on your task using extra training data."""
|
| 69 |
+
out = prepare_display_reply(raw)
|
| 70 |
+
assert out.startswith(_THINK_OPEN)
|
| 71 |
+
assert _THINK_CLOSE in out
|
| 72 |
+
assert "Finetuning teaches a small model" in out
|
| 73 |
+
assert "First, I need to plan" in out
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def test_extracts_labeled_sentence_draft():
|
| 77 |
+
raw = """First, the user wants me to explain finetuning.
|
| 78 |
+
|
| 79 |
+
Let me outline my response:
|
| 80 |
+
1. Start with a simple definition.
|
| 81 |
+
|
| 82 |
+
Now, write the response:
|
| 83 |
+
|
| 84 |
+
Sentence 1: Finetuning is training a small model to improve its performance on a specific task, like recognizing objects in photos.
|
| 85 |
+
|
| 86 |
+
Sentence 2: For example, a model might be fine-tuned on a dataset of medical scans to detect tumors more accurately.
|
| 87 |
+
|
| 88 |
+
That's two sentences. I can add one more if needed.
|
| 89 |
+
|
| 90 |
+
Sentence 3: This process enhances efficiency and reduces overfitting.
|
| 91 |
+
|
| 92 |
+
So, three"""
|
| 93 |
+
out = strip_reasoning_output(raw)
|
| 94 |
+
assert "Finetuning is training a small model" in out
|
| 95 |
+
assert "medical scans" in out
|
| 96 |
+
assert "enhances efficiency" in out
|
| 97 |
+
assert "First, the user" not in out
|
| 98 |
+
assert "Sentence 1:" not in out
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def test_prepare_display_reply_wraps_malformed_think_prefix():
|
| 102 |
+
raw = "think> We need to plan the answer.\n\nThe answer is 42."
|
| 103 |
+
out = prepare_display_reply(raw)
|
| 104 |
+
assert out.startswith(_THINK_OPEN)
|
| 105 |
+
assert _THINK_CLOSE in out
|
| 106 |
+
assert "We need to plan the answer." in out
|
libs/researchmind/src/researchmind/scope.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Shared RAG retrieval scope rules for sessions, documents, and corpus."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
def resolve_retrieve_scope(
|
| 7 |
+
session_id: str | None,
|
| 8 |
+
doc_ids: list[str] | None,
|
| 9 |
+
) -> tuple[str | None, list[str] | None]:
|
| 10 |
+
"""Return (session_id, doc_ids) arguments for ``retrieve``.
|
| 11 |
+
|
| 12 |
+
When explicit document IDs are provided, search those documents across the
|
| 13 |
+
store. Otherwise scope to the session, or the entire corpus when neither
|
| 14 |
+
session nor documents are set.
|
| 15 |
+
"""
|
| 16 |
+
if doc_ids:
|
| 17 |
+
return None, list(doc_ids)
|
| 18 |
+
if session_id:
|
| 19 |
+
return session_id, None
|
| 20 |
+
return None, None
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def rag_scope_warning(
|
| 24 |
+
*,
|
| 25 |
+
session_id: str | None,
|
| 26 |
+
doc_ids: list[str] | None,
|
| 27 |
+
) -> str:
|
| 28 |
+
if doc_ids:
|
| 29 |
+
return "No passages in selected documents for this question."
|
| 30 |
+
if session_id:
|
| 31 |
+
return "No indexed sources in this session yet."
|
| 32 |
+
return "No indexed sources in the corpus yet."
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def retrieval_query(
|
| 36 |
+
question: str,
|
| 37 |
+
*,
|
| 38 |
+
topic: str | None = None,
|
| 39 |
+
) -> str:
|
| 40 |
+
"""Build a retrieval query from the user question and optional focus topic."""
|
| 41 |
+
question = question.strip()
|
| 42 |
+
topic = (topic or "").strip()
|
| 43 |
+
if not topic:
|
| 44 |
+
return question
|
| 45 |
+
if topic.lower() in question.lower():
|
| 46 |
+
return question
|
| 47 |
+
return f"{topic}: {question}"
|
libs/researchmind/tests/test_scope.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from researchmind.scope import (
|
| 2 |
+
rag_scope_warning,
|
| 3 |
+
resolve_retrieve_scope,
|
| 4 |
+
retrieval_query,
|
| 5 |
+
)
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def test_resolve_retrieve_scope_doc_ids():
|
| 9 |
+
assert resolve_retrieve_scope("sess-1", ["d1", "d2"]) == (None, ["d1", "d2"])
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def test_resolve_retrieve_scope_session():
|
| 13 |
+
assert resolve_retrieve_scope("sess-1", None) == ("sess-1", None)
|
| 14 |
+
assert resolve_retrieve_scope("sess-1", []) == ("sess-1", None)
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def test_resolve_retrieve_scope_corpus():
|
| 18 |
+
assert resolve_retrieve_scope(None, None) == (None, None)
|
| 19 |
+
assert resolve_retrieve_scope("", None) == (None, None)
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def test_retrieval_query_combines_topic():
|
| 23 |
+
assert retrieval_query("How does it work?", topic="Photosynthesis") == (
|
| 24 |
+
"Photosynthesis: How does it work?"
|
| 25 |
+
)
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def test_retrieval_query_skips_duplicate_topic():
|
| 29 |
+
assert retrieval_query("Explain photosynthesis", topic="Photosynthesis") == (
|
| 30 |
+
"Explain photosynthesis"
|
| 31 |
+
)
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def test_rag_scope_warning_messages():
|
| 35 |
+
assert "selected documents" in rag_scope_warning(session_id="s", doc_ids=["d"])
|
| 36 |
+
assert "this session" in rag_scope_warning(session_id="s", doc_ids=None)
|
| 37 |
+
assert "corpus" in rag_scope_warning(session_id=None, doc_ids=None)
|