Deb commited on
Commit
f655146
·
1 Parent(s): b5e4278

rupkotha_1st_commit

Browse files
.gitignore ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ .venv/
5
+ *.egg-info/
6
+ *.png
7
+ *.wav
8
+
9
+ # Caches / logs
10
+ .hf_cache/
11
+ finetune/.hf_cache/
12
+ *.log
13
+ .claude
14
+ .gradio/
15
+ data/
16
+ demo/
17
+ docs/
18
+ CLAUDE.md
19
+
20
+
21
+ # Audio artifacts — regenerable demos and large recordings don't belong in git
22
+ *.wav
23
+ *.mp3
24
+ *.flac
25
+
26
+ # Large, reproducible training data (regenerate via finetune/collect.py + preprocess.py).
27
+ # The distilled dataset finetune/data/train.json IS kept (the valuable artifact).
28
+ finetune/data/images/
29
+ finetune/data/processed/
30
+ finetune/data/labelset/
31
+ finetune/data/sample/
32
+ finetune/data/train_review.md
33
+ finetune/data/train_sample_review.md
34
+ finetune/data/train_sample.json
35
+ finetune/data/train_sample.json.rejected
36
+ finetune/data/train.json.rejected
37
+
38
+ # Eval / comparison outputs
39
+ finetune/eval_results/
40
+ finetune/quality_results/
41
+ tests/
42
+ tests/quality_results/
43
+ reference_audio/
44
+
45
+ # OS
46
+ .DS_Store
README.md CHANGED
@@ -1,14 +1,152 @@
1
  ---
2
  title: Rupkotha
3
- emoji: 🐨
4
- colorFrom: purple
5
- colorTo: indigo
6
  sdk: gradio
7
- sdk_version: 6.18.0
8
- python_version: '3.13'
9
  app_file: app.py
10
  pinned: false
11
- short_description: Submission for Build Small Hackathon
 
 
 
 
 
 
 
 
 
12
  ---
13
 
14
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
  title: Rupkotha
3
+ emoji: 🌙
4
+ colorFrom: indigo
5
+ colorTo: purple
6
  sdk: gradio
7
+ sdk_version: 6.17.3
 
8
  app_file: app.py
9
  pinned: false
10
+ short_description: Bedtime stories from kids' drawings, in English & Bengali
11
+ tags:
12
+ - backyard-ai
13
+ - openbmb
14
+ - modal
15
+ - children
16
+ - storytelling
17
+ - bengali
18
+ - tts
19
+ - vision-language-model
20
  ---
21
 
