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 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 inference.config import get_app_config
18
-
19
- _app_config = get_app_config()
20
 
21
 
22
  def build_demo() -> gr.Blocks:
23
- active = _app_config.active
24
- presets_note = (
25
- f"Presets file: `{_app_config.presets_path}`"
26
- if _app_config.presets_path
27
- else "Using built-in presets (models.yaml not found)."
28
- )
 
 
 
 
 
 
29
 
30
- with gr.Blocks(title="Lesson Agent + ResearchMind — Build Small Hackathon") as demo:
31
- gr.Markdown(
32
- f"""
33
- # Lesson Agent + ResearchMind + EchoCoach + TeacherVoice
34
 
35
- Local skill-based agents — **lesson slides**, **research with MemRAG**, **voice conversation (TeacherVoice)**, and **pitch analysis (EchoCoach)** (offline).
36
 
37
- - **Model:** `{active.key}` {active.label}
38
- - **Backend:** `{active.backend}`
39
- - {presets_note}
40
 
41
- Part of the [Build Small Hackathon](https://huggingface.co/build-small-hackathon).
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=sid,
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, _, _ = run_research_question(
200
  message,
201
  session_id=session_id,
202
  doc_ids=doc_ids,
203
  model_key=model_key,
204
  )
205
- return answer
 
 
 
 
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
- gr.Markdown(
18
- """
19
- ### Model chat (debug)
20
-
21
- Test the active local model. Enable **ResearchMind RAG** to answer from ingested sessions and documents with citations.
22
- """
23
  )
24
 
25
  model_key = _app_config.active_model
26
 
27
- with gr.Row():
28
- use_rag = gr.Checkbox(label="Use ResearchMind RAG", value=False)
29
- session_dd = gr.Dropdown(
30
- label="Session",
31
- choices=list_session_choices(),
32
- value="",
33
- interactive=True,
 
 
 
 
 
 
 
 
 
 
 
34
  )
35
- refresh_sessions_btn = gr.Button("Refresh", size="sm")
36
 
37
- doc_dd = gr.CheckboxGroup(
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
- status = gr.Markdown(model_status(model_key))
51
- model_dropdown.change(fn=model_status, inputs=model_dropdown, outputs=status)
52
- gr.ChatInterface(
53
- fn=rag_aware_chat,
 
 
 
 
 
 
54
  additional_inputs=[model_dropdown, use_rag, session_dd, doc_dd],
55
  examples=[
56
- ["What do my ingested sources say about AI agents?", _app_config.active_model, True, "", []],
57
- ["Hello! What can you help me with?", _app_config.active_model, False, "", []],
 
 
 
 
 
 
 
 
 
 
 
 
58
  ],
59
  )
60
  else:
61
- status = gr.Markdown(model_status(model_key))
62
 
63
  def _chat(message, history, use_rag_flag, sid, docs):
64
- return rag_aware_chat(message, history, model_key, use_rag_flag, sid, docs)
 
 
 
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 echocoach.recording import (
10
- ServerRecordingError,
11
- recording_backend_status,
12
- recording_elapsed_seconds,
13
- recording_level_warning,
14
- start_server_recording,
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 _error_outputs(message: str) -> tuple:
32
- return (
33
- message,
34
- f'<p style="color:#8a1f1f;">{message}</p>',
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 ui_stop_recording() -> tuple[str | None, str, dict, dict]:
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
- gr.update(value=str(path)),
88
- status,
89
- gr.update(interactive=True),
90
- gr.update(interactive=False),
 
 
 
 
 
 
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 gr.update(value=str(_SAMPLE_AUDIO)), "Loaded 2s sample clip. Click **Analyze pitch** to test the pipeline."
 
 
 
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 — surface pipeline errors in UI
126
  return _error_outputs(f"EchoCoach failed: {exc}")
127
 
128
- status = "Analysis complete."
 
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
- For conversational pitch tips, try the **TeacherVoice** tab (Pitch practice mode). This tab provides deep analysis: pace charts, filler counts, and a structured rewrite.
164
- """
 
 
 
 
 
 
 
165
  )
166
 
