pranavkarthik10 commited on
Commit
81e3ca2
·
verified ·
1 Parent(s): c47483b

Deploy AI Prof hackathon submission

Browse files
.env.example ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # AI Prof configuration.
2
+ # Leave everything blank to run in MOCK mode (no weights needed — great for UI dev / demo recording).
3
+
4
+ # --- Eyes: MiniCPM-V (vision) ---
5
+ # Hosted on Modal through llama.cpp (modal_app_vision.py).
6
+ # Bring up with: modal run modal_app_vision.py::download_model
7
+ # modal run modal_app_vision.py::warm
8
+ # modal deploy modal_app_vision.py
9
+ # Set VISION_BASE_URL to the printed serve URL, with or without /v1.
10
+ VISION_BASE_URL=
11
+ VISION_API_KEY=sk-no-key-required
12
+ VISION_MODEL=minicpm-v
13
+
14
+ # --- Brain: Nemotron 3 Nano (explanation + Q&A) ---
15
+ # Any OpenAI-compatible chat endpoint (vLLM, llama.cpp, etc.)
16
+ # vllm serve nvidia/NVIDIA-Nemotron-3-Nano-30B-A3B-BF16 --port 8000
17
+ BRAIN_BASE_URL=
18
+ BRAIN_API_KEY=sk-no-key-required
19
+ BRAIN_MODEL=nemotron-3-nano
20
+
21
+ # --- Voice (TTS): VoxCPM2 served via vLLM-Omni (modal_app_vox.py) ---
22
+ # Used by the WebRTC voice layer (ai_prof/rtc.py) to speak AI Prof answers aloud.
23
+ # Bring up with: modal run modal_app_vox.py::download_model
24
+ # modal run modal_app_vox.py::warm
25
+ # modal deploy modal_app_vox.py
26
+ # Set TTS_BASE_URL to either the printed *.modal.run URL or that URL + /v1.
27
+ # Leave blank to fall back to a silent-audio mock (voice UI still works, just mute).
28
+ TTS_BASE_URL=
29
+ TTS_API_KEY=sk-no-key-required
30
+ TTS_MODEL=voxcpm2
31
+ # Use a precomputed vLLM-Omni voice name when available. Otherwise AI Prof
32
+ # creates one voice on the first utterance and reuses it for the lecture.
33
+ TTS_VOICE=default
34
+
35
+ # --- Voice (STT): distil-whisper-large-v3 via faster-whisper-server (modal_app_vox.py) ---
36
+ # Used to transcribe student microphone audio (push-to-talk + WebRTC interjections).
37
+ # Bring up with: modal run modal_app_vox.py::download_stt
38
+ # modal deploy modal_app_vox.py
39
+ # Set STT_BASE_URL to the `transcribe` function URL, with or without /v1.
40
+ # Leave blank to fall back to a "[voice input]" mock.
41
+ STT_BASE_URL=
42
+ STT_API_KEY=sk-no-key-required
43
+ STT_MODEL=distil-whisper-large-v3
44
+
45
+ # Rendering DPI for slide images (higher = sharper, slower vision pass)
46
+ SLIDE_DPI=150
47
+
48
+ # --- Processed deck cache ---
49
+ # Every uploaded PDF is hashed. Rendered slide images, text layers, MiniCPM-V
50
+ # readings, and the complete deck index are cached locally under DECK_CACHE_DIR.
51
+ DECK_CACHE_DIR=.cache/ai-prof/decks
52
+
53
+ # Optional shared Hugging Face dataset cache. Public repos can be read without a
54
+ # token; private repos require HF_TOKEN. Keep writes disabled for arbitrary user
55
+ # uploads unless the dataset is private or you have explicit permission to
56
+ # publish the lecture material.
57
+ HF_DECK_CACHE_REPO=
58
+ HF_TOKEN=
59
+ HF_DECK_CACHE_WRITE=false
.gitignore ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ .venv/
6
+ venv/
7
+ env/
8
+ .python-version
9
+
10
+ # Models / weights / data (keep large + private out of git)
11
+ *.gguf
12
+ *.safetensors
13
+ *.bin
14
+ models/
15
+ weights/
16
+ slides/
17
+ data/
18
+ .cache/
19
+ *.pdf
20
+
21
+ # Secrets / env
22
+ .env
23
+ .env.*
24
+ !.env.example
25
+ *.key
26
+
27
+ # Agent instructions (kept local, not published)
28
+ AGENTS.md
29
+ CLAUDE.md
30
+
31
+ # OS / editor
32
+ .DS_Store
33
+ .idea/
34
+ .vscode/
35
+ *.log
36
+
37
+ # Gradio
38
+ .gradio/
39
+ flagged/
ARCHITECTURE.md ADDED
@@ -0,0 +1,240 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # AI Prof Architecture
2
+
3
+ This document captures the intended production and demo architecture for AI Prof.
4
+ The core experience is a professor-like agent that understands an entire lecture,
5
+ controls the visible slide and whiteboard, speaks naturally, and responds to student
6
+ interruptions without losing its place.
7
+
8
+ ## Deployment
9
+
10
+ ```text
11
+ Hugging Face Space
12
+ Gradio UI
13
+ session state
14
+ professor orchestrator
15
+ slide and whiteboard rendering
16
+ |
17
+ v
18
+ Modal inference services
19
+ MiniCPM-V slide vision
20
+ Nemotron professor agent
21
+ VoxCPM text-to-speech
22
+ Whisper or Moonshine speech-to-text
23
+ |
24
+ v
25
+ Hugging Face storage
26
+ published demo deck
27
+ processed deck manifests
28
+ slide images and readings
29
+ ```
30
+
31
+ - **Hugging Face Space:** public Gradio application and lightweight orchestration.
32
+ - **Modal:** GPU-backed, self-hosted model inference that can scale down when idle.
33
+ - **Local development:** the same Gradio app in mock mode or pointed at local model
34
+ servers.
35
+ - **Hugging Face storage:** persistent processed lectures, including one polished
36
+ public demo deck that works immediately for judges and visitors.
37
+
38
+ ## Deck Preparation
39
+
40
+ The professor should not begin teaching after only slide 1 is processed. Navigation
41
+ and question answering depend on knowing what exists across the whole lecture.
42
+
43
+ On upload:
44
+
45
+ 1. Hash the PDF to identify an existing processed deck.
46
+ 2. If cached, load its manifest and slide assets.
47
+ 3. Otherwise, render every page and extract the PDF text layer.
48
+ 4. Run MiniCPM-V over every slide.
49
+ 5. Build a compact deck index from all slide readings.
50
+ 6. Persist the processed result when appropriate.
51
+ 7. Enable the lecture only when the complete index is ready.
52
+
53
+ Gradio displays preparation progress while this runs. It does not need to hold the
54
+ heavy processing itself; it can call Modal jobs and stream their progress.
55
+
56
+ ### Processed Deck Format
57
+
58
+ ```text
59
+ decks/<pdf_sha256>/
60
+ manifest.json
61
+ source.pdf
62
+ slides/
63
+ 001.png
64
+ 002.png
65
+ readings/
66
+ 001.json
67
+ 002.json
68
+ ```
69
+
70
+ Each index entry should remain compact enough to keep the entire deck map in the
71
+ agent context:
72
+
73
+ ```json
74
+ {
75
+ "slide": 7,
76
+ "title": "Convolution",
77
+ "summary": "Applying a kernel across an image",
78
+ "concepts": ["kernel", "stride", "weighted sum"],
79
+ "equations": ["g(x,y) = sum_i sum_j h(i,j)f(x-i,y-j)"],
80
+ "visuals": ["A 3 by 3 kernel moving across a pixel grid"]
81
+ }
82
+ ```
83
+
84
+ The agent receives the complete compact index, but only the full reading for the
85
+ current or specifically retrieved slides.
86
+
87
+ ## Professor Agent
88
+
89
+ The model should make decisions at meaningful teaching boundaries, not once per
90
+ sentence or drawing stroke. One agent turn produces a short **teaching beat**:
91
+
92
+ ```json
93
+ {
94
+ "narration": "Imagine this grid is a small patch of the image.",
95
+ "actions": [
96
+ {"tool": "draw_grid", "args": {"rows": 3, "cols": 3}, "at": 0.4}
97
+ ],
98
+ "next": "continue"
99
+ }
100
+ ```
101
+
102
+ The orchestrator executes the actions and speech. After the beat completes, it asks
103
+ the agent what to do next.
104
+
105
+ ### Agent Context
106
+
107
+ Each decision receives:
108
+
109
+ - Complete compact deck index
110
+ - Current slide number and full cached slide reading
111
+ - Current whiteboard state
112
+ - Recent conversation and teaching beats
113
+ - Saved lecture position
114
+ - Trigger: continue lecture or student question
115
+
116
+ ### Tools
117
+
118
+ - `goto_slide(index)` - move to the best supporting slide
119
+ - `next_slide()` and `prev_slide()` - ordinary navigation
120
+ - `look_closer(question)` - ask MiniCPM-V to inspect the current slide for a
121
+ specific visual detail; wire this after the core loop
122
+ - `write_latex(expression, position)` - place a typeset equation
123
+ - `draw_diagram(spec)` - render structured Excalidraw-style primitives
124
+ - `clear_whiteboard()` - reset the board when the visual context changes
125
+ - `highlight_region(bbox)` - optional later enhancement
126
+
127
+ Tool calls and their results should be logged as a publishable teaching-session
128
+ trace.
129
+
130
+ ## Student Interruption
131
+
132
+ The lecture is controlled by an explicit state machine:
133
+
134
+ ```text
135
+ NARRATING
136
+ -> student begins speaking
137
+ INTERRUPTING
138
+ -> stop TTS and cancel current generation
139
+ LISTENING
140
+ -> capture speech until push-to-talk release or VAD pause
141
+ THINKING
142
+ -> transcribe, search deck index, choose slide and visual support
143
+ ANSWERING
144
+ -> navigate or draw when useful, then speak the answer
145
+ RESUMING
146
+ -> continue from the saved teaching position
147
+ ```
148
+
149
+ When a student asks a question, the agent first decides whether the current slide
150
+ is sufficient. It may:
151
+
152
+ - Answer on the current slide
153
+ - Navigate to a more relevant earlier or later slide
154
+ - Inspect a slide more closely
155
+ - Draw an explanation on the whiteboard
156
+ - Combine navigation and drawing
157
+
158
+ The agent decides whether to return to the previous slide afterward. Automatically
159
+ returning every time would make the lecture feel mechanical.
160
+
161
+ The orchestrator saves the interrupted teaching beat and sentence position. For the
162
+ first implementation, resuming at the beginning of that beat is acceptable and much
163
+ simpler than resuming at an exact audio sample.
164
+
165
+ ## Speech
166
+
167
+ - **TTS:** VoxCPM for professor narration.
168
+ - **STT:** faster-whisper or Moonshine for short student questions.
169
+ - **Transport and turn detection:** FastRTC, initially push-to-talk and later VAD
170
+ barge-in.
171
+
172
+ Narration should be synthesized in short sentence or beat-sized chunks. This keeps
173
+ latency low and gives the orchestrator clean cancellation boundaries.
174
+
175
+ ## Whiteboard
176
+
177
+ Avoid unrestricted free-form drawing as the primary path. It requires too many model
178
+ calls and is difficult to synchronize or reproduce.
179
+
180
+ Use structured operations:
181
+
182
+ - LaTeX for equations
183
+ - Excalidraw-style primitives for boxes, arrows, labels, grids, and highlights
184
+ - Optional Mermaid for diagrams where automatic layout is useful
185
+ - Manim only for prepared showcase animations, not the live agent loop
186
+
187
+ The model emits one structured drawing plan per teaching beat. The frontend animates
188
+ the resulting primitives locally, so drawing does not require another inference call
189
+ for every stroke.
190
+
191
+ ### Speech and Drawing Synchronization
192
+
193
+ Each action may include an approximate offset relative to the narration:
194
+
195
+ ```json
196
+ {
197
+ "narration": "The center pixel is replaced using all nine neighbors.",
198
+ "actions": [
199
+ {"tool": "highlight_cell", "args": {"row": 1, "column": 1}, "at": 0.2},
200
+ {"tool": "write_latex", "args": {"expression": "1/9 sum pixels"}, "at": 2.1}
201
+ ]
202
+ }
203
+ ```
204
+
205
+ The orchestrator:
206
+
207
+ 1. Starts TTS for the teaching beat.
208
+ 2. Executes visual actions at their approximate offsets.
209
+ 3. Waits for speech and drawing to finish.
210
+ 4. Requests the next teaching beat.
211
+
212
+ This produces the feeling of talking while drawing without making an agent call per
213
+ stroke.
214
+
215
+ ## Demo Path
216
+
217
+ The public Space should offer two entry points:
218
+
219
+ 1. **Try the prepared lecture:** loads an already processed, polished deck from
220
+ Hugging Face storage immediately.
221
+ 2. **Upload your own lecture:** preprocesses the complete deck, displays progress,
222
+ then starts the professor.
223
+
224
+ The prepared lecture is the reliable judging and demo-video path. User uploads prove
225
+ the system generalizes without making the first experience depend on a long vision
226
+ preprocessing wait.
227
+
228
+ ## Implementation Order
229
+
230
+ 1. Complete-deck manifest and compact index
231
+ 2. Prepared demo deck loading from Hugging Face storage
232
+ 3. Professor teaching-beat schema and tool executor
233
+ 4. Slide navigation tools
234
+ 5. Structured whiteboard tools and local animation
235
+ 6. VoxCPM narration with cancellable chunks
236
+ 7. Push-to-talk interruption and STT
237
+ 8. Automatic slide retrieval during questions
238
+ 9. VAD barge-in
239
+ 10. Targeted `look_closer` vision calls
240
+
IDEATION.md ADDED
@@ -0,0 +1,168 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Build Small Hackathon — Ideation & Decisions
2
+
3
+ > Working notes from ideation. Project: **AI Prof** (primary submission).
4
+ > Hackathon: https://huggingface.co/build-small-hackathon · Field guide: https://build-small-hackathon-field-guide.hf.space/
5
+
6
+ ## Hackathon constraints (the rules that shape everything)
7
+ - **Model size: ≤32B parameters PER MODEL** (not aggregate). You can freely combine multiple
8
+ small models as long as each one individually stays under the cap. ("not just active params")
9
+ - **No sponsor exclusivity** — a sponsor's model just has to be *a core part of the experience*;
10
+ you may mix in other providers' models.
11
+ - **Platform:** must be built in **Gradio**, hosted as a **Hugging Face Space**.
12
+ - **Deliverables:** working Space + **demo video** + **social media post**.
13
+ - **Timeline:** hack window June 5–15, 2026.
14
+ - **Credits:** OpenAI $100 is for **Codex (their coding agent)**, *not* API/inference. Modal $250, HF $20.
15
+
16
+ ## Tracks
17
+ - **Backyard AI** — solve a genuine problem for *someone you personally know*. Judged on specificity,
18
+ **actual user adoption**, and appropriate model fit. ← our track.
19
+ - **Thousand Token Wood (TTW)** — delightful/unconventional, AI must be load-bearing (game, story, art).
20
+
21
+ ## Prize surface (it stacks — one app can hit many)
22
+ Main tracks ($18k): 1st–4th per track + Community Choice ($2k).
23
+ Sponsor awards:
24
+ - **OpenBMB $10k** — build with MiniCPM (incl. vision MiniCPM-V / omni MiniCPM-o).
25
+ - **OpenAI $10k** — won via **Codex-attributed commits** in the repo/Space (build-tool track, model-agnostic).
26
+ - **NVIDIA** — two RTX 5080 GPUs; build with **Nemotron**. One for "best space", one for community engagement (likes).
27
+ - **Modal $20k credits** — use Modal for dev/runtime, note in README.
28
+ Special awards ($8k): Bonus Quest Champion $2k · Off-Brand (best custom UI via `gr.Server`) $1.5k ·
29
+ Tiny Titan (best app on ≤4B model) $1.5k · Best Demo $1k · Best Agent $1k · Judges' Wildcard $1k.
30
+ Six merit badges: Off the Grid (no cloud APIs) · Well-Tuned (publish fine-tune) · Off-Brand (custom UI) ·
31
+ Llama Champion (llama.cpp runtime) · Sharing is Caring (publish agent trace) · Field Notes (build blog).
32
+
33
+ ## Decision: build the AI Prof (Backyard AI)
34
+ **Problem (real):** a specific classmate finds that having the slides isn't enough — they're static and
35
+ lack the in-class explanation. Test with **real, anonymized lecture slides** from our class.
36
+
37
+ **Core loop:** upload lecture PDF → model reads each slide *as an image* → explains it like a TA,
38
+ streamed in real time → classmate can **interject** with a question at any moment.
39
+
40
+ ### Two-model architecture (stacks OpenBMB + NVIDIA, cleanly justified)
41
+ - **MiniCPM-V (~4.1B, GGUF)** = *the eyes.* Reads each slide as an image (diagrams, equations, layout —
42
+ not just scraped text). Core → satisfies **OpenBMB**. Runs via **llama.cpp** → Llama Champion badge.
43
+ - **Nemotron 3 Nano (9B, or 30B-A3B MoE = only ~3.6B active → fast decode)** = *the brain.* Turns the
44
+ slide reading into the spoken explanation and answers interjections. Reasoning/agentic → **NVIDIA** fit.
45
+ - Per-model cap means no param-budget tension between the two. Division of labor (see/explain) survives
46
+ the "core part of the experience" test → not sponsor-stuffing.
47
+
48
+ ### Prize map for this one app
49
+ Backyard placement · OpenBMB $10k · NVIDIA RTX 5080 · OpenAI $10k (Codex commits) · Modal $20k (self-host) ·
50
+ Off-Brand $1.5k+badge (custom whiteboard UI) · Llama Champion · Off the Grid · Sharing is Caring (publish a
51
+ teaching-session trace) · Field Notes (blog) · Best Demo. → realistically 6+ surfaces.
52
+
53
+ ### Scope discipline (vertical slice order)
54
+ 1. Slide upload → MiniCPM-V reads → Nemotron explains, **streamed** in a simple custom UI. *(Submittable alone.)*
55
+ 2. Text interjection (pause, ask, resume).
56
+ 3. Whiteboard — model emits **Mermaid / Excalidraw JSON** (structured; small models do this reliably).
57
+ Avoid freeform tldraw generation — that's the day-eating trap.
58
+ 4. Voice / TTS — only with slack.
59
+
60
+ ## Real-time architecture
61
+ Goal: feel like a *live* lecture — explanation streams as if the prof is talking through each slide,
62
+ smooth slide-to-slide, interruptible mid-sentence.
63
+ - **Token streaming:** stream Nemotron output to the UI (Gradio generators) so it appears as it's generated.
64
+ - **Complete index before teaching:** process the full deck before starting the lecture. The professor needs a
65
+ global map to choose slides intelligently and answer interjections. Show preparation progress in Gradio.
66
+ - **Two-stage per slide, cached:** (a) MiniCPM-V → a structured "slide reading" (text + diagram desc + equations),
67
+ computed once and cached; (b) Nemotron → the explanation. Interjections reuse cached (a), never re-run vision.
68
+ - **Interjection = interrupt + branch:** need a *cancellable* generation (async cancel token / threading.Event).
69
+ On input: stop current stream, answer using the cached slide reading + history as context, then resume.
70
+ - **Streaming TTS (optional):** chunk explanation into sentences, TTS each as generated, play sequentially.
71
+ Barge-in (interrupt-on-speech) is hard mode — defer.
72
+ - **Session state:** { slides[], current_index, cached_slide_readings{}, conversation_history }.
73
+ - **Hosting tradeoff for low latency:** free HF Space hardware likely too slow for 2 models in real time.
74
+ Lean: **Modal GPU as inference backend** (uses the Modal track) + Gradio Space as frontend. Self-hosting
75
+ (not an external inference API) still supports the *Off the Grid* badge.
76
+
77
+ ## Voice (STT + TTS) — makes it feel like *class*, but it's the deepest rabbit hole
78
+ Two more tiny, on-HF models (free under the ≤32B-*per-model* cap; FAQ uses "a 7B speech model" as its example).
79
+ Keep both **open + self-hosted** (not ElevenLabs/Deepgram/OpenAI *API*) → protects the **Off the Grid** badge.
80
+
81
+ **Pipeline:** TTS (out) speaks Nemotron's streamed explanation; STT (in) transcribes the spoken interjection
82
+ into text for Nemotron; **VAD** is the referee that detects *when* the classmate starts talking → triggers barge-in.
83
+
84
+ - **STT (interjections) — pick: Whisper, or Moonshine for speed.**
85
+ - `faster-whisper` / `distil-whisper` (large-v3 ~1.5B, or base/small for latency) — accurate, OpenAI open weights.
86
+ - **Moonshine** (~tiny) — built for real-time/on-device, faster time-to-text on short clips.
87
+ - Interjections are short → small model is fine; *time-to-text*, not accuracy, is the bottleneck. Start with
88
+ `faster-whisper-base`, switch to Moonshine only if laggy.
89
+ - **TTS (narration) — pick: Kokoro, fallback Piper.**
90
+ - **Kokoro-82M** (`hexgrad/Kokoro-82M`) — 82M, good quality, fast time-to-first-audio, streamable. Sweet spot.
91
+ - **Piper** — even lower latency, CPU-friendly, slightly more robotic. Use if speech layer isn't on GPU.
92
+ - Stream sentence-by-sentence: synthesize + play each sentence as Nemotron emits it (audio ~1 sentence behind gen).
93
+ - **Barge-in / turn-taking — use FastRTC** (`fastrtc`, the Gradio/HF WebRTC stack). Gives low-latency mic+playback
94
+ over WebRTC, built-in **VAD turn detection** (`ReplyOnPause`), and the hook to cancel playback + generation the
95
+ instant the user speaks. Avoids hand-rolling silence detection; reuses our cancellable-generation design.
96
+ - Loop: narrate (TTS) → Silero VAD hears speech → kill TTS + cancel Nemotron → buffer to end-of-speech → STT →
97
+ answer using the **cached slide reading** as context → TTS answer → resume narration.
98
+ - **Latency budget (what "feels live" needs):** minimize time-to-first-audio. STT small (~100–300ms) + Nemotron
99
+ MoE fast prefill + Kokoro first chunk (~tens of ms). Run STT/TTS/VAD on the **same Modal GPU** as Nemotron
100
+ (or CPU for Piper+Silero) to avoid network hops.
101
+ - **Scope ladder (don't let voice eat the week):**
102
+ 1. **TTS narration only** — Prof talks, classmate *types* to interject. Low risk, already feels real-time.
103
+ 2. **Push-to-talk interject** — hold key / tap to ask aloud → STT. No VAD/barge-in. ~90% of magic, ~30% of work.
104
+ 3. **Full-duplex barge-in** via FastRTC + VAD — only once 1–2 are solid. The 2→3 jump is where time goes.
105
+ - **Model count after voice:** MiniCPM-V (vision) + Nemotron (brain) + Whisper/Moonshine (STT) + Kokoro/Piper (TTS)
106
+ + Silero VAD. Multi-model is explicitly blessed; more "appropriate model fit" surface, but more integration.
107
+
108
+ ## Agent loop, tools & slide grounding
109
+ The brain (Nemotron) runs as a **tool-using agent** driving the lecture — professor-esque: decides which slide
110
+ to be on, when to draw vs. just talk, when to jump back. (Strengthens the **Best Agent** award; the tool-call
111
+ sequence *is* the publishable agent trace → **Sharing is Caring** badge.)
112
+
113
+ **Tools (mutate UI / session state):**
114
+ - `goto_slide(i)` / `next_slide()` / `prev_slide()` — navigation; lets it jump back to a referenced slide.
115
+ - `look_closer(question)` — on-demand **real-time** MiniCPM-V call on the *current slide image* for detail.
116
+ - `draw(mermaid | excalidraw_json)` — render on the whiteboard surface.
117
+ - `clear_whiteboard()`
118
+ - `highlight_region(bbox)` — optional, later.
119
+ - narration = the free-text part of the response → streamed to TTS.
120
+
121
+ **Control flow:** orchestrator loop. Each turn the agent gets `{ current slide reading, deck outline, recent
122
+ history, trigger }` where trigger = "continue lecture" | "user asked: <q>". It returns narration + optional
123
+ tool calls; the orchestrator executes tools (swap displayed slide, render whiteboard) and streams narration.
124
+ **Reserve heavy reasoning for decision points (between slides), not during narration**, or latency balloons.
125
+
126
+ **Grounding — preprocess (breadth) + real-time (depth), both:**
127
+ - **On upload (once; complete before lecture begins):**
128
+ - render each slide → image (serves *both* display and vision),
129
+ - **MiniCPM-V** → cached structured slide reading (title, bullets, equations, diagram desc, key concepts),
130
+ - **PDF text-layer** extraction (exact text ground-truth; vision misreads text) to complement vision,
131
+ - build a **deck outline / index** (slide → title/concepts) — this is what lets the agent *plan* and *pick* a
132
+ slide; it can't navigate to a slide it has never seen.
133
+ - **During lecture (`look_closer`):** targeted MiniCPM-V look at the actual slide *with the specific question in
134
+ context* — handles the long tail (specific visual questions, detail the cached summary glossed over).
135
+ - Why both: preprocess = global map + fast/cheap narration + navigation; real-time = accuracy on specific asks.
136
+
137
+ **UI: show the REAL slide, never a summary.**
138
+ - Main surface = the actual rendered slide **image / PDF page**, synced to the agent's `current_slide`.
139
+ - **Whiteboard = a separate adjacent canvas** (Mermaid/Excalidraw render) so drawings read as the prof's
140
+ annotations, not edits to the original slide. (Region-highlight overlay on the slide can come later.)
141
+ - Plus: live caption / transcript + mic / interject control.
142
+
143
+ The detailed deployment, deck-cache, teaching-beat, interruption, speech, and whiteboard decisions are in
144
+ [`ARCHITECTURE.md`](ARCHITECTURE.md).
145
+
146
+ ## Fine-tuning (after core pipeline — not before)
147
+ Chosen direction: **teaching-style SFT / guided learning** — tune the brain (Nemotron) to explain like a good
148
+ TA: analogies, checks for understanding, concise, no preamble. Bootstrap dataset = (slide reading → ideal
149
+ explanation) pairs generated with a strong model + a little hand-curation; **QLoRA via TRL / Unsloth on Modal**;
150
+ publish the adapter to HF → **Well-Tuned** badge (+ feeds Bonus Quest Champion). Put a before/after note in the
151
+ model card. Strictly a step-2 enhancement — only once the live pipeline works and there's a clear objective.
152
+
153
+ ## Backburner ideas (TTW — only if a 2nd submission is feasible)
154
+ - **Emotion-driven TRPG / choose-your-own-adventure:** free text → LLM updates structured NPC emotional
155
+ state (trust/fear/affection…) → state steers the story. Best Agent + Sharing is Caring (emotion-delta traces).
156
+ - **AI Garlic Phone:** message/drawing passed down a chain of model personas, mutating; needs a reveal payoff.
157
+ If a drawing is passed, pulls in vision = OpenBMB again.
158
+ - **Sketch-to-story adventure:** you draw your action, MiniCPM-V interprets the doodle, the world reacts.
159
+ Fuses vision + emotion mechanic. Strong demo, only-AI-can-do-this.
160
+ - **Model-vs-model battle → traces for training** (browser-brawl-style, different domain): most technically
161
+ impressive (Best Agent + Well-Tuned + Sharing is Caring) but research-shaped; the training loop can eat the week.
162
+
163
+ ## Key model references
164
+ - MiniCPM-V-4 (4.1B, multimodal): https://huggingface.co/openbmb/MiniCPM-V-4
165
+ - MiniCPM-V-4 GGUF (llama.cpp): https://huggingface.co/openbmb/MiniCPM-V-4-gguf
166
+ - Nemotron 3 Nano collection: https://huggingface.co/collections/nvidia/nvidia-nemotron-v3
167
+ - Nemotron 3 Nano 4B: https://huggingface.co/blog/nvidia/nemotron-3-nano-4b
168
+ - Nemotron 3 Nano 30B-A3B: https://huggingface.co/nvidia/NVIDIA-Nemotron-3-Nano-30B-A3B-BF16
README.md CHANGED
@@ -1,13 +1,153 @@
1
  ---
2
- title: Ai Prof
3
- emoji: 📈
4
- colorFrom: gray
5
- colorTo: gray
6
  sdk: gradio
7
- sdk_version: 6.18.0
8
- python_version: '3.13'
9
  app_file: app.py
10
- pinned: false
 
 
 
 
 
 
 
 
 
11
  ---
12
 
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: AI Prof
3
+ emoji: 🎓
4
+ colorFrom: indigo
5
+ colorTo: purple
6
  sdk: gradio
7
+ sdk_version: 5.50.0
 
8
  app_file: app.py
9
+ python_version: "3.12"
10
+ tags:
11
+ - track:backyard
12
+ - sponsor:openbmb
13
+ - sponsor:openai
14
+ - sponsor:nvidia
15
+ - sponsor:modal
16
+ - achievement:offbrand
17
+ - achievement:llama
18
+ - achievement:fieldnotes
19
  ---
20
 
21
+ # AI Prof
22
+
23
+ A real-time AI teaching assistant for the [Build Small Hackathon](https://huggingface.co/build-small-hackathon)
24
+ (**Backyard AI** track).
25
+
26
+ Upload your real lecture slides and AI Prof walks through them one by one — reading each slide *as an image*
27
+ (diagrams, equations, layout, not just scraped text) and explaining it like a TA would in class. Ask a
28
+ question at any time and it pauses, answers, and picks back up. Built for a classmate who has the slides but
29
+ misses the in-class explanation.
30
+
31
+ ## Demo
32
+
33
+ - **Live Space:** **TODO before submission — add the public Space URL here**
34
+ - **Demo video:** **TODO before submission — add the public video URL here**
35
+ - **Social post:** **TODO before submission — add the public post URL here**
36
+ - **Team:** [@pranavkarthik10](https://huggingface.co/pranavkarthik10)
37
+
38
+ ### Judge-friendly walkthrough
39
+
40
+ 1. Upload a lecture PDF.
41
+ 2. Watch AI Prof index every slide before teaching, giving the agent a map of the complete lecture.
42
+ 3. Start the lecture and see the professor explain slides aloud while adding notes or equations to the whiteboard.
43
+ 4. Interrupt with a typed or spoken question. The professor can answer, navigate to a supporting slide, and continue.
44
+
45
+ ## How it works
46
+ - **Eyes — [MiniCPM-V](https://huggingface.co/openbmb/MiniCPM-V-4) (~4.1B, GGUF / llama.cpp):** reads each slide image.
47
+ - **Brain — [Nemotron 3 Nano](https://huggingface.co/collections/nvidia/nvidia-nemotron-v3):** turns the slide
48
+ reading into a teaching plan, selects tools, explains concepts, and answers interjections.
49
+ - **Voice:** VoxCPM2 speaks the professor's response; distil-Whisper transcribes student questions.
50
+ - **Runtime:** Modal hosts the four OpenAI-compatible inference services and scales them down when idle.
51
+ - **Frontend:** a custom Gradio classroom UI hosted as a Hugging Face Space.
52
+
53
+ Every model stays under the hackathon's ≤32B-per-model cap.
54
+
55
+ ## Why it is agentic
56
+
57
+ AI Prof does more than summarize a PDF. Nemotron receives a compact index of the
58
+ entire deck, the current slide reading, recent conversation, and whiteboard state.
59
+ For every teaching beat it returns validated narration and actions. The orchestrator
60
+ can navigate slides, write a note, typeset an equation, clear the board, or continue
61
+ the lecture. Student questions cancel the active lecture turn and trigger a new plan
62
+ grounded in the full deck.
63
+
64
+ Current tools: `goto_slide`, `next_slide`, `prev_slide`, `write_note`,
65
+ `write_latex`, and `clear_whiteboard`.
66
+
67
+ ## Small-model stack
68
+
69
+ | Role | Model | Size / serving |
70
+ | --- | --- | --- |
71
+ | Slide vision | MiniCPM-V-4 | ~4.1B, Q4_K_M GGUF with llama.cpp |
72
+ | Professor agent | NVIDIA Nemotron 3 Nano | 30B total / 3B active, vLLM |
73
+ | Speech synthesis | VoxCPM2 | vLLM-Omni |
74
+ | Speech recognition | distil-Whisper large-v3 | faster-whisper |
75
+
76
+ ## Run it
77
+ ```bash
78
+ uv venv --python 3.12 && uv pip install -r requirements.txt
79
+ .venv/bin/python app.py # opens the Gradio UI
80
+ ```
81
+ Out of the box it runs in **mock mode** (no weights) so you can drive the full UX —
82
+ upload a PDF, watch it stream explanations, ask questions. To plug in the real models,
83
+ copy `.env.example` to `.env` and point `VISION_BASE_URL` / `BRAIN_BASE_URL` at your
84
+ OpenAI-compatible endpoints (llama.cpp `llama-server` for MiniCPM-V, vLLM/llama.cpp for Nemotron).
85
+
86
+ MiniCPM-V is included as a Modal service:
87
+
88
+ ```bash
89
+ modal run modal_app_vision.py::download_model
90
+ modal run modal_app_vision.py::warm
91
+ modal deploy modal_app_vision.py
92
+ ```
93
+
94
+ Set `VISION_BASE_URL` to the deployed `serve` URL. The service uses MiniCPM-V-4
95
+ Q4_K_M plus its F16 vision projector on one L4 and scales down after five idle
96
+ minutes.
97
+
98
+ ## Pre-indexed deck cache
99
+
100
+ AI Prof hashes each uploaded PDF and caches its rendered slide images, extracted
101
+ text, MiniCPM-V readings, and complete deck index. Re-uploading the same deck with
102
+ the same vision model and DPI skips indexing entirely.
103
+
104
+ The cache is local by default. To share a prepared demo deck across local testing
105
+ and the public Space, create a Hugging Face dataset repo and configure:
106
+
107
+ ```bash
108
+ HF_DECK_CACHE_REPO=pranavkarthik10/ai-prof-decks
109
+ HF_TOKEN=hf_...
110
+ HF_DECK_CACHE_WRITE=true
111
+ ```
112
+
113
+ Upload the demo PDF once while writes are enabled. The processed deck is stored
114
+ under `decks/<content-key>/` in the dataset. For the public Space, set
115
+ `HF_DECK_CACHE_REPO` but leave `HF_DECK_CACHE_WRITE=false`; judges can then load
116
+ that exact deck from the **Prepared lectures** picker without uploading the PDF
117
+ or granting the app write access.
118
+
119
+ Do not enable remote writes for arbitrary uploads to a public dataset. Processed
120
+ slides can contain the original lecture material. Use a private dataset for personal
121
+ testing or only prewarm material you have permission to publish.
122
+
123
+ ## Status
124
+ **Professor agent loop working:** upload a lecture PDF → render and index the complete
125
+ deck → Nemotron plans short teaching beats → the orchestrator executes validated slide
126
+ navigation and whiteboard tools → VoxCPM speaks the explanation. Student questions
127
+ cancel active narration, can send the professor to a more relevant slide, and can add
128
+ supporting notes or equations to the whiteboard.
129
+
130
+ Targeted vision via `look_closer` and richer Excalidraw diagrams remain follow-up work.
131
+
132
+ Layout: [`app.py`](app.py) (Gradio UI) · [`ai_prof/pdf_utils.py`](ai_prof/pdf_utils.py) (PDF→slides) ·
133
+ [`ai_prof/vision.py`](ai_prof/vision.py) (eyes) · [`ai_prof/brain.py`](ai_prof/brain.py) (brain) ·
134
+ [`ai_prof/config.py`](ai_prof/config.py) (endpoints / mock fallback).
135
+
136
+ The inference services run on Modal: MiniCPM-V through llama.cpp, Nemotron
137
+ through vLLM, VoxCPM2 through vLLM-Omni, and distil-Whisper through a small
138
+ OpenAI-compatible FastAPI server.
139
+ The Gradio frontend remains deployable as a Hugging Face Space.
140
+
141
+ Run the focused test suite with:
142
+
143
+ ```bash
144
+ .venv/bin/python -m unittest discover -s tests -v
145
+ ```
146
+
147
+ Next up (see [IDEATION.md](IDEATION.md)): prepared-deck storage on Hugging Face,
148
+ animated structured diagrams, stronger cancellation during browser audio playback,
149
+ and targeted `look_closer` vision.
150
+
151
+ The current target design is documented in [ARCHITECTURE.md](ARCHITECTURE.md), including complete-deck
152
+ indexing, the professor tool loop, a preprocessed demo lecture, synchronized speech and whiteboard actions,
153
+ and interruption/resume behavior.
ai_prof/__init__.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ """AI Prof — a real-time AI teaching assistant that reads your lecture slides and explains them."""
2
+
3
+ __version__ = "0.1.0"
ai_prof/agent.py ADDED
@@ -0,0 +1,281 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Professor agent planning.
2
+
3
+ The model plans one short teaching beat at a time. The Gradio orchestrator owns
4
+ all state mutation and validates every requested tool before executing it.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import re
11
+ from dataclasses import dataclass, field
12
+ from typing import Any, Literal
13
+
14
+ from openai import OpenAI
15
+
16
+ from .config import CONFIG
17
+
18
+ Trigger = Literal["continue", "question"]
19
+ ActionName = Literal[
20
+ "goto_slide",
21
+ "next_slide",
22
+ "prev_slide",
23
+ "write_note",
24
+ "write_latex",
25
+ "clear_whiteboard",
26
+ ]
27
+
28
+ _PLANNER_SYSTEM = """You are the planning brain of AI Prof, a live professor.
29
+ Plan exactly one short teaching beat at a time.
30
+
31
+ You receive a compact index of the entire lecture, the full reading of the current
32
+ slide, recent conversation, and the current whiteboard. Decide what the student
33
+ should hear next and whether a slide or whiteboard action would help.
34
+
35
+ Return JSON only:
36
+ {
37
+ "narration": "Natural spoken explanation, usually 1-3 short paragraphs.",
38
+ "actions": [
39
+ {"tool": "goto_slide", "args": {"index": 4}},
40
+ {"tool": "write_note", "args": {"title": "Kernel", "body": "A small matrix of weights."}},
41
+ {"tool": "write_latex", "args": {"expression": "g(x,y)=sum_i sum_j h(i,j)f(x-i,y-j)"}},
42
+ {"tool": "clear_whiteboard", "args": {}}
43
+ ],
44
+ "continue_lecture": true
45
+ }
46
+
47
+ Rules:
48
+ - Slide indices are 1-based and must exist in the supplied deck index.
49
+ - Use at most one navigation action and at most two whiteboard actions.
50
+ - Stay on the current slide unless another indexed slide is clearly more useful.
51
+ - For a student question, answer it directly. Navigate only when another slide
52
+ materially improves the answer.
53
+ - Use the whiteboard only when a compact note or equation improves understanding.
54
+ - Clear it when old material would be confusing.
55
+ - Do not call a tool just to demonstrate agency.
56
+ - Keep narration conversational and suitable for text-to-speech.
57
+ - Use no markdown headings and do not mention tool names."""
58
+
59
+
60
+ @dataclass(frozen=True)
61
+ class AgentAction:
62
+ tool: ActionName
63
+ args: dict[str, Any] = field(default_factory=dict)
64
+
65
+
66
+ @dataclass(frozen=True)
67
+ class TeachingBeat:
68
+ narration: str
69
+ actions: tuple[AgentAction, ...] = ()
70
+ continue_lecture: bool = True
71
+
72
+
73
+ def _client() -> OpenAI:
74
+ return OpenAI(
75
+ base_url=CONFIG.brain.openai_base_url,
76
+ api_key=CONFIG.brain.api_key,
77
+ )
78
+
79
+
80
+ def _extract_json(text: str) -> dict[str, Any]:
81
+ text = text.strip()
82
+ if text.startswith("```"):
83
+ text = re.sub(r"^```(?:json)?\s*", "", text)
84
+ text = re.sub(r"\s*```$", "", text)
85
+ try:
86
+ value = json.loads(text)
87
+ except json.JSONDecodeError:
88
+ start = text.find("{")
89
+ end = text.rfind("}")
90
+ if start < 0 or end <= start:
91
+ raise
92
+ value = json.loads(text[start : end + 1])
93
+ if not isinstance(value, dict):
94
+ raise ValueError("agent response must be a JSON object")
95
+ return value
96
+
97
+
98
+ def _repair_json(text: str) -> str | None:
99
+ """Ask the brain to repair malformed planner JSON without re-planning."""
100
+ if not CONFIG.brain.is_live:
101
+ return None
102
+ try:
103
+ response = _client().chat.completions.create(
104
+ model=CONFIG.brain.model,
105
+ messages=[
106
+ {
107
+ "role": "system",
108
+ "content": (
109
+ "Repair the supplied malformed JSON. Preserve its intended values, "
110
+ "return one valid JSON object only, and add no commentary."
111
+ ),
112
+ },
113
+ {"role": "user", "content": text},
114
+ ],
115
+ temperature=0,
116
+ max_tokens=700,
117
+ response_format={"type": "json_object"},
118
+ )
119
+ return response.choices[0].message.content
120
+ except Exception as exc:
121
+ print(f"[agent] JSON repair error: {exc}")
122
+ return None
123
+
124
+
125
+ def _validate_actions(raw_actions: Any, total_slides: int) -> tuple[AgentAction, ...]:
126
+ if not isinstance(raw_actions, list):
127
+ return ()
128
+
129
+ valid: list[AgentAction] = []
130
+ navigation_count = 0
131
+ whiteboard_count = 0
132
+ for raw in raw_actions:
133
+ if not isinstance(raw, dict):
134
+ continue
135
+ tool = raw.get("tool")
136
+ args = raw.get("args") if isinstance(raw.get("args"), dict) else {}
137
+
138
+ if tool in {"goto_slide", "next_slide", "prev_slide"}:
139
+ if navigation_count:
140
+ continue
141
+ if tool == "goto_slide":
142
+ index = args.get("index")
143
+ if not isinstance(index, int) or not 1 <= index <= total_slides:
144
+ continue
145
+ args = {"index": index}
146
+ else:
147
+ args = {}
148
+ navigation_count += 1
149
+ elif tool == "write_note":
150
+ if whiteboard_count >= 2:
151
+ continue
152
+ title = str(args.get("title", "")).strip()[:80]
153
+ body = str(args.get("body", "")).strip()[:500]
154
+ if not title and not body:
155
+ continue
156
+ args = {"title": title, "body": body}
157
+ whiteboard_count += 1
158
+ elif tool == "write_latex":
159
+ if whiteboard_count >= 2:
160
+ continue
161
+ expression = str(args.get("expression", "")).strip()[:500]
162
+ if not expression:
163
+ continue
164
+ args = {"expression": expression}
165
+ whiteboard_count += 1
166
+ elif tool == "clear_whiteboard":
167
+ if whiteboard_count >= 2:
168
+ continue
169
+ args = {}
170
+ whiteboard_count += 1
171
+ else:
172
+ continue
173
+ valid.append(AgentAction(tool=tool, args=args))
174
+ return tuple(valid)
175
+
176
+
177
+ def _fallback_beat(
178
+ *,
179
+ trigger: Trigger,
180
+ current_slide: int,
181
+ total_slides: int,
182
+ current_reading: str,
183
+ question: str | None,
184
+ ) -> TeachingBeat:
185
+ title = next(
186
+ (
187
+ line.split(":", 1)[1].strip()
188
+ for line in current_reading.splitlines()
189
+ if line.upper().startswith("TITLE:")
190
+ ),
191
+ f"slide {current_slide}",
192
+ )
193
+ if trigger == "question":
194
+ narration = (
195
+ f"Let’s connect that question to {title}. {question or ''} "
196
+ "The important idea is how the details on this slide support the concept."
197
+ )
198
+ return TeachingBeat(narration=narration.strip(), continue_lecture=False)
199
+ return TeachingBeat(
200
+ narration=f"Let’s work through {title} and focus on the main idea.",
201
+ continue_lecture=current_slide < total_slides,
202
+ )
203
+
204
+
205
+ def plan_teaching_beat(
206
+ *,
207
+ trigger: Trigger,
208
+ deck_index: str,
209
+ current_slide: int,
210
+ total_slides: int,
211
+ current_reading: str,
212
+ whiteboard_state: list[dict[str, str]] | None = None,
213
+ history: list[dict] | None = None,
214
+ question: str | None = None,
215
+ ) -> TeachingBeat:
216
+ """Return one validated teaching beat from the professor agent."""
217
+ if not CONFIG.brain.is_live:
218
+ return _fallback_beat(
219
+ trigger=trigger,
220
+ current_slide=current_slide,
221
+ total_slides=total_slides,
222
+ current_reading=current_reading,
223
+ question=question,
224
+ )
225
+
226
+ messages: list[dict[str, str]] = [
227
+ {"role": "system", "content": _PLANNER_SYSTEM}
228
+ ]
229
+ for turn in (history or [])[-6:]:
230
+ role = turn.get("role")
231
+ content = turn.get("content")
232
+ if role in {"user", "assistant"} and isinstance(content, str) and content:
233
+ messages.append({"role": role, "content": content})
234
+ messages.append(
235
+ {
236
+ "role": "user",
237
+ "content": (
238
+ f"Trigger: {trigger}\n"
239
+ f"Student question: {question or '(none)'}\n"
240
+ f"Current slide: {current_slide} of {total_slides}\n\n"
241
+ f"Complete deck index:\n{deck_index}\n\n"
242
+ f"Current slide reading:\n{current_reading}\n\n"
243
+ "Current whiteboard JSON:\n"
244
+ f"{json.dumps(whiteboard_state or [], ensure_ascii=True)}"
245
+ ),
246
+ }
247
+ )
248
+
249
+ try:
250
+ response = _client().chat.completions.create(
251
+ model=CONFIG.brain.model,
252
+ messages=messages,
253
+ temperature=0.25,
254
+ max_tokens=700,
255
+ response_format={"type": "json_object"},
256
+ )
257
+ raw = response.choices[0].message.content or ""
258
+ try:
259
+ data = _extract_json(raw)
260
+ except (json.JSONDecodeError, ValueError):
261
+ repaired = _repair_json(raw)
262
+ if not repaired:
263
+ raise
264
+ data = _extract_json(repaired)
265
+ narration = str(data.get("narration", "")).strip()
266
+ if not narration:
267
+ raise ValueError("agent returned empty narration")
268
+ return TeachingBeat(
269
+ narration=narration,
270
+ actions=_validate_actions(data.get("actions"), total_slides),
271
+ continue_lecture=bool(data.get("continue_lecture", trigger == "continue")),
272
+ )
273
+ except Exception as exc:
274
+ print(f"[agent] planning error: {exc}")
275
+ return _fallback_beat(
276
+ trigger=trigger,
277
+ current_slide=current_slide,
278
+ total_slides=total_slides,
279
+ current_reading=current_reading,
280
+ question=question,
281
+ )
ai_prof/brain.py ADDED
@@ -0,0 +1,127 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The brain: Nemotron turns a slide reading into a streamed, TA-style explanation
2
+ and answers interjected questions using the cached reading as context.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import time
8
+ from collections.abc import Iterator
9
+ from functools import lru_cache
10
+
11
+ from openai import OpenAI
12
+
13
+ from .config import CONFIG
14
+
15
+ _SYSTEM = """You are AI Prof, teaching one student through a lecture deck in real time.
16
+
17
+ Sound like a professor speaking naturally, not a study guide generating notes.
18
+ - Open conversationally and get to the point. On the first slide, a simple opening
19
+ like "Hey, welcome back. Today we're looking at image filtering" is ideal.
20
+ - Teach one or two ideas at a time in short spoken paragraphs. Connect them to the
21
+ previous slide when useful.
22
+ - Explain jargon in plain language and use a compact example or analogy only when it
23
+ genuinely makes the idea easier to understand.
24
+ - Guide the student toward the idea instead of exhaustively reciting every item.
25
+ - Treat course codes, lecture numbers, citations, and credits as background metadata
26
+ unless they matter to the concept being taught.
27
+ - End with a brief transition or a useful content question when one fits naturally.
28
+
29
+ Never use canned headings such as "Key ideas on this slide" or "Quick check". Do not
30
+ announce "Slide 1", narrate the slide layout, repeat all visible text, or ask whether
31
+ the student is ready to begin or move on. Do not say "this slide shows". Keep the
32
+ response concise enough to sound good aloud."""
33
+
34
+
35
+ @lru_cache(maxsize=1)
36
+ def _client() -> OpenAI:
37
+ return OpenAI(base_url=CONFIG.brain.openai_base_url, api_key=CONFIG.brain.api_key)
38
+
39
+
40
+ def _stream_chat(messages: list[dict]) -> Iterator[str]:
41
+ if not CONFIG.brain.is_live:
42
+ yield from _mock_stream(messages)
43
+ return
44
+ stream = _client().chat.completions.create(
45
+ model=CONFIG.brain.model,
46
+ messages=messages,
47
+ temperature=0.6,
48
+ max_tokens=700,
49
+ stream=True,
50
+ )
51
+ for chunk in stream:
52
+ delta = chunk.choices[0].delta
53
+ if delta.content:
54
+ yield delta.content
55
+
56
+
57
+ def _mock_stream(messages: list[dict]) -> Iterator[str]:
58
+ """Token-by-token mock so the streaming UX is visible without a brain endpoint."""
59
+ user = messages[-1]["content"]
60
+ text = (
61
+ "(mock brain — set BRAIN_BASE_URL for real Nemotron output) "
62
+ "Here's the gist of this slide: "
63
+ + " ".join(user.split())[:300]
64
+ + " ... The key idea to hold onto is how these pieces connect. "
65
+ "Does that part make sense so far?"
66
+ )
67
+ for tok in text.split(" "):
68
+ yield tok + " "
69
+ time.sleep(0.02)
70
+
71
+
72
+ def explain_slide(
73
+ reading: str,
74
+ *,
75
+ slide_no: int,
76
+ total: int,
77
+ outline: str,
78
+ history: list[dict] | None = None,
79
+ ) -> Iterator[str]:
80
+ """Stream a TA-style explanation of the current slide."""
81
+ messages = [{"role": "system", "content": _SYSTEM}]
82
+ for turn in (history or [])[-6:]:
83
+ if turn.get("content"):
84
+ messages.append(turn)
85
+ transition = (
86
+ "This is the opening slide. Welcome the student briefly and introduce today's topic."
87
+ if slide_no == 1
88
+ else "Continue naturally from the previous explanation without greeting the student again."
89
+ )
90
+ messages.append(
91
+ {
92
+ "role": "user",
93
+ "content": (
94
+ f"Lecture deck outline:\n{outline}\n\n"
95
+ f"Current position: slide {slide_no} of {total}.\n"
96
+ f"Grounded slide reading:\n{reading}\n\n"
97
+ f"{transition} Teach the important idea conversationally. Do not turn the "
98
+ "response into a structured slide summary."
99
+ ),
100
+ }
101
+ )
102
+ yield from _stream_chat(messages)
103
+
104
+
105
+ def answer_question(
106
+ question: str,
107
+ *,
108
+ reading: str,
109
+ slide_no: int,
110
+ history: list[dict] | None = None,
111
+ ) -> Iterator[str]:
112
+ """Stream an answer to an interjection, grounded in the current slide reading."""
113
+ messages = [{"role": "system", "content": _SYSTEM}]
114
+ for turn in (history or [])[-6:]:
115
+ messages.append(turn)
116
+ messages.append(
117
+ {
118
+ "role": "user",
119
+ "content": (
120
+ f"We're on slide {slide_no}. Its reading:\n{reading}\n\n"
121
+ f"The student asks: {question}\n\n"
122
+ "Answer directly and conversationally. Relate the answer to the current "
123
+ "topic, then smoothly hand control back to the lecture without a canned offer."
124
+ ),
125
+ }
126
+ )
127
+ yield from _stream_chat(messages)
ai_prof/config.py ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Runtime configuration for AI Prof.
2
+
3
+ Both models are reached over OpenAI-compatible HTTP, which is what the common
4
+ self-hosted runtimes expose:
5
+ - MiniCPM-V (eyes) -> llama.cpp ``llama-server`` with ``--mmproj``
6
+ - Nemotron (brain) -> vLLM / llama.cpp chat endpoint
7
+
8
+ If a base URL is left blank, that model falls back to MOCK mode so the whole
9
+ app still runs end-to-end with no weights — useful for UI work and demos.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import os
15
+ from dataclasses import dataclass
16
+
17
+ from dotenv import load_dotenv
18
+
19
+ load_dotenv()
20
+
21
+
22
+ def _clean(value: str | None) -> str | None:
23
+ """Treat blank / placeholder env values as unset."""
24
+ if value is None:
25
+ return None
26
+ value = value.strip()
27
+ return value or None
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class ModelConfig:
32
+ base_url: str | None
33
+ api_key: str
34
+ model: str
35
+
36
+ @property
37
+ def is_live(self) -> bool:
38
+ return self.base_url is not None
39
+
40
+ @property
41
+ def openai_base_url(self) -> str | None:
42
+ """Return an OpenAI-compatible base URL ending in /v1."""
43
+ if self.base_url is None:
44
+ return None
45
+ base = self.base_url.rstrip("/")
46
+ return base if base.endswith("/v1") else f"{base}/v1"
47
+
48
+
49
+ @dataclass(frozen=True)
50
+ class Config:
51
+ vision: ModelConfig
52
+ brain: ModelConfig
53
+ tts: ModelConfig
54
+ stt: ModelConfig
55
+ slide_dpi: int
56
+ tts_voice: str
57
+ deck_cache_dir: str
58
+ hf_deck_cache_repo: str | None
59
+ hf_token: str | None
60
+ hf_deck_cache_write: bool
61
+
62
+ @property
63
+ def fully_mocked(self) -> bool:
64
+ return not self.vision.is_live and not self.brain.is_live
65
+
66
+
67
+ def load_config() -> Config:
68
+ return Config(
69
+ vision=ModelConfig(
70
+ base_url=_clean(os.getenv("VISION_BASE_URL")),
71
+ api_key=_clean(os.getenv("VISION_API_KEY")) or "sk-no-key-required",
72
+ model=_clean(os.getenv("VISION_MODEL")) or "minicpm-v",
73
+ ),
74
+ brain=ModelConfig(
75
+ base_url=_clean(os.getenv("BRAIN_BASE_URL")),
76
+ api_key=_clean(os.getenv("BRAIN_API_KEY")) or "sk-no-key-required",
77
+ model=_clean(os.getenv("BRAIN_MODEL")) or "nemotron-3-nano",
78
+ ),
79
+ tts=ModelConfig(
80
+ base_url=_clean(os.getenv("TTS_BASE_URL")),
81
+ api_key=_clean(os.getenv("TTS_API_KEY")) or "sk-no-key-required",
82
+ model=_clean(os.getenv("TTS_MODEL")) or "tts-1",
83
+ ),
84
+ stt=ModelConfig(
85
+ base_url=_clean(os.getenv("STT_BASE_URL")),
86
+ api_key=_clean(os.getenv("STT_API_KEY")) or "sk-no-key-required",
87
+ model=_clean(os.getenv("STT_MODEL")) or "whisper-1",
88
+ ),
89
+ slide_dpi=int(_clean(os.getenv("SLIDE_DPI")) or "150"),
90
+ tts_voice=_clean(os.getenv("TTS_VOICE")) or "default",
91
+ deck_cache_dir=_clean(os.getenv("DECK_CACHE_DIR")) or ".cache/ai-prof/decks",
92
+ hf_deck_cache_repo=_clean(os.getenv("HF_DECK_CACHE_REPO")),
93
+ hf_token=_clean(os.getenv("HF_TOKEN")),
94
+ hf_deck_cache_write=(
95
+ (_clean(os.getenv("HF_DECK_CACHE_WRITE")) or "").lower()
96
+ in {"1", "true", "yes", "on"}
97
+ ),
98
+ )
99
+
100
+
101
+ CONFIG = load_config()
ai_prof/deck_cache.py ADDED
@@ -0,0 +1,219 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Content-addressed cache for rendered and vision-indexed lecture decks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import shutil
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+
11
+ from .pdf_utils import Deck, Slide
12
+
13
+ SCHEMA_VERSION = 1
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class CachedDeck:
18
+ deck: Deck
19
+ readings: dict[int, str]
20
+ deck_index: str
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class DeckSummary:
25
+ key: str
26
+ title: str
27
+ slide_count: int
28
+
29
+
30
+ def pdf_sha256(pdf_path: str) -> str:
31
+ digest = hashlib.sha256()
32
+ with open(pdf_path, "rb") as source:
33
+ for chunk in iter(lambda: source.read(1024 * 1024), b""):
34
+ digest.update(chunk)
35
+ return digest.hexdigest()
36
+
37
+
38
+ class DeckCache:
39
+ def __init__(
40
+ self,
41
+ *,
42
+ root: str,
43
+ repo_id: str | None = None,
44
+ token: str | None = None,
45
+ write_remote: bool = False,
46
+ ) -> None:
47
+ self.root = Path(root).expanduser()
48
+ self.repo_id = repo_id
49
+ self.token = token
50
+ self.write_remote = write_remote
51
+
52
+ def key(self, pdf_path: str, *, dpi: int, vision_model: str) -> str:
53
+ identity = f"{pdf_sha256(pdf_path)}:{dpi}:{vision_model}:v{SCHEMA_VERSION}"
54
+ return hashlib.sha256(identity.encode("utf-8")).hexdigest()
55
+
56
+ def load(self, key: str) -> CachedDeck | None:
57
+ cached = self._load_local(key)
58
+ if cached is not None or not self.repo_id:
59
+ return cached
60
+ if self._download_remote(key):
61
+ return self._load_local(key)
62
+ return None
63
+
64
+ def list_decks(self) -> list[DeckSummary]:
65
+ manifests: dict[str, Path] = {}
66
+ if self.root.is_dir():
67
+ for path in self.root.glob("*/manifest.json"):
68
+ manifests[path.parent.name] = path
69
+
70
+ if self.repo_id:
71
+ try:
72
+ from huggingface_hub import HfApi, hf_hub_download
73
+
74
+ for filename in HfApi(token=self.token).list_repo_files(
75
+ self.repo_id,
76
+ repo_type="dataset",
77
+ ):
78
+ parts = Path(filename).parts
79
+ if (
80
+ len(parts) == 3
81
+ and parts[0] == "decks"
82
+ and parts[2] == "manifest.json"
83
+ ):
84
+ key = parts[1]
85
+ if key not in manifests:
86
+ manifests[key] = Path(
87
+ hf_hub_download(
88
+ repo_id=self.repo_id,
89
+ repo_type="dataset",
90
+ filename=filename,
91
+ token=self.token,
92
+ )
93
+ )
94
+ except Exception as exc:
95
+ print(f"[deck-cache] Hub catalog skipped: {exc}")
96
+
97
+ summaries = []
98
+ for key, path in manifests.items():
99
+ try:
100
+ manifest = json.loads(path.read_text(encoding="utf-8"))
101
+ metadata = manifest.get("metadata") or {}
102
+ slides = manifest.get("slides") or []
103
+ title = str(metadata.get("title") or f"Prepared lecture {key[:8]}")
104
+ summaries.append(
105
+ DeckSummary(key=key, title=title, slide_count=len(slides))
106
+ )
107
+ except (OSError, TypeError, ValueError, json.JSONDecodeError):
108
+ continue
109
+ return sorted(summaries, key=lambda item: item.title.lower())
110
+
111
+ def save(
112
+ self,
113
+ key: str,
114
+ *,
115
+ deck: Deck,
116
+ readings: dict[int, str],
117
+ deck_index: str,
118
+ metadata: dict[str, object] | None = None,
119
+ ) -> None:
120
+ target = self.root / key
121
+ slides_dir = target / "slides"
122
+ if target.exists():
123
+ shutil.rmtree(target)
124
+ slides_dir.mkdir(parents=True, exist_ok=True)
125
+
126
+ slides = []
127
+ for slide in deck.slides:
128
+ filename = f"{slide.index:03d}.png"
129
+ shutil.copy2(slide.image_path, slides_dir / filename)
130
+ slides.append(
131
+ {
132
+ "index": slide.index,
133
+ "image": f"slides/{filename}",
134
+ "text": slide.text,
135
+ "reading": readings.get(slide.index, ""),
136
+ }
137
+ )
138
+
139
+ manifest = {
140
+ "schema_version": SCHEMA_VERSION,
141
+ "deck_index": deck_index,
142
+ "metadata": metadata or {},
143
+ "slides": slides,
144
+ }
145
+ (target / "manifest.json").write_text(
146
+ json.dumps(manifest, ensure_ascii=True, indent=2),
147
+ encoding="utf-8",
148
+ )
149
+
150
+ if self.repo_id and self.write_remote:
151
+ self._upload_remote(key, target)
152
+
153
+ def _load_local(self, key: str) -> CachedDeck | None:
154
+ target = self.root / key
155
+ manifest_path = target / "manifest.json"
156
+ if not manifest_path.is_file():
157
+ return None
158
+ try:
159
+ manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
160
+ if manifest.get("schema_version") != SCHEMA_VERSION:
161
+ return None
162
+ slides = []
163
+ readings = {}
164
+ for raw in manifest["slides"]:
165
+ image_path = target / raw["image"]
166
+ if not image_path.is_file():
167
+ return None
168
+ index = int(raw["index"])
169
+ slides.append(
170
+ Slide(
171
+ index=index,
172
+ image_path=str(image_path),
173
+ text=str(raw.get("text", "")),
174
+ )
175
+ )
176
+ readings[index] = str(raw.get("reading", ""))
177
+ return CachedDeck(
178
+ deck=Deck(slides=slides),
179
+ readings=readings,
180
+ deck_index=str(manifest.get("deck_index", "")),
181
+ )
182
+ except (KeyError, TypeError, ValueError, json.JSONDecodeError):
183
+ return None
184
+
185
+ def _download_remote(self, key: str) -> bool:
186
+ try:
187
+ from huggingface_hub import snapshot_download
188
+
189
+ snapshot_download(
190
+ repo_id=self.repo_id,
191
+ repo_type="dataset",
192
+ token=self.token,
193
+ allow_patterns=[f"decks/{key}/**"],
194
+ local_dir=self.root,
195
+ )
196
+ remote_dir = self.root / "decks" / key
197
+ local_dir = self.root / key
198
+ if remote_dir.is_dir():
199
+ if local_dir.exists():
200
+ shutil.rmtree(local_dir)
201
+ shutil.move(str(remote_dir), str(local_dir))
202
+ return (local_dir / "manifest.json").is_file()
203
+ except Exception as exc:
204
+ print(f"[deck-cache] Hub download skipped: {exc}")
205
+ return False
206
+
207
+ def _upload_remote(self, key: str, source: Path) -> None:
208
+ try:
209
+ from huggingface_hub import HfApi
210
+
211
+ HfApi(token=self.token).upload_folder(
212
+ repo_id=self.repo_id,
213
+ repo_type="dataset",
214
+ folder_path=str(source),
215
+ path_in_repo=f"decks/{key}",
216
+ commit_message=f"Cache processed AI Prof deck {key[:12]}",
217
+ )
218
+ except Exception as exc:
219
+ print(f"[deck-cache] Hub upload skipped: {exc}")
ai_prof/pdf_utils.py ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Turn an uploaded lecture PDF into per-slide images + text-layer ground truth.
2
+
3
+ The rendered image serves double duty: it's what the user sees AND what the
4
+ vision model reads. The extracted text layer is exact (vision misreads small
5
+ text), so it's kept alongside as a complement to the vision reading.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import tempfile
11
+ from dataclasses import dataclass, field
12
+ from pathlib import Path
13
+
14
+ import fitz # PyMuPDF
15
+
16
+
17
+ @dataclass
18
+ class Slide:
19
+ index: int # 0-based
20
+ image_path: str # rendered PNG on disk
21
+ text: str # PDF text-layer extraction (may be empty for image-only decks)
22
+
23
+
24
+ @dataclass
25
+ class Deck:
26
+ slides: list[Slide] = field(default_factory=list)
27
+
28
+ def __len__(self) -> int:
29
+ return len(self.slides)
30
+
31
+ def outline(self) -> str:
32
+ """A compact slide -> first-line index, so the brain can plan/navigate."""
33
+ lines = []
34
+ for s in self.slides:
35
+ head = next((ln.strip() for ln in s.text.splitlines() if ln.strip()), "")
36
+ head = head[:80] or "(no text layer)"
37
+ lines.append(f" {s.index + 1}. {head}")
38
+ return "\n".join(lines)
39
+
40
+
41
+ def render_pdf(pdf_path: str, dpi: int = 150) -> Deck:
42
+ """Render every page to a PNG and pull its text layer."""
43
+ out_dir = Path(tempfile.mkdtemp(prefix="ai_prof_slides_"))
44
+ zoom = dpi / 72.0
45
+ matrix = fitz.Matrix(zoom, zoom)
46
+
47
+ deck = Deck()
48
+ with fitz.open(pdf_path) as doc:
49
+ for i, page in enumerate(doc):
50
+ pix = page.get_pixmap(matrix=matrix)
51
+ img_path = out_dir / f"slide_{i:03d}.png"
52
+ pix.save(img_path)
53
+ deck.slides.append(
54
+ Slide(index=i, image_path=str(img_path), text=page.get_text().strip())
55
+ )
56
+ return deck
ai_prof/rtc.py ADDED
@@ -0,0 +1,347 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Real-time WebRTC voice handler for AI Prof.
2
+
3
+ Architecture
4
+ ------------
5
+ Student mic → fastrtc ReplyOnPause → stt_transcribe()
6
+ → brain.answer_question() (streamed text)
7
+ → tts_speak() (streamed PCM)
8
+ → student speaker
9
+
10
+ The handler is exposed as ``build_rtc_handler()`` which returns a
11
+ ``fastrtc.ReplyOnPause`` instance ready to be wired into a ``gr.WebRTC``
12
+ component:
13
+
14
+ from ai_prof.rtc import build_rtc_handler
15
+ handler = build_rtc_handler(state_getter=lambda: app_state)
16
+ webrtc = gr.WebRTC(rtc_configuration=handler.rtc_configuration)
17
+ webrtc.stream(handler, inputs=[webrtc, state], outputs=[webrtc])
18
+
19
+ If fastrtc is not installed the module still imports cleanly — every public
20
+ symbol is present but raises ``RuntimeError("fastrtc not installed")`` when
21
+ called, so app.py can do a safe conditional import.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import io
27
+ import base64
28
+ import threading
29
+ import wave
30
+ from typing import Callable, Generator
31
+
32
+ import numpy as np
33
+
34
+ # ---------------------------------------------------------------------------
35
+ # Optional fastrtc import — degrade gracefully when not installed.
36
+ # ---------------------------------------------------------------------------
37
+ try:
38
+ from fastrtc import ReplyOnPause, SileroVad # type: ignore
39
+ _FASTRTC_AVAILABLE = True
40
+ except ImportError: # pragma: no cover
41
+ _FASTRTC_AVAILABLE = False
42
+ ReplyOnPause = None # type: ignore
43
+ SileroVad = None # type: ignore
44
+
45
+ from ai_prof.brain import answer_question
46
+ from ai_prof.config import CONFIG, ModelConfig
47
+
48
+ # ---------------------------------------------------------------------------
49
+ # STT helpers
50
+ # ---------------------------------------------------------------------------
51
+
52
+ _MIN_SPEECH_RMS = 0.005
53
+
54
+
55
+ def _has_speech(audio_pcm: np.ndarray) -> bool:
56
+ if audio_pcm.size == 0:
57
+ return False
58
+ pcm = audio_pcm.astype(np.float32)
59
+ if np.issubdtype(audio_pcm.dtype, np.integer):
60
+ pcm /= float(np.iinfo(audio_pcm.dtype).max)
61
+ return float(np.sqrt(np.mean(np.square(pcm)))) >= _MIN_SPEECH_RMS
62
+
63
+
64
+ def _stt_transcribe_live(audio_pcm: np.ndarray, sample_rate: int = 16_000) -> str:
65
+ """Transcribe a NumPy PCM array via OpenAI-compatible /v1/audio/transcriptions.
66
+
67
+ Returns the transcript string, or an empty string on failure / mock mode.
68
+ """
69
+ stt_cfg: ModelConfig = CONFIG.stt
70
+
71
+ if not stt_cfg.is_live:
72
+ # Mock: return a placeholder so the rest of the pipeline can be tested.
73
+ return "[STT mock — set STT_BASE_URL to transcribe real audio]"
74
+ if not _has_speech(audio_pcm):
75
+ return ""
76
+
77
+ try:
78
+ import openai # already in requirements
79
+
80
+ # Encode PCM → in-memory WAV bytes
81
+ buf = io.BytesIO()
82
+ with wave.open(buf, "wb") as wf:
83
+ wf.setnchannels(1)
84
+ wf.setsampwidth(2) # 16-bit
85
+ wf.setframerate(sample_rate)
86
+ pcm16 = (audio_pcm * 32767).astype(np.int16)
87
+ wf.writeframes(pcm16.tobytes())
88
+ buf.seek(0)
89
+ buf.name = "audio.wav" # openai SDK reads .name for MIME sniffing
90
+
91
+ client = openai.OpenAI(
92
+ base_url=stt_cfg.openai_base_url,
93
+ api_key=stt_cfg.api_key,
94
+ )
95
+ transcript = client.audio.transcriptions.create(
96
+ model=stt_cfg.model,
97
+ file=buf,
98
+ response_format="text",
99
+ )
100
+ return str(transcript).strip()
101
+ except Exception as exc: # pragma: no cover
102
+ print(f"[rtc] STT error: {exc}")
103
+ return ""
104
+
105
+
106
+ # ---------------------------------------------------------------------------
107
+ # TTS helpers
108
+ # ---------------------------------------------------------------------------
109
+
110
+ # VoxCPM2 Voice Design: prepend a description in parentheses to steer the
111
+ # synthesised voice without requiring a reference audio clip.
112
+ _PROF_VOICE = "(Warm, articulate academic professor, clear and measured pace)"
113
+ _voice_anchors: dict[str, str] = {}
114
+ _voice_anchor_lock = threading.Lock()
115
+
116
+
117
+ def reset_tts_voice(voice_key: str | None) -> None:
118
+ """Forget the generated reference voice for one lecture session."""
119
+ if not voice_key:
120
+ return
121
+ with _voice_anchor_lock:
122
+ _voice_anchors.pop(voice_key, None)
123
+
124
+
125
+ def _speech_request(text: str, voice_key: str | None = None):
126
+ tts_cfg: ModelConfig = CONFIG.tts
127
+ client = __import__("openai").OpenAI(
128
+ base_url=tts_cfg.openai_base_url,
129
+ api_key=tts_cfg.api_key,
130
+ )
131
+ extra_body = {}
132
+ if voice_key:
133
+ with _voice_anchor_lock:
134
+ ref_audio = _voice_anchors.get(voice_key)
135
+ if ref_audio:
136
+ extra_body["ref_audio"] = ref_audio
137
+ request = {
138
+ "model": tts_cfg.model,
139
+ "voice": CONFIG.tts_voice,
140
+ "input": f"{_PROF_VOICE}{text}" if not extra_body else text,
141
+ "response_format": "wav",
142
+ }
143
+ if extra_body:
144
+ request["extra_body"] = extra_body
145
+ return client.audio.speech.create(
146
+ **request,
147
+ )
148
+
149
+
150
+ def _remember_voice(voice_key: str | None, wav_bytes: bytes) -> None:
151
+ """Use the first utterance as VoxCPM reference audio for later beats."""
152
+ if not voice_key:
153
+ return
154
+ try:
155
+ source = io.BytesIO(wav_bytes)
156
+ clipped = io.BytesIO()
157
+ with wave.open(source, "rb") as reader:
158
+ params = reader.getparams()
159
+ frames = reader.readframes(min(reader.getnframes(), reader.getframerate() * 8))
160
+ with wave.open(clipped, "wb") as writer:
161
+ writer.setparams(params)
162
+ writer.writeframes(frames)
163
+ wav_bytes = clipped.getvalue()
164
+ except wave.Error:
165
+ pass
166
+ with _voice_anchor_lock:
167
+ if voice_key in _voice_anchors:
168
+ return
169
+ _voice_anchors[voice_key] = (
170
+ "data:audio/wav;base64," + base64.b64encode(wav_bytes).decode("ascii")
171
+ )
172
+
173
+
174
+ def _tts_speak_stream(text: str, sample_rate: int = 48_000) -> Generator[np.ndarray, None, None]:
175
+ """Yield a PCM chunk (float32, mono) for *text* via /v1/audio/speech.
176
+
177
+ VoxCPM2 returns a 48 kHz WAV file; we decode it and yield one chunk.
178
+ Falls back to a silent chunk when TTS_BASE_URL is unset.
179
+ """
180
+ tts_cfg: ModelConfig = CONFIG.tts
181
+
182
+ if not tts_cfg.is_live:
183
+ yield np.zeros(sample_rate // 2, dtype=np.float32)
184
+ return
185
+
186
+ try:
187
+ response = _speech_request(text)
188
+ buf = io.BytesIO(response.content)
189
+ with wave.open(buf, "rb") as wf:
190
+ raw = wf.readframes(wf.getnframes())
191
+ pcm16 = np.frombuffer(raw, dtype=np.int16)
192
+ yield pcm16.astype(np.float32) / 32768.0
193
+ except Exception as exc: # pragma: no cover
194
+ print(f"[rtc] TTS error: {exc}")
195
+ yield np.zeros(sample_rate // 2, dtype=np.float32)
196
+
197
+
198
+ def tts_speak_full(
199
+ text: str,
200
+ *,
201
+ voice_key: str | None = None,
202
+ ) -> tuple[int, np.ndarray] | None:
203
+ """Call TTS and return (sample_rate, pcm_float32) for gr.Audio, or None in mock mode.
204
+
205
+ This is used by the agent loop (on_teach_deck / on_explain) to speak each
206
+ slide's explanation. No fastrtc needed — pure HTTP to the Modal endpoint.
207
+ Returns None when TTS_BASE_URL is unset so callers can skip gracefully.
208
+ """
209
+ tts_cfg: ModelConfig = CONFIG.tts
210
+ if not tts_cfg.is_live:
211
+ return None
212
+ try:
213
+ response = _speech_request(text, voice_key)
214
+ wav_bytes = response.content
215
+ _remember_voice(voice_key, wav_bytes)
216
+ buf = io.BytesIO(wav_bytes)
217
+ with wave.open(buf, "rb") as wf:
218
+ sr = wf.getframerate()
219
+ raw = wf.readframes(wf.getnframes())
220
+ pcm = np.frombuffer(raw, dtype=np.int16).astype(np.float32) / 32768.0
221
+ return sr, pcm
222
+ except Exception as exc:
223
+ print(f"[rtc] TTS error: {exc}")
224
+ return None
225
+
226
+
227
+ # ---------------------------------------------------------------------------
228
+ # Public: build the ReplyOnPause handler
229
+ # ---------------------------------------------------------------------------
230
+
231
+ def build_rtc_handler(
232
+ state_getter: Callable[[], dict],
233
+ sample_rate: int = 16_000,
234
+ ) -> "ReplyOnPause":
235
+ """Return a ``fastrtc.ReplyOnPause`` handler wired to STT → brain → TTS.
236
+
237
+ Parameters
238
+ ----------
239
+ state_getter:
240
+ Zero-argument callable that returns the current Gradio session state
241
+ dict (same dict used by app.py — must contain ``deck``, ``index``,
242
+ ``readings`` keys).
243
+ sample_rate:
244
+ Sample rate (Hz) of the audio chunks delivered by fastrtc (default
245
+ 16 000 Hz, which matches Silero VAD's expectation).
246
+
247
+ Usage in app.py
248
+ ---------------
249
+ ::
250
+
251
+ import gradio as gr
252
+ try:
253
+ from ai_prof.rtc import build_rtc_handler
254
+ _rtc_available = True
255
+ except RuntimeError:
256
+ _rtc_available = False
257
+
258
+ if _rtc_available:
259
+ _rtc_state_ref = {} # populated in on_upload / on_explain
260
+
261
+ handler = build_rtc_handler(state_getter=lambda: _rtc_state_ref["state"])
262
+
263
+ with gr.Column(scale=2):
264
+ # ... existing chat column ...
265
+ webrtc = gr.WebRTC(
266
+ label="Voice interjection (hold to speak)",
267
+ rtc_configuration=handler.rtc_configuration,
268
+ mode="send-receive",
269
+ )
270
+ webrtc.stream(
271
+ handler,
272
+ inputs=[webrtc, state],
273
+ outputs=[webrtc],
274
+ time_limit=120,
275
+ )
276
+ else:
277
+ # TODO: wire fastrtc when installed — see ai_prof/rtc.py
278
+ pass
279
+ """
280
+ if not _FASTRTC_AVAILABLE:
281
+ raise RuntimeError(
282
+ "fastrtc is not installed. "
283
+ "Install it with: pip install 'fastrtc[vad]'"
284
+ )
285
+
286
+ def _voice_reply(
287
+ audio: tuple[int, np.ndarray],
288
+ gradio_state: dict | None = None,
289
+ ) -> Generator[tuple[int, np.ndarray], None, None]:
290
+ """Called by ReplyOnPause once the student stops speaking.
291
+
292
+ Receives the buffered audio segment since the last pause, runs the
293
+ full STT → brain → TTS pipeline, and yields PCM chunks back to the
294
+ student's speaker.
295
+
296
+ Parameters
297
+ ----------
298
+ audio:
299
+ (sample_rate, pcm_array) tuple delivered by fastrtc.
300
+ gradio_state:
301
+ The Gradio session state dict (passed as a gr.State input).
302
+ """
303
+ in_sr, pcm = audio
304
+ pcm = pcm.astype(np.float32)
305
+ if pcm.ndim > 1:
306
+ pcm = pcm.mean(axis=1) # stereo → mono
307
+ # Resample if fastrtc delivers a different rate than we asked for.
308
+ if in_sr != sample_rate and in_sr > 0:
309
+ factor = sample_rate / in_sr
310
+ new_len = int(len(pcm) * factor)
311
+ pcm = np.interp(
312
+ np.linspace(0, len(pcm) - 1, new_len),
313
+ np.arange(len(pcm)),
314
+ pcm,
315
+ ).astype(np.float32)
316
+
317
+ # 1. Speech-to-text
318
+ question = _stt_transcribe_live(pcm, sample_rate=sample_rate)
319
+ if not question:
320
+ return
321
+
322
+ # 2. Brain: stream the text answer
323
+ state = gradio_state or state_getter()
324
+ deck = state.get("deck")
325
+ if deck is None:
326
+ # No deck loaded yet — skip answering.
327
+ return
328
+ idx = state.get("index", 0)
329
+ reading = state.get("readings", {}).get(idx, "")
330
+
331
+ answer_chunks: list[str] = []
332
+ for tok in answer_question(question, reading=reading, slide_no=idx + 1, history=[]):
333
+ answer_chunks.append(tok)
334
+ answer_text = "".join(answer_chunks)
335
+
336
+ if not answer_text.strip():
337
+ return
338
+
339
+ # 3. TTS: stream PCM back to the caller
340
+ tts_sr = 48_000 # VoxCPM2 outputs 48 kHz WAV
341
+ for pcm_chunk in _tts_speak_stream(answer_text, sample_rate=tts_sr):
342
+ yield tts_sr, pcm_chunk
343
+
344
+ return ReplyOnPause(
345
+ _voice_reply,
346
+ vad_parameters={"threshold": 0.5},
347
+ )
ai_prof/vision.py ADDED
@@ -0,0 +1,130 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The eyes: MiniCPM-V reads a slide image into a structured 'slide reading'.
2
+
3
+ Computed once per slide and cached — interjections reuse the reading and never
4
+ re-run the (heavy) vision pass.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import base64
10
+ import mimetypes
11
+ from functools import lru_cache
12
+
13
+ from openai import OpenAI
14
+
15
+ from .config import CONFIG
16
+
17
+ _READING_PROMPT = (
18
+ "You are reading a single lecture slide shown as an image. "
19
+ "Respond in English only. "
20
+ "Produce a compact, structured reading another model will use to explain the slide aloud. "
21
+ "Use exactly these plain-text sections, omitting any that don't apply:\n"
22
+ "TITLE: <slide title>\n"
23
+ "BULLETS: <key points, one per line>\n"
24
+ "EQUATIONS: <any formulas, written out>\n"
25
+ "DIAGRAM: <what any figure/diagram/chart shows>\n"
26
+ "CONCEPTS: <the core concepts this slide is about>\n"
27
+ "Be faithful to what's actually on the slide. Do not invent content. "
28
+ "Do not use XML or JSON — plain text only."
29
+ )
30
+
31
+ _RETRY_PROMPT = (
32
+ "Read this lecture slide image and respond in English only. "
33
+ "Use plain text with these sections (omit any that don't apply):\n"
34
+ "TITLE: ...\nBULLETS: ...\nEQUATIONS: ...\nDIAGRAM: ...\nCONCEPTS: ...\n"
35
+ "No XML, no JSON, no repetition."
36
+ )
37
+
38
+
39
+ @lru_cache(maxsize=1)
40
+ def _client() -> OpenAI:
41
+ return OpenAI(base_url=CONFIG.vision.openai_base_url, api_key=CONFIG.vision.api_key)
42
+
43
+
44
+ def _data_uri(image_path: str) -> str:
45
+ mime = mimetypes.guess_type(image_path)[0] or "image/png"
46
+ with open(image_path, "rb") as f:
47
+ b64 = base64.b64encode(f.read()).decode("ascii")
48
+ return f"data:{mime};base64,{b64}"
49
+
50
+
51
+ def _mock_reading(text: str, question: str | None) -> str:
52
+ head = next((ln.strip() for ln in text.splitlines() if ln.strip()), "Untitled slide")
53
+ body = " ".join(text.split())[:400]
54
+ if question:
55
+ return f"[mock vision] Looking closely for: {question}\nVisible text: {body or '(none)'}"
56
+ return (
57
+ f"TITLE: {head}\n"
58
+ f"BULLETS:\n{text or '(no extractable text)'}\n"
59
+ "CONCEPTS: (mock reading — set VISION_BASE_URL for a real MiniCPM-V pass)"
60
+ )
61
+
62
+
63
+ def _is_degenerate(text: str) -> bool:
64
+ """Detect infinite-loop or language-drift outputs."""
65
+ if not text:
66
+ return True
67
+ lines = [ln for ln in text.splitlines() if ln.strip()]
68
+ if not lines:
69
+ return True
70
+ # Flag if >40% of non-empty lines are duplicates (repetition loop)
71
+ if len(set(lines)) / len(lines) < 0.6:
72
+ return True
73
+ # Flag if majority of characters are non-ASCII (language drift)
74
+ non_ascii = sum(1 for c in text if ord(c) > 127)
75
+ if non_ascii / max(len(text), 1) > 0.3:
76
+ return True
77
+ return False
78
+
79
+
80
+ def _call_vision(instruction: str, image_path: str) -> str:
81
+ resp = _client().chat.completions.create(
82
+ model=CONFIG.vision.model,
83
+ messages=[
84
+ {
85
+ "role": "user",
86
+ "content": [
87
+ {"type": "text", "text": instruction},
88
+ {"type": "image_url", "image_url": {"url": _data_uri(image_path)}},
89
+ ],
90
+ }
91
+ ],
92
+ temperature=0.1,
93
+ max_tokens=512,
94
+ )
95
+ return (resp.choices[0].message.content or "").strip()
96
+
97
+
98
+ def read_slide(
99
+ image_path: str,
100
+ text_layer: str = "",
101
+ question: str | None = None,
102
+ prior_reading: str | None = None,
103
+ ) -> str:
104
+ """Return a structured reading of the slide image.
105
+
106
+ ``question`` switches to a targeted 'look closer' read for a specific ask.
107
+ ``prior_reading`` is the reading of the previous slide; passed when slides
108
+ are part of an animation sequence so the model has context on what changed.
109
+ Falls back to a mock reading derived from the PDF text layer if no endpoint
110
+ is configured.
111
+ """
112
+ if not CONFIG.vision.is_live:
113
+ return _mock_reading(text_layer, question)
114
+
115
+ if question:
116
+ instruction = f"Look closely at this slide and answer in English: {question}"
117
+ else:
118
+ instruction = _READING_PROMPT
119
+ if prior_reading:
120
+ instruction = (
121
+ f"The previous slide reading was:\n{prior_reading}\n\n"
122
+ + instruction
123
+ )
124
+
125
+ result = _call_vision(instruction, image_path)
126
+ if _is_degenerate(result):
127
+ result = _call_vision(_RETRY_PROMPT, image_path)
128
+ if _is_degenerate(result):
129
+ return _mock_reading(text_layer, question)
130
+ return result
app.py ADDED
@@ -0,0 +1,1051 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """AI Prof — Gradio app.
2
+
3
+ Vertical slice #1 + text interjection: upload a lecture PDF, AI Prof reads each
4
+ slide as an image (MiniCPM-V) and explains it like a TA (Nemotron, streamed).
5
+ Ask a question at any time; it answers using the cached slide reading, then you
6
+ continue the walkthrough.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import io
12
+ import html
13
+ from pathlib import Path
14
+ import threading
15
+ import time
16
+ import uuid
17
+ import wave
18
+
19
+ import numpy as np
20
+ import gradio as gr
21
+ from openai import OpenAI
22
+
23
+ from ai_prof.agent import AgentAction, plan_teaching_beat
24
+ from ai_prof.brain import explain_slide
25
+ from ai_prof.config import CONFIG
26
+ from ai_prof.deck_cache import DeckCache
27
+ from ai_prof.pdf_utils import Deck, render_pdf
28
+ from ai_prof.vision import read_slide
29
+
30
+ # ---------------------------------------------------------------------------
31
+ # Optional WebRTC real-time voice layer (requires: pip install "fastrtc[vad]")
32
+ # When fastrtc is installed, replace the push-to-talk gr.Audio mic below with
33
+ # a gr.WebRTC component and wire it through build_rtc_handler — see
34
+ # ai_prof/rtc.py for full wiring instructions.
35
+ # ---------------------------------------------------------------------------
36
+ try:
37
+ from ai_prof.rtc import (
38
+ _has_speech,
39
+ build_rtc_handler as _build_rtc_handler,
40
+ reset_tts_voice,
41
+ tts_speak_full,
42
+ )
43
+ _RTC_AVAILABLE = True
44
+ except Exception:
45
+ try:
46
+ from ai_prof.rtc import _has_speech, reset_tts_voice, tts_speak_full
47
+ except Exception:
48
+ tts_speak_full = lambda _text, **_kwargs: None # type: ignore
49
+ reset_tts_voice = lambda _key: None # type: ignore
50
+ _has_speech = lambda _audio: True # type: ignore
51
+ _RTC_AVAILABLE = False
52
+
53
+ # module-level pre-read cache: {session_id: {slide_idx: reading_str}}
54
+ _preread: dict[str, dict[int, str]] = {}
55
+ _preread_lock = threading.Lock()
56
+ _deck_cache = DeckCache(
57
+ root=CONFIG.deck_cache_dir,
58
+ repo_id=CONFIG.hf_deck_cache_repo,
59
+ token=CONFIG.hf_token,
60
+ write_remote=CONFIG.hf_deck_cache_write,
61
+ )
62
+
63
+
64
+ def _prepared_deck_choices() -> list[tuple[str, str]]:
65
+ return [
66
+ (f"{item.title} ({item.slide_count} slides)", item.key)
67
+ for item in _deck_cache.list_decks()
68
+ ]
69
+
70
+
71
+ def _new_state() -> dict:
72
+ return {
73
+ "deck": None,
74
+ "index": 0,
75
+ "readings": {},
76
+ "deck_index": "",
77
+ "whiteboard": [],
78
+ "session_id": str(uuid.uuid4()),
79
+ }
80
+
81
+
82
+ def _ensure_reading(state: dict, idx: int) -> str:
83
+ """Read + cache the slide once; reused by both explanation and Q&A."""
84
+ sid = state["session_id"]
85
+ with _preread_lock:
86
+ if sid in _preread and idx in _preread[sid]:
87
+ result = _preread[sid][idx]
88
+ state["readings"][idx] = result
89
+ return result
90
+ cache = state["readings"]
91
+ if idx not in cache:
92
+ slide = state["deck"].slides[idx]
93
+ prior = cache.get(idx - 1) if idx > 0 else None
94
+ cache[idx] = read_slide(slide.image_path, text_layer=slide.text, prior_reading=prior)
95
+ return cache[idx]
96
+
97
+
98
+ def _build_deck_index(state: dict) -> str:
99
+ deck: Deck | None = state["deck"]
100
+ if not deck:
101
+ return ""
102
+ lines = []
103
+ for idx, slide in enumerate(deck.slides):
104
+ reading = state["readings"].get(idx, "")
105
+ title = _reading_field(reading, "TITLE")
106
+ if not title:
107
+ title = next(
108
+ (line.strip() for line in slide.text.splitlines() if line.strip()),
109
+ f"Slide {idx + 1}",
110
+ )
111
+ concepts = _reading_field(reading, "CONCEPTS")
112
+ summary = concepts or " ".join(slide.text.split())[:220] or "(visual slide)"
113
+ lines.append(f"{idx + 1}. {title} — {summary}")
114
+ return "\n".join(lines)
115
+
116
+
117
+ def _slide_view(state: dict):
118
+ deck: Deck | None = state["deck"]
119
+ if not deck:
120
+ return None, "No deck loaded."
121
+ idx = state["index"]
122
+ slide = deck.slides[idx]
123
+ return slide.image_path, f"Slide {idx + 1} / {len(deck)}"
124
+
125
+
126
+ def _index_choices(state: dict) -> list[tuple[str, int]]:
127
+ deck: Deck | None = state["deck"]
128
+ if not deck:
129
+ return []
130
+ choices = []
131
+ for idx, slide in enumerate(deck.slides):
132
+ reading = state["readings"].get(idx, "")
133
+ title = _reading_field(reading, "TITLE")
134
+ if not title:
135
+ title = next(
136
+ (line.strip() for line in slide.text.splitlines() if line.strip()),
137
+ f"Slide {idx + 1}",
138
+ )
139
+ choices.append((f"{idx + 1}. {title[:90]}", idx))
140
+ return choices
141
+
142
+
143
+ def _reading_field(reading: str, name: str) -> str:
144
+ prefix = f"{name}:"
145
+ for line in reading.splitlines():
146
+ if line.upper().startswith(prefix):
147
+ return line[len(prefix):].strip()
148
+ return ""
149
+
150
+
151
+ def _whiteboard_view(state: dict, reading: str | None = None) -> str:
152
+ deck: Deck | None = state["deck"]
153
+ if not deck:
154
+ return (
155
+ '<div class="whiteboard-empty">'
156
+ "<strong>Professor's whiteboard</strong>"
157
+ "<span>Key ideas and worked notes will appear here.</span>"
158
+ "</div>"
159
+ )
160
+ board = state.get("whiteboard", [])
161
+ if board:
162
+ items = []
163
+ for item in board:
164
+ if item.get("type") == "latex":
165
+ expression = item.get("expression", "").replace("$$", "")
166
+ items.append(
167
+ '<div class="whiteboard-equation">'
168
+ f"\n\n$$\n{expression}\n$$\n\n"
169
+ "</div>"
170
+ )
171
+ else:
172
+ title = html.escape(item.get("title", ""))
173
+ body = html.escape(item.get("body", ""))
174
+ items.append(
175
+ '<div class="whiteboard-note">'
176
+ f"<strong>{title}</strong><p>{body}</p>"
177
+ "</div>"
178
+ )
179
+ return (
180
+ '<div class="whiteboard-sheet">'
181
+ '<div class="whiteboard-kicker">Professor notes</div>'
182
+ + "".join(items)
183
+ + "</div>"
184
+ )
185
+
186
+ idx = state["index"]
187
+ reading = reading or state["readings"].get(idx, "")
188
+ title = _reading_field(reading, "TITLE") or f"Slide {idx + 1}"
189
+ concepts = _reading_field(reading, "CONCEPTS")
190
+ if not concepts:
191
+ concepts = "Listening for the central idea..."
192
+ return (
193
+ '<div class="whiteboard-sheet">'
194
+ f'<div class="whiteboard-kicker">Working notes · {idx + 1}/{len(deck)}</div>'
195
+ f"<h3>{html.escape(title)}</h3>"
196
+ f"<p>{html.escape(concepts)}</p>"
197
+ '<div class="whiteboard-line"></div>'
198
+ '<span class="whiteboard-hint">The professor can draw here as the lecture develops.</span>'
199
+ "</div>"
200
+ )
201
+
202
+
203
+ def _execute_actions(
204
+ state: dict,
205
+ actions: tuple[AgentAction, ...],
206
+ *,
207
+ allow_navigation: bool = True,
208
+ ) -> bool:
209
+ """Apply validated agent actions. Return whether navigation occurred."""
210
+ deck: Deck | None = state["deck"]
211
+ if not deck:
212
+ return False
213
+ navigated = False
214
+ for action in actions:
215
+ if action.tool in {"goto_slide", "next_slide", "prev_slide"} and not allow_navigation:
216
+ continue
217
+ if action.tool == "goto_slide":
218
+ state["index"] = action.args["index"] - 1
219
+ navigated = True
220
+ elif action.tool == "next_slide":
221
+ state["index"] = min(len(deck) - 1, state["index"] + 1)
222
+ navigated = True
223
+ elif action.tool == "prev_slide":
224
+ state["index"] = max(0, state["index"] - 1)
225
+ navigated = True
226
+ elif action.tool == "clear_whiteboard":
227
+ state["whiteboard"] = []
228
+ elif action.tool == "write_note":
229
+ state["whiteboard"].append(
230
+ {
231
+ "type": "note",
232
+ "title": action.args.get("title", ""),
233
+ "body": action.args.get("body", ""),
234
+ }
235
+ )
236
+ elif action.tool == "write_latex":
237
+ state["whiteboard"].append(
238
+ {
239
+ "type": "latex",
240
+ "expression": action.args.get("expression", ""),
241
+ }
242
+ )
243
+ state["whiteboard"] = state["whiteboard"][-4:]
244
+ return navigated
245
+
246
+
247
+ # ----------------------------------------------------------------------------- handlers
248
+
249
+
250
+ def on_upload(pdf_file, state):
251
+ old_sid = state.get("session_id")
252
+ state = _new_state()
253
+ sid = state["session_id"]
254
+
255
+ if old_sid:
256
+ reset_tts_voice(old_sid)
257
+ with _preread_lock:
258
+ _preread.pop(old_sid, None)
259
+
260
+ if pdf_file is None:
261
+ img, caption = _slide_view(state)
262
+ yield state, img, caption, [], _whiteboard_view(state), _STATUS_IDLE, gr.update(choices=[], value=None)
263
+ return
264
+
265
+ cache_key = _deck_cache.key(
266
+ pdf_file,
267
+ dpi=CONFIG.slide_dpi,
268
+ vision_model=CONFIG.vision.model,
269
+ )
270
+ cached = _deck_cache.load(cache_key)
271
+ if cached is not None:
272
+ state["deck"] = cached.deck
273
+ state["readings"] = cached.readings
274
+ state["deck_index"] = cached.deck_index or _build_deck_index(state)
275
+ with _preread_lock:
276
+ _preread[sid] = dict(cached.readings)
277
+ img, caption = _slide_view(state)
278
+ yield (
279
+ state,
280
+ img,
281
+ caption,
282
+ [],
283
+ _whiteboard_view(state, state["readings"].get(0, "")),
284
+ _STATUS_CACHE_HIT,
285
+ gr.update(choices=_index_choices(state), value=0),
286
+ )
287
+ return
288
+
289
+ deck = render_pdf(pdf_file, dpi=CONFIG.slide_dpi)
290
+ state["deck"] = deck
291
+ img, caption = _slide_view(state)
292
+ yield (
293
+ state,
294
+ img,
295
+ caption,
296
+ [],
297
+ _whiteboard_view(state),
298
+ _status_indexing(0, len(deck)),
299
+ gr.update(choices=_index_choices(state), value=0),
300
+ )
301
+
302
+ with _preread_lock:
303
+ _preread[sid] = {}
304
+
305
+ for idx, slide in enumerate(deck.slides):
306
+ prior = state["readings"].get(idx - 1) if idx > 0 else None
307
+ reading = read_slide(slide.image_path, text_layer=slide.text, prior_reading=prior)
308
+ state["readings"][idx] = reading
309
+ with _preread_lock:
310
+ _preread[sid][idx] = reading
311
+ yield (
312
+ state,
313
+ img,
314
+ caption,
315
+ [],
316
+ _whiteboard_view(state, reading if idx == 0 else None),
317
+ _status_indexing(idx + 1, len(deck)),
318
+ gr.update(choices=_index_choices(state), value=0),
319
+ )
320
+ state["deck_index"] = _build_deck_index(state)
321
+ _deck_cache.save(
322
+ cache_key,
323
+ deck=deck,
324
+ readings=state["readings"],
325
+ deck_index=state["deck_index"],
326
+ metadata={
327
+ "title": Path(pdf_file).stem,
328
+ "dpi": CONFIG.slide_dpi,
329
+ "vision_model": CONFIG.vision.model,
330
+ },
331
+ )
332
+
333
+ img, caption = _slide_view(state)
334
+ yield (
335
+ state,
336
+ img,
337
+ caption,
338
+ [],
339
+ _whiteboard_view(state, state["readings"][0]),
340
+ _STATUS_IDLE,
341
+ gr.update(choices=_index_choices(state), value=0),
342
+ )
343
+
344
+
345
+ def on_load_prepared(cache_key, state):
346
+ old_sid = state.get("session_id")
347
+ state = _new_state()
348
+ sid = state["session_id"]
349
+ if old_sid:
350
+ reset_tts_voice(old_sid)
351
+ with _preread_lock:
352
+ _preread.pop(old_sid, None)
353
+
354
+ cached = _deck_cache.load(str(cache_key or ""))
355
+ if cached is None:
356
+ gr.Warning("That prepared lecture could not be loaded.")
357
+ yield (
358
+ state,
359
+ *_slide_view(state),
360
+ [],
361
+ _whiteboard_view(state),
362
+ _STATUS_IDLE,
363
+ gr.update(choices=[], value=None),
364
+ )
365
+ return
366
+
367
+ state["deck"] = cached.deck
368
+ state["readings"] = cached.readings
369
+ state["deck_index"] = cached.deck_index or _build_deck_index(state)
370
+ with _preread_lock:
371
+ _preread[sid] = dict(cached.readings)
372
+ yield (
373
+ state,
374
+ *_slide_view(state),
375
+ [],
376
+ _whiteboard_view(state, state["readings"].get(0, "")),
377
+ _STATUS_CACHE_HIT,
378
+ gr.update(choices=_index_choices(state), value=0),
379
+ )
380
+
381
+
382
+ _STATUS_READING = (
383
+ '<div style="background:#f0f9ff;border-left:3px solid #3b82f6;padding:6px 12px;'
384
+ 'font-size:0.85rem;color:#1e40af;border-radius:0 4px 4px 0">📖 Reading slide…</div>'
385
+ )
386
+ _STATUS_EXPLAINING = (
387
+ '<div style="background:#f0fdf4;border-left:3px solid #22c55e;padding:6px 12px;'
388
+ 'font-size:0.85rem;color:#166534;border-radius:0 4px 4px 0">💬 Explaining…</div>'
389
+ )
390
+ _STATUS_SPEAKING = (
391
+ '<div style="background:#fdf4ff;border-left:3px solid #a855f7;padding:6px 12px;'
392
+ 'font-size:0.85rem;color:#6b21a8;border-radius:0 4px 4px 0">🔊 Professor speaking…</div>'
393
+ )
394
+ _STATUS_THINKING = (
395
+ '<div style="background:#fff7ed;border-left:3px solid #f97316;padding:6px 12px;'
396
+ 'font-size:0.85rem;color:#9a3412;border-radius:0 4px 4px 0">Thinking…</div>'
397
+ )
398
+ _STATUS_IDLE = ""
399
+ _STATUS_CACHE_HIT = (
400
+ '<div style="background:#ecfdf5;border-left:3px solid #10b981;padding:6px 12px;'
401
+ 'font-size:0.85rem;color:#065f46;border-radius:0 4px 4px 0">'
402
+ "Loaded pre-indexed lecture from cache"
403
+ "</div>"
404
+ )
405
+
406
+
407
+ def _status_indexing(done: int, total: int) -> str:
408
+ return (
409
+ '<div style="background:#f5f3ff;border-left:3px solid #625ce7;padding:6px 12px;'
410
+ 'font-size:0.85rem;color:#4c46a8;border-radius:0 4px 4px 0">'
411
+ f"Indexing lecture… {done} / {total} slides"
412
+ "</div>"
413
+ )
414
+
415
+
416
+ def on_explain(state, chat):
417
+ deck: Deck | None = state["deck"]
418
+ if not deck:
419
+ gr.Warning("Upload a lecture PDF first.")
420
+ yield chat, _STATUS_IDLE, None
421
+ return
422
+ idx = state["index"]
423
+ yield chat, _STATUS_READING, None
424
+ reading = _ensure_reading(state, idx)
425
+ yield chat, _STATUS_EXPLAINING, None
426
+ chat = chat + [{"role": "assistant", "content": ""}]
427
+ acc = ""
428
+ for tok in explain_slide(
429
+ reading,
430
+ slide_no=idx + 1,
431
+ total=len(deck),
432
+ outline=deck.outline(),
433
+ history=chat,
434
+ ):
435
+ acc += tok
436
+ chat[-1]["content"] = acc
437
+ yield chat, _STATUS_EXPLAINING, None
438
+ audio = tts_speak_full(acc, voice_key=state["session_id"])
439
+ if audio is not None:
440
+ yield chat, _STATUS_SPEAKING, gr.update(value=audio, visible=True)
441
+ time.sleep(len(audio[1]) / audio[0])
442
+ yield chat, _STATUS_IDLE, gr.update(value=None, visible=False)
443
+
444
+
445
+ def on_teach_deck(state, chat):
446
+ """Run professor-planned teaching beats with navigation, board tools, and TTS."""
447
+ deck: Deck | None = state["deck"]
448
+ if not deck:
449
+ gr.Warning("Upload a lecture PDF first.")
450
+ img, caption = _slide_view(state)
451
+ yield state, img, caption, chat, _STATUS_IDLE, _whiteboard_view(state), None
452
+ return
453
+
454
+ max_beats = max(1, len(deck) * 2)
455
+ for _ in range(max_beats):
456
+ idx = state["index"]
457
+ img, caption = _slide_view(state)
458
+ yield state, img, caption, chat, _STATUS_READING, _whiteboard_view(state), None
459
+
460
+ reading = _ensure_reading(state, idx)
461
+ beat = plan_teaching_beat(
462
+ trigger="continue",
463
+ deck_index=state["deck_index"],
464
+ current_slide=idx + 1,
465
+ total_slides=len(deck),
466
+ current_reading=reading,
467
+ whiteboard_state=state["whiteboard"],
468
+ history=chat,
469
+ )
470
+ _execute_actions(state, beat.actions, allow_navigation=False)
471
+ img, caption = _slide_view(state)
472
+ board = _whiteboard_view(state)
473
+ chat = chat + [{"role": "assistant", "content": beat.narration}]
474
+ yield state, img, caption, chat, _STATUS_EXPLAINING, board, None
475
+
476
+ audio = tts_speak_full(beat.narration, voice_key=state["session_id"])
477
+ if audio is not None:
478
+ sr, pcm = audio
479
+ yield state, img, caption, chat, _STATUS_SPEAKING, board, gr.update(value=audio, visible=True)
480
+ time.sleep(len(pcm) / sr)
481
+
482
+ if not beat.continue_lecture:
483
+ break
484
+ if state["index"] >= len(deck) - 1:
485
+ break
486
+ state["index"] += 1
487
+
488
+ yield state, *_slide_view(state), chat, _STATUS_IDLE, _whiteboard_view(state), gr.update(value=None, visible=False)
489
+
490
+
491
+ def on_ask(question, state, chat):
492
+ deck: Deck | None = state["deck"]
493
+ if not deck:
494
+ gr.Warning("Upload a lecture PDF first.")
495
+ yield state, *_slide_view(state), chat, "", _whiteboard_view(state), None, ""
496
+ return
497
+ question = (question or "").strip()
498
+ if not question:
499
+ yield state, *_slide_view(state), chat, "", _whiteboard_view(state), None, ""
500
+ return
501
+ idx = state["index"]
502
+ reading = _ensure_reading(state, idx)
503
+ history = chat + [{"role": "user", "content": question}]
504
+ img, caption = _slide_view(state)
505
+ yield (
506
+ state,
507
+ img,
508
+ caption,
509
+ history,
510
+ _STATUS_THINKING,
511
+ _whiteboard_view(state),
512
+ None,
513
+ "",
514
+ )
515
+ beat = plan_teaching_beat(
516
+ trigger="question",
517
+ deck_index=state["deck_index"],
518
+ current_slide=idx + 1,
519
+ total_slides=len(deck),
520
+ current_reading=reading,
521
+ whiteboard_state=state["whiteboard"],
522
+ history=history,
523
+ question=question,
524
+ )
525
+ _execute_actions(state, beat.actions)
526
+ chat = history + [{"role": "assistant", "content": beat.narration}]
527
+ img, caption = _slide_view(state)
528
+ board = _whiteboard_view(state)
529
+ yield state, img, caption, chat, _STATUS_EXPLAINING, board, None, ""
530
+ audio = tts_speak_full(beat.narration, voice_key=state["session_id"])
531
+ if audio is not None:
532
+ sr, pcm = audio
533
+ yield state, img, caption, chat, _STATUS_SPEAKING, board, gr.update(value=audio, visible=True), ""
534
+ time.sleep(len(pcm) / sr)
535
+ yield state, img, caption, chat, _STATUS_IDLE, board, gr.update(value=None, visible=False), ""
536
+
537
+
538
+ def on_nav(delta, state):
539
+ deck: Deck | None = state["deck"]
540
+ if deck:
541
+ state["index"] = max(0, min(len(deck) - 1, state["index"] + delta))
542
+ img, caption = _slide_view(state)
543
+ return state, img, caption, _whiteboard_view(state), state["index"] if deck else None
544
+
545
+
546
+ def on_index_select(index, state):
547
+ deck: Deck | None = state["deck"]
548
+ if deck and index is not None:
549
+ state["index"] = max(0, min(len(deck) - 1, int(index)))
550
+ img, caption = _slide_view(state)
551
+ return state, img, caption, _whiteboard_view(state)
552
+
553
+
554
+ def on_transcribe(audio):
555
+ if audio is None:
556
+ return ""
557
+ if not CONFIG.stt.is_live:
558
+ return "[voice input]"
559
+ sr, data = audio
560
+ if data is None or len(data) == 0:
561
+ return ""
562
+ if not _has_speech(data):
563
+ return ""
564
+ buf = io.BytesIO()
565
+ if data.dtype != np.int16:
566
+ data = (data * 32767).astype(np.int16)
567
+ if data.ndim > 1:
568
+ data = data[:, 0]
569
+ with wave.open(buf, "wb") as wf:
570
+ wf.setnchannels(1)
571
+ wf.setsampwidth(2)
572
+ wf.setframerate(sr)
573
+ wf.writeframes(data.tobytes())
574
+ buf.seek(0)
575
+ buf.name = "audio.wav"
576
+ client = OpenAI(base_url=CONFIG.stt.openai_base_url, api_key=CONFIG.stt.api_key)
577
+ transcript = client.audio.transcriptions.create(model=CONFIG.stt.model, file=buf)
578
+ return transcript.text
579
+
580
+
581
+ # ----------------------------------------------------------------------------- UI
582
+
583
+ _BANNER = (
584
+ "⚠️ Running in **mock mode** — set `VISION_BASE_URL` / `BRAIN_BASE_URL` (see `.env.example`) "
585
+ "to plug in real MiniCPM-V + Nemotron."
586
+ if CONFIG.fully_mocked
587
+ else None
588
+ )
589
+
590
+ _CSS = """
591
+ .gradio-container {
592
+ max-width: 1320px !important;
593
+ margin: 0 auto !important;
594
+ padding-inline: 24px !important;
595
+ }
596
+ .app-title {
597
+ max-width: 1280px;
598
+ margin: 0 auto 18px;
599
+ padding: 4px 2px 8px;
600
+ }
601
+ .app-title h1 {
602
+ margin: 0 0 4px !important;
603
+ color: #24283b;
604
+ font-size: 2.15rem !important;
605
+ font-weight: 800 !important;
606
+ letter-spacing: -.035em;
607
+ }
608
+ .app-title p {
609
+ margin: 0 !important;
610
+ color: #697386;
611
+ font-size: 1rem;
612
+ }
613
+ .workspace-row {
614
+ width: 100%;
615
+ max-width: 1280px;
616
+ margin-inline: auto;
617
+ align-items: stretch !important;
618
+ }
619
+ .panel-card {
620
+ min-width: 0 !important;
621
+ overflow: hidden;
622
+ gap: 0 !important;
623
+ border: 1px solid #e1e5eb;
624
+ border-radius: 14px;
625
+ background: #fff;
626
+ }
627
+ .panel-title {
628
+ flex: 0 0 46px !important;
629
+ min-height: 46px !important;
630
+ height: 46px !important;
631
+ display: flex !important;
632
+ align-items: center !important;
633
+ margin: 0 !important;
634
+ padding: 0 14px !important;
635
+ background: #ecebff;
636
+ border-bottom: 1px solid #dedcff;
637
+ color: #5b55c7;
638
+ }
639
+ .panel-title p {
640
+ margin: 0 !important;
641
+ font-size: .86rem;
642
+ font-weight: 700;
643
+ line-height: 1.35;
644
+ }
645
+ .panel-body {
646
+ padding: 12px !important;
647
+ }
648
+ .teaching-panel { min-height: 655px; }
649
+ .slide-frame {
650
+ flex: 0 0 550px !important;
651
+ height: 550px !important;
652
+ min-height: 550px !important;
653
+ border: 0 !important;
654
+ border-radius: 0 !important;
655
+ }
656
+ .slide-frame img {
657
+ height: 550px !important;
658
+ object-fit: contain !important;
659
+ background: #f8f9fb;
660
+ }
661
+ .slide-footer {
662
+ padding: 0 12px 12px !important;
663
+ }
664
+ .slide-caption {
665
+ min-height: 24px;
666
+ margin: 0 !important;
667
+ color: #667085;
668
+ }
669
+ .slide-index {
670
+ margin: 2px 0 8px !important;
671
+ }
672
+ .slide-controls {
673
+ gap: 10px !important;
674
+ }
675
+ .slide-controls button {
676
+ min-height: 42px !important;
677
+ border-radius: 10px !important;
678
+ font-weight: 700 !important;
679
+ }
680
+ .nav-button button {
681
+ color: #5b55c7 !important;
682
+ border: 1px solid #d8d6ff !important;
683
+ background: #f7f6ff !important;
684
+ }
685
+ .explain-button button {
686
+ color: #fff !important;
687
+ border-color: #625ce7 !important;
688
+ background: #625ce7 !important;
689
+ box-shadow: 0 5px 14px rgb(98 92 231 / 20%) !important;
690
+ }
691
+ .whiteboard {
692
+ flex: 1 1 auto !important;
693
+ min-height: 550px;
694
+ border: 0;
695
+ border-radius: 0;
696
+ overflow: hidden;
697
+ background:
698
+ linear-gradient(#e8edf3 1px, transparent 1px),
699
+ linear-gradient(90deg, #e8edf3 1px, transparent 1px),
700
+ #fbfcfe;
701
+ background-size: 28px 28px;
702
+ }
703
+ .whiteboard-empty, .whiteboard-sheet {
704
+ min-height: 550px;
705
+ padding: 28px 32px;
706
+ color: #172033;
707
+ }
708
+ .whiteboard-empty {
709
+ display: flex;
710
+ flex-direction: column;
711
+ align-items: center;
712
+ justify-content: center;
713
+ gap: 8px;
714
+ color: #667085;
715
+ }
716
+ .whiteboard-sheet h3 { font-size: 1.8rem; margin: 24px 0 18px; }
717
+ .whiteboard-sheet p { font-size: 1.2rem; line-height: 1.7; max-width: 90%; }
718
+ .whiteboard-note {
719
+ margin-top: 22px;
720
+ padding: 16px 18px;
721
+ border-left: 4px solid #625ce7;
722
+ border-radius: 0 10px 10px 0;
723
+ background: rgb(255 255 255 / 82%);
724
+ }
725
+ .whiteboard-note strong { font-size: 1.08rem; }
726
+ .whiteboard-note p { margin: 6px 0 0; font-size: 1rem; line-height: 1.55; }
727
+ .whiteboard-equation {
728
+ margin-top: 22px;
729
+ padding: 18px;
730
+ border-radius: 10px;
731
+ background: rgb(255 255 255 / 86%);
732
+ text-align: center;
733
+ }
734
+ .whiteboard-equation code {
735
+ color: #24283b;
736
+ font-size: 1.15rem;
737
+ white-space: normal;
738
+ }
739
+ .whiteboard-kicker {
740
+ color: #3157a4;
741
+ font-size: .78rem;
742
+ font-weight: 700;
743
+ letter-spacing: .08em;
744
+ text-transform: uppercase;
745
+ }
746
+ .whiteboard-line { width: 72px; height: 4px; margin: 30px 0 14px; background: #f4b740; }
747
+ .whiteboard-hint { color: #7a8496; font-size: .82rem; }
748
+ .bottom-panel {
749
+ min-height: 382px;
750
+ }
751
+ .transcript-panel {
752
+ flex: 1 1 auto !important;
753
+ height: 334px !important;
754
+ min-height: 334px !important;
755
+ border: 0 !important;
756
+ border-radius: 0 !important;
757
+ }
758
+ .transcript-panel .placeholder {
759
+ height: 100% !important;
760
+ display: flex !important;
761
+ align-items: center !important;
762
+ justify-content: center !important;
763
+ padding: 24px !important;
764
+ color: #8a94a6 !important;
765
+ text-align: center !important;
766
+ }
767
+ .question-body, .upload-body {
768
+ flex: 1 1 auto !important;
769
+ min-height: 334px;
770
+ padding: 18px !important;
771
+ }
772
+ .question-body {
773
+ display: flex !important;
774
+ flex-direction: column !important;
775
+ gap: 12px !important;
776
+ }
777
+ .question-row {
778
+ align-items: stretch !important;
779
+ gap: 10px !important;
780
+ }
781
+ .question-input textarea {
782
+ min-height: 58px !important;
783
+ }
784
+ .question-input {
785
+ flex: 1 1 auto !important;
786
+ }
787
+ .ask-button {
788
+ min-width: 96px !important;
789
+ max-width: 110px !important;
790
+ }
791
+ .ask-button button {
792
+ height: 100% !important;
793
+ min-height: 58px !important;
794
+ font-weight: 750 !important;
795
+ }
796
+ .mic-label {
797
+ margin: 2px 0 -4px !important;
798
+ color: #697386;
799
+ font-size: .82rem;
800
+ font-weight: 650;
801
+ }
802
+ .mic-control {
803
+ min-height: 108px !important;
804
+ max-height: 122px !important;
805
+ overflow: hidden !important;
806
+ }
807
+ .mic-control button[aria-label="Record"],
808
+ .mic-control button.record {
809
+ min-width: 150px !important;
810
+ min-height: 58px !important;
811
+ border-radius: 999px !important;
812
+ color: #fff !important;
813
+ background: #625ce7 !important;
814
+ border-color: #625ce7 !important;
815
+ font-size: 1rem !important;
816
+ font-weight: 750 !important;
817
+ }
818
+ .teach-button {
819
+ margin-top: auto !important;
820
+ }
821
+ .upload-body {
822
+ display: flex !important;
823
+ flex-direction: column !important;
824
+ gap: 12px !important;
825
+ }
826
+ .upload-control {
827
+ min-height: 210px !important;
828
+ }
829
+ .upload-copy {
830
+ margin: 0 !important;
831
+ color: #667085;
832
+ font-size: .88rem;
833
+ }
834
+ @media (max-width: 900px) {
835
+ .gradio-container { padding-inline: 12px !important; }
836
+ .teaching-panel, .bottom-panel { min-width: 100% !important; }
837
+ }
838
+ """
839
+
840
+ with gr.Blocks(title="AI Prof", theme=gr.themes.Soft(), css=_CSS) as demo:
841
+ state = gr.State(_new_state())
842
+
843
+ gr.Markdown(
844
+ "# AI Prof\nA live, guided walkthrough of your lecture.",
845
+ elem_classes=["app-title"],
846
+ )
847
+ if _BANNER:
848
+ gr.Markdown(_BANNER)
849
+
850
+ with gr.Row(equal_height=True, elem_classes=["workspace-row"]):
851
+ with gr.Column(scale=1, elem_classes=["panel-card", "teaching-panel"]):
852
+ gr.Markdown("Lecture slides", elem_classes=["panel-title"])
853
+ slide_img = gr.Image(
854
+ show_label=False,
855
+ height=470,
856
+ elem_classes=["slide-frame"],
857
+ )
858
+ with gr.Column(elem_classes=["slide-footer"]):
859
+ caption = gr.Markdown("No deck loaded.", elem_classes=["slide-caption"])
860
+ slide_index = gr.Dropdown(
861
+ label="Lecture index",
862
+ choices=[],
863
+ value=None,
864
+ interactive=True,
865
+ elem_classes=["slide-index"],
866
+ )
867
+ with gr.Row(elem_classes=["slide-controls"]):
868
+ prev_btn = gr.Button("Previous", elem_classes=["nav-button"])
869
+ explain_btn = gr.Button(
870
+ "Explain slide",
871
+ variant="primary",
872
+ elem_classes=["explain-button"],
873
+ )
874
+ next_btn = gr.Button("Next", elem_classes=["nav-button"])
875
+
876
+ with gr.Column(scale=1, elem_classes=["panel-card", "teaching-panel"]):
877
+ gr.Markdown("Whiteboard", elem_classes=["panel-title"])
878
+ whiteboard = gr.Markdown(
879
+ value=_whiteboard_view(_new_state()),
880
+ elem_classes=["whiteboard"],
881
+ )
882
+
883
+ with gr.Row(equal_height=True, elem_classes=["workspace-row"]):
884
+ with gr.Column(scale=5, elem_classes=["panel-card", "bottom-panel"]):
885
+ gr.Markdown("Lecture transcript", elem_classes=["panel-title"])
886
+ status_strip = gr.HTML(value=_STATUS_IDLE)
887
+ prof_audio = gr.Audio(
888
+ autoplay=True,
889
+ show_label=False,
890
+ visible=False, # hidden; plays automatically via autoplay
891
+ interactive=False,
892
+ )
893
+ chat = gr.Chatbot(
894
+ show_label=False,
895
+ height=320,
896
+ type="messages",
897
+ layout="panel",
898
+ placeholder=(
899
+ "Upload a lecture to begin. The professor's explanation "
900
+ "will appear here as it is spoken."
901
+ ),
902
+ elem_classes=["transcript-panel"],
903
+ )
904
+
905
+ with gr.Column(scale=3, elem_classes=["panel-card", "bottom-panel"]):
906
+ gr.Markdown("Ask a question", elem_classes=["panel-title"])
907
+ with gr.Column(elem_classes=["question-body"]):
908
+ with gr.Row(equal_height=True, elem_classes=["question-row"]):
909
+ question = gr.Textbox(
910
+ placeholder="Type a question...",
911
+ show_label=False,
912
+ lines=1,
913
+ elem_classes=["question-input"],
914
+ scale=5,
915
+ )
916
+ ask_btn = gr.Button(
917
+ "Ask",
918
+ variant="primary",
919
+ elem_classes=["ask-button"],
920
+ scale=1,
921
+ )
922
+ gr.Markdown("Or ask out loud", elem_classes=["mic-label"])
923
+ mic = gr.Audio(
924
+ sources=["microphone"],
925
+ type="numpy",
926
+ streaming=False,
927
+ show_label=False,
928
+ elem_classes=["mic-control"],
929
+ )
930
+ teach_btn = gr.Button(
931
+ "Teach from current slide",
932
+ variant="secondary",
933
+ elem_classes=["teach-button"],
934
+ )
935
+ # TODO: wire fastrtc when installed — replace `mic` above with:
936
+ #
937
+ # if _RTC_AVAILABLE:
938
+ # _rtc_handler = _build_rtc_handler(state_getter=lambda: state.value)
939
+ # webrtc = gr.WebRTC(
940
+ # label="Live voice (real-time)",
941
+ # rtc_configuration=_rtc_handler.rtc_configuration,
942
+ # mode="send-receive",
943
+ # )
944
+ # webrtc.stream(
945
+ # _rtc_handler,
946
+ # inputs=[webrtc, state],
947
+ # outputs=[webrtc],
948
+ # time_limit=120,
949
+ # )
950
+ #
951
+ # See ai_prof/rtc.py for the full pipeline:
952
+ # student mic → STT (/v1/audio/transcriptions)
953
+ # → brain.answer_question (streamed text)
954
+ # → TTS (/v1/audio/speech, PCM chunks)
955
+ # → student speaker (sub-second latency)
956
+
957
+ with gr.Column(scale=2, elem_classes=["panel-card", "bottom-panel"]):
958
+ gr.Markdown("Choose a lecture", elem_classes=["panel-title"])
959
+ with gr.Column(elem_classes=["upload-body"]):
960
+ prepared_deck = gr.Dropdown(
961
+ label="Prepared lectures",
962
+ choices=_prepared_deck_choices(),
963
+ value=None,
964
+ interactive=True,
965
+ )
966
+ load_prepared_btn = gr.Button(
967
+ "Load prepared lecture",
968
+ variant="primary",
969
+ )
970
+ gr.Markdown("Or upload your own PDF", elem_classes=["mic-label"])
971
+ pdf = gr.File(
972
+ label="Drop a PDF to begin",
973
+ file_types=[".pdf"],
974
+ type="filepath",
975
+ height=130,
976
+ elem_classes=["upload-control"],
977
+ )
978
+ gr.Markdown(
979
+ "The professor starts at slide 1 and advances automatically. "
980
+ "Use the slide controls to revisit anything.",
981
+ elem_classes=["upload-copy"],
982
+ )
983
+
984
+ lecture_outputs = [state, slide_img, caption, chat, status_strip, whiteboard, prof_audio]
985
+ question_outputs = [
986
+ state,
987
+ slide_img,
988
+ caption,
989
+ chat,
990
+ status_strip,
991
+ whiteboard,
992
+ prof_audio,
993
+ question,
994
+ ]
995
+ upload_event = pdf.change(
996
+ on_upload,
997
+ [pdf, state],
998
+ [state, slide_img, caption, chat, whiteboard, status_strip, slide_index],
999
+ ).then(
1000
+ on_teach_deck,
1001
+ [state, chat],
1002
+ lecture_outputs,
1003
+ )
1004
+ prepared_event = load_prepared_btn.click(
1005
+ on_load_prepared,
1006
+ [prepared_deck, state],
1007
+ [state, slide_img, caption, chat, whiteboard, status_strip, slide_index],
1008
+ ).then(
1009
+ on_teach_deck,
1010
+ [state, chat],
1011
+ lecture_outputs,
1012
+ )
1013
+
1014
+ explain_event = explain_btn.click(
1015
+ on_explain,
1016
+ [state, chat],
1017
+ [chat, status_strip, prof_audio],
1018
+ )
1019
+ teach_event = teach_btn.click(on_teach_deck, [state, chat], lecture_outputs)
1020
+ question.submit(
1021
+ on_ask,
1022
+ [question, state, chat],
1023
+ question_outputs,
1024
+ cancels=[upload_event, prepared_event, explain_event, teach_event],
1025
+ )
1026
+ ask_btn.click(
1027
+ on_ask,
1028
+ [question, state, chat],
1029
+ question_outputs,
1030
+ cancels=[upload_event, prepared_event, explain_event, teach_event],
1031
+ )
1032
+ prev_btn.click(
1033
+ on_nav,
1034
+ [gr.State(-1), state],
1035
+ [state, slide_img, caption, whiteboard, slide_index],
1036
+ )
1037
+ next_btn.click(
1038
+ on_nav,
1039
+ [gr.State(1), state],
1040
+ [state, slide_img, caption, whiteboard, slide_index],
1041
+ )
1042
+ slide_index.change(
1043
+ on_index_select,
1044
+ [slide_index, state],
1045
+ [state, slide_img, caption, whiteboard],
1046
+ )
1047
+ mic.stop_recording(on_transcribe, inputs=[mic], outputs=[question])
1048
+
1049
+
1050
+ if __name__ == "__main__":
1051
+ demo.launch()
modal_app.py ADDED
@@ -0,0 +1,150 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Modal backend for AI Prof — the brain (Nemotron 3 Nano) served over vLLM.
2
+
3
+ OpenAI-compatible endpoint that the Gradio app points ``BRAIN_BASE_URL`` at. The
4
+ GPU container scales to zero when idle.
5
+
6
+ Nemotron 3 Nano is a hybrid Mamba-2 + MoE *reasoning* model. On first run vLLM
7
+ JIT-compiles CUDA kernels — notably FlashInfer's CUTLASS fused-MoE kernel (slow,
8
+ minutes). We persist those compile caches to Volumes and warm ONCE, so every
9
+ later cold start reuses them (~tens of seconds) instead of recompiling on an
10
+ expensive GPU. Cost discipline: ``max_containers=1`` so a request burst can never
11
+ fan out into multiple GPUs, and warming is a single controlled ``modal run`` —
12
+ never a curl storm against the live endpoint.
13
+
14
+ Bring-up:
15
+ modal run modal_app.py::download_model # 1. pull weights to a Volume (CPU, cheap)
16
+ modal run modal_app.py::warm # 2. ONE GPU run: compile + cache kernels
17
+ modal deploy modal_app.py # 3. serve; cold starts now reuse the cache
18
+ # the printed *.modal.run URL + "/v1" is BRAIN_BASE_URL
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import json
24
+ import subprocess
25
+ import time
26
+ import urllib.request
27
+
28
+ import modal
29
+
30
+ # --- what to serve -----------------------------------------------------------
31
+ MODEL_NAME = "nvidia/NVIDIA-Nemotron-3-Nano-30B-A3B-BF16"
32
+ SERVED_NAME = "nemotron-3-nano" # must match BRAIN_MODEL in the app's .env
33
+ GPU = "H100"
34
+ MAX_MODEL_LEN = 16384 # ample for slide reading + outline + history; small KV cache
35
+ VLLM_PORT = 8000
36
+ MINUTES = 60
37
+
38
+ app = modal.App("ai-prof-brain")
39
+
40
+ # Persistent caches: weights, vLLM torch.compile artifacts, and FlashInfer's JIT
41
+ # kernels. Mounting the FlashInfer cache is what stops the CUTLASS MoE kernel from
42
+ # recompiling on every cold start.
43
+ hf_cache = modal.Volume.from_name("ai-prof-hf-cache", create_if_missing=True)
44
+ vllm_cache = modal.Volume.from_name("ai-prof-vllm-cache", create_if_missing=True)
45
+ flashinfer_cache = modal.Volume.from_name("ai-prof-flashinfer-cache", create_if_missing=True)
46
+
47
+ triton_cache = modal.Volume.from_name("ai-prof-triton-cache", create_if_missing=True)
48
+
49
+ VOLUMES = {
50
+ "/root/.cache/huggingface": hf_cache,
51
+ "/root/.cache/vllm": vllm_cache,
52
+ "/root/.cache/flashinfer": flashinfer_cache,
53
+ "/root/.triton": triton_cache, # Mamba2 SSD Triton kernel cache (~11 min compile)
54
+ }
55
+
56
+ # CUDA *devel* base ships nvcc — Nemotron-H's Mamba-2 kernels JIT-compile at init.
57
+ vllm_image = (
58
+ modal.Image.from_registry("nvidia/cuda:12.8.1-devel-ubuntu22.04", add_python="3.12")
59
+ .entrypoint([])
60
+ .pip_install("vllm>=0.12.0", "huggingface_hub[hf_transfer]>=0.27")
61
+ .env({"HF_HUB_ENABLE_HF_TRANSFER": "1"})
62
+ )
63
+
64
+
65
+ def _vllm_cmd() -> list[str]:
66
+ # No --enforce-eager: we WANT torch.compile + CUDA-graph capture so the
67
+ # artifacts get cached to the vLLM volume (FAST_BOOT=False pattern).
68
+ return [
69
+ "vllm", "serve", MODEL_NAME,
70
+ "--served-model-name", SERVED_NAME,
71
+ "--host", "0.0.0.0", "--port", str(VLLM_PORT),
72
+ "--max-model-len", str(MAX_MODEL_LEN),
73
+ "--max-num-seqs", "8",
74
+ "--tensor-parallel-size", "1",
75
+ "--trust-remote-code",
76
+ "--reasoning-parser", "nemotron_v3",
77
+ ]
78
+
79
+
80
+ def _wait_healthy(timeout_s: int = 25 * MINUTES) -> None:
81
+ base = f"http://127.0.0.1:{VLLM_PORT}"
82
+ deadline = time.time() + timeout_s
83
+ while time.time() < deadline:
84
+ try:
85
+ urllib.request.urlopen(f"{base}/health", timeout=5)
86
+ return
87
+ except Exception:
88
+ time.sleep(5)
89
+ raise TimeoutError("vLLM did not become healthy in time")
90
+
91
+
92
+ @app.function(
93
+ image=vllm_image,
94
+ volumes={"/root/.cache/huggingface": hf_cache},
95
+ timeout=60 * MINUTES,
96
+ )
97
+ def download_model(model_name: str = MODEL_NAME) -> None:
98
+ """Pull weights to the Volume on CPU (no GPU billed during the big download)."""
99
+ from huggingface_hub import snapshot_download
100
+
101
+ print(f"Downloading {model_name} -> /root/.cache/huggingface ...")
102
+ snapshot_download(model_name, ignore_patterns=["*.pt", "*.pth"])
103
+ hf_cache.commit()
104
+ print("Done.")
105
+
106
+
107
+ @app.function(image=vllm_image, gpu=GPU, volumes=VOLUMES, timeout=60 * MINUTES)
108
+ def warm() -> None:
109
+ """One controlled GPU run: boot vLLM, fire a single request to trigger every
110
+ kernel compile, then commit the compile caches. Run with ``modal run``."""
111
+ proc = subprocess.Popen(_vllm_cmd())
112
+ try:
113
+ print("Waiting for vLLM to compile + become healthy (first time is slow)...")
114
+ _wait_healthy()
115
+ req = urllib.request.Request(
116
+ f"http://127.0.0.1:{VLLM_PORT}/v1/chat/completions",
117
+ data=json.dumps(
118
+ {
119
+ "model": SERVED_NAME,
120
+ "messages": [{"role": "user", "content": "Say hello."}],
121
+ "max_tokens": 32,
122
+ }
123
+ ).encode(),
124
+ headers={"Content-Type": "application/json"},
125
+ )
126
+ print("Response:", urllib.request.urlopen(req, timeout=120).read().decode()[:400])
127
+ finally:
128
+ proc.terminate()
129
+ try:
130
+ proc.wait(timeout=30)
131
+ except Exception:
132
+ proc.kill()
133
+ vllm_cache.commit()
134
+ flashinfer_cache.commit()
135
+ print("Warm complete — compile caches committed. Cold starts will now be fast.")
136
+
137
+
138
+ @app.function(
139
+ image=vllm_image,
140
+ gpu=GPU,
141
+ volumes=VOLUMES,
142
+ scaledown_window=20 * MINUTES, # keep warm between requests; cold start is ~2 min
143
+ timeout=60 * MINUTES,
144
+ max_containers=1, # cost guard: a request burst can never fan out into many GPUs
145
+ )
146
+ @modal.concurrent(max_inputs=8) # vLLM batches concurrent requests on the one replica
147
+ @modal.web_server(port=VLLM_PORT, startup_timeout=30 * MINUTES)
148
+ def serve() -> None:
149
+ print("Launching:", " ".join(_vllm_cmd()))
150
+ subprocess.Popen(_vllm_cmd())
modal_app_vision.py ADDED
@@ -0,0 +1,261 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Modal backend for AI Prof's eyes: MiniCPM-V-4 served by llama.cpp.
2
+
3
+ The endpoint is OpenAI-compatible and accepts the image_url content produced by
4
+ ``ai_prof/vision.py``. Point ``VISION_BASE_URL`` at the deployed URL; the app
5
+ adds ``/v1`` when needed.
6
+
7
+ The GGUF route is intentional: it gives this small, bursty vision service quick
8
+ cold starts and keeps the project on llama.cpp for the hackathon's Llama
9
+ Champion track. An L4 comfortably holds the Q4_K_M language model, F16 vision
10
+ projector, and an 8K context.
11
+
12
+ Bring-up:
13
+ modal run modal_app_vision.py::download_model
14
+ modal run modal_app_vision.py::warm
15
+ modal deploy modal_app_vision.py
16
+ # .env: VISION_BASE_URL=<serve URL> VISION_MODEL=minicpm-v
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import base64
22
+ import binascii
23
+ import contextlib
24
+ import json
25
+ import struct
26
+ import subprocess
27
+ import time
28
+ import urllib.request
29
+ import zlib
30
+
31
+ import modal
32
+
33
+ MODEL_REPO = "openbmb/MiniCPM-V-4-gguf"
34
+ MODEL_REVISION = "a5a17436782fb15dff8df61ab0ec3126c3564695"
35
+ MODEL_FILE = "ggml-model-Q4_K_M.gguf"
36
+ MMPROJ_FILE = "mmproj-model-f16.gguf"
37
+ SERVED_NAME = "minicpm-v"
38
+
39
+ # Official llama.cpp CUDA server image b9570, pinned to its amd64 manifest so an
40
+ # upstream image update cannot silently alter multimodal behavior during judging.
41
+ LLAMA_CPP_IMAGE = (
42
+ "ghcr.io/ggml-org/llama.cpp:server-cuda"
43
+ "@sha256:3f167c81f5f281f642be62d1d9750b609fa38e7aa7be9b9ea2017a7f43a0d5eb"
44
+ )
45
+ LLAMA_SERVER = "/app/llama-server"
46
+ MODEL_DIR = "/models/minicpm-v-4"
47
+
48
+ GPU = "L4"
49
+ LLAMA_PORT = 8081
50
+ MAX_MODEL_LEN = 8192
51
+ MINUTES = 60
52
+
53
+ app = modal.App("ai-prof-vision")
54
+ model_volume = modal.Volume.from_name("ai-prof-vision-models", create_if_missing=True)
55
+
56
+ llama_image = (
57
+ modal.Image.from_registry(LLAMA_CPP_IMAGE, add_python="3.12")
58
+ .entrypoint([])
59
+ .pip_install("fastapi[standard]", "httpx", "huggingface_hub>=1.0")
60
+ .env({"HF_XET_HIGH_PERFORMANCE": "1"})
61
+ )
62
+
63
+
64
+ def _server_cmd() -> list[str]:
65
+ return [
66
+ LLAMA_SERVER,
67
+ "--model",
68
+ f"{MODEL_DIR}/{MODEL_FILE}",
69
+ "--mmproj",
70
+ f"{MODEL_DIR}/{MMPROJ_FILE}",
71
+ "--alias",
72
+ SERVED_NAME,
73
+ "--host",
74
+ "127.0.0.1",
75
+ "--port",
76
+ str(LLAMA_PORT),
77
+ "--ctx-size",
78
+ str(MAX_MODEL_LEN),
79
+ "--n-gpu-layers",
80
+ "99",
81
+ "--parallel",
82
+ "1",
83
+ ]
84
+
85
+
86
+ def _wait_healthy(timeout_s: int = 10 * MINUTES) -> None:
87
+ deadline = time.time() + timeout_s
88
+ while time.time() < deadline:
89
+ try:
90
+ with urllib.request.urlopen(
91
+ f"http://127.0.0.1:{LLAMA_PORT}/health", timeout=5
92
+ ) as response:
93
+ if response.status == 200:
94
+ return
95
+ except Exception:
96
+ time.sleep(2)
97
+ raise TimeoutError("llama-server did not become healthy in time")
98
+
99
+
100
+ def _png_data_uri(width: int = 64, height: int = 64) -> str:
101
+ """Create a tiny valid RGB test image without adding Pillow to the image."""
102
+
103
+ def chunk(kind: bytes, data: bytes) -> bytes:
104
+ body = kind + data
105
+ return struct.pack(">I", len(data)) + body + struct.pack(">I", binascii.crc32(body))
106
+
107
+ # A white image with one blue horizontal stripe gives the vision encoder
108
+ # real, non-uniform pixels while keeping the warm-up payload tiny.
109
+ rows = []
110
+ for y in range(height):
111
+ pixel = b"\x20\x70\xd0" if height // 3 <= y < 2 * height // 3 else b"\xf8\xf8\xf8"
112
+ rows.append(b"\x00" + pixel * width)
113
+ png = (
114
+ b"\x89PNG\r\n\x1a\n"
115
+ + chunk(b"IHDR", struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0))
116
+ + chunk(b"IDAT", zlib.compress(b"".join(rows)))
117
+ + chunk(b"IEND", b"")
118
+ )
119
+ return "data:image/png;base64," + base64.b64encode(png).decode("ascii")
120
+
121
+
122
+ @app.function(
123
+ image=llama_image,
124
+ volumes={"/models": model_volume},
125
+ timeout=30 * MINUTES,
126
+ )
127
+ def download_model() -> None:
128
+ """Download only the two GGUF files required by llama-server on CPU."""
129
+ from huggingface_hub import hf_hub_download
130
+
131
+ for filename in (MODEL_FILE, MMPROJ_FILE):
132
+ print(f"Downloading {MODEL_REPO}/{filename} ...")
133
+ hf_hub_download(
134
+ repo_id=MODEL_REPO,
135
+ filename=filename,
136
+ revision=MODEL_REVISION,
137
+ local_dir=MODEL_DIR,
138
+ )
139
+ model_volume.commit()
140
+ print("MiniCPM-V model and projector downloaded.")
141
+
142
+
143
+ @app.function(
144
+ image=llama_image,
145
+ gpu=GPU,
146
+ volumes={"/models": model_volume},
147
+ timeout=20 * MINUTES,
148
+ )
149
+ def warm() -> None:
150
+ """Smoke-test model loading and the complete multimodal request path."""
151
+ proc = subprocess.Popen(_server_cmd())
152
+ try:
153
+ print("Waiting for llama-server to load MiniCPM-V...")
154
+ _wait_healthy()
155
+ payload = {
156
+ "model": SERVED_NAME,
157
+ "messages": [
158
+ {
159
+ "role": "user",
160
+ "content": [
161
+ {"type": "text", "text": "What color is the stripe? Answer briefly."},
162
+ {"type": "image_url", "image_url": {"url": _png_data_uri()}},
163
+ ],
164
+ }
165
+ ],
166
+ "temperature": 0,
167
+ "max_tokens": 16,
168
+ }
169
+ request = urllib.request.Request(
170
+ f"http://127.0.0.1:{LLAMA_PORT}/v1/chat/completions",
171
+ data=json.dumps(payload).encode(),
172
+ headers={"Content-Type": "application/json"},
173
+ )
174
+ response = urllib.request.urlopen(request, timeout=180).read().decode()
175
+ print("Multimodal warm-up response:", response[:800])
176
+ finally:
177
+ proc.terminate()
178
+ try:
179
+ proc.wait(timeout=30)
180
+ except subprocess.TimeoutExpired:
181
+ proc.kill()
182
+
183
+
184
+ @app.function(
185
+ image=llama_image,
186
+ gpu=GPU,
187
+ volumes={"/models": model_volume},
188
+ scaledown_window=5 * MINUTES,
189
+ timeout=30 * MINUTES,
190
+ max_containers=1,
191
+ )
192
+ @modal.concurrent(max_inputs=4)
193
+ @modal.asgi_app()
194
+ def serve():
195
+ """Expose llama.cpp only after the model has finished loading."""
196
+ import httpx
197
+ from fastapi import FastAPI, Request
198
+ from fastapi.responses import StreamingResponse
199
+
200
+ # ``from __future__ import annotations`` makes the route annotation a
201
+ # string; FastAPI resolves it through module globals.
202
+ globals()["_ProxyRequest"] = Request
203
+
204
+ @contextlib.asynccontextmanager
205
+ async def lifespan(_app: FastAPI):
206
+ print("Launching:", " ".join(_server_cmd()))
207
+ proc = subprocess.Popen(_server_cmd())
208
+ try:
209
+ # Modal does not route traffic to the ASGI app until startup exits.
210
+ # This avoids llama-server's temporary 503 while weights are loading.
211
+ await __import__("asyncio").to_thread(_wait_healthy)
212
+ print("MiniCPM-V is ready.")
213
+ yield
214
+ finally:
215
+ proc.terminate()
216
+ try:
217
+ proc.wait(timeout=30)
218
+ except subprocess.TimeoutExpired:
219
+ proc.kill()
220
+
221
+ proxy = FastAPI(lifespan=lifespan)
222
+ upstream = f"http://127.0.0.1:{LLAMA_PORT}"
223
+
224
+ @proxy.api_route(
225
+ "/{path:path}",
226
+ methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"],
227
+ )
228
+ async def forward(path: str, request: _ProxyRequest):
229
+ client = httpx.AsyncClient(timeout=None)
230
+ upstream_request = client.build_request(
231
+ request.method,
232
+ f"{upstream}/{path}",
233
+ params=request.query_params,
234
+ headers={
235
+ key: value
236
+ for key, value in request.headers.items()
237
+ if key.lower() not in {"host", "content-length"}
238
+ },
239
+ content=await request.body(),
240
+ )
241
+ response = await client.send(upstream_request, stream=True)
242
+
243
+ async def body():
244
+ try:
245
+ async for chunk in response.aiter_raw():
246
+ yield chunk
247
+ finally:
248
+ await response.aclose()
249
+ await client.aclose()
250
+
251
+ return StreamingResponse(
252
+ body(),
253
+ status_code=response.status_code,
254
+ headers={
255
+ key: value
256
+ for key, value in response.headers.items()
257
+ if key.lower() not in {"content-length", "transfer-encoding", "connection"}
258
+ },
259
+ )
260
+
261
+ return proxy
modal_app_vox.py ADDED
@@ -0,0 +1,262 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Modal backend for AI Prof — VoxCPM2 TTS + distil-Whisper STT.
2
+
3
+ Two endpoints in one Modal app:
4
+ serve() → /v1/audio/speech (TTS — VoxCPM2 via vLLM-Omni)
5
+ transcribe() → /v1/audio/transcriptions (STT — distil-whisper-large-v3)
6
+
7
+ OpenAI-compatible /v1/audio/speech endpoint; point TTS_BASE_URL at the
8
+ printed *.modal.run URL (no /v1 suffix — the client appends it).
9
+
10
+ VoxCPM2 (2B, MiniCPM-4 backbone) outputs 48 kHz WAV and supports Voice
11
+ Design via a natural-language prefix prepended to the synthesis input — used
12
+ in ai_prof/rtc.py to give AI Prof a consistent professor voice.
13
+
14
+ vLLM-Omni layers omni-modal (TTS/STT) support on top of vLLM's scheduler
15
+ and exposes a drop-in /v1/audio/speech endpoint with PagedAttention and
16
+ continuous batching.
17
+
18
+ Bring-up:
19
+ modal run modal_app_vox.py::download_model # 1. pull VoxCPM2 weights (CPU, cheap)
20
+ modal run modal_app_vox.py::download_stt # 2. pull Whisper weights (CPU, cheap)
21
+ modal run modal_app_vox.py::warm # 3. ONE GPU run: compile CUDA kernels
22
+ modal deploy modal_app_vox.py # 4. serve both endpoints
23
+ # .env: TTS_BASE_URL=<serve URL> TTS_MODEL=voxcpm2
24
+ # STT_BASE_URL=<transcribe URL> STT_MODEL=distil-whisper-large-v3
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import json
30
+ import os
31
+ import subprocess
32
+ import time
33
+ import urllib.request
34
+
35
+ import modal
36
+
37
+ # --- TTS: VoxCPM2 ------------------------------------------------------------
38
+ MODEL_NAME = "openbmb/VoxCPM2"
39
+ SERVED_NAME = "voxcpm2" # must match TTS_MODEL in the app's .env
40
+ GPU = "A10G" # 24 GB VRAM; VoxCPM2 needs ~8 GB
41
+ MAX_MODEL_LEN = 2048 # generous for any TTS input chunk
42
+ VLLM_PORT = 8000
43
+ MINUTES = 60
44
+
45
+ # --- STT: distil-Whisper -----------------------------------------------------
46
+ # Use the full CTranslate2 HF path; faster-whisper loads these natively.
47
+ STT_MODEL = "Systran/faster-distil-whisper-large-v3"
48
+ STT_PORT = 8001
49
+
50
+ # Minimal OpenAI-compatible /v1/audio/transcriptions endpoint.
51
+ # Written to /tmp/stt_app.py inside the container at startup.
52
+ _STT_SERVER = """\
53
+ import io, os, tempfile
54
+ from fastapi import FastAPI, File, Form, UploadFile
55
+ from faster_whisper import WhisperModel
56
+
57
+ _MODEL_ID = os.environ.get("WHISPER_MODEL", "Systran/faster-distil-whisper-large-v3")
58
+ _model = WhisperModel(_MODEL_ID, device="cuda", compute_type="float16")
59
+
60
+ app = FastAPI()
61
+
62
+ @app.post("/v1/audio/transcriptions")
63
+ async def transcribe(
64
+ file: UploadFile = File(...),
65
+ model: str = Form(default=_MODEL_ID),
66
+ language: str = Form(default=None),
67
+ response_format: str = Form(default="json"),
68
+ ):
69
+ data = await file.read()
70
+ with tempfile.NamedTemporaryFile(suffix=os.path.splitext(file.filename or ".wav")[1] or ".wav", delete=False) as tmp:
71
+ tmp.write(data)
72
+ tmp_path = tmp.name
73
+ try:
74
+ segments, _ = _model.transcribe(tmp_path, language=language or None)
75
+ text = " ".join(s.text for s in segments).strip()
76
+ finally:
77
+ os.unlink(tmp_path)
78
+ return {"text": text}
79
+ """
80
+
81
+ app = modal.App("ai-prof-vox")
82
+
83
+ # Persistent caches — same pattern as modal_app.py (brain).
84
+ # FlashInfer cache is kept even though VoxCPM2 is transformer-only:
85
+ # vLLM itself may emit FlashInfer kernels depending on the backend.
86
+ hf_cache = modal.Volume.from_name("ai-prof-vox-hf-cache", create_if_missing=True)
87
+ vllm_cache = modal.Volume.from_name("ai-prof-vox-vllm-cache", create_if_missing=True)
88
+ flashinfer_cache = modal.Volume.from_name("ai-prof-vox-fi-cache", create_if_missing=True)
89
+ triton_cache = modal.Volume.from_name("ai-prof-vox-triton-cache", create_if_missing=True)
90
+
91
+ VOLUMES = {
92
+ "/root/.cache/huggingface": hf_cache,
93
+ "/root/.cache/vllm": vllm_cache,
94
+ "/root/.cache/flashinfer": flashinfer_cache,
95
+ "/root/.triton": triton_cache,
96
+ }
97
+
98
+ # vLLM-Omni installation recipe from the official README:
99
+ # 1. pip install vllm==0.19.0
100
+ # 2. git clone vllm-omni && pip install -e .
101
+ # We use uv for faster resolution and to avoid pip's slower solver on large graphs.
102
+ vox_image = (
103
+ modal.Image.from_registry("nvidia/cuda:12.8.1-devel-ubuntu22.04", add_python="3.12")
104
+ .entrypoint([])
105
+ .apt_install("git")
106
+ .run_commands(
107
+ "pip install uv 'huggingface_hub[hf_transfer]>=0.27'",
108
+ "uv pip install --system 'vllm==0.22.1'",
109
+ "pip install hf_transfer", # vllm's uv install drops hf_transfer; reinstall after
110
+ "git clone --depth 1 https://github.com/vllm-project/vllm-omni.git /opt/vllm-omni",
111
+ "cd /opt/vllm-omni && uv pip install --system -e .",
112
+ "pip install 'voxcpm>=2.0'", # vllm-omni requires this for VoxCPM2; it's a separate PyPI package
113
+ )
114
+ .env({"HF_HUB_ENABLE_HF_TRANSFER": "1"})
115
+ )
116
+
117
+
118
+ def _vllm_omni_cmd() -> list[str]:
119
+ # Use `vllm-omni` CLI (installed by pip install -e vllm-omni).
120
+ # With vllm 0.22.1 the plugin also patches `vllm serve` to accept --omni,
121
+ # but the explicit CLI is the documented/safe path.
122
+ return [
123
+ "vllm-omni", "serve", MODEL_NAME,
124
+ "--omni",
125
+ "--served-model-name", SERVED_NAME,
126
+ "--host", "0.0.0.0", "--port", str(VLLM_PORT),
127
+ "--max-model-len", str(MAX_MODEL_LEN),
128
+ "--max-num-seqs", "4",
129
+ "--tensor-parallel-size", "1",
130
+ "--trust-remote-code",
131
+ ]
132
+
133
+
134
+ def _wait_healthy(timeout_s: int = 20 * MINUTES) -> None:
135
+ base = f"http://127.0.0.1:{VLLM_PORT}"
136
+ deadline = time.time() + timeout_s
137
+ while time.time() < deadline:
138
+ try:
139
+ urllib.request.urlopen(f"{base}/health", timeout=5)
140
+ return
141
+ except Exception:
142
+ time.sleep(5)
143
+ raise TimeoutError("vLLM-Omni did not become healthy in time")
144
+
145
+
146
+ @app.function(
147
+ image=vox_image,
148
+ volumes={"/root/.cache/huggingface": hf_cache},
149
+ timeout=60 * MINUTES,
150
+ )
151
+ def download_model(model_name: str = MODEL_NAME) -> None:
152
+ """Pull VoxCPM2 weights to the Volume on CPU (no GPU billed)."""
153
+ from huggingface_hub import snapshot_download
154
+
155
+ print(f"Downloading {model_name} ...")
156
+ snapshot_download(model_name, ignore_patterns=["*.pt", "*.pth"])
157
+ hf_cache.commit()
158
+ print("Done.")
159
+
160
+
161
+ @app.function(image=vox_image, gpu=GPU, volumes=VOLUMES, timeout=60 * MINUTES)
162
+ def warm() -> None:
163
+ """One controlled GPU run: boot vLLM-Omni, fire a warm-up TTS request to
164
+ trigger every kernel compile, then commit the caches."""
165
+ proc = subprocess.Popen(_vllm_omni_cmd())
166
+ try:
167
+ print("Waiting for vLLM-Omni to compile + become healthy (first run is slow)...")
168
+ _wait_healthy()
169
+ req_data = json.dumps({
170
+ "model": SERVED_NAME,
171
+ "input": "Warm-up synthesis complete.",
172
+ "voice": "default",
173
+ "response_format": "wav",
174
+ }).encode()
175
+ req = urllib.request.Request(
176
+ f"http://127.0.0.1:{VLLM_PORT}/v1/audio/speech",
177
+ data=req_data,
178
+ headers={"Content-Type": "application/json"},
179
+ )
180
+ wav_bytes = urllib.request.urlopen(req, timeout=120).read()
181
+ print(f"Warm-up TTS response: {len(wav_bytes):,} bytes of WAV audio.")
182
+ finally:
183
+ proc.terminate()
184
+ try:
185
+ proc.wait(timeout=30)
186
+ except Exception:
187
+ proc.kill()
188
+ vllm_cache.commit()
189
+ flashinfer_cache.commit()
190
+ triton_cache.commit()
191
+ print("Warm complete — caches committed.")
192
+
193
+
194
+ @app.function(
195
+ image=vox_image,
196
+ gpu=GPU,
197
+ volumes=VOLUMES,
198
+ scaledown_window=10 * MINUTES,
199
+ timeout=60 * MINUTES,
200
+ max_containers=1,
201
+ )
202
+ @modal.concurrent(max_inputs=4)
203
+ @modal.web_server(port=VLLM_PORT, startup_timeout=25 * MINUTES)
204
+ def serve() -> None:
205
+ print("Launching:", " ".join(_vllm_omni_cmd()))
206
+ subprocess.Popen(_vllm_omni_cmd())
207
+
208
+
209
+ # =============================================================================
210
+ # STT — distil-whisper-large-v3 via faster-whisper-server
211
+ # Exposes OpenAI-compatible POST /v1/audio/transcriptions.
212
+ # Point STT_BASE_URL at the printed *.modal.run URL; set STT_MODEL=distil-whisper-large-v3.
213
+ # =============================================================================
214
+
215
+ stt_image = (
216
+ modal.Image.from_registry(
217
+ "nvidia/cuda:12.1.0-cudnn8-runtime-ubuntu22.04", add_python="3.12"
218
+ )
219
+ .entrypoint([])
220
+ .pip_install(
221
+ "faster-whisper",
222
+ "fastapi",
223
+ "uvicorn[standard]",
224
+ "python-multipart",
225
+ "hf_transfer",
226
+ "huggingface_hub",
227
+ )
228
+ .env({"HF_HUB_ENABLE_HF_TRANSFER": "1"})
229
+ )
230
+
231
+
232
+ @app.function(
233
+ image=stt_image,
234
+ volumes={"/root/.cache/huggingface": hf_cache},
235
+ timeout=30 * MINUTES,
236
+ )
237
+ def download_stt(model_name: str = STT_MODEL) -> None:
238
+ """Pull distil-Whisper weights to the shared HF cache Volume (CPU, no GPU billed)."""
239
+ from huggingface_hub import snapshot_download
240
+
241
+ print(f"Downloading {model_name} ...")
242
+ snapshot_download(model_name)
243
+ hf_cache.commit()
244
+ print("Done.")
245
+
246
+
247
+ @app.function(
248
+ image=stt_image,
249
+ gpu="T4",
250
+ volumes={"/root/.cache/huggingface": hf_cache},
251
+ scaledown_window=5 * MINUTES,
252
+ timeout=30 * MINUTES,
253
+ max_containers=1,
254
+ )
255
+ @modal.concurrent(max_inputs=4)
256
+ @modal.web_server(port=STT_PORT, startup_timeout=5 * MINUTES)
257
+ def transcribe() -> None:
258
+ with open("/tmp/stt_app.py", "w") as f:
259
+ f.write(_STT_SERVER)
260
+ cmd = ["uvicorn", "stt_app:app", "--host", "0.0.0.0", "--port", str(STT_PORT)]
261
+ print("Launching STT server (model:", STT_MODEL, ")")
262
+ subprocess.Popen(cmd, cwd="/tmp", env={**os.environ, "WHISPER_MODEL": STT_MODEL})
requirements.txt ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ gradio>=4.44
2
+ fastrtc>=0.0.18 # WebRTC real-time voice layer; install with: pip install "fastrtc[vad]"
3
+ pymupdf>=1.24
4
+ openai>=1.40
5
+ pillow>=10.0
6
+ python-dotenv>=1.0
7
+ huggingface_hub>=0.27
tests/test_agent.py ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import unittest
2
+
3
+ from ai_prof.agent import _extract_json, _validate_actions
4
+
5
+
6
+ class AgentValidationTests(unittest.TestCase):
7
+ def test_extract_json_accepts_fenced_output(self):
8
+ self.assertEqual(
9
+ _extract_json('```json\n{"narration": "Hello"}\n```'),
10
+ {"narration": "Hello"},
11
+ )
12
+
13
+ def test_validate_actions_bounds_navigation_and_whiteboard(self):
14
+ actions = _validate_actions(
15
+ [
16
+ {"tool": "goto_slide", "args": {"index": 3}},
17
+ {"tool": "next_slide", "args": {}},
18
+ {"tool": "goto_slide", "args": {"index": 99}},
19
+ {
20
+ "tool": "write_note",
21
+ "args": {"title": "Kernel", "body": "Weights"},
22
+ },
23
+ {"tool": "write_latex", "args": {"expression": "a+b"}},
24
+ {"tool": "clear_whiteboard", "args": {}},
25
+ {"tool": "unknown", "args": {}},
26
+ ],
27
+ total_slides=8,
28
+ )
29
+
30
+ self.assertEqual(
31
+ [action.tool for action in actions],
32
+ ["goto_slide", "write_note", "write_latex"],
33
+ )
34
+ self.assertEqual(actions[0].args, {"index": 3})
35
+
36
+ def test_validate_actions_rejects_empty_whiteboard_content(self):
37
+ actions = _validate_actions(
38
+ [
39
+ {"tool": "write_note", "args": {"title": "", "body": ""}},
40
+ {"tool": "write_latex", "args": {"expression": ""}},
41
+ ],
42
+ total_slides=2,
43
+ )
44
+ self.assertEqual(actions, ())
45
+
46
+
47
+ if __name__ == "__main__":
48
+ unittest.main()
tests/test_deck_cache.py ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import tempfile
2
+ import unittest
3
+ from pathlib import Path
4
+
5
+ from ai_prof.deck_cache import DeckCache
6
+ from ai_prof.pdf_utils import Deck, Slide
7
+
8
+
9
+ class DeckCacheTests(unittest.TestCase):
10
+ def test_save_and_load_round_trip(self):
11
+ with tempfile.TemporaryDirectory() as tmp:
12
+ source = Path(tmp) / "source.png"
13
+ source.write_bytes(b"png")
14
+ cache = DeckCache(root=str(Path(tmp) / "cache"))
15
+ deck = Deck(
16
+ slides=[
17
+ Slide(index=0, image_path=str(source), text="First slide"),
18
+ ]
19
+ )
20
+
21
+ cache.save(
22
+ "deck-key",
23
+ deck=deck,
24
+ readings={0: "TITLE: First\nCONCEPTS: caching"},
25
+ deck_index="1. First - caching",
26
+ metadata={"title": "Caching 101", "dpi": 150},
27
+ )
28
+ loaded = cache.load("deck-key")
29
+
30
+ self.assertIsNotNone(loaded)
31
+ self.assertEqual(loaded.deck.slides[0].text, "First slide")
32
+ self.assertEqual(
33
+ loaded.readings[0],
34
+ "TITLE: First\nCONCEPTS: caching",
35
+ )
36
+ self.assertEqual(loaded.deck_index, "1. First - caching")
37
+ self.assertTrue(Path(loaded.deck.slides[0].image_path).is_file())
38
+ self.assertEqual(
39
+ cache.list_decks()[0].title,
40
+ "Caching 101",
41
+ )
42
+
43
+ def test_key_changes_with_processing_settings(self):
44
+ with tempfile.TemporaryDirectory() as tmp:
45
+ pdf = Path(tmp) / "lecture.pdf"
46
+ pdf.write_bytes(b"same lecture")
47
+ cache = DeckCache(root=str(Path(tmp) / "cache"))
48
+
49
+ first = cache.key(str(pdf), dpi=150, vision_model="minicpm-v")
50
+ second = cache.key(str(pdf), dpi=200, vision_model="minicpm-v")
51
+ third = cache.key(str(pdf), dpi=150, vision_model="other-model")
52
+
53
+ self.assertNotEqual(first, second)
54
+ self.assertNotEqual(first, third)
55
+
56
+
57
+ if __name__ == "__main__":
58
+ unittest.main()
tests/test_orchestrator.py ADDED
@@ -0,0 +1,110 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import unittest
2
+ from unittest.mock import patch
3
+
4
+ from ai_prof.agent import AgentAction, TeachingBeat
5
+ from ai_prof.pdf_utils import Deck, Slide
6
+ import app
7
+
8
+
9
+ def _state() -> dict:
10
+ state = app._new_state()
11
+ state["deck"] = Deck(
12
+ slides=[
13
+ Slide(index=0, image_path="/tmp/slide-1.png", text="One"),
14
+ Slide(index=1, image_path="/tmp/slide-2.png", text="Two"),
15
+ Slide(index=2, image_path="/tmp/slide-3.png", text="Three"),
16
+ ]
17
+ )
18
+ state["readings"] = {
19
+ 0: "TITLE: One\nCONCEPTS: first",
20
+ 1: "TITLE: Two\nCONCEPTS: second",
21
+ 2: "TITLE: Three\nCONCEPTS: third",
22
+ }
23
+ state["deck_index"] = "1. One\n2. Two\n3. Three"
24
+ return state
25
+
26
+
27
+ class OrchestratorTests(unittest.TestCase):
28
+ def test_execute_actions_navigates_and_updates_whiteboard(self):
29
+ state = _state()
30
+ navigated = app._execute_actions(
31
+ state,
32
+ (
33
+ AgentAction("goto_slide", {"index": 3}),
34
+ AgentAction(
35
+ "write_note",
36
+ {"title": "Remember", "body": "Use the third slide."},
37
+ ),
38
+ ),
39
+ )
40
+
41
+ self.assertTrue(navigated)
42
+ self.assertEqual(state["index"], 2)
43
+ self.assertEqual(state["whiteboard"][0]["title"], "Remember")
44
+
45
+ @patch.object(app, "tts_speak_full", return_value=None)
46
+ @patch.object(app, "plan_teaching_beat")
47
+ def test_question_can_move_to_supporting_slide(self, plan, _tts):
48
+ plan.return_value = TeachingBeat(
49
+ narration="Let’s revisit the definition.",
50
+ actions=(AgentAction("goto_slide", {"index": 2}),),
51
+ continue_lecture=False,
52
+ )
53
+ outputs = list(app.on_ask("What was the definition?", _state(), []))
54
+ final = outputs[-1]
55
+
56
+ self.assertEqual(final[0]["index"], 1)
57
+ self.assertEqual(final[2], "Slide 2 / 3")
58
+ self.assertEqual(final[3][-1]["content"], "Let’s revisit the definition.")
59
+
60
+ @patch.object(app, "tts_speak_full", return_value=None)
61
+ @patch.object(app, "plan_teaching_beat")
62
+ def test_lecture_advances_when_agent_does_not_navigate(self, plan, _tts):
63
+ plan.side_effect = [
64
+ TeachingBeat("First beat.", continue_lecture=True),
65
+ TeachingBeat("Second beat.", continue_lecture=False),
66
+ ]
67
+ outputs = list(app.on_teach_deck(_state(), []))
68
+ final = outputs[-1]
69
+
70
+ self.assertEqual(final[0]["index"], 1)
71
+ self.assertEqual(
72
+ [message["content"] for message in final[3]],
73
+ ["First beat.", "Second beat."],
74
+ )
75
+
76
+ @patch.object(app, "tts_speak_full", return_value=None)
77
+ @patch.object(app, "plan_teaching_beat")
78
+ def test_lecture_ignores_agent_navigation_and_stays_sequential(self, plan, _tts):
79
+ plan.side_effect = [
80
+ TeachingBeat(
81
+ "First beat.",
82
+ actions=(AgentAction("goto_slide", {"index": 3}),),
83
+ continue_lecture=True,
84
+ ),
85
+ TeachingBeat("Second beat.", continue_lecture=False),
86
+ ]
87
+
88
+ outputs = list(app.on_teach_deck(_state(), []))
89
+ final = outputs[-1]
90
+
91
+ self.assertEqual(final[0]["index"], 1)
92
+ self.assertEqual(final[2], "Slide 2 / 3")
93
+
94
+ def test_index_select_moves_to_requested_slide(self):
95
+ state, _img, caption, _board = app.on_index_select(2, _state())
96
+
97
+ self.assertEqual(state["index"], 2)
98
+ self.assertEqual(caption, "Slide 3 / 3")
99
+
100
+ def test_whiteboard_emits_math_block_for_latex(self):
101
+ state = _state()
102
+ state["whiteboard"] = [{"type": "latex", "expression": r"x^2 + y^2"}]
103
+
104
+ board = app._whiteboard_view(state)
105
+
106
+ self.assertIn("$$\nx^2 + y^2\n$$", board)
107
+
108
+
109
+ if __name__ == "__main__":
110
+ unittest.main()
tests/test_voice.py ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import unittest
2
+ from unittest.mock import MagicMock, patch
3
+
4
+ import numpy as np
5
+
6
+ from ai_prof import rtc
7
+ from ai_prof.rtc import _has_speech
8
+
9
+
10
+ class VoiceGateTests(unittest.TestCase):
11
+ def test_silence_is_rejected(self):
12
+ self.assertFalse(_has_speech(np.zeros(16_000, dtype=np.float32)))
13
+
14
+ def test_audible_signal_is_accepted(self):
15
+ t = np.arange(16_000, dtype=np.float32) / 16_000
16
+ signal = 0.05 * np.sin(2 * np.pi * 220 * t)
17
+ self.assertTrue(_has_speech(signal))
18
+
19
+ def test_int16_audio_is_normalized(self):
20
+ signal = np.full(2_000, 1_000, dtype=np.int16)
21
+ self.assertTrue(_has_speech(signal))
22
+
23
+ @patch.object(rtc, "_voice_anchors", {"lecture": "data:audio/wav;base64,abc"})
24
+ @patch("openai.OpenAI")
25
+ def test_tts_reuses_session_voice_reference(self, openai_client):
26
+ speech = MagicMock()
27
+ openai_client.return_value.audio.speech = speech
28
+
29
+ rtc._speech_request("Next slide.", voice_key="lecture")
30
+
31
+ kwargs = speech.create.call_args.kwargs
32
+ self.assertEqual(
33
+ kwargs["extra_body"]["ref_audio"],
34
+ "data:audio/wav;base64,abc",
35
+ )
36
+ self.assertEqual(kwargs["input"], "Next slide.")
37
+
38
+
39
+ if __name__ == "__main__":
40
+ unittest.main()