22
+ # রূপকথা · Rupkotha
23
+
24
+ A bedtime-story app for kids. A child shows their drawings or toys, asks for a
25
+ story in their own voice (English or Bengali), and hears it read back in a warm
26
+ grandmother's voice. Built for the Build Small Hackathon (Track 1: Backyard AI).
27
+
28
+ Inference runs on **Modal** (cloud GPUs) — the Gradio Space is a thin client with
29
+ zero model weights.
30
+
31
+ ## 🏆 Build Small Hackathon — submission
32
+
33
+ - **Track:** Practical · **Backyard AI** (a custom story generator — the track's own example use case).
34
+ - **Targeting:** 🥇 **OpenBMB Best MiniCPM Build** · ⚡ **Modal Best Use**.
35
+ - **🎬 Demo video:** https://youtu.be/mUUmy5JwBYo
36
+ - **📣 Social post:** `TODO_ADD_SOCIAL_URL`
37
+
38
+ **The idea.** Rupkotha (রূপকথা, "fairy tale") is a bedtime-story app for 4–9 year-olds
39
+ who can't yet type. A child shows a crayon drawing or a toy, asks for a story **by
40
+ voice** in **English or Bengali**, and hears it read aloud in a warm grandmother's
41
+ voice — gentle, short, always ending in sleep.
42
+
43
+ **The technical approach.**
44
+ - **Vision → story:** `MiniCPM-V 4.5` (8B) reads 1–4 images + the request and writes
45
+ a 120–150-word bedtime fable.
46
+ - **Native Bengali (our differentiator):** the stock model's Bengali was garbled, so
47
+ we **fine-tuned MiniCPM-V itself** — distilled ~389 native Bengali stories from a
48
+ Gemma teacher, LoRA-trained via ms-SWIFT, merged, and served on **vLLM**. A held-out
49
+ eval (Bengali speaker) confirmed it beats the base decisively.
50
+ - **Voice:** `faster-whisper` for speech input; **VoxCPM2** (English) and **AI4Bharat
51
+ Indic-TTS** (Bengali) for the grandmother voice output.
52
+ - **Infra:** every model runs on **Modal** (Ollama + vLLM + TTS containers, scale-to-zero
53
+ A10G/A100); the HF Space holds **zero weights** and just calls Modal functions. All
54
+ models are well under the 32B limit. Switching/serving is driven by one config object
55
+ (`core/model_config.py`).
56
+
57
+ > **Running this Space:** it calls Modal at runtime — set `MODAL_TOKEN_ID` and
58
+ > `MODAL_TOKEN_SECRET` as Space secrets, and deploy `core/modal_infra.py` +
59
+ > `finetune/serve_vllm.py` first. First call after idle is a cold start (~1–5 min);
60
+ > see the demo video for a smooth walkthrough.
61
+
62
+ ## Status
63
+
64
+ Story (EN + BN), STT, and TTS run on Modal via `core/modal_infra.py`. The Bengali
65
+ path is served by a **fine-tuned MiniCPM-V 4.5** (see below); English uses the
66
+ stock model. Each `core/` function degrades gracefully to a safe fallback if a
67
+ model is unavailable, so the app stays runnable. See `CLAUDE.md` for the full
68
+ build order and remaining UI/polish items.
69
+
70
+ ## The stack
71
+
72
+ The submission runs a single stack — **Stack A**, the OpenBMB prize path — defined in
73
+ `core/model_config.py`. (The `StackConfig` machinery remains so a stack could be
74
+ swapped in, but only Stack A is shipped.)
75
+
76
+ | Layer | Model | ~Params |
77
+ |---|---|---|
78
+ | Vision + story | MiniCPM-V 4.5 (Bengali fine-tuned), via Ollama / vLLM | 8B |
79
+ | STT | faster-whisper large-v3 | 1.55B |
80
+ | English TTS | VoxCPM2 (Voice Design) | 2B |
81
+ | Bengali TTS | AI4Bharat Indic-TTS (FastPitch + HiFi-GAN) | ~0.13B |
82
+
83
+ All OpenBMB-family core models (MiniCPM-V + VoxCPM2) → eligible for the OpenBMB Best
84
+ MiniCPM Build prize, strengthened by fine-tuning MiniCPM-V itself for Bengali.
85
+
86
+ ## Infrastructure
87
+
88
+ All model inference runs on **Modal** — the Gradio Space holds zero model weights
89
+ and makes zero local inference calls. Two Modal apps back the app:
90
+
91
+ - **`rupkotha`** (`core/modal_infra.py`) — the base runtime: vision/story
92
+ (MiniCPM-V 4.5 via Ollama), STT (faster-whisper), TTS (VoxCPM2 for English,
93
+ AI4Bharat Indic-TTS for Bengali), and the IndicTrans2 translation path.
94
+ - **`rupkotha-ft-serve`** (`finetune/serve_vllm.py`) — the **Bengali fine-tuned**
95
+ MiniCPM-V, served via **vLLM** on an A100-40GB (the full bf16 8B + vision
96
+ encoder needs more than a 24 GB card).
97
+
98
+ The `core/` wrappers call these functions remotely; switching
99
+ `COMPUTE_LOCATION` in `model_config.py` is the only change needed to run base
100
+ inference locally (requires a GPU with 8+ GB VRAM).
101
+
102
+ ```bash
103
+ uv run modal deploy core/modal_infra.py # base runtime (EN vision, STT, TTS)
104
+ uv run modal deploy finetune/serve_vllm.py # Bengali fine-tuned model (vLLM)
105
+ ```
106
+
107
+ ## Bengali fine-tune — improving the OpenBMB model itself
108
+
109
+ Stack A's one weak spot was native Bengali: stock MiniCPM-V 4.5 produced garbled,
110
+ repetitive Bengali. Rather than swap in a different model, we **fine-tuned the
111
+ OpenBMB model itself** to fix it — the strongest possible OpenBMB-prize story
112
+ (*"we improved MiniCPM-V for Bengali"*). The whole pipeline lives in `finetune/`
113
+ and runs on Modal:
114
+
115
+ 1. **Distill** native Bengali bedtime stories from a Gemma 3 teacher over ~450
116
+ children's drawings, filtered by a purity gate → **389 high-quality examples**.
117
+ 2. **LoRA fine-tune** MiniCPM-V 4.5 with **ms-SWIFT** on an A100-80GB — vision
118
+ encoder frozen, LoRA (r=16) on the LLM self-attention only (the weak-Bengali part).
119
+ 3. **Merge** the adapter into standalone weights and **serve via vLLM**.
120
+ 4. **Evaluate** FT vs the stock model on held-out drawings (`finetune/eval_ft.py`):
121
+ the fine-tune wins decisively — coherent native রূপকথা vs garbled, looping output
122
+ — confirmed by a Bengali speaker.
123
+
124
+ Bengali story requests now route to the fine-tuned model
125
+ (`FINETUNED_VISION_MODEL` in `model_config.py`, one-line revert to `None`); English
126
+ and audio paths are unchanged. See `finetune/README.md` for the full pipeline.
127
+
128
+ ## Prizes targeted
129
+
130
+ | Prize | Why eligible |
131
+ |---|---|
132
+ | Backyard AI track | Storybook generators are the track's example use case |
133
+ | OpenBMB Best MiniCPM Build | Stack A is all-OpenBMB, **and we fine-tuned MiniCPM-V 4.5 itself** for native Bengali |
134
+ | Modal Best Use | All inference on Modal; two apps — base runtime + a vLLM-served fine-tuned model, plus LoRA training on A100 |
135
+
136
+ ## Environment & setup
137
+
138
+ This project uses **[uv](https://docs.astral.sh/uv/)** for all package and
139
+ environment management (Python 3.10–3.12; VoxCPM2 requires < 3.13).
140
+
141
+ ```bash
142
+ uv venv --python 3.12 # create the environment
143
+ uv sync # install locked dependencies
144
+ uv run python app.py # launch the Gradio app
145
+ ```
146
+
147
+ Add dependencies with `uv add <pkg>` (never `pip`). `requirements.txt` is
148
+ generated from the lockfile (`uv export`), not hand-edited.
149
+
150
+ ## Layout
151
+
152
+ See `CLAUDE.md` §3 for the full repository layout and §6 for the build order.
app.py ADDED
@@ -0,0 +1,232 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # app.py — Gradio Blocks entry point. UI + wiring only. ZERO model references.
2
+ """Rupkotha (রূপকথা) — a bedtime-story app for kids.
3
+
4
+ This file orchestrates the UI and chains core functions:
5
+ transcribe() → generate_story() → speak()
6
+ It must contain no model names, paths, or model logic — those live only in core/.
7
+
8
+ Layout: a two-panel "studio" — a Create panel (language/style, pictures, ask) and a
9
+ Story panel (text + audio + save) — over a night-sky theme. Session memory uses
10
+ gr.State, never browser storage (CLAUDE.md §11).
11
+ """
12
+
13
+ from pathlib import Path
14
+
15
+ import gradio as gr
16
+
17
+ from core.vision_story import generate_story
18
+ from core.stt import transcribe
19
+ from core.tts import speak
20
+ from core.prompts import STYLES
21
+
22
+ # Language radio: display label → internal code passed to core functions.
23
+ _LANGUAGES = [("English", "en"), ("বাংলা", "bn")]
24
+ _STYLE_CHOICES = {lang: list(styles.keys()) for lang, styles in STYLES.items()}
25
+
26
+ _CSS_PATH = Path(__file__).parent / "assets" / "styles.css"
27
+
28
+ HISTORY_SIZE = 3 # how many recent stories to keep (CLAUDE.md §11: last 3)
29
+
30
+
31
+ def _styles_for(language: str):
32
+ """Return a style-dropdown update for the chosen language."""
33
+ choices = _STYLE_CHOICES.get(language, _STYLE_CHOICES["en"])
34
+ return gr.update(choices=choices, value=choices[0])
35
+
36
+
37
+ def _preview(files):
38
+ """Show uploaded images in the preview gallery; hide it when empty."""
39
+ files = files or []
40
+ return gr.update(value=files, visible=bool(files))
41
+
42
+
43
+ def _voice_to_text(audio_path, language):
44
+ """Transcribe a mic recording into the instruction box. On empty/failed
45
+ transcription, leave whatever the child already typed untouched."""
46
+ print(f"[mic] stop_recording fired · audio={audio_path!r} · lang={language}", flush=True)
47
+ text = transcribe(audio_path, language)
48
+ print(f"[mic] transcribe -> {text!r}", flush=True)
49
+ return text if text else gr.update()
50
+
51
+
52
+ def _tell_a_story(images, instruction, language, style, child_name):
53
+ """Chain: images + instruction → story text → grandmother-voice audio.
54
+
55
+ Each core call degrades gracefully (never raises), so the UI always shows
56
+ a story even if Modal is unreachable or audio synthesis fails. Also returns
57
+ a `current` dict so the Save button can capture the exact result shown.
58
+ """
59
+ image_paths = [img for img in (images or [])]
60
+ story, model_label = generate_story(
61
+ image_paths=image_paths,
62
+ instruction=instruction or "",
63
+ language=language,
64
+ style=style,
65
+ child_name=child_name or "",
66
+ )
67
+ wav_path, tts_label = speak(story, language)
68
+ badge = f"📖 {model_label} · 🔊 {tts_label}"
69
+ current = {"story": story, "audio": wav_path, "badge": badge}
70
+ return story, wav_path, badge, current
71
+
72
+
73
+ def _history_updates(history):
74
+ """Flatten `history` into per-slot updates: (group, markdown, audio) × N."""
75
+ updates = []
76
+ for i in range(HISTORY_SIZE):
77
+ if i < len(history):
78
+ entry = history[i]
79
+ body = f"{entry['story']}\n\n<span class='saved-badge'>{entry['badge']}</span>"
80
+ updates += [
81
+ gr.update(visible=True),
82
+ gr.update(value=body),
83
+ gr.update(value=entry.get("audio")),
84
+ ]
85
+ else:
86
+ updates += [
87
+ gr.update(visible=False),
88
+ gr.update(value=""),
89
+ gr.update(value=None),
90
+ ]
91
+ return updates
92
+
93
+
94
+ def _save_story(current, history):
95
+ """Prepend the current story to the session history (newest first, max N)."""
96
+ history = list(history or [])
97
+ if current and current.get("story"):
98
+ history = ([current] + history)[:HISTORY_SIZE]
99
+ return [history, *_history_updates(history)]
100
+
101
+
102
+ def build_ui() -> gr.Blocks:
103
+ theme = gr.themes.Soft(
104
+ primary_hue="amber",
105
+ secondary_hue="orange",
106
+ neutral_hue="slate",
107
+ radius_size="lg",
108
+ font=[gr.themes.GoogleFont("Nunito"), "ui-sans-serif", "sans-serif"],
109
+ )
110
+ css_kw = {"css_paths": [str(_CSS_PATH)]} if _CSS_PATH.exists() else {}
111
+ with gr.Blocks(title="রূপকথা · Rupkotha", theme=theme, fill_width=True, **css_kw) as demo:
112
+ # ── Hero ─────────────────────────────────────────────────────────
113
+ gr.HTML(
114
+ """
115
+ <div id="hero">
116
+ <div class="hero-moon">🌙</div>
117
+ <h1>রূপকথা · Rupkotha</h1>
118
+ <p>Show a picture, ask for a story — and hear it told in a warm
119
+ grandmother's voice.</p>
120
+ </div>
121
+ """
122
+ )
123
+
124
+ with gr.Row(elem_id="studio", equal_height=False):
125
+ # ── Create panel ─────────────────────────────────────────────
126
+ with gr.Column(scale=5, elem_classes="panel"):
127
+ gr.HTML('<div class="panel-head"><span class="step">1</span>Choose</div>')
128
+ with gr.Row():
129
+ language = gr.Radio(
130
+ choices=_LANGUAGES, value="en",
131
+ label="Language · ভাষা", elem_classes="seg",
132
+ )
133
+ style = gr.Dropdown(
134
+ choices=_STYLE_CHOICES["en"], value=_STYLE_CHOICES["en"][0],
135
+ label="Story style",
136
+ )
137
+
138
+ gr.HTML('<div class="panel-head"><span class="step">2</span>Show your pictures</div>')
139
+ images = gr.File(
140
+ file_count="multiple",
141
+ type="filepath",
142
+ file_types=["image"],
143
+ label="Drawings or toys — 1 to 4 pictures",
144
+ elem_classes="upload-box",
145
+ )
146
+ preview = gr.Gallery(
147
+ label="Your pictures",
148
+ columns=4,
149
+ height="auto",
150
+ object_fit="contain", # show the whole image, don't crop/trim
151
+ show_label=True,
152
+ visible=False,
153
+ elem_classes="preview",
154
+ )
155
+
156
+ gr.HTML('<div class="panel-head"><span class="step">3</span>Ask for a story</div>')
157
+ mic = gr.Audio(
158
+ sources=["microphone"],
159
+ type="filepath",
160
+ label="🎤 Speak your request (optional) — it fills the box below",
161
+ )
162
+ instruction = gr.Textbox(
163
+ label="What story do you want?",
164
+ placeholder="tell me a story about my dragon…",
165
+ lines=2,
166
+ )
167
+ child_name = gr.Textbox(
168
+ label="Your name (optional)",
169
+ placeholder="e.g. Rupa — woven into the story",
170
+ lines=1,
171
+ )
172
+ generate_btn = gr.Button(
173
+ "✨ Tell me a story", variant="primary", size="lg",
174
+ elem_id="generate-btn",
175
+ )
176
+
177
+ # ── Story panel ──────────────────────────────────────────────
178
+ with gr.Column(scale=6, elem_classes="panel story-panel"):
179
+ gr.HTML('<div class="panel-head">📖 Your story</div>')
180
+ story_out = gr.Textbox(
181
+ show_label=False,
182
+ lines=8,
183
+ max_lines=40, # grow to fit the whole story (no inner scrollbar)
184
+ autoscroll=False,
185
+ placeholder="Your bedtime story will appear here… ✨",
186
+ elem_classes="story-text",
187
+ container=False,
188
+ )
189
+ audio_out = gr.Audio(label="🔊 Listen (press play to replay)", type="filepath")
190
+ badge_out = gr.Markdown(elem_classes="model-badge")
191
+ save_btn = gr.Button("💾 Save this story", elem_id="save-btn")
192
+
193
+ # ── Saved stories: last 3, each replayable (gr.State session memory) ─
194
+ current = gr.State(None)
195
+ history = gr.State([])
196
+ gr.HTML('<div class="section-title">🌟 Your saved stories</div>')
197
+ slots = []
198
+ with gr.Row(elem_id="history-row", equal_height=False):
199
+ for _ in range(HISTORY_SIZE):
200
+ with gr.Column(scale=1, min_width=240):
201
+ with gr.Group(visible=False, elem_classes="saved-card") as slot_group:
202
+ slot_md = gr.Markdown(elem_classes="saved-text")
203
+ slot_audio = gr.Audio(type="filepath", label="Replay")
204
+ slots.append((slot_group, slot_md, slot_audio))
205
+
206
+ # ── Wiring ───────────────────────────────────────────────────────
207
+ language.change(_styles_for, inputs=language, outputs=style)
208
+
209
+ # Show thumbnails of the uploaded pictures.
210
+ images.change(_preview, inputs=images, outputs=preview)
211
+
212
+ # Voice is a bonus: it fills the typed box, which stays primary (§2, §14).
213
+ mic.stop_recording(_voice_to_text, inputs=[mic, language], outputs=instruction)
214
+
215
+ generate_btn.click(
216
+ _tell_a_story,
217
+ inputs=[images, instruction, language, style, child_name],
218
+ outputs=[story_out, audio_out, badge_out, current],
219
+ )
220
+
221
+ # Flatten slots for the Save outputs: history + (group, md, audio) × N.
222
+ slot_outputs = [comp for slot in slots for comp in slot]
223
+ save_btn.click(
224
+ _save_story,
225
+ inputs=[current, history],
226
+ outputs=[history, *slot_outputs],
227
+ )
228
+ return demo
229
+
230
+
231
+ if __name__ == "__main__":
232
+ build_ui().queue().launch()
assets/styles.css ADDED
@@ -0,0 +1,211 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* Rupkotha — calm "storybook" theme. Professional, low-contrast, presentable.
2
+ Soft parchment/lavender background, deep slate-indigo ink, muted terracotta +
3
+ dusty-gold accents. Warm and gentle — not high-contrast. */
4
+
5
+ @import url('https://fonts.googleapis.com/css2?family=Baloo+2:wght@500;600;700&family=Noto+Sans+Bengali:wght@500;600;700&family=Nunito:wght@400;500;600;700;800&display=swap');
6
+
7
+ :root {
8
+ --bg-1: #eef0f7; /* soft lavender mist */
9
+ --bg-2: #f7f2ea; /* warm cream */
10
+ --ink: #333a5c; /* deep slate-indigo (headings) */
11
+ --ink-body: #4a5170; /* body text */
12
+ --ink-soft: #7b81a0; /* secondary/placeholder */
13
+ --panel: #ffffff;
14
+ --panel-2: #fbfaf6;
15
+ --panel-brd: rgba(51, 58, 92, 0.10);
16
+ --accent: #c4865a; /* muted terracotta */
17
+ --accent-2: #d6a463; /* dusty gold */
18
+ --accent-ink: #5a3a1e;
19
+ --story-bg: #fbf6ec; /* parchment */
20
+ --story-ink: #43392b;
21
+ --radius: 20px;
22
+ --radius-sm: 13px;
23
+ --shadow: 0 12px 34px rgba(46, 52, 88, 0.10);
24
+ --shadow-sm: 0 4px 14px rgba(46, 52, 88, 0.08);
25
+ }
26
+
27
+ /* ── Background: soft, calm gradient (no harsh dark) ──────────────────── */
28
+ .gradio-container,
29
+ .gradio-container .fillable,
30
+ body {
31
+ background:
32
+ radial-gradient(900px 480px at 85% -12%, rgba(214, 164, 99, 0.10), transparent 60%),
33
+ radial-gradient(820px 460px at 5% -6%, rgba(150, 160, 210, 0.12), transparent 58%),
34
+ linear-gradient(165deg, var(--bg-1) 0%, var(--bg-2) 70%) !important;
35
+ color: var(--ink-body) !important;
36
+ font-family: 'Nunito', ui-sans-serif, system-ui, sans-serif;
37
+ }
38
+
39
+ .gradio-container { max-width: 100% !important; margin: 0 !important; padding: 0 28px !important; }
40
+
41
+ /* ── Hero ────────────────────────────────────────────────────────────── */
42
+ #hero { text-align: center; padding: 1.7rem 1rem 0.4rem; }
43
+ #hero .hero-moon {
44
+ font-size: 2.6rem; line-height: 1;
45
+ filter: drop-shadow(0 6px 14px rgba(196, 134, 90, 0.30));
46
+ animation: float 6s ease-in-out infinite;
47
+ }
48
+ @keyframes float { 0%,100%{ transform: translateY(0) } 50%{ transform: translateY(-6px) } }
49
+ #hero h1 {
50
+ font-family: 'Baloo 2', 'Noto Sans Bengali', sans-serif;
51
+ font-size: 2.5rem; font-weight: 700; margin: .3rem 0 .25rem;
52
+ color: var(--ink);
53
+ letter-spacing: .2px;
54
+ }
55
+ #hero p { color: var(--ink-soft); font-size: 1.05rem; max-width: 560px; margin: 0 auto; }
56
+
57
+ /* ── Panels (soft cards) ─────────────────────────────────────────────── */
58
+ #studio { gap: 22px !important; margin-top: .8rem; align-items: stretch; }
59
+ .panel {
60
+ background: var(--panel) !important;
61
+ border: 1px solid var(--panel-brd) !important;
62
+ border-radius: var(--radius) !important;
63
+ box-shadow: var(--shadow);
64
+ padding: 18px 20px !important;
65
+ }
66
+ .panel .block, .panel .form, .panel .gr-group, .panel fieldset {
67
+ background: transparent !important; border: none !important; box-shadow: none !important;
68
+ }
69
+
70
+ /* Section headers inside panels. */
71
+ .panel-head {
72
+ display: flex; align-items: center; gap: .55rem;
73
+ font-family: 'Baloo 2', sans-serif; font-weight: 600;
74
+ font-size: 1.1rem; color: var(--ink);
75
+ margin: .7rem 0 .4rem;
76
+ }
77
+ .panel-head .step {
78
+ display: inline-grid; place-items: center;
79
+ width: 1.5rem; height: 1.5rem; border-radius: 50%;
80
+ background: linear-gradient(135deg, var(--accent-2), var(--accent));
81
+ color: #fff; font-size: .82rem; font-weight: 700;
82
+ box-shadow: var(--shadow-sm);
83
+ }
84
+
85
+ /* ── Labels & text ───────────────────────────────────────────────────── */
86
+ .gradio-container label,
87
+ .gradio-container .gr-text { color: var(--ink-body) !important; }
88
+ .gradio-container label > span,
89
+ .gradio-container .gr-form > span { font-weight: 600 !important; color: var(--ink) !important; }
90
+
91
+ /* ── Inputs ──────────────────────────────────────────────────────────── */
92
+ .gradio-container input[type="text"],
93
+ .gradio-container textarea,
94
+ .gradio-container .gr-dropdown,
95
+ .gradio-container select {
96
+ background: var(--panel-2) !important;
97
+ border: 1px solid rgba(51, 58, 92, 0.14) !important;
98
+ border-radius: var(--radius-sm) !important;
99
+ color: var(--ink) !important;
100
+ font-size: 1.04rem !important;
101
+ }
102
+ .gradio-container input::placeholder,
103
+ .gradio-container textarea::placeholder { color: var(--ink-soft) !important; }
104
+ .gradio-container input:focus,
105
+ .gradio-container textarea:focus {
106
+ border-color: var(--accent) !important;
107
+ box-shadow: 0 0 0 3px rgba(196, 134, 90, 0.16) !important;
108
+ }
109
+
110
+ /* Upload drop zone. */
111
+ .upload-box, .upload-box .center, .upload-box .wrap {
112
+ border-radius: var(--radius-sm) !important;
113
+ border: 1.5px dashed rgba(196, 134, 90, 0.45) !important;
114
+ background: rgba(214, 164, 99, 0.06) !important;
115
+ color: var(--ink-body) !important;
116
+ }
117
+
118
+ /* ── Image preview gallery ───────────────────────────────────────────── */
119
+ .preview {
120
+ border-radius: var(--radius-sm) !important;
121
+ border: 1px solid var(--panel-brd) !important;
122
+ background: var(--panel-2) !important;
123
+ padding: 6px !important; margin-top: .4rem;
124
+ }
125
+ .preview .thumbnail-item, .preview img, .preview button.thumbnail-item {
126
+ border-radius: 10px !important;
127
+ box-shadow: var(--shadow-sm);
128
+ }
129
+
130
+ /* ── Buttons ─────────────────────────────────────────────────────────── */
131
+ #generate-btn {
132
+ font-family: 'Baloo 2', sans-serif !important;
133
+ font-size: 1.3rem !important; font-weight: 700 !important;
134
+ padding: .85rem 1.5rem !important; margin-top: .9rem !important; width: 100% !important;
135
+ background: linear-gradient(135deg, var(--accent-2), var(--accent)) !important;
136
+ color: #fff !important; border: none !important; border-radius: 999px !important;
137
+ box-shadow: 0 8px 22px rgba(196, 134, 90, 0.32) !important;
138
+ transition: transform .15s ease, box-shadow .15s ease !important;
139
+ }
140
+ #generate-btn:hover { transform: translateY(-2px); box-shadow: 0 12px 28px rgba(196,134,90,.40) !important; }
141
+ #generate-btn:active { transform: translateY(0); }
142
+ #save-btn {
143
+ font-weight: 700 !important; border-radius: 999px !important;
144
+ border: 1.5px solid var(--accent) !important; color: var(--accent) !important;
145
+ background: transparent !important; margin-top: .5rem !important;
146
+ }
147
+ #save-btn:hover { background: rgba(196, 134, 90, 0.10) !important; }
148
+
149
+ /* ── Story panel ─────────────────────────────────────────────────────── */
150
+ .story-panel { display: flex; flex-direction: column; }
151
+ .story-text textarea {
152
+ font-family: 'Baloo 2', 'Noto Sans Bengali', serif !important;
153
+ font-size: 1.32rem !important; line-height: 1.85 !important;
154
+ background: var(--story-bg) !important;
155
+ color: var(--story-ink) !important;
156
+ border: 1px solid rgba(196, 134, 90, 0.18) !important;
157
+ border-radius: var(--radius-sm) !important;
158
+ padding: 1.1rem 1.3rem !important;
159
+ min-height: 180px; /* small floor for the empty state; grows to fit content */
160
+ height: auto !important;
161
+ overflow-y: auto;
162
+ }
163
+
164
+ /* ── Model badge ─────────────────────────────────────────────────────── */
165
+ .model-badge p {
166
+ display: inline-block; margin: .3rem 0 0 !important;
167
+ background: rgba(214, 164, 99, 0.12);
168
+ border: 1px solid rgba(196, 134, 90, 0.30);
169
+ border-radius: 999px; padding: .35rem 1rem;
170
+ font-size: .82rem; color: var(--accent-ink) !important;
171
+ }
172
+
173
+ /* ── Saved stories ───────────────────────────────────────────────────── */
174
+ .section-title {
175
+ font-family: 'Baloo 2', sans-serif; font-weight: 600; font-size: 1.28rem;
176
+ color: var(--ink); text-align: center; margin: 1.8rem 0 .7rem;
177
+ }
178
+ #history-row { gap: 16px !important; }
179
+ .saved-card {
180
+ background: var(--panel) !important;
181
+ border: 1px solid var(--panel-brd) !important;
182
+ border-radius: var(--radius) !important;
183
+ box-shadow: var(--shadow-sm); padding: 14px 16px !important;
184
+ }
185
+ .saved-text, .saved-text p {
186
+ font-family: 'Baloo 2', 'Noto Sans Bengali', sans-serif;
187
+ font-size: 1rem !important; line-height: 1.62; color: var(--story-ink) !important;
188
+ }
189
+ .saved-badge { display: inline-block; margin-top: .5rem; font-size: .72rem; color: var(--accent); }
190
+
191
+ #stack-note {
192
+ text-align: center; color: var(--ink-soft);
193
+ font-size: .9rem; margin: 1.8rem 0 1rem;
194
+ }
195
+ #stack-note b { color: var(--accent); }
196
+
197
+ /* ── Accessibility ───────────────────────────────────────────────────── */
198
+ .gradio-container *:focus-visible {
199
+ outline: 3px solid var(--accent) !important; outline-offset: 2px;
200
+ }
201
+ @media (prefers-reduced-motion: reduce) {
202
+ #hero .hero-moon { animation: none !important; }
203
+ .gradio-container * { animation: none !important; transition: none !important; }
204
+ }
205
+
206
+ /* ── Mobile ──────────────────────────────────────────────────────────── */
207
+ @media (max-width: 820px) {
208
+ #studio { flex-direction: column !important; }
209
+ #hero h1 { font-size: 2rem; }
210
+ .story-text textarea { font-size: 1.22rem !important; min-height: 240px; }
211
+ }
core/__init__.py ADDED
File without changes
core/modal_infra.py ADDED
@@ -0,0 +1,785 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ core/modal_infra.py — ALL Modal GPU functions live here.
3
+
4
+ app.py and the other core/ wrappers call these remote functions.
5
+ Nothing outside this file should import torch, transformers, parler_tts,
6
+ faster_whisper, or any other ML library directly.
7
+
8
+ GPU tier and container settings are read from model_config.get_compute()
9
+ so that changing MODAL_GPU or MODAL_MIN_CONTAINERS in model_config.py
10
+ propagates here automatically.
11
+ """
12
+
13
+ import base64
14
+
15
+ import modal
16
+ from core.model_config import get_compute, get_config
17
+ from core.prompts import SCENE_SENTINEL
18
+
19
+ _compute = get_compute()
20
+
21
+ app = modal.App("rupkotha")
22
+
23
+ # Vision image: Ollama serves every stack's vision model behind one uniform API
24
+ # (MiniCPM-V for Stack A, Gemma 3 for Stacks B/C). Switching stacks only changes
25
+ # the model tag passed in from model_config.py — never this code.
26
+ _ollama_image = (
27
+ modal.Image.debian_slim(python_version="3.12")
28
+ # zstd is required by the Ollama install script to extract its release archive.
29
+ .apt_install("curl", "zstd")
30
+ .run_commands("curl -fsSL https://ollama.com/install.sh | sh")
31
+ .pip_install("ollama>=0.3")
32
+ # This module imports core.model_config at load; current Modal no longer
33
+ # auto-mounts the project, so make `core` importable in the container.
34
+ .add_local_python_source("core")
35
+ )
36
+
37
+ # Pulled Ollama models persist here across containers, so a model is downloaded
38
+ # at most once per stack (not on every cold start).
39
+ _ollama_volume = modal.Volume.from_name("rupkotha-ollama", create_if_missing=True)
40
+
41
+ # HuggingFace weights (TTS/STT) persist here so they download at most once.
42
+ _hf_volume = modal.Volume.from_name("rupkotha-hf", create_if_missing=True)
43
+ _HF_CACHE = "/root/.cache/huggingface"
44
+
45
+ # HF auth for gated repos (e.g. ai4bharat/indic-parler-tts). Reuses the existing
46
+ # 'algaeguard-secrets' Modal secret, which provides HF_TOKEN — huggingface_hub /
47
+ # transformers read HF_TOKEN from the environment automatically.
48
+ _hf_secrets = [modal.Secret.from_name("algaeguard-secrets")]
49
+
50
+ # Warm-container model caches for the TTS/STT singletons. Populated lazily inside
51
+ # their respective functions; reused across calls in the same container.
52
+ _indic_parler: dict = {}
53
+ _voxcpm: dict = {}
54
+ _whisper: dict = {}
55
+ _indictrans: dict = {}
56
+ _indictts: dict = {}
57
+
58
+ # ML image: used by the (still-stubbed) TTS/STT functions — transformers,
59
+ # faster-whisper, parler-tts. Kept separate from the vision image so each
60
+ # function pulls only what it needs and cold-starts faster.
61
+ _ml_image = (
62
+ modal.Image.debian_slim(python_version="3.12")
63
+ .apt_install("git") # needed for the git-based parler-tts install below
64
+ .pip_install(
65
+ "torch>=2.2",
66
+ "transformers>=4.40",
67
+ "faster-whisper>=1.0",
68
+ "voxcpm", # English TTS — VoxCPM2 (needs Python < 3.13; 3.12 here is fine)
69
+ "indictranstoolkit", # IndicTrans2 pre/post-processing for the Bengali pivot
70
+ "soundfile",
71
+ "numpy",
72
+ )
73
+ .run_commands(
74
+ "pip install git+https://github.com/huggingface/parler-tts.git"
75
+ )
76
+ .add_local_python_source("core") # see note on _ollama_image above
77
+ )
78
+
79
+ # AI4Bharat Indic-TTS (FastPitch + HiFi-GAN) image. AI4Bharat's repo isn't a Python
80
+ # package — inference just uses a Coqui `Synthesizer` over their checkpoints. We use
81
+ # the maintained `coqui-tts` fork (Synthesizer API unchanged) and skip their heavy
82
+ # Indic text-normalization/denoiser layer (fragile pinned deps; unneeded for our
83
+ # clean Bengali-script input). Python 3.10 for best coqui-tts/checkpoint compat.
84
+ _indictts_image = (
85
+ modal.Image.debian_slim(python_version="3.10")
86
+ .apt_install("wget", "unzip", "libsndfile1", "espeak-ng")
87
+ # coqui-tts 0.27 imports transformers.pytorch_utils.isin_mps_friendly, removed in
88
+ # transformers 5.x — pin to 4.x so `import TTS` (pulls XTTS/tortoise) works.
89
+ .pip_install("coqui-tts[codec]", "transformers<5", "torch", "torchaudio", "soundfile", "numpy")
90
+ .add_local_python_source("core")
91
+ )
92
+ # Persists the ~1.5 GB Bengali checkpoint zip (downloaded + unzipped once).
93
+ _indictts_volume = modal.Volume.from_name("rupkotha-indictts", create_if_missing=True)
94
+
95
+ _gpu = _compute["gpu"]
96
+ _min_containers = _compute["min_containers"]
97
+
98
+
99
+ def _ensure_ollama_server() -> None:
100
+ """Start `ollama serve` in the background if it isn't already responding.
101
+
102
+ Idempotent: safe to call on every invocation. Runs only inside the Modal
103
+ container (never in the local Gradio process)."""
104
+ import subprocess
105
+ import time
106
+ import urllib.request
107
+
108
+ def _ready() -> bool:
109
+ try:
110
+ urllib.request.urlopen("http://127.0.0.1:11434/api/version", timeout=1)
111
+ return True
112
+ except Exception:
113
+ return False
114
+
115
+ if _ready():
116
+ return
117
+ subprocess.Popen(
118
+ ["ollama", "serve"],
119
+ stdout=subprocess.DEVNULL,
120
+ stderr=subprocess.DEVNULL,
121
+ )
122
+ for _ in range(120): # up to ~60s for the server to come up
123
+ if _ready():
124
+ return
125
+ time.sleep(0.5)
126
+ raise RuntimeError("ollama server did not become ready in time")
127
+
128
+
129
+ # ─────────────────────────────────────────────────────────────────────────────
130
+ # Vision + story generation
131
+ # ─────────────────────────────────────────────────────────────────────────────
132
+
133
+ @app.function(
134
+ gpu=_gpu,
135
+ image=_ollama_image,
136
+ volumes={"/root/.ollama": _ollama_volume},
137
+ timeout=900, # generous: covers a first-time model pull on a cold container
138
+ min_containers=_min_containers,
139
+ )
140
+ def run_vision_story(
141
+ image_bytes_list: list[bytes],
142
+ prompt: str,
143
+ model_id: str,
144
+ options: dict | None = None,
145
+ describe_prompt: str | None = None,
146
+ ) -> str:
147
+ """Generate a bedtime story from images + prompt via Ollama.
148
+
149
+ Model-agnostic: the call serves whatever Ollama tag `model_id` names — Stack A
150
+ uses MiniCPM-V 4.5. (Bengali is served separately by the fine-tuned model via
151
+ finetune/serve_vllm.py; this Ollama path handles English.)
152
+
153
+ Args:
154
+ image_bytes_list: Raw bytes of each uploaded image (1–4 images).
155
+ prompt: Full story-generation prompt (built by core/prompts.py).
156
+ model_id: Ollama model tag from get_config().vision_model
157
+ (e.g. 'openbmb/minicpm-v4.5').
158
+ options: Ollama decoding params (temperature, top_p, repeat_penalty, …).
159
+ Per-language profiles come from model_config.get_vision_options().
160
+ describe_prompt: Lever C (two-pass). When set, the model first DESCRIBES
161
+ the image(s) in English using this prompt, then narrates the story
162
+ text-only — `prompt` must contain SCENE_SENTINEL, which is replaced
163
+ with that description. Keeps perception and Bengali prose separate.
164
+
165
+ Returns:
166
+ Generated story text, or '' on failure.
167
+ """
168
+ import time
169
+
170
+ import ollama
171
+
172
+ _ensure_ollama_server()
173
+
174
+ # Pull on first use; the Volume keeps the weights warm for later calls.
175
+ # NOTE: the Stack A tag 'openbmb/minicpm-v4.5' is confirmed valid in the
176
+ # Ollama registry.
177
+ listed = ollama.list()
178
+ models = getattr(listed, "models", None) or listed.get("models", []) or []
179
+ local_tags = {(getattr(m, "model", None) or m.get("model", "")) for m in models}
180
+ if model_id not in local_tags and f"{model_id}:latest" not in local_tags:
181
+ print(f"[run_vision_story] pulling {model_id} (first use, may take minutes) ...")
182
+ ollama.pull(model_id)
183
+ _ollama_volume.commit()
184
+ print(f"[run_vision_story] pull complete for {model_id}")
185
+
186
+ chat_options = options or {"temperature": 0.8}
187
+
188
+ def _chat_once(prompt_text: str, images: list[bytes]) -> tuple[str, object]:
189
+ # The ollama client base64-encodes raw image bytes itself.
190
+ kwargs = dict(
191
+ model=model_id,
192
+ messages=[
193
+ {"role": "user", "content": prompt_text, "images": list(images or [])}
194
+ ],
195
+ options=chat_options,
196
+ )
197
+ # think=False disables MiniCPM-V's reasoning mode, which can otherwise
198
+ # consume the whole token budget (done_reason='length') and leave the
199
+ # answer 'content' empty. Fall back for older ollama clients.
200
+ try:
201
+ resp = ollama.chat(**kwargs, think=False)
202
+ except TypeError:
203
+ resp = ollama.chat(**kwargs)
204
+ # ollama returns a typed ChatResponse (subscriptable) or a plain dict.
205
+ try:
206
+ c = resp["message"]["content"]
207
+ except Exception:
208
+ c = getattr(getattr(resp, "message", None), "content", "") or ""
209
+ return (c or "").strip(), resp
210
+
211
+ def _chat_retry(prompt_text: str, images: list[bytes]) -> str:
212
+ # A freshly started server sometimes returns empty on the very first call
213
+ # while the model finishes loading into VRAM — retry a couple of times.
214
+ text, resp = _chat_once(prompt_text, images)
215
+ for attempt in range(2):
216
+ if text:
217
+ break
218
+ print(f"[run_vision_story] empty content (attempt {attempt + 1}); resp={repr(resp)[:200]}")
219
+ time.sleep(2)
220
+ text, resp = _chat_once(prompt_text, images)
221
+ return text
222
+
223
+ if describe_prompt:
224
+ # Lever C, pass 1: describe the image(s) in English (the model's strength).
225
+ description = _chat_retry(describe_prompt, image_bytes_list)
226
+ print(f"[run_vision_story] scene description: {description[:200]}")
227
+ # Pass 2: narrate from the description, text-only (no image attached).
228
+ story_prompt = prompt.replace(SCENE_SENTINEL, description)
229
+ return _chat_retry(story_prompt, [])
230
+
231
+ # Single pass: image(s) + prompt together.
232
+ return _chat_retry(prompt, image_bytes_list)
233
+
234
+
235
+ def generate_story_remote(
236
+ images_b64: list[str],
237
+ prompt: str,
238
+ options: dict | None = None,
239
+ describe_prompt: str | None = None,
240
+ ) -> str:
241
+ """Plain-callable entry point used by core/vision_story.py.
242
+
243
+ Decodes the base64 images, reads the active vision model tag from
244
+ get_config() internally (model names never leave model_config.py), and
245
+ dispatches to the deployed Modal `run_vision_story` function. `options`
246
+ carries the per-language decoding profile from get_vision_options();
247
+ `describe_prompt` enables two-pass (Lever C) for Bengali.
248
+
249
+ Requires the Modal app to be deployed (`modal deploy core/modal_infra.py`)
250
+ and Modal credentials available to the Gradio process. May raise — callers
251
+ in core/vision_story.py wrap this in try/except and fall back to a friendly
252
+ bedtime message, so the app stays runnable even if Modal is unreachable.
253
+ """
254
+ image_bytes_list = [base64.b64decode(b) for b in (images_b64 or [])]
255
+ model_id = get_config().vision_model
256
+ fn = modal.Function.from_name("rupkotha", "run_vision_story")
257
+ return fn.remote(image_bytes_list, prompt, model_id, options, describe_prompt)
258
+
259
+
260
+ def generate_story_ft_remote(images_b64: list[str], prompt: str) -> str:
261
+ """Plain-callable entry point for the Bengali-fine-tuned model, deployed
262
+ separately as the `rupkotha-ft-serve` app (finetune/serve_vllm.py — merged
263
+ LoRA served via vLLM). Used by core/vision_story.py only when
264
+ model_config.FINETUNED_VISION_MODEL is set. Mirrors generate_story_remote's
265
+ contract; may raise — callers wrap in try/except. Kept here (not imported from
266
+ finetune/) so core/ stays independent of the training package."""
267
+ image_bytes_list = [base64.b64decode(b) for b in (images_b64 or [])]
268
+ fn = modal.Function.from_name("rupkotha-ft-serve", "run_vision_story_ft")
269
+ return fn.remote(image_bytes_list, prompt)
270
+
271
+
272
+ # ─────────────────────────────────────────────────────────────────────────────
273
+ # Translation — IndicTrans2 (English → Bengali "pivot" path)
274
+ # ─────────────────────────────────────────────────────────────────────────────
275
+
276
+ @app.function(
277
+ gpu=_gpu,
278
+ image=_ml_image,
279
+ volumes={_HF_CACHE: _hf_volume},
280
+ secrets=_hf_secrets,
281
+ timeout=600, # generous: covers a first-time weight download on a cold start
282
+ min_containers=_min_containers,
283
+ )
284
+ def run_translate(text: str, src_lang: str, tgt_lang: str, model_repo: str) -> str:
285
+ """Translate text with IndicTrans2 (e.g. English → Bengali).
286
+
287
+ Args:
288
+ text: Source text (may be multiple sentences).
289
+ src_lang / tgt_lang: IndicTrans2 FLORES codes ('eng_Latn', 'ben_Beng').
290
+ model_repo: HF repo from get_config-side TRANSLATION_MODEL.
291
+
292
+ Returns:
293
+ Translated text, or '' on failure.
294
+ """
295
+ import re
296
+
297
+ import torch
298
+ from IndicTransToolkit import IndicProcessor
299
+ from transformers import AutoModelForSeq2SeqLM, AutoTokenizer
300
+
301
+ if not (text or "").strip():
302
+ return ""
303
+
304
+ # Lazy singleton — load once per warm container.
305
+ if "model" not in _indictrans:
306
+ device = "cuda" if torch.cuda.is_available() else "cpu"
307
+ tokenizer = AutoTokenizer.from_pretrained(model_repo, trust_remote_code=True)
308
+ model = AutoModelForSeq2SeqLM.from_pretrained(
309
+ model_repo, trust_remote_code=True
310
+ ).to(device)
311
+ _indictrans.update(
312
+ model=model, tokenizer=tokenizer, ip=IndicProcessor(inference=True), device=device
313
+ )
314
+ _hf_volume.commit()
315
+
316
+ model = _indictrans["model"]
317
+ tokenizer = _indictrans["tokenizer"]
318
+ ip = _indictrans["ip"]
319
+ device = _indictrans["device"]
320
+
321
+ # IndicTrans2 translates sentence-by-sentence; split the story first.
322
+ sentences = [s.strip() for s in re.split(r"(?<=[.!?।])\s+", text.strip()) if s.strip()]
323
+ if not sentences:
324
+ return ""
325
+
326
+ batch = ip.preprocess_batch(sentences, src_lang=src_lang, tgt_lang=tgt_lang)
327
+ enc = tokenizer(
328
+ batch, padding="longest", truncation=True, max_length=256, return_tensors="pt"
329
+ ).to(device)
330
+ with torch.inference_mode():
331
+ out = model.generate(**enc, num_beams=5, num_return_sequences=1, max_length=256)
332
+ decoded = tokenizer.batch_decode(
333
+ out, skip_special_tokens=True, clean_up_tokenization_spaces=True
334
+ )
335
+ translated = ip.postprocess_batch(decoded, lang=tgt_lang)
336
+ return " ".join(t.strip() for t in translated if t and t.strip())
337
+
338
+
339
+ def translate_remote(text: str, src_language: str, tgt_language: str) -> str:
340
+ """Plain-callable entry point used by core/vision_story.py for the Bengali
341
+ translation pivot. Resolves the model + FLORES codes from model_config and
342
+ dispatches to the deployed Modal `run_translate`. May raise — caller falls back.
343
+ """
344
+ from core.model_config import TRANSLATION_MODEL, get_indictrans_code
345
+
346
+ src = get_indictrans_code(src_language)
347
+ tgt = get_indictrans_code(tgt_language)
348
+ fn = modal.Function.from_name("rupkotha", "run_translate")
349
+ return fn.remote(text, src, tgt, TRANSLATION_MODEL)
350
+
351
+
352
+ # ─────────────────────────────────────────────────────────────────────────────
353
+ # Bengali TTS — Indic Parler-TTS
354
+ # ─────────────────────────────────────────────────────────────────────────────
355
+
356
+ @app.function(
357
+ gpu=_gpu,
358
+ image=_ml_image,
359
+ volumes={_HF_CACHE: _hf_volume},
360
+ secrets=_hf_secrets,
361
+ timeout=600, # generous: covers a cold-start model download + load
362
+ min_containers=_min_containers,
363
+ )
364
+ def run_tts_bengali(text: str, caption: str, model_repo: str) -> bytes:
365
+ """Synthesise Bengali speech using Indic Parler-TTS.
366
+
367
+ Args:
368
+ text: Story text in Bengali.
369
+ caption: Voice-description prompt that controls the speaker persona.
370
+ model_repo: HuggingFace repo ID from get_tts_repo() (e.g.
371
+ 'ai4bharat/indic-parler-tts').
372
+
373
+ Returns:
374
+ WAV audio as bytes, or b'' on failure.
375
+ """
376
+ import io
377
+ import re
378
+
379
+ import numpy as np
380
+ import soundfile as sf
381
+ import torch
382
+ from parler_tts import ParlerTTSForConditionalGeneration
383
+ from transformers import AutoTokenizer, set_seed
384
+
385
+ # Lazy singleton — load once per warm container, reuse across calls.
386
+ if "model" not in _indic_parler:
387
+ device = "cuda" if torch.cuda.is_available() else "cpu"
388
+ model = ParlerTTSForConditionalGeneration.from_pretrained(model_repo).to(device)
389
+ tokenizer = AutoTokenizer.from_pretrained(model_repo)
390
+ desc_tokenizer = AutoTokenizer.from_pretrained(
391
+ model.config.text_encoder._name_or_path
392
+ )
393
+ _indic_parler.update(
394
+ model=model,
395
+ tokenizer=tokenizer,
396
+ desc_tokenizer=desc_tokenizer,
397
+ device=device,
398
+ sampling_rate=model.config.sampling_rate,
399
+ )
400
+ _hf_volume.commit() # persist downloaded weights for the next cold start
401
+
402
+ model = _indic_parler["model"]
403
+ tokenizer = _indic_parler["tokenizer"]
404
+ desc_tokenizer = _indic_parler["desc_tokenizer"]
405
+ device = _indic_parler["device"]
406
+
407
+ sr = _indic_parler["sampling_rate"]
408
+
409
+ # Indic Parler-TTS caps each generation at ~30s of audio, so synthesising a
410
+ # whole 150-word story in one pass truncates it (audio stops mid-script) and
411
+ # rushes the prosody. Instead render it sentence-by-sentence and stitch the
412
+ # segments with a short silence — no cut-offs, and a natural bedtime pause at
413
+ # each punctuation mark.
414
+ def _chunk(t: str, max_chars: int = 220) -> list[str]:
415
+ # Split on Bengali daari (।) and ? ! … . — keep the delimiter attached,
416
+ # then pack consecutive sentences up to max_chars so chunks stay well
417
+ # under the ~30s generation limit.
418
+ parts = [p for p in re.split(r"(?<=[।!?.…])\s+", t.strip()) if p]
419
+ chunks, cur = [], ""
420
+ for p in parts:
421
+ if cur and len(cur) + len(p) + 1 > max_chars:
422
+ chunks.append(cur)
423
+ cur = p
424
+ else:
425
+ cur = f"{cur} {p}".strip()
426
+ if cur:
427
+ chunks.append(cur)
428
+ return chunks or [t.strip()]
429
+
430
+ desc = desc_tokenizer(caption, return_tensors="pt").to(device)
431
+ gap = np.zeros(int(sr * 0.35), dtype=np.float32) # ~0.35s pause between sentences
432
+ # Sample instead of greedy decoding: greedy makes Parler sound flat/monotone.
433
+ # do_sample + temperature gives more lively, natural prosody (the closest lever
434
+ # Bengali has — it has no emotion-prompt support, unlike VoxCPM2 for English).
435
+ # Fixed seed keeps a given story's audio reproducible across runs.
436
+ set_seed(0)
437
+ segments: list = []
438
+ for chunk in _chunk(text):
439
+ prompt = tokenizer(chunk, return_tensors="pt").to(device)
440
+ with torch.no_grad():
441
+ generation = model.generate(
442
+ input_ids=desc.input_ids,
443
+ attention_mask=desc.attention_mask,
444
+ prompt_input_ids=prompt.input_ids,
445
+ prompt_attention_mask=prompt.attention_mask,
446
+ do_sample=True,
447
+ temperature=1.0,
448
+ )
449
+ seg = generation.cpu().numpy().squeeze().astype(np.float32)
450
+ if seg.size == 0:
451
+ continue
452
+ segments.append(seg)
453
+ segments.append(gap)
454
+
455
+ if not segments:
456
+ return b""
457
+ audio = np.concatenate(segments[:-1]) # drop the trailing gap
458
+ buf = io.BytesIO()
459
+ sf.write(buf, audio, sr, format="WAV")
460
+ return buf.getvalue()
461
+
462
+
463
+ @app.function(
464
+ gpu=_gpu,
465
+ image=_indictts_image,
466
+ volumes={"/models": _indictts_volume},
467
+ timeout=1800, # first call downloads + unzips the ~1.5 GB checkpoint
468
+ min_containers=_min_containers,
469
+ )
470
+ def run_tts_indic_ai4bharat(text: str, checkpoint_url: str) -> bytes:
471
+ """Synthesise Bengali speech with AI4Bharat Indic-TTS (FastPitch + HiFi-GAN).
472
+
473
+ No reference clip — a dedicated, MOS-tuned Bengali acoustic model with a fixed
474
+ voice. The language checkpoint zip is downloaded + unzipped once into a volume.
475
+
476
+ Returns WAV bytes (model sample rate), or b'' on failure.
477
+ """
478
+ import glob
479
+ import io
480
+ import os
481
+ import re
482
+ import subprocess
483
+
484
+ import numpy as np
485
+ import soundfile as sf
486
+ import torch
487
+
488
+ ckpt_dir = "/models/indic_tts_bn"
489
+ # One-time download + unzip into the persistent volume.
490
+ if not glob.glob(f"{ckpt_dir}/**/fastpitch/best_model.pth", recursive=True):
491
+ os.makedirs(ckpt_dir, exist_ok=True)
492
+ zip_path = "/tmp/indictts_bn.zip"
493
+ subprocess.run(["wget", "-q", "-O", zip_path, checkpoint_url], check=True)
494
+ subprocess.run(["unzip", "-o", "-q", zip_path, "-d", ckpt_dir], check=True)
495
+ os.remove(zip_path)
496
+ _indictts_volume.commit()
497
+
498
+ def _cfg_near(ckpt_path: str) -> str | None:
499
+ d = os.path.dirname(ckpt_path)
500
+ for cand in (os.path.join(d, "config.json"),
501
+ os.path.join(os.path.dirname(d), "config.json")):
502
+ if os.path.exists(cand):
503
+ return cand
504
+ hits = glob.glob(os.path.join(os.path.dirname(d), "**", "config.json"), recursive=True)
505
+ return hits[0] if hits else None
506
+
507
+ # Lazy singleton — build the Coqui Synthesizer once per warm container.
508
+ if "syn" not in _indictts:
509
+ from TTS.utils.synthesizer import Synthesizer
510
+
511
+ fp = sorted(glob.glob(f"{ckpt_dir}/**/fastpitch/best_model.pth", recursive=True))
512
+ voc = sorted(glob.glob(f"{ckpt_dir}/**/hifigan/best_model.pth", recursive=True))
513
+ if not fp or not voc:
514
+ print("[indic_tts] checkpoints not found:", glob.glob(f"{ckpt_dir}/**", recursive=True)[:20])
515
+ return b""
516
+ import json
517
+
518
+ spk_file = os.path.join(os.path.dirname(fp[0]), "speakers.pth")
519
+ fp_cfg_path = _cfg_near(fp[0])
520
+ # The config bakes a RELATIVE speakers_file path from AI4Bharat's training
521
+ # tree (resolved against CWD → FileNotFoundError). Rewrite it to our
522
+ # absolute path so coqui loads the right speaker map.
523
+ if fp_cfg_path and os.path.exists(spk_file):
524
+ with open(fp_cfg_path) as f:
525
+ cfg_json = json.load(f)
526
+ cfg_json["speakers_file"] = spk_file
527
+ if isinstance(cfg_json.get("model_args"), dict):
528
+ cfg_json["model_args"]["speakers_file"] = spk_file
529
+ fp_cfg_path = "/tmp/fastpitch_config_patched.json"
530
+ with open(fp_cfg_path, "w") as f:
531
+ json.dump(cfg_json, f)
532
+ syn = Synthesizer(
533
+ tts_checkpoint=fp[0],
534
+ tts_config_path=fp_cfg_path,
535
+ tts_speakers_file=spk_file if os.path.exists(spk_file) else None,
536
+ vocoder_checkpoint=voc[0],
537
+ vocoder_config=_cfg_near(voc[0]),
538
+ use_cuda=torch.cuda.is_available(),
539
+ )
540
+ _indictts["syn"] = syn
541
+ # Multi-speaker model (trained on male+female): prefer the female voice for
542
+ # the grandmother persona. AI4Bharat names them literally "male"/"female".
543
+ speaker = None
544
+ try:
545
+ names = list(syn.tts_model.speaker_manager.name_to_id.keys())
546
+ speaker = next((s for s in names if "fem" in s.lower()), names[0]) if names else None
547
+ except Exception: # noqa: BLE001 — single-speaker model, no manager
548
+ speaker = None
549
+ _indictts["speaker"] = speaker
550
+ print("[indic_tts] loaded; speakers available:",
551
+ locals().get("names", "?"), "| using:", speaker)
552
+
553
+ syn = _indictts["syn"]
554
+ speaker = _indictts.get("speaker")
555
+ sr = syn.output_sample_rate
556
+
557
+ def _chunk(t: str, max_chars: int = 220, min_chars: int = 8) -> list[str]:
558
+ parts = [p for p in re.split(r"(?<=[।!?.…])\s+", t.strip()) if p.strip()]
559
+ chunks, cur = [], ""
560
+ for p in parts:
561
+ if cur and len(cur) + len(p) + 1 > max_chars:
562
+ chunks.append(cur)
563
+ cur = p
564
+ else:
565
+ cur = f"{cur} {p}".strip()
566
+ if cur:
567
+ chunks.append(cur)
568
+ # Merge too-short fragments (a lone quote/word) into a neighbour — FastPitch's
569
+ # conv kernel errors when a chunk is shorter than the kernel size.
570
+ merged: list = []
571
+ for c in chunks:
572
+ if merged and len(c.strip()) < min_chars:
573
+ merged[-1] = (merged[-1] + " " + c).strip()
574
+ else:
575
+ merged.append(c)
576
+ if len(merged) > 1 and len(merged[0].strip()) < min_chars:
577
+ merged[1] = (merged[0] + " " + merged[1]).strip()
578
+ merged = merged[1:]
579
+ return [c for c in merged if c.strip()] or [t.strip()]
580
+
581
+ gap = np.zeros(int(sr * 0.35), dtype=np.float32)
582
+ segments: list = []
583
+ for chunk in _chunk(text):
584
+ if len(chunk.strip()) < 2: # never feed FastPitch a 1-char chunk
585
+ continue
586
+ kwargs = {"speaker_name": speaker} if speaker else {}
587
+ try:
588
+ wav = syn.tts(chunk, **kwargs)
589
+ except Exception as e: # noqa: BLE001 — skip a bad chunk, don't fail the story
590
+ print(f"[indic_tts] chunk skipped ({e}): {chunk[:40]!r}", flush=True)
591
+ continue
592
+ seg = np.asarray(wav, dtype=np.float32).squeeze()
593
+ if seg.size == 0:
594
+ continue
595
+ segments.append(seg)
596
+ segments.append(gap)
597
+
598
+ if not segments:
599
+ return b""
600
+ audio = np.concatenate(segments[:-1])
601
+ buf = io.BytesIO()
602
+ sf.write(buf, audio, sr, format="WAV")
603
+ return buf.getvalue()
604
+
605
+
606
+ def synthesize_bengali_remote(text: str, caption: str) -> bytes:
607
+ """Plain-callable entry point used by core/tts.py for the Bengali path.
608
+
609
+ Reads the active Bengali TTS backend from get_config() and dispatches to the
610
+ right deployed Modal function. May raise — core/tts.py wraps this and falls
611
+ back to text-only so audio failure never breaks the app.
612
+
613
+ - 'indic_tts': AI4Bharat Indic-TTS (FastPitch + HiFi-GAN), no reference clip.
614
+ - 'indic_parler': description-controlled Indic Parler-TTS.
615
+ """
616
+ from core.model_config import get_tts_repo
617
+
618
+ cfg = get_config()
619
+ backend = cfg.tts_bn_backend
620
+
621
+ if backend == "indic_tts":
622
+ # AI4Bharat Indic-TTS (FastPitch + HiFi-GAN) — no caption/reference; the
623
+ # 'repo' here is the checkpoint-zip URL.
624
+ fn = modal.Function.from_name("rupkotha", "run_tts_indic_ai4bharat")
625
+ return fn.remote(text, get_tts_repo("indic_tts"))
626
+
627
+ fn = modal.Function.from_name("rupkotha", "run_tts_bengali")
628
+ return fn.remote(text, caption, get_tts_repo("indic_parler"))
629
+
630
+
631
+ # ─────────────────────────────────────────────────────────────────────────────
632
+ # English TTS — VoxCPM2
633
+ # ─────────────────────────────────────────────────────────────────────────────
634
+
635
+ @app.function(
636
+ gpu=_gpu,
637
+ image=_ml_image,
638
+ volumes={_HF_CACHE: _hf_volume},
639
+ secrets=_hf_secrets,
640
+ timeout=900, # generous: covers a cold-start model download + load
641
+ min_containers=_min_containers,
642
+ )
643
+ def run_tts_english(text: str, voice_prompt: str, model_repo: str) -> bytes:
644
+ """Synthesise English speech using VoxCPM2 Voice Design.
645
+
646
+ The persona is supplied via Voice Design: VoxCPM2 reads a parenthetical
647
+ description at the start of the text and generates a matching novel voice
648
+ (no reference audio needed).
649
+
650
+ Args:
651
+ text: Story text in English.
652
+ voice_prompt: Voice Design persona description (without parentheses).
653
+ model_repo: HuggingFace repo ID from get_tts_repo() (e.g. 'openbmb/VoxCPM2').
654
+
655
+ Returns:
656
+ WAV audio as bytes, or b'' on failure.
657
+ """
658
+ import io
659
+
660
+ import soundfile as sf
661
+ from voxcpm import VoxCPM
662
+
663
+ # Lazy singleton — load once per warm container, reuse across calls.
664
+ if "model" not in _voxcpm:
665
+ model = VoxCPM.from_pretrained(model_repo, load_denoiser=False)
666
+ _voxcpm.update(model=model, sampling_rate=model.tts_model.sample_rate)
667
+ _hf_volume.commit() # persist downloaded weights for the next cold start
668
+
669
+ model = _voxcpm["model"]
670
+ # Voice Design: the persona goes in parentheses at the start of the text.
671
+ design_text = f"({voice_prompt}){text}"
672
+ audio = model.generate(
673
+ text=design_text,
674
+ cfg_value=2.0,
675
+ inference_timesteps=10,
676
+ )
677
+
678
+ buf = io.BytesIO()
679
+ sf.write(buf, audio, _voxcpm["sampling_rate"], format="WAV")
680
+ return buf.getvalue()
681
+
682
+
683
+ def synthesize_english_remote(text: str, voice_prompt: str) -> bytes:
684
+ """Plain-callable entry point used by core/tts.py for the English path.
685
+
686
+ Reads the active English TTS backend from get_config(), resolves it to a
687
+ repo ID via get_tts_repo(), and dispatches to the deployed Modal
688
+ `run_tts_english` function. May raise — core/tts.py wraps this and falls
689
+ back to text-only so audio failure never breaks the app.
690
+ """
691
+ from core.model_config import get_tts_repo
692
+
693
+ repo = get_tts_repo(get_config().tts_en_backend)
694
+ fn = modal.Function.from_name("rupkotha", "run_tts_english")
695
+ return fn.remote(text, voice_prompt, repo)
696
+
697
+
698
+ # ────────────────────────────────────────────────────���────────────────────────
699
+ # Speech-to-text — faster-whisper
700
+ # ─────────────────────────────────────────────────────────────────────────────
701
+
702
+ def _stt_faster_whisper(audio_bytes: bytes, model_size: str, language: str) -> str:
703
+ """Transcribe with faster-whisper (used for size tags like 'large-v3')."""
704
+ import io
705
+
706
+ import torch
707
+ from faster_whisper import WhisperModel
708
+
709
+ key = ("fw", model_size)
710
+ if key not in _whisper:
711
+ device = "cuda" if torch.cuda.is_available() else "cpu"
712
+ compute_type = "float16" if device == "cuda" else "int8"
713
+ _whisper[key] = WhisperModel(
714
+ model_size, device=device, compute_type=compute_type, download_root=_HF_CACHE
715
+ )
716
+ _hf_volume.commit() # persist the downloaded model for the next cold start
717
+
718
+ model = _whisper[key]
719
+ segments, _ = model.transcribe(
720
+ io.BytesIO(audio_bytes), language=language, vad_filter=True
721
+ )
722
+ return "".join(seg.text for seg in segments).strip()
723
+
724
+
725
+ def _stt_transformers(audio_bytes: bytes, model_repo: str) -> str:
726
+ """Transcribe with a HF transformers ASR checkpoint (e.g. a Bengali-specific
727
+ fine-tuned Whisper). The pipeline ffmpeg-decodes raw bytes and resamples."""
728
+ import torch
729
+ from transformers import pipeline
730
+
731
+ key = ("hf", model_repo)
732
+ if key not in _whisper:
733
+ device = 0 if torch.cuda.is_available() else -1
734
+ _whisper[key] = pipeline(
735
+ "automatic-speech-recognition", model=model_repo, device=device
736
+ )
737
+ _hf_volume.commit()
738
+
739
+ result = _whisper[key](audio_bytes)
740
+ return (result.get("text") or "").strip()
741
+
742
+
743
+ @app.function(
744
+ gpu=_gpu,
745
+ image=_ml_image,
746
+ volumes={_HF_CACHE: _hf_volume},
747
+ secrets=_hf_secrets,
748
+ timeout=600, # generous: covers a cold-start model download + load
749
+ min_containers=_min_containers,
750
+ )
751
+ def run_stt(audio_bytes: bytes, language: str, model: str) -> str:
752
+ """Transcribe audio to text.
753
+
754
+ Two backends, chosen by the model identifier:
755
+ - a faster-whisper size tag (e.g. 'large-v3', no '/') → faster-whisper
756
+ - a HuggingFace repo (e.g. 'bangla-asr/whisper-medium-bn', has '/')
757
+ → transformers ASR pipeline (Bengali-specific models)
758
+
759
+ Args:
760
+ audio_bytes: Raw audio bytes (any format ffmpeg accepts).
761
+ language: 'en' or 'bn'.
762
+ model: stt_model (EN) or stt_bn_model (BN) from get_config().
763
+
764
+ Returns:
765
+ Transcribed text, or '' on failure (caller falls back to typed input).
766
+ """
767
+ if not audio_bytes:
768
+ return ""
769
+ try:
770
+ if "/" in model:
771
+ return _stt_transformers(audio_bytes, model)
772
+ return _stt_faster_whisper(audio_bytes, model, language)
773
+ except Exception as e: # noqa: BLE001 — never raise; caller falls back to text
774
+ print(f"[modal_infra] run_stt failed: {e}")
775
+ return ""
776
+
777
+
778
+ def transcribe_remote(audio_bytes: bytes, language: str, model: str) -> str:
779
+ """Plain-callable entry point used by core/stt.py.
780
+
781
+ Dispatches to the deployed Modal `run_stt` function. May raise — core/stt.py
782
+ wraps this and returns '' so the caller falls back to typed input.
783
+ """
784
+ fn = modal.Function.from_name("rupkotha", "run_stt")
785
+ return fn.remote(audio_bytes, language, model)
core/model_config.py ADDED
@@ -0,0 +1,177 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # core/model_config.py
2
+
3
+ import os
4
+ from dataclasses import dataclass
5
+
6
+ # UI-only mock mode for local development: set RUPKOTHA_MOCK=1 to make the core
7
+ # wrappers return canned story/audio/transcript WITHOUT calling Modal (no GPU, no
8
+ # cost). Lets you iterate on layout/CSS/aesthetics offline. Default off (real models).
9
+ UI_MOCK: bool = os.environ.get("RUPKOTHA_MOCK", "") == "1"
10
+
11
+ # ─────────────────────────────────────────────
12
+ # ACTIVE STACK — Stack A is the sole submission stack (OpenBMB prize path).
13
+ # The StackConfig machinery below is kept so a stack could be added/swapped, but
14
+ # only "A" is defined; Stacks B/C were dropped.
15
+ ACTIVE_STACK: str = "A"
16
+ # ─────────────────────────────────────────────
17
+ # WHERE INFERENCE RUNS
18
+ # "modal" → models served on Modal cloud GPUs (current; see CLAUDE.md infra note)
19
+ # "local" → models served on the local machine (legacy offline path; unsupported)
20
+ COMPUTE_LOCATION: str = "modal"
21
+ # Modal GPU tier for inference containers. A10G (24 GB) covers Stack A's runtime.
22
+ MODAL_GPU: str = "A10G"
23
+ # Keep one container warm to hide cold starts. =1 during a demo (no cold start,
24
+ # but bills idle GPU — incl. the A100 FT-serve container, so revert to 0 after).
25
+ # =0 scales to zero and only bills per request (cold start ~3-5 min on first call).
26
+ MODAL_MIN_CONTAINERS: int = 0
27
+ # ─────────────────────────────────────────────
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class StackConfig:
32
+ name: str
33
+ description: str
34
+ vision_model: str # Ollama model tag
35
+ vision_backend: str # "ollama" (all stacks use Ollama)
36
+ stt_model: str # faster-whisper model size
37
+ stt_bn_model: str | None # optional Bengali-specific STT model (HF repo)
38
+ tts_en_backend: str # "voxcpm2"
39
+ tts_bn_backend: str # "indic_tts" (chosen) | "indic_parler" (alt)
40
+ tts_bn_ref_audio: str | None # unused (was for the removed IndicF5 voice-clone)
41
+ total_params_b: float # informational — for README generation
42
+ openbmb_prize_eligible: bool
43
+
44
+
45
+ STACKS: dict[str, StackConfig] = {
46
+ "A": StackConfig(
47
+ name="Stack A — OpenBMB Prize Path",
48
+ description="MiniCPM-V 4.5 + VoxCPM2 + AI4Bharat Indic-TTS. ~12.4B. OpenBMB prize eligible.",
49
+ # Default (~Q4, 6.1GB). q8_0 was tested and gave NO Bengali quality gain at
50
+ # higher cost/latency — precision is not the bottleneck, the 8B model's
51
+ # Bengali capability is. Bengali quality is addressed via two-pass (Lever C).
52
+ vision_model="openbmb/minicpm-v4.5",
53
+ vision_backend="ollama",
54
+ stt_model="large-v3",
55
+ stt_bn_model="bangla-asr/whisper-medium-bn",
56
+ tts_en_backend="voxcpm2",
57
+ # Bengali TTS: AI4Bharat Indic-TTS (FastPitch). Chosen over Indic Parler-TTS
58
+ # (sounded artificial) and IndicF5 (voice clone — removed: too slow even on
59
+ # A100, needs a reference clip). FastPitch is fast, no reference needed.
60
+ tts_bn_backend="indic_tts",
61
+ tts_bn_ref_audio=None,
62
+ total_params_b=12.4,
63
+ openbmb_prize_eligible=True,
64
+ ),
65
+ }
66
+
67
+
68
+ def get_config() -> StackConfig:
69
+ """Returns the active stack config. Import this everywhere model details are needed."""
70
+ if ACTIVE_STACK not in STACKS:
71
+ raise ValueError(
72
+ f"ACTIVE_STACK='{ACTIVE_STACK}' is not valid. Stack A is the only defined stack."
73
+ )
74
+ return STACKS[ACTIVE_STACK]
75
+
76
+
77
+ def get_all_stacks() -> dict[str, StackConfig]:
78
+ """Returns all defined stacks (currently just Stack A)."""
79
+ return STACKS
80
+
81
+
82
+ # HF repo IDs for the TTS backends. Model names live ONLY in this file — the
83
+ # StackConfig stores the backend *key* ('voxcpm2' | 'indic_parler' | 'indic_tts');
84
+ # this maps that key to the actual repo passed to core/modal_infra.py.
85
+ TTS_BACKEND_REPOS: dict[str, str] = {
86
+ "voxcpm2": "openbmb/VoxCPM2", # English (Voice Design)
87
+ "indic_parler": "ai4bharat/indic-parler-tts",
88
+ # AI4Bharat Indic-TTS (FastPitch + HiFi-GAN) — no HF repo; the value is the
89
+ # GitHub-release checkpoint zip (per-language). Bengali = bn.zip (~1.5 GB).
90
+ # Dedicated, MOS-tuned, no reference clip; fixed voice (no persona control).
91
+ "indic_tts": "https://github.com/AI4Bharat/Indic-TTS/releases/download/v1-checkpoints-release/bn.zip",
92
+ }
93
+
94
+
95
+ def get_tts_repo(backend: str) -> str:
96
+ """Resolve a TTS backend key to its HuggingFace repo ID."""
97
+ try:
98
+ return TTS_BACKEND_REPOS[backend]
99
+ except KeyError:
100
+ raise ValueError(
101
+ f"Unknown TTS backend '{backend}'. Known: {list(TTS_BACKEND_REPOS)}"
102
+ )
103
+
104
+
105
+ # Per-language decoding params for the vision/story model (passed to Ollama).
106
+ # Bengali uses a more conservative profile: lower temperature + min_p floor +
107
+ # repetition penalty suppress the wrong-script (Latin) tokens, invented non-words,
108
+ # and phrase repetition that high-temperature sampling produces in a lower-resource
109
+ # language. English can afford a livelier profile. Tune these here only.
110
+ VISION_GEN_OPTIONS: dict[str, dict] = {
111
+ "en": {
112
+ "temperature": 0.8,
113
+ "top_p": 0.95,
114
+ "repeat_penalty": 1.1,
115
+ "num_predict": 500, # bound the response; a bedtime story is short
116
+ },
117
+ "bn": {
118
+ "temperature": 0.45,
119
+ "top_p": 0.9,
120
+ "top_k": 40,
121
+ "min_p": 0.05,
122
+ "repeat_penalty": 1.18,
123
+ "repeat_last_n": 64,
124
+ "num_predict": 700, # Bengali uses more tokens per word; still bounded
125
+ },
126
+ }
127
+
128
+
129
+ def get_vision_options(language: str) -> dict:
130
+ """Return a copy of the decoding params for the given language ('en'|'bn')."""
131
+ return dict(VISION_GEN_OPTIONS.get(language, VISION_GEN_OPTIONS["en"]))
132
+
133
+
134
+ # Translation-pivot path (research option #1): MiniCPM writes the story in English
135
+ # (its strength), then IndicTrans2 translates it to fluent Bengali. Model name lives
136
+ # here only. 1B (gated, MIT) for quality; dist-200M is the faster/smaller option.
137
+ TRANSLATION_MODEL = "ai4bharat/indictrans2-en-indic-1B"
138
+ # IndicTrans2 FLORES-style language codes.
139
+ INDICTRANS_LANG_CODES: dict[str, str] = {"en": "eng_Latn", "bn": "ben_Beng"}
140
+
141
+
142
+ def get_indictrans_code(language: str) -> str:
143
+ """Map our 'en'/'bn' codes to IndicTrans2's FLORES codes."""
144
+ try:
145
+ return INDICTRANS_LANG_CODES[language]
146
+ except KeyError:
147
+ raise ValueError(f"No IndicTrans2 code for language '{language}'.")
148
+
149
+
150
+ # ── Bengali distillation fine-tuning (see finetune/) ────────────────────────
151
+ # Teacher that writes native Bengali story labels from a drawing. Gemma 3 is
152
+ # multimodal and writes excellent Bengali (চাঁদমামা/পুকুর register). 27B gives the
153
+ # best labels (fewer code-switch leaks); 12B is faster. Label quality caps the
154
+ # student, and data-gen is a one-time job, so quality is prioritised here.
155
+ TEACHER_MODEL = "gemma3:27b"
156
+ # Base student that gets fine-tuned (the Stack A vision model, HF repo form for
157
+ # training — Ollama tag form for serving is in the StackConfig).
158
+ STUDENT_BASE_REPO = "openbmb/MiniCPM-V-4_5"
159
+ # Once a LoRA is trained + merged and served via vLLM, set this to the merged
160
+ # model path/repo and route the vision path to it. None = use the base model.
161
+ # Set 2026-06-13 after the held-out eval (finetune/eval_ft.py): the fine-tuned
162
+ # model decisively beats the base on Bengali (base output was garbled + looping;
163
+ # FT is coherent native রূপকথা), confirmed by a Bengali speaker. Bengali now routes
164
+ # to the FT model served by finetune/serve_vllm.py (app `rupkotha-ft-serve`).
165
+ FINETUNED_VISION_MODEL: str | None = "/data/out/minicpm-v-bengali-merged"
166
+
167
+
168
+ def get_compute() -> dict:
169
+ """Returns the active compute-location settings (Modal infra). Import this in
170
+ core/modal_infra.py and the core/ wrappers — never hardcode GPU tier or location."""
171
+ if COMPUTE_LOCATION not in ("modal", "local"):
172
+ raise ValueError(f"COMPUTE_LOCATION='{COMPUTE_LOCATION}' is not valid. Use 'modal' or 'local'.")
173
+ return {
174
+ "location": COMPUTE_LOCATION,
175
+ "gpu": MODAL_GPU,
176
+ "min_containers": MODAL_MIN_CONTAINERS,
177
+ }
core/prompts.py ADDED
@@ -0,0 +1,167 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # core/prompts.py
2
+ """All prompt templates (EN + BN, per style).
3
+
4
+ Stories must read like a grandmother's bedtime tale: warm, simple, rhythmic,
5
+ 120–150 words, gentle sleepy ending, never scary, never a cliffhanger.
6
+
7
+ Templates use {instruction}, {name_line}, {words}, {style_rule} placeholders.
8
+ Bengali prompts are written natively in বাংলা — never "translate to Bengali".
9
+
10
+ NOTE: This is a Step 1 skeleton. Real templates are filled in at Build Order
11
+ steps 2 (EN) and 3 (BN).
12
+ """
13
+
14
+ # Story-style keys exposed to the UI. EN and BN have separate style sets.
15
+ STYLES: dict[str, dict[str, str]] = {
16
+ "en": {
17
+ "Adventure 🌟": "a gentle adventure where nothing truly scary happens",
18
+ "Funny 😄": "a silly, giggly tale full of light-hearted fun",
19
+ "Magical ✨": "a soft, wondrous tale with a touch of quiet magic",
20
+ },
21
+ "bn": {
22
+ "রূপকথা": "একটি কোমল রূপকথা যেখানে ভয় পাওয়ার কিছু নেই",
23
+ "মজার": "একটি হাসিখুশি, মজার গল্প",
24
+ "জাদু": "একটি কোমল, জাদুমাখা গল্প",
25
+ },
26
+ }
27
+
28
+ DEFAULT_WORDS = "120 to 150"
29
+
30
+ # Hard cap on images woven into one story (CLAUDE.md §1: 1–4 images).
31
+ MAX_IMAGES = 4
32
+
33
+
34
+ def _images_rule(language: str, num_images: int) -> str:
35
+ """Count-aware instruction for weaving 0–4 pictures into ONE story cast."""
36
+ n = max(0, min(num_images, MAX_IMAGES))
37
+ if language == "bn":
38
+ if n == 0:
39
+ return "এবার কোনো ছবি নেই — তুমি নিজেই একটা আরামদায়ক ছোট্ট দৃশ্য কল্পনা করে নাও।"
40
+ if n == 1:
41
+ return "ছবিটি ভালো করে দেখো আর তাতে যা সত্যিই আছে — শিশুর নিজের আঁকা ও খেলনা — গল্পে বুনে দাও।"
42
+ return (
43
+ f"{n}টি ছবি আছে। সবগুলোকে একটিই গল্পে বুনে দাও — প্রতিটি ছবি যেন একই "
44
+ "গল্পের এক একটি চরিত্র, বন্ধু বা জায়গা হয়, সবাই একসাথে মিলেমিশে।"
45
+ )
46
+ if n == 0:
47
+ return "There is no picture this time — gently imagine a cosy little scene yourself."
48
+ if n == 1:
49
+ return "Use what is actually in the picture — the child's own drawing or toy."
50
+ return (
51
+ f"There are {n} pictures. Weave them ALL into a SINGLE story — let each "
52
+ "picture become a character, friend, or place that meets in the same gentle tale."
53
+ )
54
+
55
+ # Gemma (Stacks B/C) overshoots word counts and writes lush prose; MiniCPM (Stack A)
56
+ # runs terse. Nudge the cap per stack family. See CLAUDE.md §8.
57
+ _WORDS_BY_STACK = {"A": "130 to 160", "B": "110 to 140", "C": "110 to 140"}
58
+
59
+ # Bengali: a shorter target than English. A long Bengali span pushes the model
60
+ # past its reliable range and invites invented words / script drift — keep it tight.
61
+ _BN_WORDS = "80 to 110"
62
+
63
+ # A tiny clean রূপকথা anchor (few-shot): locks Bengali script, register, and the
64
+ # soft sleepy rhythm so the model imitates good বাংলা instead of drifting.
65
+ _BN_EXAMPLE = (
66
+ "যেমন: “এক যে ছিল ছোট্ট খরগোশ, থাকত পুকুরপাড়ে। সারাদিন মাঠে খেলত, "
67
+ "সন্ধ্যা হলে মায়ের কোলে ঘুমিয়ে পড়ত। শুভরাত্রি।”"
68
+ )
69
+
70
+ # Lever C — two-pass. Pass 1 asks the model to DESCRIBE the image(s) in English
71
+ # (its vision strength); pass 2 narrates from that text so the model spends all
72
+ # its capacity on Bengali prose, not on perceiving the image at the same time.
73
+ SCENE_SENTINEL = "[[SCENE_DESCRIPTION]]"
74
+
75
+ DESCRIBE_PROMPT_EN = (
76
+ "Look carefully at the picture(s) a child has shared. In 3–5 simple English "
77
+ "sentences, describe ONLY what you actually see: the objects, their colours, "
78
+ "and the overall mood. Do not tell a story — just describe what is in the picture(s)."
79
+ )
80
+
81
+ _TEMPLATE_EN = """You are a warm, loving grandmother telling a bedtime story to a small child.
82
+ Look at the picture(s) the child has shared and weave what you see into {style_rule}.
83
+ {name_line}The child asked: "{instruction}"
84
+ {scene}
85
+ Rules:
86
+ - Write {words} words. Simple words a young child knows. Gentle, rhythmic, soothing.
87
+ - {images_rule}
88
+ - Never anything scary, sad, or a cliffhanger.
89
+ - End with everyone safe, cosy, and settling down to sleep.
90
+
91
+ Tell the story now, in English:"""
92
+
93
+ _TEMPLATE_BN = """তুমি একজন স্নেহময়ী ঠাকুমা, ছোট্ট এক শিশুকে ঘুমপাড়ানি গল্প বলছ।
94
+ শিশুটি যে ছবি(গুলি) দেখিয়েছে তা দেখো এবং সেটিকে গল্পে বুনে দাও — {style_rule}।
95
+ {name_line}শিশুটি বলেছে: "{instruction}"
96
+ {scene}
97
+ নিয়ম:
98
+ - শুধু বিশুদ্ধ বাংলা অক্ষরে লেখো — কোনো ইংরেজি বা রোমান হরফ ব্যবহার করবে না।
99
+ - কেবল চেনা, সঠিক বাংলা শব্দ ব্যবহার করো — কোনো বানানো বা অর্থহীন শব্দ নয়।
100
+ - {words}টি শব্দে লেখো। ছোট শিশুর চেনা সহজ শব্দ। কোমল, ছন্দময়, আদুরে।
101
+ - {images_rule}
102
+ - পুকুর, মাঠ, জোনাকি, চাঁদমামার মতো চেনা বাংলা ছবি ব্যবহার করো।
103
+ - কখনও ভয়ের, দুঃখের বা অসমাপ্ত কিছু নয়।
104
+ - সবাই নিরাপদে, আরামে, ঘুমিয়ে পড়ার মধ্য দিয়ে গল্প শেষ করো।
105
+
106
+ {example}
107
+
108
+ এবার বাংলায় গল্পটি বলো:"""
109
+
110
+
111
+ def _scene_block(language: str, scene_description: str) -> str:
112
+ """Two-pass: a grounding block holding the (English) scene description for
113
+ pass 2. Empty in single-pass mode."""
114
+ if not scene_description:
115
+ return ""
116
+ if language == "bn":
117
+ return f"\nছবিতে যা আছে তার বর্ণনা: {scene_description}\n"
118
+ return f"\nWhat is in the picture: {scene_description}\n"
119
+
120
+
121
+ def _scene_rule(language: str) -> str:
122
+ """Two-pass replacement for the images_rule: weave the described scene in."""
123
+ if language == "bn":
124
+ return "উপরের বর্ণনায় থাকা জিনিসগুলো গল্পে বুনে দাও।"
125
+ return "Weave the things in the description above into the story."
126
+
127
+
128
+ def build_story_prompt(
129
+ instruction: str,
130
+ language: str,
131
+ style: str,
132
+ child_name: str = "",
133
+ stack_key: str = "A",
134
+ num_images: int = 1,
135
+ scene_description: str = "",
136
+ ) -> str:
137
+ """Build the full story prompt for the vision model. language: 'en' | 'bn'.
138
+
139
+ num_images (0–4) drives how the prompt asks the model to weave the pictures
140
+ into one shared story cast.
141
+
142
+ scene_description: two-pass (Lever C). When set, this is the pass-2 prompt —
143
+ it injects the (English) scene description as text and the model narrates from
144
+ it with NO image attached. Pass a sentinel (SCENE_SENTINEL) here and have the
145
+ Modal layer substitute the real description after the describe pass."""
146
+ lang = language if language in ("en", "bn") else "en"
147
+ styles = STYLES[lang]
148
+ style_rule = styles.get(style) or next(iter(styles.values()))
149
+ words = _WORDS_BY_STACK.get(stack_key, DEFAULT_WORDS)
150
+ # In two-pass mode the story is grounded in the description, not the raw image.
151
+ images_rule = _scene_rule(lang) if scene_description else _images_rule(lang, num_images)
152
+ scene = _scene_block(lang, scene_description)
153
+
154
+ if lang == "bn":
155
+ name_line = f"শিশুটির নাম {child_name}; গল্পে তার নাম বুনে দাও।\n" if child_name else ""
156
+ instr = instruction.strip() or "আমাকে একটা গল্প বলো"
157
+ return _TEMPLATE_BN.format(
158
+ style_rule=style_rule, name_line=name_line, instruction=instr,
159
+ words=_BN_WORDS, images_rule=images_rule, example=_BN_EXAMPLE, scene=scene,
160
+ )
161
+
162
+ name_line = f"The child's name is {child_name}; weave their name in warmly.\n" if child_name else ""
163
+ instr = instruction.strip() or "tell me a bedtime story"
164
+ return _TEMPLATE_EN.format(
165
+ style_rule=style_rule, name_line=name_line, instruction=instr,
166
+ words=words, images_rule=images_rule, scene=scene,
167
+ )
core/stt.py ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # core/stt.py
2
+ """Whisper audio → text (EN/BN).
3
+
4
+ Uses get_config().stt_model (EN) or stt_bn_model (BN, if set), transcribed on
5
+ Modal. Returns '' on failure so the caller can fall back to typed input — the
6
+ primary, reliable path for Bengali especially (CLAUDE.md §2, §14). Never raises.
7
+ """
8
+
9
+ from core.model_config import UI_MOCK, get_config
10
+ from core.modal_infra import transcribe_remote
11
+
12
+
13
+ def transcribe(audio_path: str, language: str) -> str:
14
+ """language is 'en' or 'bn'. Returns '' on failure. Never raises."""
15
+ if UI_MOCK: # local UI dev — no Modal/GPU
16
+ return "tell me a story about my dragon"
17
+ if not audio_path:
18
+ return ""
19
+ cfg = get_config()
20
+ if language == "bn" and cfg.stt_bn_model:
21
+ model = cfg.stt_bn_model
22
+ else:
23
+ model = cfg.stt_model
24
+ try:
25
+ with open(audio_path, "rb") as f:
26
+ audio_bytes = f.read()
27
+ return transcribe_remote(audio_bytes, language, model) or ""
28
+ except Exception as e: # noqa: BLE001 — never raise; caller uses typed input
29
+ print(f"[stt.py] transcription failed: {e}")
30
+ return ""
core/tts.py ADDED
@@ -0,0 +1,92 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # core/tts.py
2
+ """Story text → grandmother-voice audio (EN/BN).
3
+
4
+ EN → VoxCPM2 Voice Design persona (Build Order step 5 — still stubbed).
5
+ BN → get_config().tts_bn_backend ('indic_parler' or 'indic_tts'), synthesised on
6
+ Modal via Indic Parler-TTS. The grandmother persona is controlled by the
7
+ caption below — tune it for warmth and storytelling pace (CLAUDE.md §9).
8
+ Returns (None, label) if TTS unavailable or failed. Never raises.
9
+ """
10
+
11
+ import tempfile
12
+ from pathlib import Path
13
+
14
+ from core.model_config import UI_MOCK, get_config
15
+ from core.modal_infra import synthesize_bengali_remote, synthesize_english_remote
16
+
17
+ # A pre-generated sample clip for RUPKOTHA_MOCK=1, so the audio player has something
18
+ # to show during local UI dev (no Modal/GPU). None if the sample isn't present.
19
+ _MOCK_WAV = next(
20
+ (str(p) for p in [
21
+ Path("finetune/data/sample/demo_story_ai4bharat.wav"),
22
+ Path("finetune/data/sample/demo_story_output_sampled.wav"),
23
+ ] if p.exists()),
24
+ None,
25
+ )
26
+
27
+ # Grandmother voice caption — controls the Indic Parler-TTS speaker persona.
28
+ # Tune for warmth and pace; run 5–10 samples and keep the best (CLAUDE.md §9).
29
+ GRANDMOTHER_CAPTION_BN = (
30
+ "Aditi speaks in the warm, tender voice of a loving elderly Bengali "
31
+ "grandmother telling a bedtime story to her beloved grandchild. Her pace is "
32
+ "very slow, gentle, and unhurried, pausing softly at every sentence. Her "
33
+ "delivery is affectionate and expressive, rising and falling with the natural "
34
+ "soothing melody of Bengali storytelling, full of warmth and calm. The "
35
+ "recording is very clear, close, and intimate, with no background noise."
36
+ )
37
+
38
+ # Grandmother Voice Design persona for VoxCPM2 (English). This is prefixed to the
39
+ # story text in parentheses by core/modal_infra.py — keep it short and concrete.
40
+ # Tune for warmth and a slow, storytelling pace (CLAUDE.md §11, build step 5).
41
+ GRANDMOTHER_VOICE_EN = (
42
+ "a warm, loving elderly grandmother reading a bedtime story, "
43
+ "slow and gentle pace, soft and soothing tone"
44
+ )
45
+
46
+
47
+ def _write_wav(audio_bytes: bytes) -> str:
48
+ """Persist WAV bytes to a temp file and return its path."""
49
+ tmp = tempfile.NamedTemporaryFile(suffix=".wav", delete=False)
50
+ try:
51
+ tmp.write(audio_bytes)
52
+ finally:
53
+ tmp.close()
54
+ return tmp.name
55
+
56
+
57
+ def speak(
58
+ text: str,
59
+ language: str, # 'en' or 'bn'
60
+ voice: str = "grandmother",
61
+ ) -> tuple[str | None, str]:
62
+ """Returns (wav_path_or_None, tts_model_label). Never raises."""
63
+ cfg = get_config()
64
+
65
+ if UI_MOCK: # local UI dev — sample clip, no Modal/GPU
66
+ return _MOCK_WAV, "UI-mock"
67
+
68
+ if language == "bn":
69
+ label = cfg.tts_bn_backend
70
+ if not (text or "").strip():
71
+ return None, label
72
+ try:
73
+ audio_bytes = synthesize_bengali_remote(text, GRANDMOTHER_CAPTION_BN)
74
+ if not audio_bytes:
75
+ return None, label
76
+ return _write_wav(audio_bytes), label
77
+ except Exception as e: # noqa: BLE001 — never raise to the UI
78
+ print(f"[tts.py] Bengali TTS failed: {e}")
79
+ return None, label
80
+
81
+ # English path: VoxCPM2 Voice Design.
82
+ label = cfg.tts_en_backend
83
+ if not (text or "").strip():
84
+ return None, label
85
+ try:
86
+ audio_bytes = synthesize_english_remote(text, GRANDMOTHER_VOICE_EN)
87
+ if not audio_bytes:
88
+ return None, label
89
+ return _write_wav(audio_bytes), label
90
+ except Exception as e: # noqa: BLE001 — never raise to the UI
91
+ print(f"[tts.py] English TTS failed: {e}")
92
+ return None, label
core/vision_story.py ADDED
@@ -0,0 +1,160 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # core/vision_story.py
2
+ """Image(s) + instruction → bedtime story text.
3
+
4
+ Builds the prompt locally, then calls the Modal VisionStory function (Ollama
5
+ serving get_config().vision_model). Never raises to the UI: returns a friendly
6
+ fallback string on any error so the app always shows a story.
7
+ """
8
+
9
+ import base64
10
+
11
+ from core.model_config import (
12
+ ACTIVE_STACK,
13
+ FINETUNED_VISION_MODEL,
14
+ UI_MOCK,
15
+ get_config,
16
+ get_vision_options,
17
+ )
18
+ from core.modal_infra import (
19
+ generate_story_ft_remote,
20
+ generate_story_remote,
21
+ translate_remote,
22
+ )
23
+ from core.prompts import (
24
+ DESCRIBE_PROMPT_EN,
25
+ MAX_IMAGES,
26
+ SCENE_SENTINEL,
27
+ STYLES,
28
+ build_story_prompt,
29
+ )
30
+
31
+ # Languages that use two-pass generation (Lever C): describe the image in English
32
+ # first, then narrate from that text. DISABLED — tested on Stack A and it did NOT
33
+ # improve Bengali: pass 1 produces an excellent English description, but pass 2's
34
+ # Bengali is still poor, proving the ceiling is MiniCPM-V's Bengali *generation*,
35
+ # not perception. (Bengali now uses the fine-tuned model instead — see below.)
36
+ # The two-pass plumbing is kept but off by default. Add "bn" back to enable.
37
+ _TWO_PASS_LANGUAGES: set[str] = set()
38
+
39
+ # Translation pivot (research option #1): MiniCPM writes the story in English, then
40
+ # IndicTrans2 translates to Bengali. DISABLED — owner (a Bengali speaker) judged the
41
+ # native Lever-#1 output better: the pivot is grammatically clean but reads translated
42
+ # (stiff formal register তিনি/করুন, no native রূপকথা imagery like চাঁদমামা/পুকুর).
43
+ # Authentic register beats textbook grammar for a bedtime tale. Plumbing kept (Modal
44
+ # `run_translate` + this toggle) in case it's wanted later. Set True to re-enable.
45
+ _PIVOT_BENGALI = False
46
+
47
+ # Bengali style key → its positional English counterpart (রূপকথা→Adventure, etc.),
48
+ # used to generate the English draft before translating.
49
+ _BN_TO_EN_STYLE = dict(zip(STYLES["bn"].keys(), STYLES["en"].keys()))
50
+
51
+ _FALLBACK = (
52
+ "Once upon a time, the stars grew sleepy and the moon pulled a soft cloud "
53
+ "over the world like a blanket. Everyone yawned, snuggled close, and drifted "
54
+ "off to dream. Goodnight. 💤"
55
+ )
56
+
57
+ _FALLBACK_BN = (
58
+ "এক যে ছিল রাত, তারারা ঘুমে ঢুলছিল আর চাঁদমামা নরম মেঘের কাঁথা টেনে দিল সারা "
59
+ "পৃথিবীর গায়ে। সবাই হাই তুলল, একসাথে গুটিসুটি মেরে ঘুমিয়ে পড়ল। শুভরাত্রি। 💤"
60
+ )
61
+
62
+ # Canned stories for RUPKOTHA_MOCK=1 (local UI dev — no Modal/GPU). Long enough to
63
+ # exercise the story panel's layout and scrolling.
64
+ _MOCK_STORY_EN = (
65
+ "Once upon a time, on a soft green hill, a little rabbit named Pip watched the "
66
+ "sun slip behind the mountains. The sky turned gold, then rosy pink. Pip hopped "
67
+ "past the sleepy pond where fireflies began to glow like tiny stars.\n\n"
68
+ "A gentle breeze hummed a lullaby through the tall grass. Pip curled up under the "
69
+ "big banyan tree, where mother rabbit waited with a warm hug. The moon climbed up "
70
+ "and pulled a soft cloud over the world like a blanket.\n\n"
71
+ "All the little creatures yawned and snuggled close. Pip closed both sleepy eyes "
72
+ "and drifted into a sweet, happy dream. Goodnight, little one. 💤"
73
+ )
74
+ _MOCK_STORY_BN = (
75
+ "এক যে ছিল ছোট্ট খরগোশ, নাম তার টুনি। সে থাকত সবুজ পাহাড়ের ধারে। সন্ধ্যা হলে "
76
+ "সূর্যিমামা পাহাড়ের পিছনে লুকিয়ে পড়ল, আকাশ হলো সোনালি আর গোলাপি।\n\n"
77
+ "টুনি পুকুরপাড়ে গেল, সেখানে জোনাকিরা মিটিমিটি জ্বলছিল তারার মতো। নরম বাতাস ঘাসের "
78
+ "মধ্যে দিয়ে ঘুমপাড়ানি গান গাইল। টুনি বড় বটগাছের নিচে মায়ের কোলে গিয়ে বসল।\n\n"
79
+ "চাঁদমামা উঠে এসে সারা পৃথিবীর গায়ে নরম মেঘের কাঁথা টেনে দিল। সব ছোট্ট প্রাণীরা "
80
+ "হাই তুলল, গুটিসুটি মেরে ঘুমিয়ে পড়ল। শুভরাত্রি, সোনা। 💤"
81
+ )
82
+
83
+
84
+ def _encode(path: str) -> str:
85
+ with open(path, "rb") as f:
86
+ return base64.b64encode(f.read()).decode()
87
+
88
+
89
+ def generate_story(
90
+ image_paths: list[str], # 1–4 image paths
91
+ instruction: str, # transcribed or typed; may be ''
92
+ language: str, # 'en' or 'bn'
93
+ style: str, # key from prompts.STYLES
94
+ child_name: str = "", # optional, woven into story if provided
95
+ ) -> tuple[str, str]:
96
+ """Returns (story_text, model_used_label). Never raises."""
97
+ cfg = get_config()
98
+ model_label = cfg.vision_model
99
+ fallback = _FALLBACK_BN if language == "bn" else _FALLBACK
100
+
101
+ if UI_MOCK: # local UI dev — canned story, no Modal/GPU
102
+ return _MOCK_STORY_BN if language == "bn" else _MOCK_STORY_EN, f"{model_label} · UI-mock"
103
+
104
+ try:
105
+ # Cap to 1–4 images and weave them all into one story (CLAUDE.md §1, step 7).
106
+ paths = [p for p in (image_paths or []) if p][:MAX_IMAGES]
107
+ images_b64 = [_encode(p) for p in paths]
108
+
109
+ # Bengali translation pivot: write the story in English, then translate.
110
+ if _PIVOT_BENGALI and language == "bn":
111
+ en_prompt = build_story_prompt(
112
+ instruction=instruction,
113
+ language="en",
114
+ style=_BN_TO_EN_STYLE.get(style, next(iter(STYLES["en"]))),
115
+ child_name=child_name,
116
+ stack_key=ACTIVE_STACK,
117
+ num_images=len(paths),
118
+ )
119
+ en_story = (
120
+ generate_story_remote(images_b64, en_prompt, get_vision_options("en")) or ""
121
+ ).strip()
122
+ bn_story = (translate_remote(en_story, "en", "bn") or "").strip() if en_story else ""
123
+ return (bn_story or fallback), f"{cfg.vision_model} → IndicTrans2"
124
+
125
+ # Bengali fine-tuned model (see finetune/). When a merged FT model is
126
+ # configured, route Bengali to it — the held-out eval (finetune/eval_ft.py)
127
+ # showed it decisively beats the base's native Bengali. Served separately
128
+ # via finetune/serve_vllm.py; gets the same single-pass prompt the base would.
129
+ if FINETUNED_VISION_MODEL and language == "bn":
130
+ ft_prompt = build_story_prompt(
131
+ instruction=instruction,
132
+ language="bn",
133
+ style=style,
134
+ child_name=child_name,
135
+ stack_key=ACTIVE_STACK,
136
+ num_images=len(paths),
137
+ )
138
+ ft_story = (generate_story_ft_remote(images_b64, ft_prompt) or "").strip()
139
+ return (ft_story or fallback), f"{cfg.vision_model} · Bengali fine-tune"
140
+
141
+ two_pass = language in _TWO_PASS_LANGUAGES and len(paths) > 0
142
+ # In two-pass mode the story prompt is grounded in a sentinel that the Modal
143
+ # layer replaces with the English description produced by the describe pass.
144
+ prompt = build_story_prompt(
145
+ instruction=instruction,
146
+ language=language,
147
+ style=style,
148
+ child_name=child_name,
149
+ stack_key=ACTIVE_STACK,
150
+ num_images=len(paths),
151
+ scene_description=SCENE_SENTINEL if two_pass else "",
152
+ )
153
+ describe_prompt = DESCRIBE_PROMPT_EN if two_pass else None
154
+ options = get_vision_options(language)
155
+ story = generate_story_remote(images_b64, prompt, options, describe_prompt)
156
+ story = (story or "").strip()
157
+ return (story or fallback), model_label
158
+ except Exception as e: # noqa: BLE001 — never raise to the UI
159
+ print(f"[vision_story.py] generation failed: {e}")
160
+ return fallback, model_label
finetune/README.md ADDED
@@ -0,0 +1,91 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Bengali distillation fine-tuning (`finetune/`)
2
+
3
+ Improve Stack A's weak Bengali by **fine-tuning MiniCPM-V 4.5 itself** — the
4
+ strongest OpenBMB-prize story (*"we improved the OpenBMB model for Bengali"*).
5
+ This stays on Stack A; only the served model weights change.
6
+
7
+ > Why fine-tune and not the IndicTrans2 pivot? The owner judged the pivot's
8
+ > translated register worse than native output. The native ceiling is MiniCPM's
9
+ > Bengali *generation*; fine-tuning on great Bengali raises that ceiling while
10
+ > keeping the warm native register. See the `bengali-quality-investigation` memory.
11
+
12
+ ## The pipeline
13
+
14
+ ```
15
+ drawings ──► [Gemma teacher] ──► Bengali stories ──► [purity gate] ──► train.json
16
+
17
+ LoRA fine-tune MiniCPM-V (vision frozen) ◄───┘
18
+
19
+ merge adapter
20
+
21
+ serve via vLLM ──► wire into Bengali path
22
+ ```
23
+
24
+ | Stage | File | Where | Notes |
25
+ |---|---|---|---|
26
+ | 0. Source images | (manual, see below) | local | the real bottleneck |
27
+ | 1. Label + filter | `gen_labels.py` + `purity.py` | Modal (Gemma) | image → Bengali story, drop leaks |
28
+ | 2. LoRA train | `train_lora.py` | Modal A100 | ms-SWIFT (`model_type=minicpmv4_5`) |
29
+ | 3. Merge | `merge.py` | Modal A100 | adapter → full weights |
30
+ | 4. Serve | `serve_vllm.py` | Modal A10G | vLLM, no GGUF |
31
+ | 5. Wire | one branch in `core/vision_story.py` | local | route Bengali to the FT model |
32
+
33
+ ## Stage 0 — source the drawings (`finetune/data/images/`)
34
+ Target ~300–500 diverse, on-distribution images. Mix all four:
35
+ - **Team-drawn crayon pics** — photograph ~100–150. Most authentic; do these first.
36
+ - **Quick, Draw!** — Google's doodle dataset (`https://quickdraw.withgoogle.com/data`);
37
+ pull a few categories (`.ndjson`/`.npy`), render strokes to PNG, optionally
38
+ composite 2–3 doodles into a scene.
39
+ - **Synthetic crayon** — an image model (e.g. SDXL) with prompts like
40
+ *"a child's crayon drawing of a house and the sun, simple, on white paper"*.
41
+ - **Toy / play photos** — stock or self-taken photos of toys/equipment.
42
+
43
+ Quality > quantity for LoRA. Keep them varied (objects, colours, scenes).
44
+
45
+ ## Stage 1 — generate labels
46
+ ```bash
47
+ modal deploy core/modal_infra.py # teacher served via run_vision_story
48
+ uv run python finetune/gen_labels.py --images finetune/data/images \
49
+ --out finetune/data/train.json --teacher gemma3:27b
50
+ ```
51
+ Produces `train.json` (kept) and `train.json.rejected` (failed the gate). **Have a
52
+ Bengali speaker spot-check `kept`** — distillation caps the student at label quality.
53
+ Tune strictness in `purity.py` (`is_clean(max_foreign_letters=...)`).
54
+
55
+ ## Stage 2 — LoRA train
56
+ Upload `finetune/data/` to the `rupkotha-finetune` Modal Volume, then validate
57
+ cheaply before the full run:
58
+ ```bash
59
+ uv run modal run finetune/train_lora.py --max-steps 4 # smoke test
60
+ uv run modal run finetune/train_lora.py # full 3-epoch run
61
+ ```
62
+ `train_lora.py` drives **ms-SWIFT** (`swift sft --model_type minicpmv4_5`), not
63
+ OpenBMB's official `finetune.py` — the latter pins torch 2.1.2 while MiniCPM-V 4.5
64
+ remote code needs torch>=2.4, and its finetune path doesn't target 4.5. SWIFT ships
65
+ a maintained recipe for this model so the dep matrix is solved. It converts our
66
+ MiniCPM `conversations` JSON to SWIFT's `messages`/`images` JSONL in-container.
67
+ Vision encoder frozen (`--freeze_vit true`), LoRA r=16 on the LLM `q/k/v/o_proj`.
68
+ SWIFT's nested checkpoint is copied to `out/lora-bengali/` so Stage 3 is unchanged.
69
+
70
+ ## Stage 3 — merge
71
+ ```bash
72
+ uv run modal run finetune/merge.py # → out/minicpm-v-bengali-merged/
73
+ ```
74
+
75
+ ## Stage 4 — serve + Stage 5 — wire
76
+ ```bash
77
+ uv run modal deploy finetune/serve_vllm.py
78
+ ```
79
+ Then set `FINETUNED_VISION_MODEL` in `core/model_config.py` to the merged path and
80
+ add a one-line branch in `core/vision_story.py`: when it's set and `language=="bn"`,
81
+ call `serve_vllm.generate_story_ft_remote(...)` instead of `generate_story_remote`.
82
+ (`run_translate`/pivot plumbing stays available as a fallback.)
83
+
84
+ ## Evaluate
85
+ Hold out ~20 drawings; compare FT Bengali vs native Lever-#1 (`demo_story_bn_v2.wav`)
86
+ with a Bengali speaker. Ship the FT model only if it clearly wins.
87
+
88
+ ## Cost / risk (hackathon reality)
89
+ Heaviest path: image curation (days) + ~1–3h A100 training + integration. Validated
90
+ upside: the Gemma teacher writes genuinely native Bengali (চাঁদমামা/পুকুর/খই), so the
91
+ student has an excellent target. Main risk is data volume/quality, not the method.
finetune/collect.py ADDED
@@ -0,0 +1,207 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # finetune/collect.py
2
+ """Stage 0 — collect open-source images into finetune/data/images/<source>/.
3
+
4
+ Zero-friction sources (no creds) are implemented: Google Quick, Draw! and
5
+ file-based HF image datasets. Cred-gated sources (Kaggle, Roboflow) and generated
6
+ ones (synthetic crayon, toy stock) are documented stubs — wire them once you drop
7
+ in the relevant API key.
8
+
9
+ Examples:
10
+ uv run python finetune/collect.py quickdraw --per 40
11
+ uv run python finetune/collect.py hf --dataset ironDong/Children_Drawings --per 150
12
+ uv run python finetune/collect.py hf --dataset 6chan/children-hand-drawn-style-transfer --per 150
13
+
14
+ The later preprocessor (finetune/preprocess.py) sweeps the whole images/ pool.
15
+ """
16
+
17
+ import argparse
18
+ import json
19
+ import os
20
+ import urllib.parse
21
+ import urllib.request
22
+ from pathlib import Path
23
+
24
+ # Redirect the HF cache to a writable dir BEFORE huggingface_hub/datasets import
25
+ # (the global ~/.cache/huggingface is read-only in this sandbox).
26
+ _HF_CACHE = str(Path(__file__).resolve().parent / ".hf_cache")
27
+ os.environ.setdefault("HF_HOME", _HF_CACHE)
28
+ os.environ.setdefault("HF_DATASETS_CACHE", _HF_CACHE + "/datasets")
29
+ os.environ.setdefault("HF_HUB_CACHE", _HF_CACHE + "/hub")
30
+
31
+ from PIL import Image, ImageDraw # noqa: E402
32
+
33
+ IMAGES = Path(__file__).resolve().parent / "data" / "images"
34
+
35
+ # Quick, Draw! public bucket (no auth). Categories that fit bedtime-story scenes.
36
+ _QD_BASE = "https://storage.googleapis.com/quickdraw_dataset/full/simplified/"
37
+ _QD_CATS = [
38
+ "house", "tree", "sun","birthday cake", "cat","castle", "dog", "flower", "bird", "fish", "star",
39
+ "moon", "sailboat", "butterfly", "rainbow", "cloud", "duck", "elephant", "umbrella",
40
+ ]
41
+
42
+
43
+ def _render_strokes(strokes, size=256, line=3) -> Image.Image:
44
+ """Render a Quick, Draw! simplified drawing (list of [xs, ys] strokes)."""
45
+ img = Image.new("RGB", (size, size), "white")
46
+ draw = ImageDraw.Draw(img)
47
+ for stroke in strokes:
48
+ xs, ys = stroke[0], stroke[1]
49
+ pts = list(zip(xs, ys))
50
+ if len(pts) >= 2:
51
+ draw.line(pts, fill="black", width=line, joint="curve")
52
+ elif pts:
53
+ draw.point(pts, fill="black")
54
+ return img
55
+
56
+
57
+ def collect_quickdraw(categories: list[str], per: int) -> int:
58
+ out = IMAGES / "quickdraw"
59
+ out.mkdir(parents=True, exist_ok=True)
60
+ total = 0
61
+ for cat in categories:
62
+ url = _QD_BASE + urllib.parse.quote(cat) + ".ndjson"
63
+ try:
64
+ resp = urllib.request.urlopen(url, timeout=60) # streams line-by-line
65
+ except Exception as e: # noqa: BLE001
66
+ print(f" {cat}: download failed ({e})")
67
+ continue
68
+ n = 0
69
+ for raw in resp: # stop early — we don't pull the whole (huge) file
70
+ if n >= per:
71
+ break
72
+ try:
73
+ obj = json.loads(raw)
74
+ if obj.get("recognized") is False:
75
+ continue
76
+ _render_strokes(obj["drawing"]).save(
77
+ out / f"qd_{cat.replace(' ', '_')}_{n}.png"
78
+ )
79
+ n += 1
80
+ total += 1
81
+ except Exception: # noqa: BLE001
82
+ continue
83
+ print(f" {cat}: {n}")
84
+ print(f"Quick, Draw!: {total} images -> {out}")
85
+ return total
86
+
87
+
88
+ def _example_image(example: dict):
89
+ """Find a PIL image in a datasets example (Image-feature columns decode to PIL)."""
90
+ for key in ("image", "img", "png", "jpg", "jpeg"):
91
+ v = example.get(key)
92
+ if isinstance(v, Image.Image):
93
+ return v
94
+ for v in example.values(): # fall back to any PIL value
95
+ if isinstance(v, Image.Image):
96
+ return v
97
+ return None
98
+
99
+
100
+ def collect_hf(dataset: str, per: int) -> int:
101
+ """Pull images from an HF image dataset. Tries raw files first, then falls back
102
+ to parquet via `datasets` (run with: uv run --group finetune ...)."""
103
+ import shutil
104
+
105
+ from huggingface_hub import snapshot_download
106
+
107
+ out = IMAGES / ("hf_" + dataset.split("/")[-1].lower().replace("-", "_"))
108
+ out.mkdir(parents=True, exist_ok=True)
109
+
110
+ # 1) file-based datasets that ship raw images
111
+ try:
112
+ local = snapshot_download(
113
+ repo_id=dataset, repo_type="dataset", cache_dir=_HF_CACHE,
114
+ allow_patterns=["*.png", "*.jpg", "*.jpeg", "*.webp"],
115
+ )
116
+ imgs = [p for ext in ("png", "jpg", "jpeg", "webp") for p in Path(local).rglob(f"*.{ext}")][:per]
117
+ except Exception: # noqa: BLE001
118
+ imgs = []
119
+ if imgs:
120
+ for i, p in enumerate(imgs):
121
+ shutil.copy(p, out / f"{p.stem}_{i}{p.suffix.lower()}")
122
+ print(f"HF {dataset}: {len(imgs)} image files -> {out}")
123
+ return len(imgs)
124
+
125
+ # 2) parquet datasets via `datasets` (streaming so we don't pull the whole thing)
126
+ try:
127
+ from datasets import load_dataset
128
+ except ImportError:
129
+ print(f"HF {dataset}: parquet dataset — run with `uv run --group finetune ...`")
130
+ return 0
131
+ n = 0
132
+ last_err = None
133
+ for split in ("train", "validation", "test"):
134
+ try:
135
+ ds = load_dataset(dataset, split=split, streaming=True)
136
+ except Exception as e: # noqa: BLE001
137
+ last_err = e
138
+ continue
139
+ for ex in ds:
140
+ if n >= per:
141
+ break
142
+ img = _example_image(ex)
143
+ if img is None:
144
+ continue
145
+ img.convert("RGB").save(out / f"{n}.png")
146
+ n += 1
147
+ if n >= per:
148
+ break
149
+ if n == 0 and last_err is not None:
150
+ msg = str(last_err)
151
+ if "gated" in msg or "authenticated" in msg or "401" in msg:
152
+ print(f"HF {dataset}: GATED — accept its license on the Hub and set a local "
153
+ f"HF_TOKEN (export HF_TOKEN=hf_xxx), then retry.")
154
+ else:
155
+ print(f"HF {dataset}: failed — {msg[:160]}")
156
+ return 0
157
+ print(f"HF {dataset}: {n} images (parquet) -> {out}")
158
+ return n
159
+
160
+
161
+ # ── Cred-gated / generative sources: documented stubs ───────────────────────
162
+ _STUBS = """
163
+ Cred-gated and generated sources (wire once you have the key):
164
+
165
+ Kaggle (needs ~/.kaggle/kaggle.json):
166
+ pip install kaggle
167
+ kaggle datasets download -d vishmiperera/children-drawings -p data/images/kaggle --unzip
168
+ kaggle datasets download -d lachin007/drawaperson-handdrawn-sketches-by-children \\
169
+ -p data/images/kaggle --unzip
170
+
171
+ Roboflow ESRA (needs Roboflow API key):
172
+ pip install roboflow # then use the dataset's export snippet → data/images/roboflow
173
+
174
+ Synthetic crayon (SDXL on Modal — generate to fill volume/diversity):
175
+ prompt e.g. "a child's crayon drawing of <scene>, simple, on white paper"
176
+ → save to data/images/synthetic/ (a generate_synthetic.py can be added on request)
177
+
178
+ Toy / play photos (Unsplash/Pexels API):
179
+ fetch with your API key → data/images/toys/
180
+ """
181
+
182
+
183
+ def main() -> None:
184
+ ap = argparse.ArgumentParser(description=__doc__)
185
+ sub = ap.add_subparsers(dest="cmd", required=True)
186
+
187
+ qd = sub.add_parser("quickdraw", help="render Quick, Draw! doodles")
188
+ qd.add_argument("--per", type=int, default=40, help="images per category")
189
+ qd.add_argument("--categories", nargs="*", default=_QD_CATS)
190
+
191
+ hf = sub.add_parser("hf", help="pull a file-based HF image dataset")
192
+ hf.add_argument("--dataset", required=True)
193
+ hf.add_argument("--per", type=int, default=150)
194
+
195
+ sub.add_parser("sources", help="print cred-gated source instructions")
196
+
197
+ args = ap.parse_args()
198
+ if args.cmd == "quickdraw":
199
+ collect_quickdraw(args.categories, args.per)
200
+ elif args.cmd == "hf":
201
+ collect_hf(args.dataset, args.per)
202
+ elif args.cmd == "sources":
203
+ print(_STUBS)
204
+
205
+
206
+ if __name__ == "__main__":
207
+ main()
finetune/eval_ft.py ADDED
@@ -0,0 +1,154 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # finetune/eval_ft.py
2
+ """Stage 5 gate — side-by-side Bengali eval: fine-tuned MiniCPM-V (vLLM) vs the
3
+ native base model (the current shipping "Lever #1" path).
4
+
5
+ CLAUDE.md / finetune/README are explicit: ship the fine-tune ONLY if it clearly
6
+ beats the native path in a human read. The train metrics (loss/token_acc) say the
7
+ LoRA fit the distilled targets — they do NOT say Bengali quality improved. This
8
+ script produces the artifact a Bengali speaker needs to make that call before
9
+ FINETUNED_VISION_MODEL is ever set.
10
+
11
+ Fairness: BOTH paths get the EXACT same app-built Bengali prompt (build_story_prompt)
12
+ and the same image — exactly what each would receive in production. The only
13
+ difference under test is the model weights.
14
+
15
+ native: core.modal_infra.generate_story_remote → base openbmb/MiniCPM-V-4_5 (Ollama)
16
+ FT: finetune.serve_vllm.generate_story_ft_remote → merged LoRA (vLLM)
17
+
18
+ Held-out set: the 61 labelset images that the purity gate rejected, so they were
19
+ NEVER trained on, yet are on-distribution. (Override with --images for your own.)
20
+
21
+ Run:
22
+ uv run modal deploy finetune/serve_vllm.py # FT server must be live
23
+ uv run python finetune/eval_ft.py --n 10 # 10 held-out images
24
+ uv run python finetune/eval_ft.py --images path/to/dir --n 8 --style রূপকথা
25
+ Out:
26
+ finetune/eval_results/ft_vs_native_YYYYMMDD_HHMM.md
27
+ """
28
+
29
+ import argparse
30
+ import base64
31
+ import json
32
+ import os
33
+ import sys
34
+ import time
35
+ from datetime import datetime
36
+ from pathlib import Path
37
+
38
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
39
+
40
+ from core.model_config import ACTIVE_STACK, get_vision_options
41
+ from core.modal_infra import generate_story_remote
42
+ from core.prompts import STYLES, build_story_prompt
43
+ from finetune.serve_vllm import generate_story_ft_remote
44
+
45
+ TRAIN_JSON = Path("finetune/data/train.json")
46
+ LABELSET = Path("finetune/data/labelset")
47
+
48
+
49
+ def held_out_images() -> list[Path]:
50
+ """Labelset images that are NOT in train.json — unseen but on-distribution."""
51
+ trained = {x["image"].split("/")[-1] for x in json.loads(TRAIN_JSON.read_text())}
52
+ imgs = [
53
+ p for p in sorted(LABELSET.glob("*"))
54
+ if p.suffix.lower() in (".jpg", ".jpeg", ".png") and p.name not in trained
55
+ ]
56
+ return imgs
57
+
58
+
59
+ def _encode(path: Path) -> str:
60
+ return base64.b64encode(path.read_bytes()).decode()
61
+
62
+
63
+ def main() -> None:
64
+ ap = argparse.ArgumentParser()
65
+ ap.add_argument("--images", default=None, help="dir of images (default: held-out labelset)")
66
+ ap.add_argument("--n", type=int, default=10, help="number of images to evaluate")
67
+ ap.add_argument("--style", default="রূপকথা", choices=list(STYLES["bn"].keys()))
68
+ ap.add_argument("--instruction", default="একটা গল্প বলো")
69
+ args = ap.parse_args()
70
+
71
+ if args.images:
72
+ imgs = [
73
+ p for p in sorted(Path(args.images).glob("*"))
74
+ if p.suffix.lower() in (".jpg", ".jpeg", ".png")
75
+ ]
76
+ else:
77
+ imgs = held_out_images()
78
+ imgs = imgs[: args.n]
79
+ if not imgs:
80
+ sys.exit("No images found to evaluate.")
81
+
82
+ print(f"Evaluating {len(imgs)} images · style={args.style} · stack={ACTIVE_STACK}", flush=True)
83
+ options = get_vision_options("bn")
84
+
85
+ # Precompute the (identical) prompt + encoded bytes per image.
86
+ items = []
87
+ for img in imgs:
88
+ prompt = build_story_prompt(
89
+ instruction=args.instruction,
90
+ language="bn",
91
+ style=args.style,
92
+ child_name="",
93
+ stack_key=ACTIVE_STACK,
94
+ num_images=1,
95
+ )
96
+ items.append((img, prompt, [_encode(img)]))
97
+
98
+ # Two phases so each serverless model cold-starts ONCE, not per image (the
99
+ # alternating native→FT loop kept scaling the other model back to zero).
100
+ print("Phase 1/2: native (base) ...", flush=True)
101
+ natives = []
102
+ for i, (img, prompt, b64) in enumerate(items, 1):
103
+ t0 = time.time()
104
+ story = (generate_story_remote(b64, prompt, options) or "").strip()
105
+ dt = round(time.time() - t0, 1)
106
+ print(f" native [{i}/{len(items)}] {img.name} {dt}s", flush=True)
107
+ natives.append((story, dt))
108
+
109
+ print("Phase 2/2: fine-tuned (vLLM) ...", flush=True)
110
+ fts = []
111
+ for i, (img, prompt, b64) in enumerate(items, 1):
112
+ t0 = time.time()
113
+ story = (generate_story_ft_remote(b64, prompt) or "").strip()
114
+ dt = round(time.time() - t0, 1)
115
+ print(f" ft [{i}/{len(items)}] {img.name} {dt}s", flush=True)
116
+ fts.append((story, dt))
117
+
118
+ rows = [
119
+ (items[i][0], natives[i][0], natives[i][1], fts[i][0], fts[i][1])
120
+ for i in range(len(items))
121
+ ]
122
+
123
+ out_dir = Path("finetune/eval_results")
124
+ out_dir.mkdir(exist_ok=True)
125
+ fname = out_dir / f"ft_vs_native_{datetime.now():%Y%m%d_%H%M}.md"
126
+ lines = [
127
+ f"# FT vs Native — Bengali story quality ({args.style})",
128
+ f"Generated: {datetime.now():%Y-%m-%d %H:%M} · stack {ACTIVE_STACK} · {len(rows)} held-out images\n",
129
+ "**Native** = base openbmb/MiniCPM-V-4_5 (current shipping Lever #1). ",
130
+ "**FT** = merged Bengali LoRA via vLLM. Same prompt + image for both.\n",
131
+ "> For the Bengali reviewer: for each image, which story reads more like a real "
132
+ "grandmother's bedtime tale (natural words, রূপকথা imagery, no English/garbled "
133
+ "words, calm sleepy ending)? Mark a winner per row.\n",
134
+ "---\n",
135
+ ]
136
+ for img, native, tn, ft, tf in rows:
137
+ lines += [
138
+ f"## {img.name}",
139
+ f"![{img.name}]({os.path.relpath(img, out_dir)})\n",
140
+ f"### Native (base) — {tn}s",
141
+ native or "_(empty)_", "",
142
+ f"### FT (LoRA) — {tf}s",
143
+ ft or "_(empty)_", "",
144
+ "**Winner (reviewer):** ☐ Native ☐ FT ☐ Tie · notes: ____",
145
+ "\n---\n",
146
+ ]
147
+ fname.write_text("\n".join(lines))
148
+ print(f"\nReport written to {fname}")
149
+ print("Open it, have a Bengali speaker mark winners, and only set "
150
+ "FINETUNED_VISION_MODEL if FT clearly wins.")
151
+
152
+
153
+ if __name__ == "__main__":
154
+ main()
finetune/gen_labels.py ADDED
@@ -0,0 +1,123 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # finetune/gen_labels.py
2
+ """Stage 1 — generate the Bengali SFT dataset by distilling the Gemma teacher.
3
+
4
+ For every drawing/photo in an images dir, ask the multimodal teacher
5
+ (TEACHER_MODEL, e.g. gemma3:27b) to write a native Bengali bedtime story, run it
6
+ through the purity gate, and emit MiniCPM-V fine-tuning JSON. The teacher does the
7
+ SAME task as inference (image → Bengali story), so labels are image-grounded.
8
+
9
+ Run (after `modal deploy core/modal_infra.py`):
10
+ uv run python finetune/gen_labels.py --images finetune/data/images \
11
+ --out finetune/data/train.json --teacher gemma3:27b
12
+
13
+ Output:
14
+ <out> kept examples (MiniCPM-V conversations format)
15
+ <out>.rejected labels that failed the purity gate (for review)
16
+ """
17
+
18
+ import argparse
19
+ import itertools
20
+ import json
21
+ import sys
22
+ from pathlib import Path
23
+
24
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
25
+
26
+ import modal # noqa: E402
27
+
28
+ from core.model_config import TEACHER_MODEL, get_vision_options # noqa: E402
29
+ from core.prompts import STYLES, build_story_prompt # noqa: E402
30
+ from finetune.purity import foreign_words, is_clean # noqa: E402
31
+
32
+ IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".webp"}
33
+ # Light diversity so the student sees varied styles / instructions / names.
34
+ _BN_STYLES = list(STYLES["bn"].keys())
35
+ _INSTRUCTIONS = ["একটা গল্প বলো", "আমাকে একটা মজার গল্প শোনাও", "এই ছবি নিয়ে গল্প বলো", ""]
36
+ _NAMES = ["রূপা", "অর্ক", "মিঠি", ""]
37
+
38
+
39
+ def _teacher_story(fn, image_bytes: bytes, prompt: str) -> str:
40
+ """One image → Bengali story via the deployed Modal vision function."""
41
+ return fn.remote([image_bytes], prompt, TEACHER_MODEL, get_vision_options("bn"), None) or ""
42
+
43
+
44
+ def main() -> None:
45
+ ap = argparse.ArgumentParser()
46
+ ap.add_argument("--images", required=True, help="dir of drawings/photos")
47
+ ap.add_argument("--out", default="finetune/data/train.json")
48
+ ap.add_argument("--teacher", default=TEACHER_MODEL, help="override TEACHER_MODEL")
49
+ ap.add_argument("--max-foreign", type=int, default=0, help="allowed foreign letters")
50
+ args = ap.parse_args()
51
+
52
+ images = sorted(
53
+ p for p in Path(args.images).rglob("*") if p.suffix.lower() in IMAGE_EXTS
54
+ )
55
+ if not images:
56
+ sys.exit(f"No images found under {args.images}")
57
+ print(f"Found {len(images)} images. Teacher = {args.teacher}")
58
+
59
+ out = Path(args.out)
60
+ rej_out = out.with_suffix(out.suffix + ".rejected")
61
+ out.parent.mkdir(parents=True, exist_ok=True)
62
+
63
+ # Resume: load any existing output and skip images already labelled. This makes
64
+ # a long run crash-safe — re-running continues where it stopped.
65
+ kept = json.loads(out.read_text(encoding="utf-8")) if out.exists() else []
66
+ rejected = json.loads(rej_out.read_text(encoding="utf-8")) if rej_out.exists() else []
67
+ done = {r["image"] for r in kept} | {r["image"] for r in rejected}
68
+ if done:
69
+ print(f"Resuming: {len(done)} already labelled, skipping those.")
70
+
71
+ def _flush() -> None:
72
+ out.write_text(json.dumps(kept, ensure_ascii=False, indent=2), encoding="utf-8")
73
+ rej_out.write_text(json.dumps(rejected, ensure_ascii=False, indent=2), encoding="utf-8")
74
+
75
+ fn = modal.Function.from_name("rupkotha", "run_vision_story")
76
+ style_cycle = itertools.cycle(_BN_STYLES)
77
+ instr_cycle = itertools.cycle(_INSTRUCTIONS)
78
+ name_cycle = itertools.cycle(_NAMES)
79
+
80
+ for i, img_path in enumerate(images):
81
+ if str(img_path) in done:
82
+ continue
83
+ style, instruction, name = next(style_cycle), next(instr_cycle), next(name_cycle)
84
+ prompt = build_story_prompt(
85
+ instruction=instruction, language="bn", style=style,
86
+ child_name=name, stack_key="B", num_images=1,
87
+ )
88
+ try:
89
+ story = _teacher_story(fn, img_path.read_bytes(), prompt).strip()
90
+ except Exception as e: # noqa: BLE001
91
+ print(f"[{i}] {img_path.name}: teacher error {e}")
92
+ continue
93
+
94
+ ok, stats = is_clean(story, max_foreign_letters=args.max_foreign)
95
+ record = {
96
+ "id": str(i),
97
+ "image": str(img_path),
98
+ "conversations": [
99
+ {"role": "user", "content": "<image>\n" + prompt},
100
+ {"role": "assistant", "content": story},
101
+ ],
102
+ }
103
+ if ok:
104
+ kept.append(record)
105
+ print(f"[{i}] {img_path.name}: kept ({stats['bengali_ratio']:.2f})")
106
+ else:
107
+ record["_stats"] = stats
108
+ record["_leaks"] = foreign_words(story)
109
+ rejected.append(record)
110
+ print(f"[{i}] {img_path.name}: REJECTED leaks={foreign_words(story)}")
111
+
112
+ if (i + 1) % 20 == 0: # checkpoint periodically
113
+ _flush()
114
+ print(f" …checkpoint: {len(kept)} kept, {len(rejected)} rejected")
115
+
116
+ _flush()
117
+ print(f"\nKept {len(kept)} ({len(rejected)} rejected) of {len(images)} total")
118
+ print(f"Wrote {out} and {rej_out}")
119
+ print("⚠️ Have a Bengali speaker spot-check a sample of `kept` before training.")
120
+
121
+
122
+ if __name__ == "__main__":
123
+ main()
finetune/merge.py ADDED
@@ -0,0 +1,90 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # finetune/merge.py
2
+ """Stage 3 — merge the trained LoRA adapter into full MiniCPM-V weights.
3
+
4
+ vLLM serves a full model, not a base+adapter pair, so we bake the adapter in.
5
+ Output is a standalone HF model dir that finetune/serve_vllm.py loads.
6
+
7
+ Run:
8
+ uv run modal run finetune/merge.py
9
+ """
10
+
11
+ import modal
12
+
13
+ from core.model_config import STUDENT_BASE_REPO
14
+
15
+ app = modal.App("rupkotha-finetune-merge")
16
+
17
+ _vol = modal.Volume.from_name("rupkotha-finetune", create_if_missing=True)
18
+ _hf = modal.Volume.from_name("rupkotha-hf", create_if_missing=True)
19
+
20
+ # Mirror finetune/train_lora.py's dependency matrix so the adapter loads against the
21
+ # same stack it was trained on: transformers 4.x (the 5.x line breaks MiniCPM-V's
22
+ # remote-code loader), peft<0.19 (SWIFT 3.12.6's pin), torch>=2.4 (4.5 remote code),
23
+ # and timm/sentencepiece for the vision tower + tokenizer.
24
+ _image = (
25
+ modal.Image.debian_slim(python_version="3.10")
26
+ .pip_install(
27
+ "torch>=2.4",
28
+ "transformers>=4.49,<4.58",
29
+ "peft>=0.11,<0.19",
30
+ "accelerate",
31
+ "timm",
32
+ "sentencepiece",
33
+ "pillow",
34
+ )
35
+ # merge.py imports core.model_config at module load; make `core` importable
36
+ # in the container (`modal run <file>` doesn't auto-mount the project).
37
+ .add_local_python_source("core")
38
+ )
39
+
40
+
41
+ @app.function(
42
+ gpu="A100-40GB",
43
+ image=_image,
44
+ volumes={"/data": _vol, "/root/.cache/huggingface": _hf},
45
+ secrets=[modal.Secret.from_name("algaeguard-secrets")],
46
+ timeout=60 * 30,
47
+ )
48
+ def merge() -> str:
49
+ import torch
50
+ from peft import PeftModel
51
+ from transformers import AutoModel, AutoProcessor, AutoTokenizer
52
+
53
+ adapter_dir = "/data/out/lora-bengali"
54
+ merged_dir = "/data/out/minicpm-v-bengali-merged"
55
+
56
+ # Defensive: ensure the base's remote-code .py files are present in the shared
57
+ # HF cache (they were cached weights-first by vLLM; train_lora.py repairs them,
58
+ # but keep merge self-contained). Cheap — weights stay cached, only code refetched.
59
+ from huggingface_hub import snapshot_download
60
+ snapshot_download(
61
+ STUDENT_BASE_REPO,
62
+ allow_patterns=["*.py", "*.json", "*.txt", "*.model", "tokenizer*"],
63
+ force_download=True,
64
+ )
65
+
66
+ # MiniCPM-V loads with trust_remote_code; it's an AutoModel (custom class).
67
+ base = AutoModel.from_pretrained(
68
+ STUDENT_BASE_REPO, trust_remote_code=True, torch_dtype=torch.bfloat16
69
+ )
70
+ tokenizer = AutoTokenizer.from_pretrained(STUDENT_BASE_REPO, trust_remote_code=True)
71
+
72
+ model = PeftModel.from_pretrained(base, adapter_dir)
73
+ model = model.merge_and_unload() # fold LoRA into the base weights
74
+
75
+ model.save_pretrained(merged_dir, safe_serialization=True)
76
+ tokenizer.save_pretrained(merged_dir)
77
+ # MiniCPM-V is multimodal: vLLM needs the image processor too (preprocessor_
78
+ # config.json + processing code). Without it vLLM finds only the tokenizer and
79
+ # rejects it ("Invalid type of HuggingFace processor"). Copy it from the base.
80
+ processor = AutoProcessor.from_pretrained(STUDENT_BASE_REPO, trust_remote_code=True)
81
+ processor.save_pretrained(merged_dir)
82
+ _vol.commit()
83
+ return merged_dir
84
+
85
+
86
+ @app.local_entrypoint()
87
+ def main():
88
+ path = merge.remote()
89
+ print(f"Merged model written to volume rupkotha-finetune at {path}")
90
+ print("Next: set FINETUNED_VISION_MODEL and serve via finetune/serve_vllm.py.")
finetune/preprocess.py ADDED
@@ -0,0 +1,139 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # finetune/preprocess.py
2
+ """Stage 0.5 — clean the collected image pool into a training-ready set.
3
+
4
+ Sweeps finetune/data/images/**, and for each image:
5
+ 1. optional fixed fractional crop (per source) — strips page furniture like the
6
+ "Kye Drawing for student" header / signature footer on the village scans;
7
+ 2. auto-trim near-uniform borders (white margins on doodles & scans);
8
+ 3. resize so the longest side ≤ MAX_SIZE;
9
+ 4. RGB-normalise and re-encode;
10
+ 5. drop near-duplicates via a 64-bit average hash (Hamming distance).
11
+
12
+ Outputs finetune/data/processed/<source>/<name>.jpg + a manifest.json. The
13
+ labeler (gen_labels.py) can then point at finetune/data/processed.
14
+
15
+ Run:
16
+ uv run python finetune/preprocess.py # process everything
17
+ uv run python finetune/preprocess.py --max-size 1024 --dedupe-distance 4
18
+ """
19
+
20
+ import argparse
21
+ import json
22
+ from pathlib import Path
23
+
24
+ from PIL import Image, ImageChops
25
+
26
+ ROOT = Path(__file__).resolve().parent
27
+ SRC = ROOT / "data" / "images"
28
+ DST = ROOT / "data" / "processed"
29
+
30
+ # Per-source fractional crop (top, bottom, left, right) to remove page furniture.
31
+ # "root" = images placed directly in data/images/ (the village drawing-book scans).
32
+ CROP_FRACTIONS: dict[str, tuple[float, float, float, float]] = {
33
+ "root": (0.07, 0.06, 0.02, 0.02), # trim header text + signature/date footer
34
+ }
35
+
36
+
37
+ def _source_of(path: Path) -> str:
38
+ rel = path.relative_to(SRC)
39
+ return rel.parts[0] if len(rel.parts) > 1 else "root"
40
+
41
+
42
+ def _frac_crop(img: Image.Image, fracs: tuple[float, float, float, float]) -> Image.Image:
43
+ t, b, l, r = fracs
44
+ w, h = img.size
45
+ box = (int(w * l), int(h * t), int(w * (1 - r)), int(h * (1 - b)))
46
+ return img.crop(box) if box[2] > box[0] and box[3] > box[1] else img
47
+
48
+
49
+ def _autotrim(img: Image.Image, tol: int = 18) -> Image.Image:
50
+ """Trim a near-uniform border using the top-left pixel as the background."""
51
+ bg = Image.new("RGB", img.size, img.getpixel((0, 0)))
52
+ diff = ImageChops.difference(img, bg).convert("L").point(lambda p: 255 if p > tol else 0)
53
+ bbox = diff.getbbox()
54
+ return img.crop(bbox) if bbox else img
55
+
56
+
57
+ def _resize(img: Image.Image, max_size: int) -> Image.Image:
58
+ w, h = img.size
59
+ scale = max_size / max(w, h)
60
+ if scale < 1:
61
+ img = img.resize((max(1, int(w * scale)), max(1, int(h * scale))), Image.LANCZOS)
62
+ return img
63
+
64
+
65
+ def _ahash(img: Image.Image) -> int:
66
+ """64-bit average hash for near-duplicate detection (no extra deps)."""
67
+ small = img.convert("L").resize((8, 8), Image.LANCZOS)
68
+ px = list(small.getdata())
69
+ avg = sum(px) / len(px)
70
+ bits = 0
71
+ for i, p in enumerate(px):
72
+ if p >= avg:
73
+ bits |= 1 << i
74
+ return bits
75
+
76
+
77
+ def _hamming(a: int, b: int) -> int:
78
+ return bin(a ^ b).count("1")
79
+
80
+
81
+ def process(max_size: int, dedupe_distance: int) -> None:
82
+ exts = {".png", ".jpg", ".jpeg", ".webp"}
83
+ paths = sorted(p for p in SRC.rglob("*") if p.suffix.lower() in exts)
84
+ if not paths:
85
+ raise SystemExit(f"No images under {SRC}")
86
+
87
+ DST.mkdir(parents=True, exist_ok=True)
88
+ hashes: list[int] = []
89
+ manifest, kept, dups, errors = [], 0, 0, 0
90
+
91
+ for p in paths:
92
+ source = _source_of(p)
93
+ try:
94
+ img = Image.open(p).convert("RGB")
95
+ img = _frac_crop(img, CROP_FRACTIONS.get(source, (0, 0, 0, 0)))
96
+ img = _autotrim(img)
97
+ img = _resize(img, max_size)
98
+ except Exception as e: # noqa: BLE001
99
+ print(f" skip {p.name}: {e}")
100
+ errors += 1
101
+ continue
102
+
103
+ h = _ahash(img)
104
+ if any(_hamming(h, prev) <= dedupe_distance for prev in hashes):
105
+ dups += 1
106
+ continue
107
+ hashes.append(h)
108
+
109
+ out_dir = DST / source
110
+ out_dir.mkdir(parents=True, exist_ok=True)
111
+ out_path = out_dir / f"{p.stem}.jpg"
112
+ img.save(out_path, "JPEG", quality=90)
113
+ manifest.append({"processed": str(out_path.relative_to(ROOT)),
114
+ "source_image": str(p.relative_to(ROOT)), "source": source})
115
+ kept += 1
116
+
117
+ (DST / "manifest.json").write_text(
118
+ json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8"
119
+ )
120
+ print(f"\nProcessed {len(paths)} → kept {kept}, dropped {dups} dups, {errors} errors")
121
+ by_src: dict[str, int] = {}
122
+ for m in manifest:
123
+ by_src[m["source"]] = by_src.get(m["source"], 0) + 1
124
+ for s, c in sorted(by_src.items()):
125
+ print(f" {s}: {c}")
126
+ print(f"Output: {DST} (+ manifest.json)")
127
+
128
+
129
+ def main() -> None:
130
+ ap = argparse.ArgumentParser()
131
+ ap.add_argument("--max-size", type=int, default=1024)
132
+ ap.add_argument("--dedupe-distance", type=int, default=4,
133
+ help="max aHash Hamming distance to treat as duplicate (0=exact)")
134
+ args = ap.parse_args()
135
+ process(args.max_size, args.dedupe_distance)
136
+
137
+
138
+ if __name__ == "__main__":
139
+ main()
finetune/purity.py ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # finetune/purity.py
2
+ """Bengali script-purity quality gate for teacher labels.
3
+
4
+ The teacher (Gemma) writes mostly clean Bengali, but occasionally code-switches
5
+ (e.g. a stray Latin or Cyrillic word — we saw `зеленая` leak once). Distillation
6
+ caps the student at the label quality, so we filter/repair bad labels before
7
+ training. This is the gate referenced in the Bengali-quality investigation.
8
+
9
+ Heuristics only — no model needed. A human Bengali speaker should still spot-check
10
+ a sample of what passes.
11
+ """
12
+
13
+ import re
14
+ import unicodedata
15
+
16
+ # Bengali Unicode block: U+0980–U+09FF.
17
+ _BENGALI = re.compile(r"[ঀ-৿]")
18
+ # Letters from other scripts that must NOT appear in a Bengali story body.
19
+ _LATIN = re.compile(r"[A-Za-z]")
20
+ _CYRILLIC = re.compile(r"[Ѐ-ӿ]")
21
+ # Allowed non-letter noise: digits, whitespace, common punctuation, emoji, danda.
22
+ _ALLOWED_NONLETTER = re.compile(r"[\s\d\.,!?…\"'“”‘’—\-–—:;()।॥☀-➿\U0001F300-\U0001FAFF]")
23
+
24
+
25
+ def script_stats(text: str) -> dict:
26
+ """Counts of Bengali vs foreign letters and the Bengali-letter ratio."""
27
+ text = unicodedata.normalize("NFC", text or "")
28
+ bengali = len(_BENGALI.findall(text))
29
+ latin = len(_LATIN.findall(text))
30
+ cyrillic = len(_CYRILLIC.findall(text))
31
+ letters = bengali + latin + cyrillic
32
+ ratio = (bengali / letters) if letters else 0.0
33
+ return {
34
+ "bengali": bengali,
35
+ "latin": latin,
36
+ "cyrillic": cyrillic,
37
+ "foreign": latin + cyrillic,
38
+ "bengali_ratio": round(ratio, 4),
39
+ }
40
+
41
+
42
+ def is_clean(
43
+ text: str,
44
+ min_bengali_ratio: float = 0.98,
45
+ max_foreign_letters: int = 0,
46
+ min_length: int = 40,
47
+ ) -> tuple[bool, dict]:
48
+ """Decide whether a teacher label is clean enough to train on.
49
+
50
+ Defaults are strict: essentially zero foreign-script letters. Loosen
51
+ max_foreign_letters to 1–2 if you'd rather repair than drop.
52
+
53
+ Returns (ok, stats).
54
+ """
55
+ stats = script_stats(text)
56
+ ok = (
57
+ len((text or "").strip()) >= min_length
58
+ and stats["foreign"] <= max_foreign_letters
59
+ and stats["bengali_ratio"] >= min_bengali_ratio
60
+ )
61
+ return ok, stats
62
+
63
+
64
+ def foreign_words(text: str) -> list[str]:
65
+ """Return whitespace tokens that contain any Latin/Cyrillic letter — useful
66
+ for eyeballing exactly what leaked (e.g. ['зеленая'])."""
67
+ out = []
68
+ for tok in (text or "").split():
69
+ if _LATIN.search(tok) or _CYRILLIC.search(tok):
70
+ out.append(tok)
71
+ return out
72
+
73
+
74
+ if __name__ == "__main__":
75
+ samples = {
76
+ "good": "আচ্ছা রূপা, চোখ বুজে নাও। চাঁদমামা হাসছে, পুকুরের ধারে ঘাস দুলছে। শুভরাত্রি।",
77
+ "leak": "দেখছো зеленая গোল বলটা? ওটা রূপার প্রিয় খেলনা সবুজ আপেল!",
78
+ "english": "Once upon a time there was a small red house under the sun.",
79
+ }
80
+ for name, s in samples.items():
81
+ ok, stats = is_clean(s)
82
+ print(f"{name:8} ok={ok!s:5} {stats} leaks={foreign_words(s)}")
finetune/serve_vllm.py ADDED
@@ -0,0 +1,119 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # finetune/serve_vllm.py
2
+ """Stage 4 — serve the merged fine-tuned MiniCPM-V via vLLM on Modal.
3
+
4
+ Chosen over GGUF→Ollama because custom MiniCPM-V → GGUF has known vision (mmproj)
5
+ breakage. vLLM loads the merged HF model directly with trust_remote_code and
6
+ handles the multimodal input natively.
7
+
8
+ This exposes `run_vision_story_ft(image_bytes_list, prompt)` — a drop-in for the
9
+ Bengali vision path. To route the app to it, in core/vision_story.py call this
10
+ function (via a small dispatcher in core/modal_infra.py) when a fine-tuned model
11
+ is active, i.e. when model_config.FINETUNED_VISION_MODEL is set.
12
+
13
+ Run / deploy:
14
+ uv run modal deploy finetune/serve_vllm.py
15
+ """
16
+
17
+ import modal
18
+
19
+ from core.model_config import MODAL_MIN_CONTAINERS
20
+
21
+ app = modal.App("rupkotha-ft-serve")
22
+
23
+ _vol = modal.Volume.from_name("rupkotha-finetune", create_if_missing=True)
24
+ _MERGED_DIR = "/data/out/minicpm-v-bengali-merged"
25
+
26
+ # vLLM 0.10.2 is the version the official MiniCPM-V CookBook pins for 4.5. It only
27
+ # requires transformers>=4.55.2 (no upper bound), so pip otherwise grabs transformers
28
+ # 5.x — whose tokenizer backend breaks vLLM (TokenizersBackend.all_special_tokens_
29
+ # extended) AND MiniCPM-V's remote code. Pin transformers==4.57.6 (same 4.x the merge
30
+ # step used with this model) to stay compatible with both.
31
+ _vllm_image = (
32
+ modal.Image.debian_slim(python_version="3.12")
33
+ .pip_install("vllm==0.10.2", "transformers==4.57.6", "pillow")
34
+ # serve_vllm.py imports core.model_config at module load; make `core`
35
+ # importable in the container (current Modal doesn't auto-mount the project).
36
+ .add_local_python_source("core")
37
+ )
38
+
39
+ _engine: dict = {}
40
+
41
+
42
+ @app.function(
43
+ # A100-40GB, not A10G: the full bf16 8B weights (~16GB) + vision encoder + vLLM
44
+ # overhead leave no room for the KV cache on a 24GB A10G (CUDA OOM at startup).
45
+ # 40GB gives comfortable headroom at max_model_len=4096. Scales to zero when idle.
46
+ gpu="A100-40GB",
47
+ image=_vllm_image,
48
+ volumes={"/data": _vol},
49
+ timeout=600,
50
+ min_containers=MODAL_MIN_CONTAINERS, # keep-warm honors model_config toggle
51
+ )
52
+ def run_vision_story_ft(image_bytes_list: list[bytes], prompt: str) -> str:
53
+ """Generate a story from the fine-tuned model via vLLM. Mirrors the contract
54
+ of core/modal_infra.run_vision_story (returns text, '' on failure)."""
55
+ import io
56
+
57
+ from PIL import Image
58
+ from transformers import AutoTokenizer
59
+ from vllm import LLM, SamplingParams
60
+
61
+ if "llm" not in _engine:
62
+ # max_model_len 4096 matches the SWIFT training length: the verbose Bengali
63
+ # prompt + expanded image tokens exceed 2048 for some inputs.
64
+ _engine["llm"] = LLM(
65
+ model=_MERGED_DIR,
66
+ trust_remote_code=True,
67
+ max_model_len=4096,
68
+ disable_mm_preprocessor_cache=True,
69
+ limit_mm_per_prompt={"image": 4},
70
+ )
71
+ tok = AutoTokenizer.from_pretrained(_MERGED_DIR, trust_remote_code=True)
72
+ _engine["tok"] = tok
73
+ # Stop on MiniCPM-V's chat-end tokens so we don't bleed past the story.
74
+ stop = ["<|im_end|>", "<|endoftext|>"]
75
+ _engine["sp"] = SamplingParams(
76
+ temperature=0.45, top_p=0.9, max_tokens=768,
77
+ stop_token_ids=[tok.convert_tokens_to_ids(t) for t in stop],
78
+ )
79
+
80
+ llm = _engine["llm"]
81
+ tok = _engine["tok"]
82
+ images = [Image.open(io.BytesIO(b)).convert("RGB") for b in (image_bytes_list or [])]
83
+ # MiniCPM-V 4.5: one (<image>./</image>) placeholder per image, then the text.
84
+ # Wrap via the model's chat template (add_generation_prompt) so inference matches
85
+ # the minicpmv4_5 template the LoRA was trained under — critical for quality.
86
+ placeholders = "".join("(<image>./</image>)\n" for _ in images)
87
+ messages = [{"role": "user", "content": placeholders + prompt}]
88
+ # MiniCPM-V 4.5 has a hybrid thinking mode that prepends <think>...</think> and
89
+ # eats the token budget. Disable it (best-effort — older templates lack the kwarg).
90
+ try:
91
+ text = tok.apply_chat_template(
92
+ messages, tokenize=False, add_generation_prompt=True, enable_thinking=False
93
+ )
94
+ except TypeError:
95
+ text = tok.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)
96
+ try:
97
+ out = llm.generate(
98
+ {"prompt": text, "multi_modal_data": {"image": images}},
99
+ _engine["sp"],
100
+ )
101
+ story = (out[0].outputs[0].text or "").strip()
102
+ # Strip any residual think block the model still emits.
103
+ import re
104
+ story = re.sub(r"^\s*<think>.*?</think>\s*", "", story, flags=re.DOTALL).strip()
105
+ return story
106
+ except Exception as e: # noqa: BLE001
107
+ print(f"[serve_vllm] generation failed: {e}")
108
+ return ""
109
+
110
+
111
+ def generate_story_ft_remote(images_b64: list[str], prompt: str) -> str:
112
+ """Plain-callable dispatcher (import into core/modal_infra.py to route the
113
+ Bengali path here when FINETUNED_VISION_MODEL is set). Mirrors
114
+ generate_story_remote so core/vision_story.py needs only a one-line branch."""
115
+ import base64
116
+
117
+ image_bytes = [base64.b64decode(b) for b in (images_b64 or [])]
118
+ fn = modal.Function.from_name("rupkotha-ft-serve", "run_vision_story_ft")
119
+ return fn.remote(image_bytes, prompt)
finetune/train_lora.py ADDED
@@ -0,0 +1,166 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # finetune/train_lora.py
2
+ """Stage 2 — LoRA fine-tune MiniCPM-V 4.5 on the distilled Bengali dataset.
3
+
4
+ Uses **ms-SWIFT** (modelscope/ms-swift) rather than OpenBMB's official finetune
5
+ scripts. The official path hit a hard dependency wall: its requirements pin torch
6
+ 2.1.2, but MiniCPM-V 4.5's remote code needs torch>=2.4 (`torch.library.custom_op`),
7
+ and the repo's finetune.py targets older MiniCPM releases (4.0/2.6/2.5), not 4.5.
8
+ SWIFT ships a maintained recipe for this exact model — `model_type=minicpmv4_5`,
9
+ `template=minicpmv4_5`, deps `timm, transformers>=4.36, decord` — so the version
10
+ matrix is solved for us.
11
+
12
+ Vision encoder frozen (`--freeze_vit true`); LoRA on the LLM self-attention layers
13
+ only (`q/k/v/o_proj`) — that's the weak-Bengali part. ViT and aligner stay frozen.
14
+
15
+ Inputs (Modal Volume `rupkotha-finetune`): /train.json + /labelset/*.jpg
16
+ (train.json 'image' fields are container-absolute: /data/labelset/<name>)
17
+ Converted in-container to SWIFT's messages/images JSONL schema.
18
+ Output (same volume): /out/lora-bengali/ (PEFT adapter — feed to finetune/merge.py)
19
+
20
+ Run a SHORT validation first to surface integration errors cheaply:
21
+ uv run modal run finetune/train_lora.py --max-steps 4
22
+ Then the full run:
23
+ uv run modal run finetune/train_lora.py
24
+ """
25
+
26
+ import modal
27
+
28
+ from core.model_config import STUDENT_BASE_REPO # openbmb/MiniCPM-V-4_5
29
+
30
+ app = modal.App("rupkotha-finetune")
31
+
32
+ _vol = modal.Volume.from_name("rupkotha-finetune", create_if_missing=True)
33
+ _hf = modal.Volume.from_name("rupkotha-hf", create_if_missing=True)
34
+
35
+ # ms-SWIFT brings its own pinned transformers/peft/accelerate; we only add the
36
+ # MiniCPM-V 4.5 model extras (timm, decord) and force torch>=2.4 for its remote
37
+ # code. No deepspeed/nvcc needed — a single A100-80GB fits an 8B base + LoRA, and
38
+ # attn defaults to sdpa (no flash-attn build), so debian_slim + pip wheels suffice.
39
+ # Pin ms-swift 3.12.6 (last 3.x): it lists model_type `minicpmv4_5` AND pins
40
+ # transformers>=4.33,<4.58. The 4.x line pulls transformers 5.x, whose remote-code
41
+ # loader follows the HF-cache symlink into blobs/ and fails to resolve MiniCPM-V's
42
+ # relative imports (modeling_navit_siglip.py). 4.x transformers loads it cleanly.
43
+ _train_image = (
44
+ modal.Image.debian_slim(python_version="3.10")
45
+ .apt_install("git")
46
+ .pip_install(
47
+ "ms-swift==3.12.6",
48
+ "torch>=2.4",
49
+ "timm",
50
+ "decord",
51
+ "pillow",
52
+ "sentencepiece",
53
+ )
54
+ # train_lora.py imports core.model_config at module load; `modal run <file>`
55
+ # doesn't auto-mount the project, so make `core` importable in the container.
56
+ .add_local_python_source("core")
57
+ )
58
+
59
+
60
+ @app.function(
61
+ gpu="A100-80GB", # headroom for an 8B base + LoRA on one GPU
62
+ image=_train_image,
63
+ volumes={"/data": _vol, "/root/.cache/huggingface": _hf},
64
+ secrets=[modal.Secret.from_name("algaeguard-secrets")], # HF_TOKEN for the base
65
+ timeout=60 * 60 * 6,
66
+ )
67
+ def train(max_steps: int = 0) -> str:
68
+ import glob
69
+ import json
70
+ import os
71
+ import shutil
72
+ import subprocess
73
+
74
+ # SWIFT defaults to ModelScope; force HuggingFace so it pulls openbmb/MiniCPM-V-4_5
75
+ # (and reuses the HF_TOKEN from the algaeguard-secrets secret).
76
+ os.environ["USE_HF"] = "1"
77
+ # Mirror the original max_slice_nums=9 (SWIFT reads this env for MiniCPM-V).
78
+ os.environ.setdefault("MAX_SLICE_NUMS", "9")
79
+
80
+ # Repair the shared HF cache: the base model was cached weights-first (vLLM),
81
+ # leaving the MiniCPM-V remote-code .py files as dangling symlinks (e.g.
82
+ # modeling_navit_siglip.py). Refetch the small code/config files cleanly so
83
+ # trust_remote_code loads; the .safetensors weights stay cached untouched.
84
+ from huggingface_hub import snapshot_download
85
+ snapshot_download(
86
+ STUDENT_BASE_REPO,
87
+ allow_patterns=["*.py", "*.json", "*.txt", "*.model", "tokenizer*"],
88
+ force_download=True,
89
+ )
90
+
91
+ # ── Convert MiniCPM conversations format → SWIFT messages/images JSONL ──
92
+ # Source rows already carry role/content turns with a leading <image> in the
93
+ # user content and a container-absolute image path. SWIFT wants `messages` +
94
+ # an `images` list (one path per <image> placeholder).
95
+ src = "/data/train.json" # volume copy: image paths already /data/labelset/<name>
96
+ swift_data = "/data/train_swift.jsonl"
97
+ rows = json.load(open(src))
98
+ with open(swift_data, "w") as f:
99
+ for r in rows:
100
+ f.write(json.dumps(
101
+ {"messages": r["conversations"], "images": [r["image"]]},
102
+ ensure_ascii=False,
103
+ ) + "\n")
104
+ print(f"Converted {len(rows)} examples → {swift_data}")
105
+
106
+ adapter_dir = "/data/out/lora-bengali" # canonical path merge.py expects
107
+ swift_out = "/data/out/swift-runs" # SWIFT writes vX-<ts>/checkpoint-N here
108
+
109
+ # Mirror finetune_lora.sh intent via SWIFT's CLI: vision frozen, LoRA r=16 on
110
+ # the LLM self-attention projections only.
111
+ cmd = [
112
+ "swift", "sft",
113
+ "--model", STUDENT_BASE_REPO,
114
+ "--model_type", "minicpmv4_5",
115
+ "--train_type", "lora",
116
+ "--dataset", swift_data,
117
+ "--freeze_vit", "true",
118
+ "--target_modules", "q_proj", "k_proj", "v_proj", "o_proj",
119
+ "--lora_rank", "16", "--lora_alpha", "32", "--lora_dropout", "0.05",
120
+ "--torch_dtype", "bfloat16",
121
+ # 4096, not 2048: the verbose Bengali prompt + MiniCPM image slices
122
+ # (max_slice_nums=9) push some rows to ~2083 tokens. At 2048 SWIFT raises
123
+ # MaxLengthError per over-long row and silently drops it from training;
124
+ # 4096 keeps all 389 samples (peak mem was only ~28 GiB of 80).
125
+ "--max_length", "4096",
126
+ "--per_device_train_batch_size", "1",
127
+ "--gradient_accumulation_steps", "8",
128
+ "--learning_rate", "1e-4",
129
+ "--gradient_checkpointing", "true",
130
+ "--save_strategy", "steps", "--save_steps", "200", "--save_total_limit", "2",
131
+ "--logging_steps", "5", "--report_to", "none",
132
+ "--dataloader_num_workers", "4",
133
+ "--output_dir", swift_out,
134
+ ]
135
+ # Short validation run vs full run.
136
+ if max_steps and max_steps > 0:
137
+ cmd += ["--max_steps", str(max_steps)]
138
+ else:
139
+ cmd += ["--num_train_epochs", "3"]
140
+
141
+ print("Running:", " ".join(cmd))
142
+ subprocess.run(cmd, check=True)
143
+
144
+ # SWIFT nests output under output_dir/<version>/checkpoint-<step>/. Find the
145
+ # latest dir that actually holds a PEFT adapter and copy it to the canonical
146
+ # path so finetune/merge.py (which loads /data/out/lora-bengali) works unchanged.
147
+ adapters = glob.glob(f"{swift_out}/**/adapter_config.json", recursive=True)
148
+ if not adapters:
149
+ raise RuntimeError(f"No adapter produced under {swift_out}")
150
+ final_ckpt = max(
151
+ (os.path.dirname(p) for p in adapters), key=os.path.getmtime
152
+ )
153
+ print(f"Final adapter checkpoint: {final_ckpt}")
154
+ if os.path.exists(adapter_dir):
155
+ shutil.rmtree(adapter_dir)
156
+ shutil.copytree(final_ckpt, adapter_dir)
157
+
158
+ _vol.commit()
159
+ return adapter_dir
160
+
161
+
162
+ @app.local_entrypoint()
163
+ def main(max_steps: int = 0):
164
+ path = train.remote(max_steps=max_steps)
165
+ print(f"LoRA adapter written to volume rupkotha-finetune at {path}")
166
+ print("Next: finetune/merge.py to fold the adapter into full weights.")
pyproject.toml ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [project]
2
+ name = "rupkatha"
3
+ version = "0.1.0"
4
+ requires-python = ">=3.10,<3.13"
5
+ dependencies = [
6
+ "gradio>=6.17.3",
7
+ "modal>=1.5.0",
8
+ ]
9
+
10
+ [dependency-groups]
11
+ finetune = [
12
+ "datasets>=5.0.0",
13
+ ]
requirements.txt ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ # Hugging Face Space (thin client) dependencies ONLY.
2
+ # The Space runs app.py, which calls Modal for all inference — it holds zero model
3
+ # weights and imports no ML libraries locally. So this is intentionally minimal:
4
+ # just Gradio (the UI) and the Modal client (to invoke the deployed functions).
5
+ # The heavy deps (torch, vllm, transformers, coqui-tts, …) live in the Modal images
6
+ # in core/modal_infra.py + finetune/, NOT here. Local dev uses uv (pyproject/uv.lock).
7
+ gradio==6.17.3
8
+ modal>=1.5
uv.lock ADDED
The diff for this file is too large to render. See raw diff