167
- with gr.Row():
168
- with gr.Column(scale=1):
169
- record_status_md = gr.Markdown(mic_status)
170
- with gr.Accordion("Record from this computer (recommended)", open=True):
171
- gr.Markdown(
172
- "Click **Start recording**, speak your pitch, then **Stop recording** when done. "
173
- "The slider sets the maximum length (auto-stop safety cap)."
174
- )
175
- record_seconds = gr.Slider(
176
- label="Max recording length (seconds)",
177
- minimum=3,
178
- maximum=_config.max_seconds,
179
- value=min(30, _config.max_seconds),
180
- step=1,
 
181
  )
182
- with gr.Row():
183
- record_start_btn = gr.Button("Start recording", variant="secondary")
184
- record_stop_btn = gr.Button("Stop recording", variant="stop", interactive=False)
185
- sample_btn = gr.Button("Load sample clip", variant="secondary")
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
- asr_preset = gr.Dropdown(
198
- label="ASR preset",
199
- choices=asr_choices,
200
- value=default_asr,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
201
  )
202
- speak_rewrite = gr.Checkbox(
203
- label="VoiceOut speaks full rewrite (otherwise summary + tip)",
204
- value=False,
 
 
 
 
 
 
 
 
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
- sample_btn.click(
234
- load_sample_pitch,
235
- outputs=[audio_in, status],
236
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
237
 
238
  analyze_btn.click(
239
  analyze_pitch,
240
- inputs=[audio_in, language, asr_preset, speak_rewrite],
 
 
 
 
 
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
- trace_note,
249
- trace_json,
 
 
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("&", "&amp;")
32
+ .replace("<", "&lt;")
33
+ .replace(">", "&gt;")
 
 
 
 
 
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, model_status
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 search (suggest & confirm)", "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 — show agent errors in UI
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
- model_key = get_active_model_key()
194
-
195
- gr.Markdown(
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.Row():
206
  topic = gr.Textbox(
207
- label="Lesson topic",
208
- placeholder="e.g. Photosynthesis, Fractions, The water cycle",
 
 
 
209
  )
 
 
210
  grade = gr.Dropdown(
211
- label="Grade level",
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="Content slides",
 
221
  )
222
 
223
- gr.Markdown("#### Research sources (optional)")
224
- with gr.Row():
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.Dropdown(
231
- label="Search workflow",
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
- session_dd = gr.Dropdown(
240
- label="ResearchMind session",
241
- choices=list_session_choices(),
242
- value="",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
243
  visible=False,
244
  )
 
 
 
 
 
 
 
245
 
246
- url_choices = gr.CheckboxGroup(
247
- label="Suggested URLs to use",
248
- choices=[],
249
- visible=False,
250
- )
251
- urls_text = gr.Textbox(
252
- label="URLs (one per line, optional)",
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
- generate_btn = gr.Button("Generate lesson slides", variant="primary")
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.Markdown(
298
- """
299
- **Open in Google Docs:** download the `.docx` file, upload it to [Google Drive](https://drive.google.com),
 
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
- with gr.Accordion("Trace summary", open=False):
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, model_status
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
- INGEST_MODES = [
28
- ("Suggest URLs (confirm)", "suggest"),
29
- ("Auto search & ingest", "auto"),
30
- ]
 
31
 
32
 
33
  def discover_sources(
34
  topic: str,
35
- ingest_mode: str,
36
  session_id: str,
37
- ) -> tuple[str, gr.Update, str, str, str, str, object]:
 
 
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
- if not topic.strip():
52
- msg = "Enter a topic to discover sources."
53
  return (
54
- msg,
55
- gr.update(choices=[], value=[]),
56
  session_id,
57
- msg,
58
- msg,
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 switch to **Auto search & ingest**."
99
  )
100
  else:
101
  summary = (
102
- f"Found **{len(choices)} verified URL(s)** via web search "
103
- f"(Google + fallbacks). Select sources and click **Ingest selected**."
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 ingest_selected(
129
  topic: str,
130
- urls_text: str,
131
- selected_urls: list[str],
132
- upload_files: list[str] | None,
133
  session_id: str,
134
- ) -> tuple[str, str, str, str, object, object]:
 
 
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
- memory_summary(session_id),
 
141
  load_error,
142
  load_error,
143
- refresh_sessions(session_id),
144
  refresh_doc_choices(session_id, []),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
145
  )
146
 
147
- direct_urls = [ln.strip() for ln in urls_text.splitlines() if ln.strip()]
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 = "Provide URLs, select suggested sources, or upload a file."
153
  return (
154
  msg,
155
- memory_summary(session_id),
156
  msg,
157
  msg,
158
- refresh_sessions(session_id),
159
- refresh_doc_choices(session_id, []),
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 None,
167
  urls=all_urls,
168
  files=files,
169
  auto_search=False,
170
- session_id=session_id or None,
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(session_id),
189
  msg,
190
  msg,
191
- refresh_sessions(session_id),
192
- refresh_doc_choices(session_id, []),
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
- ) -> tuple[list[dict], str, str, str]:
 
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
- return history, trace_json, trace_summary, rag_scope_hint(session_id, doc_ids)
 
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
- """ResearchMind UI — ingest, memory, trace, and corpus chat."""
226
- model_key = get_active_model_key()
227
- cfg = get_config()
228
-
229
- gr.Markdown(
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.Row():
 
 
 
 
 
 
 
 
 
240
  session_dd = gr.Dropdown(
241
  label="Session",
242
  choices=list_session_choices(),
243
  value="",
244
- interactive=True,
245
  )
246
- refresh_btn = gr.Button("Refresh sessions", size="sm")
247
-
248
- with gr.Tabs():
249
- with gr.Tab("Ingest"):
250
- gr.Markdown(
251
- """
252
- - **Suggest mode:** Google web search → verified URLs → you confirm → ingest
253
- - **Auto search:** same search, ingests top verified URLs immediately
254
- - **Direct:** paste URLs or upload PDF/DOCX
255
- """
256
- )
257
- with gr.Row():
258
- topic = gr.Textbox(
259
- label="Topic (optional)",
260
- placeholder="e.g. Photosynthesis, American Revolution",
 
261
  )
262
- ingest_mode = gr.Dropdown(
263
- label="Ingest mode",
264
- choices=[m[0] for m in INGEST_MODES],
265
- value=INGEST_MODES[0][0],
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
266
  )
267
 
268
- urls_text = gr.Textbox(
269
- label="URLs (one per line, optional)",
270
- lines=3,
271
- placeholder="https://en.wikipedia.org/wiki/...",
272
  )
273
- upload_files = gr.File(
274
- label="Upload PDF or DOCX",
275
- file_count="multiple",
276
- file_types=[".pdf", ".docx"],
 
 
 
 
 
 
 
 
 
 
277
  )
278
 
279
- discover_btn = gr.Button("Discover sources", variant="secondary")
280
- url_choices = gr.CheckboxGroup(label="Suggested URLs to ingest", choices=[])
281
- ingest_btn = gr.Button("Ingest selected", variant="primary")
282
- ingest_status = gr.Markdown()
283
-
284
- with gr.Tab("Memory"):
285
- gr.Markdown("Indexed documents and chunk counts for the selected session.")
286
- memory_md = gr.Markdown(value=memory_summary(""))
287
- refresh_memory_btn = gr.Button("Refresh memory view", size="sm")
288
-
289
- with gr.Tab("Trace"):
290
- trace_summary = gr.Markdown()
291
- trace_box = gr.Textbox(label="Trace JSON", lines=14, interactive=False)
292
-
293
- gr.Markdown("---")
294
- gr.Markdown("### Chat with your corpus")
295
- gr.Markdown(
296
- "Ask questions about ingested sources. Limit search to specific documents below, "
297
- "or leave all checked to search the whole session."
298
- )
299
- rag_hint = gr.Markdown(value=rag_scope_hint("", []))
300
- doc_dd = gr.CheckboxGroup(
301
- label="Documents in session",
302
- choices=[],
303
- value=[],
304
- )
305
- chatbot = gr.Chatbot(label="Research chat", height=360)
306
- question = gr.Textbox(
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=lambda topic, mode, sid: discover_sources(
328
- topic,
329
- "auto" if mode == INGEST_MODES[1][0] else "suggest",
330
- sid,
331
- ),
332
- inputs=[topic, ingest_mode, session_dd],
333
- outputs=[
334
- ingest_status,
335
- url_choices,
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=[ingest_status, memory_md, trace_box, trace_summary, session_dd, doc_dd],
 
 
 
 
 
 
 
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.recording import (
8
- ServerRecordingError,
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
- None,
37
- "Start recording, speak your question, stop, then click **Send turn**.",
38
  "",
39
  {},
 
40
  )
41
 
42
 
43
- def ui_start_recording(max_seconds: int) -> tuple[str, dict, dict]:
44
- try:
45
- start_server_recording(int(max_seconds))
46
- except ServerRecordingError as exc:
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
- gr.update(value=str(path)),
87
  status,
88
- gr.update(interactive=True),
89
- gr.update(interactive=False),
 
90
  )
91
 
92
 
93
- def clear_conversation() -> tuple:
94
- return _empty_turn()
 
 
 
 
 
 
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
- None,
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
- None,
146
- f"TeacherVoice failed: {exc}",
147
  "",
148
  {},
 
149
  )
150
 
151
- status = f"Turn complete — transcribed {len(result.user_text)} chars, replied in voice."
152
- if result.voiceout_warning:
153
- status += f" VoiceOut: {result.voiceout_warning}"
 
 
 
 
 
 
 
 
 
 
 
 
154
 
155
- playback = result.voiceout_first_path or result.voiceout_path
156
- return (
157
- result.history,
158
- playback,
159
- status,
160
- f"Trace saved: `{result.trace_path}`",
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
- 1. Choose a mode → record a short turn (max **{_TURN_MAX}s**) → **Send turn** → hear the reply.
194
- 2. **Explain** — tutor any topic. **Lesson coach** — outline and discuss lessons. **Pitch practice** — live speaking tips.
195
- 3. For deep pitch analysis (pace charts, filler counts), use the **EchoCoach** tab.
196
-
197
- Latency is typically a few seconds per turn on GPU; CPU may take longer.
198
- {omni_note or ""}
199
- """
 
 
 
 
200
  )
201
 
202
- with gr.Row():
203
- with gr.Column(scale=1):
204
- mode_dd = gr.Dropdown(
205
- label="Mode",
 
 
206
  choices=_MODE_CHOICES,
207
  value="explain",
 
208
  )
 
209
  topic_tb = gr.Textbox(
210
- label="Topic (Explain / Lesson modes)",
211
- placeholder="e.g. Photosynthesis for grade 6",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
212
  )
213
- record_status_md = gr.Markdown(mic_status)
214
- with gr.Accordion("Record from this computer", open=True):
215
- record_seconds = gr.Slider(
216
- label="Max turn length (seconds)",
217
- minimum=3,
218
- maximum=_TURN_MAX,
219
- value=_TURN_MAX,
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
- audio_in = gr.Audio(
226
- label="Your turn (browser mic or upload)",
227
- sources=["upload", "microphone"],
228
- type="filepath",
229
- format="wav",
230
  )
231
- language = gr.Dropdown(label="Language", choices=lang_choices, value=default_lang)
232
- asr_preset = gr.Dropdown(label="ASR preset", choices=asr_choices, value=default_asr)
233
- with gr.Accordion("ResearchMind RAG (Explain / Lesson)", open=False):
234
- use_rag = gr.Checkbox(label="Ground answers in ingested sources", value=False)
235
- session_dd = gr.Dropdown(
236
- label="Session",
237
- choices=list_session_choices(),
238
- value="",
239
  )
240
- refresh_sessions_btn = gr.Button("Refresh sessions", size="sm")
241
- doc_dd = gr.CheckboxGroup(label="Documents (empty = all in session)", choices=[], value=[])
242
- rag_hint = gr.Markdown(value=rag_scope_hint("", []))
243
- with gr.Row():
244
- send_btn = gr.Button("Send turn", variant="primary")
245
- clear_btn = gr.Button("Clear conversation", variant="secondary")
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
- voiceout = gr.Audio(
258
- label="Teacher reply (auto after Send turn, or use Speak buttons)",
259
- type="filepath",
260
- autoplay=True,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
261
  )
262
- trace_note = gr.Markdown()
263
- trace_json = gr.JSON(label="Trace")
264
 
265
- record_start_btn.click(
266
- ui_start_recording,
267
- inputs=[record_seconds],
268
- outputs=[status, record_start_btn, record_stop_btn],
269
- )
270
- record_stop_btn.click(
271
- ui_stop_recording,
272
- outputs=[audio_in, status, record_start_btn, record_stop_btn],
273
  ).then(
274
- lambda: recording_backend_status(),
275
- outputs=[record_status_md],
 
276
  )
277
 
278
  refresh_sessions_btn.click(fn=refresh_sessions, inputs=[session_dd], outputs=[session_dd])
279
- session_dd.change(
280
- fn=refresh_doc_choices,
281
- inputs=[session_dd, doc_dd],
282
- outputs=[doc_dd],
283
- )
284
  for trigger in (use_rag, session_dd, doc_dd):
285
  trigger.change(
286
- fn=lambda rag_on, sid, docs: (
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
- send_btn.click(
294
- send_turn,
295
- inputs=[
296
- audio_in,
297
- chatbot,
298
- mode_dd,
299
- language,
300
- asr_preset,
301
- topic_tb,
302
- use_rag,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
303
  session_dd,
304
  doc_dd,
305
  ],
306
- outputs=[chatbot, voiceout, status, trace_note, trace_json],
 
 
 
307
  )
308
 
309
- clear_btn.click(clear_conversation, outputs=[chatbot, voiceout, status, trace_note, trace_json])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- if doc_ids:
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 if session_id and not doc_ids else None
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
- if doc_ids:
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
- Keep answers concise (2-5 sentences) so they work well when spoken aloud.
 
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 strip_reasoning_output
13
- from researchmind.citations import format_context_block, format_references
14
- from researchmind.config import get_config as get_researchmind_config
 
 
15
  from researchmind.ingest import IngestPipeline
16
- from researchmind.retrieve import retrieve
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
- ) -> list[dict[str, str]]:
 
 
 
53
  """Append a turn in Gradio 5 messages format."""
54
- updated: list[dict[str, 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
- updated.append({"role": "assistant", "content": assistant_text})
 
 
 
 
 
 
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": item["role"], "content": extract_message_text(item["content"])}
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
- {"role": "assistant", "content": extract_message_text(assistant_msg)}
 
 
 
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 if session_id and not doc_ids else None
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
- if doc_ids:
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(history, omni_user, omni_reply)
 
 
 
 
 
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
- rag: RagContext | None = None
238
- rag_refs: str | None = None
239
- if use_rag and mode in RAG_MODES:
240
- sid = session_id
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
- tts_preset=tts_key,
271
- chunk_first=True,
272
- out_subdir="teacher_voice",
273
- )
274
- if voiceout_path:
275
- trace.set_artifact(voiceout_path)
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 messages[-1]["content"].endswith("How do plants eat?")
 
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 _extract_answer_from_reasoning(text: str) -> str | None:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
51
  for pattern in _ANSWER_SPLITS:
52
  match = pattern.search(text)
53
  if not match:
54
  continue
55
- rest = _normalize_extracted(text[match.end() :])
56
- rest = _META_TAIL.split(rest, maxsplit=1)[0].strip()
57
- if len(rest) >= 40:
58
- return rest
59
- return None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
60
 
61
 
62
  def looks_like_reasoning_only(text: str) -> bool:
63
- sample = text[:240].lower()
64
- return any(sample.startswith(opener) for opener in _REASONING_OPENERS)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 = _extract_answer_from_reasoning(body)
78
  if extracted:
79
  return extracted
80
  cleaned = body
81
 
82
- if looks_like_reasoning_only(cleaned):
83
- extracted = _extract_answer_from_reasoning(cleaned)
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)