Spaces:
Running
Running
Deploy AI Prof hackathon submission
Browse files- .env.example +59 -0
- .gitignore +39 -0
- ARCHITECTURE.md +240 -0
- IDEATION.md +168 -0
- README.md +148 -8
- ai_prof/__init__.py +3 -0
- ai_prof/agent.py +281 -0
- ai_prof/brain.py +127 -0
- ai_prof/config.py +101 -0
- ai_prof/deck_cache.py +219 -0
- ai_prof/pdf_utils.py +56 -0
- ai_prof/rtc.py +347 -0
- ai_prof/vision.py +130 -0
- app.py +1051 -0
- modal_app.py +150 -0
- modal_app_vision.py +261 -0
- modal_app_vox.py +262 -0
- requirements.txt +7 -0
- tests/test_agent.py +48 -0
- tests/test_deck_cache.py +58 -0
- tests/test_orchestrator.py +110 -0
- tests/test_voice.py +40 -0
.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:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: gradio
|
| 7 |
-
sdk_version:
|
| 8 |
-
python_version: '3.13'
|
| 9 |
app_file: app.py
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
---
|
| 12 |
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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()
|