curieous commited on
Commit
cecced3
·
verified ·
1 Parent(s): d0e5d2f

Wire Epic Errands V1 Modal model smoke

Browse files
.gitattributes CHANGED
@@ -41,3 +41,9 @@ frontend/assets/sc2-hero-scene-clean.png filter=lfs diff=lfs merge=lfs -text
41
  frontend/assets/sc2-hero-scene-only.png filter=lfs diff=lfs merge=lfs -text
42
  frontend/assets/source-sc1.png filter=lfs diff=lfs merge=lfs -text
43
  frontend/assets/source-sc2.png filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
 
41
  frontend/assets/sc2-hero-scene-only.png filter=lfs diff=lfs merge=lfs -text
42
  frontend/assets/source-sc1.png filter=lfs diff=lfs merge=lfs -text
43
  frontend/assets/source-sc2.png filter=lfs diff=lfs merge=lfs -text
44
+ frontend/assets/generated-audio-1-clean-up-my-room-before-dinner-56693c13.wav filter=lfs diff=lfs merge=lfs -text
45
+ frontend/assets/generated-audio-2-finish-my-class-project-outline-62a0674b.wav filter=lfs diff=lfs merge=lfs -text
46
+ frontend/assets/generated-audio-3-read-for-20-minutes-5e944b16.wav filter=lfs diff=lfs merge=lfs -text
47
+ frontend/assets/generated-hero-1-clean-up-my-room-before-dinner-07b300bc.png filter=lfs diff=lfs merge=lfs -text
48
+ frontend/assets/generated-hero-2-finish-my-class-project-outline-4a8a06bc.png filter=lfs diff=lfs merge=lfs -text
49
+ frontend/assets/generated-hero-3-read-for-20-minutes-3781199a.png filter=lfs diff=lfs merge=lfs -text
epic_errands_space/quest_generation.py CHANGED
@@ -21,6 +21,18 @@ ASSETS_DIR = FRONTEND_ROOT / "assets"
21
  CACHE_MANIFEST = ASSETS_DIR / "generated-hero-manifest.json"
22
  FALLBACK_HERO = "sc2-hero-scene-clean.png"
23
  MODEL_ID = "black-forest-labs/FLUX.2-klein-9B"
 
 
 
 
 
 
 
 
 
 
 
 
24
 
25
  STEP_LIBRARY = {
26
  "project": [
@@ -54,6 +66,11 @@ def default_goal_payload() -> list[dict[str, str]]:
54
  return [{"id": goal.id, "text": goal.text} for goal in DEFAULT_GOALS]
55
 
56
 
 
 
 
 
 
57
  def normalize_goals(raw_goals: Any) -> list[dict[str, str]]:
58
  goals: list[dict[str, str]] = []
59
  if isinstance(raw_goals, list):
@@ -66,15 +83,17 @@ def normalize_goals(raw_goals: Any) -> list[dict[str, str]]:
66
  goals.append({
67
  "id": str(item.get("id") or f"goal-{index + 1}"),
68
  "text": text,
 
69
  })
70
  if len(goals) == 3:
71
  break
72
 
73
- for fallback in default_goal_payload():
 
74
  if len(goals) == 3:
75
  break
76
  if not any(goal["text"].casefold() == fallback["text"].casefold() for goal in goals):
77
- goals.append(fallback)
78
 
79
  return goals[:3]
80
 
@@ -85,6 +104,12 @@ def generated_file_name(goal: dict[str, str], index: int) -> str:
85
  return f"generated-hero-{index + 1}-{slug}-{digest}.png"
86
 
87
 
 
 
 
 
 
 
88
  def _asset_to_data_uri(file_name: str) -> str:
89
  path = ASSETS_DIR / file_name
90
  mime_type = mimetypes.guess_type(path.name)[0] or "image/png"
@@ -119,8 +144,14 @@ def _step_key(goal_text: str) -> str:
119
  return "default"
120
 
121
 
122
- def _quest_title(goal_text: str, tone: str) -> str:
123
  lowered = goal_text.lower()
 
 
 
 
 
 
124
  tone_word = {
125
  "cozy": "Cozy",
126
  "brave": "Brave",
@@ -129,12 +160,14 @@ def _quest_title(goal_text: str, tone: str) -> str:
129
  "epic": "Epic",
130
  }.get(tone, "Cozy")
131
  if "project" in lowered or "class" in lowered:
132
- return f"{tone_word} Bridge of Bright Ideas"
133
  if "read" in lowered:
134
- return f"{tone_word} Reading Lantern Quest"
135
  if "laundry" in lowered or "clothes" in lowered:
136
- return f"{tone_word} Laundry Kingdom Rescue"
137
- return f"{tone_word} Quest for {goal_text[:28]}"
 
 
138
 
139
 
140
  def _why_it_helps(goal_text: str) -> str:
@@ -145,12 +178,27 @@ def _why_it_helps(goal_text: str) -> str:
145
 
146
 
147
  def _prompt_for_goal(goal: dict[str, str], tone: str) -> str:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
148
  return (
149
  "A delightful family questbook hero image for a children's "
150
- "goal-planning app. Warm handcrafted illustration, parchment storybook "
151
- "mood, clear everyday adventure, no readable text, no UI, "
152
  "kid-safe, expressive, polished. "
153
- f"Tone: {tone}. Ordinary goal transformed visually: {goal['text']}."
 
154
  )
155
 
156
 
@@ -169,6 +217,44 @@ def _fallback_image_result(goal: dict[str, str], index: int, reason: str) -> dic
169
  }
170
 
171
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
172
  def _load_manifest() -> dict[str, Any]:
173
  try:
174
  return json.loads(CACHE_MANIFEST.read_text(encoding="utf-8"))
@@ -225,7 +311,7 @@ def _cached_images_for(goals: list[dict[str, str]]) -> list[dict[str, Any]] | No
225
 
226
 
227
  def _call_modal(goals: list[dict[str, str]], tone: str) -> tuple[list[dict[str, Any]], dict[str, Any]]:
228
- base_url = os.environ.get("APP_MODAL_BASE_URL", "").strip()
229
  token = os.environ.get("APP_MODAL_AUTH_TOKEN", "").strip()
230
  timeout = float(os.environ.get("APP_MODAL_TIMEOUT_SECONDS", "180"))
231
  if not base_url or not token:
@@ -236,6 +322,7 @@ def _call_modal(goals: list[dict[str, str]], tone: str) -> tuple[list[dict[str,
236
  {
237
  "goal_id": goal["id"],
238
  "ordinary_goal": goal["text"],
 
239
  "prompt": _prompt_for_goal(goal, tone),
240
  "seed": 26000 + index,
241
  }
@@ -300,29 +387,221 @@ def _call_modal(goals: list[dict[str, str]], tone: str) -> tuple[list[dict[str,
300
  return saved, metadata
301
 
302
 
303
- def _quest_for_goal(goal: dict[str, str], image_result: dict[str, Any], index: int, tone: str) -> dict[str, Any]:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
304
  steps = [
305
  {"id": f"{goal['id']}-step-{step_index + 1}", "label": label, "icon": icon}
306
  for step_index, (label, icon) in enumerate(STEP_LIBRARY[_step_key(goal["text"])])
307
  ]
 
 
 
 
 
 
 
 
 
 
 
308
  return {
309
  "id": f"quest-{goal['id']}",
310
  "source_goal_id": goal["id"],
311
  "ordinary_goal": goal["text"],
312
- "title": _quest_title(goal["text"], tone),
 
313
  "subtitle": f"Quest {index + 1} of 3",
314
  "hero_asset_name": image_result["asset_name"],
315
  "hero_src": image_result.get("hero_src", ""),
316
  "hero_alt": f"Storybook classroom adventure for {goal['text']}",
317
  "reward": {
318
- "label": ["Idea Builder", "Lantern Reader", "Tidy Hero"][index] if index < 3 else "Quest Hero",
319
- "description": "Reward earned after all steps are complete.",
320
  },
321
  "steps": steps,
 
322
  "why_it_helps": _why_it_helps(goal["text"]),
323
  "image_origin": image_result.get("origin", "unknown"),
324
  "prompt_summary": image_result.get("prompt_summary", ""),
325
  "runtime_metadata": image_result.get("runtime_metadata", _runtime_metadata()),
 
 
 
 
 
 
 
 
 
 
326
  }
327
 
328
 
@@ -352,13 +631,110 @@ def build_quest_pack(payload: dict[str, Any] | None = None, *, use_cache: bool =
352
  images = [_fallback_image_result(goal, index, blocker) for index, goal in enumerate(goals)]
353
  metadata = _runtime_metadata(fallback_used=True, fallback_reason=blocker)
354
 
355
- quests = [_quest_for_goal(goal, images[index], index, tone) for index, goal in enumerate(goals)]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
356
  return {
357
  "goals": goals,
358
  "quests": quests,
359
  "runtime_metadata": metadata,
360
- "fallback_used": any(bool(quest["runtime_metadata"].get("fallback_used", True)) for quest in quests),
361
- "blocker": blocker,
 
 
 
 
 
 
 
 
 
 
362
  "generated_at": datetime.now(timezone.utc).isoformat(),
363
  }
364
 
 
21
  CACHE_MANIFEST = ASSETS_DIR / "generated-hero-manifest.json"
22
  FALLBACK_HERO = "sc2-hero-scene-clean.png"
23
  MODEL_ID = "black-forest-labs/FLUX.2-klein-9B"
24
+ MINICPM_MODEL_ID = "openbmb/MiniCPM4.1-8B"
25
+ NEMOTRON3_MODEL_ID = "nvidia/NVIDIA-Nemotron-3-Nano-4B-BF16"
26
+ VOXCPM2_MODEL_ID = "openbmb/VoxCPM2"
27
+ NEMOTRON_SPEECH_MODEL_ID = "nvidia/nemotron-speech-streaming-en-0.6b"
28
+
29
+ THEME_ALIASES = {
30
+ "quest": "magical",
31
+ "magical": "magical",
32
+ "class": "classroom",
33
+ "classroom": "classroom",
34
+ "comic": "comic",
35
+ }
36
 
37
  STEP_LIBRARY = {
38
  "project": [
 
66
  return [{"id": goal.id, "text": goal.text} for goal in DEFAULT_GOALS]
67
 
68
 
69
+ def _theme(value: Any, fallback: str = "magical") -> str:
70
+ normalized = str(value or fallback).strip().lower()
71
+ return THEME_ALIASES.get(normalized, fallback)
72
+
73
+
74
  def normalize_goals(raw_goals: Any) -> list[dict[str, str]]:
75
  goals: list[dict[str, str]] = []
76
  if isinstance(raw_goals, list):
 
83
  goals.append({
84
  "id": str(item.get("id") or f"goal-{index + 1}"),
85
  "text": text,
86
+ "theme": _theme(item.get("theme") or item.get("style_id"), "magical"),
87
  })
88
  if len(goals) == 3:
89
  break
90
 
91
+ fallback_themes = ["magical", "classroom", "comic"]
92
+ for fallback_index, fallback in enumerate(default_goal_payload()):
93
  if len(goals) == 3:
94
  break
95
  if not any(goal["text"].casefold() == fallback["text"].casefold() for goal in goals):
96
+ goals.append({**fallback, "theme": fallback_themes[fallback_index % len(fallback_themes)]})
97
 
98
  return goals[:3]
99
 
 
104
  return f"generated-hero-{index + 1}-{slug}-{digest}.png"
105
 
106
 
107
+ def generated_audio_file_name(goal: dict[str, str], index: int) -> str:
108
+ slug = re.sub(r"[^a-z0-9]+", "-", goal["text"].lower()).strip("-")[:36] or f"goal-{index + 1}"
109
+ digest = hashlib.sha1(f"audio:{goal['id']}:{goal['text']}".encode("utf-8")).hexdigest()[:8]
110
+ return f"generated-audio-{index + 1}-{slug}-{digest}.wav"
111
+
112
+
113
  def _asset_to_data_uri(file_name: str) -> str:
114
  path = ASSETS_DIR / file_name
115
  mime_type = mimetypes.guess_type(path.name)[0] or "image/png"
 
144
  return "default"
145
 
146
 
147
+ def _quest_title(goal_text: str, tone: str, theme: str = "magical") -> str:
148
  lowered = goal_text.lower()
149
+ if theme == "classroom":
150
+ noun = "Quest"
151
+ elif theme == "comic":
152
+ noun = "Mission"
153
+ else:
154
+ noun = "Quest"
155
  tone_word = {
156
  "cozy": "Cozy",
157
  "brave": "Brave",
 
160
  "epic": "Epic",
161
  }.get(tone, "Cozy")
162
  if "project" in lowered or "class" in lowered:
163
+ return f"{noun}: Bright Idea Bridge"
164
  if "read" in lowered:
165
+ return f"{noun}: Reading Lantern"
166
  if "laundry" in lowered or "clothes" in lowered:
167
+ return f"{noun}: Laundry Launch"
168
+ if "room" in lowered or "clean" in lowered:
169
+ return f"{noun}: Tidy Room Rescue"
170
+ return f"{tone_word} {noun} for {goal_text[:28]}"
171
 
172
 
173
  def _why_it_helps(goal_text: str) -> str:
 
178
 
179
 
180
  def _prompt_for_goal(goal: dict[str, str], tone: str) -> str:
181
+ theme = _theme(goal.get("theme"), "magical")
182
+ visual_direction = {
183
+ "magical": (
184
+ "premium storybook quest card, warm paper, gentle fantasy props, "
185
+ "earned crest, no monsters or threats"
186
+ ),
187
+ "classroom": (
188
+ "cozy classroom adventure, stickers, notebooks, pencils, sunny desk, "
189
+ "teacher-approved kid-safe energy"
190
+ ),
191
+ "comic": (
192
+ "bright comic mission panel, bold shapes, action burst composition, "
193
+ "playful but calm, no readable text"
194
+ ),
195
+ }[theme]
196
  return (
197
  "A delightful family questbook hero image for a children's "
198
+ "goal-planning app. Warm handcrafted illustration, clear everyday adventure, no readable text, no UI, "
 
199
  "kid-safe, expressive, polished. "
200
+ f"Theme: {theme}. Tone: {tone}. Visual direction: {visual_direction}. "
201
+ f"Ordinary goal transformed visually: {goal['text']}."
202
  )
203
 
204
 
 
217
  }
218
 
219
 
220
+ def _fallback_card_text(goal: dict[str, str], tone: str, reason: str) -> dict[str, Any]:
221
+ steps = [label for label, _icon in STEP_LIBRARY[_step_key(goal["text"])]][:3]
222
+ return {
223
+ "goal_id": goal["id"],
224
+ "ordinary_goal": goal["text"],
225
+ "theme": _theme(goal.get("theme"), "magical"),
226
+ "model_key": "deterministic_fallback",
227
+ "model_id": "none",
228
+ "schema_valid": True,
229
+ "raw_text": "",
230
+ "card_text": {
231
+ "title": _quest_title(goal["text"], tone, _theme(goal.get("theme"), "magical")),
232
+ "narration": f"Your goal card is ready: {goal['text']}. Take it one small step at a time.",
233
+ "steps": steps,
234
+ "reward_label": "Quest Hero",
235
+ "completion_check": "Done when the ordinary goal is truly finished.",
236
+ },
237
+ "runtime_metadata": _runtime_metadata(fallback_used=True, fallback_reason=reason),
238
+ }
239
+
240
+
241
+ def _fallback_audio_result(goal: dict[str, str], index: int, reason: str) -> dict[str, Any]:
242
+ return {
243
+ "goal_id": goal["id"],
244
+ "ordinary_goal": goal["text"],
245
+ "theme": _theme(goal.get("theme"), "magical"),
246
+ "audio_asset_name": "",
247
+ "audio_src": "",
248
+ "spoken_text": "",
249
+ "runtime_metadata": _runtime_metadata(
250
+ model_backend="modal_http",
251
+ model_id=VOXCPM2_MODEL_ID,
252
+ fallback_used=True,
253
+ fallback_reason=reason,
254
+ ),
255
+ }
256
+
257
+
258
  def _load_manifest() -> dict[str, Any]:
259
  try:
260
  return json.loads(CACHE_MANIFEST.read_text(encoding="utf-8"))
 
311
 
312
 
313
  def _call_modal(goals: list[dict[str, str]], tone: str) -> tuple[list[dict[str, Any]], dict[str, Any]]:
314
+ base_url = os.environ.get("EPIC_MODAL_IMAGE_URL", "").strip() or os.environ.get("APP_MODAL_BASE_URL", "").strip()
315
  token = os.environ.get("APP_MODAL_AUTH_TOKEN", "").strip()
316
  timeout = float(os.environ.get("APP_MODAL_TIMEOUT_SECONDS", "180"))
317
  if not base_url or not token:
 
322
  {
323
  "goal_id": goal["id"],
324
  "ordinary_goal": goal["text"],
325
+ "theme": _theme(goal.get("theme"), "magical"),
326
  "prompt": _prompt_for_goal(goal, tone),
327
  "seed": 26000 + index,
328
  }
 
387
  return saved, metadata
388
 
389
 
390
+ def _modal_json(url: str, payload: dict[str, Any], timeout: float) -> dict[str, Any]:
391
+ token = os.environ.get("APP_MODAL_AUTH_TOKEN", "").strip()
392
+ if not url or not token:
393
+ raise RuntimeError("Modal URL and APP_MODAL_AUTH_TOKEN are required.")
394
+ request = urllib.request.Request(
395
+ url,
396
+ data=json.dumps(payload).encode("utf-8"),
397
+ headers={
398
+ "Authorization": f"Bearer {token}",
399
+ "Content-Type": "application/json",
400
+ },
401
+ method="POST",
402
+ )
403
+ with urllib.request.urlopen(request, timeout=timeout) as response:
404
+ return json.loads(response.read().decode("utf-8"))
405
+
406
+
407
+ def _call_text_model(goals: list[dict[str, str]], model_key: str) -> tuple[list[dict[str, Any]], dict[str, Any]]:
408
+ base_url = os.environ.get("EPIC_MODAL_TEXT_URL", "").strip()
409
+ timeout = float(os.environ.get("EPIC_MODAL_TEXT_TIMEOUT_SECONDS", os.environ.get("APP_MODAL_TIMEOUT_SECONDS", "300")))
410
+ if not base_url:
411
+ raise RuntimeError("EPIC_MODAL_TEXT_URL is required for Modal text generation.")
412
+ started = time.monotonic()
413
+ body = _modal_json(
414
+ base_url,
415
+ {
416
+ "model": model_key,
417
+ "items": [
418
+ {
419
+ "goal_id": goal["id"],
420
+ "ordinary_goal": goal["text"],
421
+ "theme": _theme(goal.get("theme"), "magical"),
422
+ }
423
+ for goal in goals
424
+ ],
425
+ "max_new_tokens": 260,
426
+ },
427
+ timeout,
428
+ )
429
+ latency_ms = int((time.monotonic() - started) * 1000)
430
+ outputs = body.get("outputs")
431
+ if not isinstance(outputs, list) or len(outputs) < len(goals):
432
+ raise RuntimeError(f"Modal text response missing outputs for {model_key}.")
433
+ metadata = {
434
+ "lifecycle_stage": "testing" if os.environ.get("SPACE_ID") else "dev",
435
+ "app_host": "hf_space" if os.environ.get("SPACE_ID") else "local",
436
+ "model_runtime": "modal",
437
+ "model_backend": body.get("model_backend", "modal_http"),
438
+ "inference_engine": body.get("inference_engine", "transformers"),
439
+ "model_artifact_format": body.get("model_artifact_format", "safetensors"),
440
+ "quantization": body.get("quantization", "bf16"),
441
+ "model_id": body.get("model_id", MINICPM_MODEL_ID if model_key == "minicpm" else NEMOTRON3_MODEL_ID),
442
+ "latency_ms": body.get("latency_ms", latency_ms),
443
+ "fallback_used": bool(body.get("fallback_used", False)),
444
+ }
445
+ return outputs[: len(goals)], metadata
446
+
447
+
448
+ def _call_voxcpm2(goals: list[dict[str, str]], card_texts: dict[str, dict[str, Any]]) -> tuple[list[dict[str, Any]], dict[str, Any]]:
449
+ base_url = os.environ.get("EPIC_MODAL_VOXCPM2_URL", "").strip()
450
+ timeout = float(os.environ.get("EPIC_MODAL_SPEECH_TIMEOUT_SECONDS", os.environ.get("APP_MODAL_TIMEOUT_SECONDS", "300")))
451
+ if not base_url:
452
+ raise RuntimeError("EPIC_MODAL_VOXCPM2_URL is required for Modal VoxCPM2 generation.")
453
+ started = time.monotonic()
454
+ body = _modal_json(
455
+ base_url,
456
+ {
457
+ "items": [
458
+ {
459
+ "goal_id": goal["id"],
460
+ "ordinary_goal": goal["text"],
461
+ "theme": _theme(goal.get("theme"), "magical"),
462
+ "narration": str(card_texts.get(goal["id"], {}).get("narration") or ""),
463
+ }
464
+ for goal in goals
465
+ ],
466
+ "cfg_value": 2.0,
467
+ "inference_timesteps": 10,
468
+ },
469
+ timeout,
470
+ )
471
+ latency_ms = int((time.monotonic() - started) * 1000)
472
+ outputs = body.get("outputs")
473
+ if not isinstance(outputs, list) or len(outputs) < len(goals):
474
+ raise RuntimeError("Modal VoxCPM2 response missing outputs.")
475
+
476
+ saved: list[dict[str, Any]] = []
477
+ metadata = {
478
+ "lifecycle_stage": "testing" if os.environ.get("SPACE_ID") else "dev",
479
+ "app_host": "hf_space" if os.environ.get("SPACE_ID") else "local",
480
+ "model_runtime": "modal",
481
+ "model_backend": body.get("model_backend", "modal_http"),
482
+ "inference_engine": body.get("inference_engine", "voxcpm"),
483
+ "model_artifact_format": body.get("model_artifact_format", "safetensors"),
484
+ "quantization": body.get("quantization", "bf16"),
485
+ "model_id": body.get("model_id", VOXCPM2_MODEL_ID),
486
+ "latency_ms": body.get("latency_ms", latency_ms),
487
+ "fallback_used": bool(body.get("fallback_used", False)),
488
+ }
489
+ for index, goal in enumerate(goals):
490
+ output = outputs[index]
491
+ audio_b64 = output.get("audio_wav_base64")
492
+ if not audio_b64:
493
+ raise RuntimeError("Modal VoxCPM2 output missing audio_wav_base64.")
494
+ audio_bytes = base64.b64decode(audio_b64)
495
+ asset_name = generated_audio_file_name(goal, index)
496
+ (ASSETS_DIR / asset_name).write_bytes(audio_bytes)
497
+ saved.append({
498
+ "goal_id": goal["id"],
499
+ "ordinary_goal": goal["text"],
500
+ "theme": _theme(goal.get("theme"), "magical"),
501
+ "audio_asset_name": asset_name,
502
+ "audio_src": f"data:audio/wav;base64,{base64.b64encode(audio_bytes).decode('ascii')}",
503
+ "audio_wav_base64": audio_b64,
504
+ "spoken_text": output.get("spoken_text", ""),
505
+ "sample_rate": output.get("sample_rate"),
506
+ "runtime_metadata": metadata,
507
+ })
508
+ return saved, metadata
509
+
510
+
511
+ def _call_nemotron_speech(audio_results: list[dict[str, Any]]) -> tuple[list[dict[str, Any]], dict[str, Any]]:
512
+ base_url = os.environ.get("EPIC_MODAL_NEMOTRON_SPEECH_URL", "").strip()
513
+ timeout = float(os.environ.get("EPIC_MODAL_ASR_TIMEOUT_SECONDS", os.environ.get("APP_MODAL_TIMEOUT_SECONDS", "300")))
514
+ if not base_url:
515
+ raise RuntimeError("EPIC_MODAL_NEMOTRON_SPEECH_URL is required for Modal Nemotron Speech ASR.")
516
+ started = time.monotonic()
517
+ body = _modal_json(
518
+ base_url,
519
+ {
520
+ "items": [
521
+ {
522
+ "goal_id": item["goal_id"],
523
+ "ordinary_goal": item["ordinary_goal"],
524
+ "audio_wav_base64": item.get("audio_wav_base64", ""),
525
+ }
526
+ for item in audio_results
527
+ ],
528
+ },
529
+ timeout,
530
+ )
531
+ latency_ms = int((time.monotonic() - started) * 1000)
532
+ outputs = body.get("outputs")
533
+ if not isinstance(outputs, list):
534
+ raise RuntimeError("Modal Nemotron Speech response missing outputs.")
535
+ metadata = {
536
+ "lifecycle_stage": "testing" if os.environ.get("SPACE_ID") else "dev",
537
+ "app_host": "hf_space" if os.environ.get("SPACE_ID") else "local",
538
+ "model_runtime": "modal",
539
+ "model_backend": body.get("model_backend", "modal_http"),
540
+ "inference_engine": body.get("inference_engine", "nemo"),
541
+ "model_artifact_format": body.get("model_artifact_format", "nemo_archive"),
542
+ "quantization": body.get("quantization", "bf16"),
543
+ "model_id": body.get("model_id", NEMOTRON_SPEECH_MODEL_ID),
544
+ "latency_ms": body.get("latency_ms", latency_ms),
545
+ "fallback_used": bool(body.get("fallback_used", False)),
546
+ }
547
+ return outputs, metadata
548
+
549
+
550
+ def _quest_for_goal(
551
+ goal: dict[str, str],
552
+ image_result: dict[str, Any],
553
+ index: int,
554
+ tone: str,
555
+ text_results: dict[str, dict[str, Any]] | None = None,
556
+ secondary_text_results: dict[str, dict[str, Any]] | None = None,
557
+ audio_results: dict[str, dict[str, Any]] | None = None,
558
+ asr_results: dict[str, dict[str, Any]] | None = None,
559
+ ) -> dict[str, Any]:
560
  steps = [
561
  {"id": f"{goal['id']}-step-{step_index + 1}", "label": label, "icon": icon}
562
  for step_index, (label, icon) in enumerate(STEP_LIBRARY[_step_key(goal["text"])])
563
  ]
564
+ theme = _theme(goal.get("theme"), "magical")
565
+ primary_text = (text_results or {}).get(goal["id"], {})
566
+ secondary_text = (secondary_text_results or {}).get(goal["id"], {})
567
+ card_text = primary_text.get("card_text") or _fallback_card_text(goal, tone, "Primary text model did not return card_text.")["card_text"]
568
+ if isinstance(card_text.get("steps"), list) and card_text["steps"]:
569
+ steps = [
570
+ {"id": f"{goal['id']}-step-{step_index + 1}", "label": str(label), "icon": "flag"}
571
+ for step_index, label in enumerate(card_text["steps"][:4])
572
+ ]
573
+ audio = (audio_results or {}).get(goal["id"], {})
574
+ asr = (asr_results or {}).get(goal["id"], {})
575
  return {
576
  "id": f"quest-{goal['id']}",
577
  "source_goal_id": goal["id"],
578
  "ordinary_goal": goal["text"],
579
+ "theme": theme,
580
+ "title": str(card_text.get("title") or _quest_title(goal["text"], tone, theme)),
581
  "subtitle": f"Quest {index + 1} of 3",
582
  "hero_asset_name": image_result["asset_name"],
583
  "hero_src": image_result.get("hero_src", ""),
584
  "hero_alt": f"Storybook classroom adventure for {goal['text']}",
585
  "reward": {
586
+ "label": str(card_text.get("reward_label") or (["Idea Builder", "Lantern Reader", "Tidy Hero"][index] if index < 3 else "Quest Hero")),
587
+ "description": str(card_text.get("completion_check") or "Reward earned after all steps are complete."),
588
  },
589
  "steps": steps,
590
+ "narration": str(card_text.get("narration") or ""),
591
  "why_it_helps": _why_it_helps(goal["text"]),
592
  "image_origin": image_result.get("origin", "unknown"),
593
  "prompt_summary": image_result.get("prompt_summary", ""),
594
  "runtime_metadata": image_result.get("runtime_metadata", _runtime_metadata()),
595
+ "text_models": {
596
+ "primary": primary_text,
597
+ "secondary": secondary_text,
598
+ },
599
+ "audio_asset_name": audio.get("audio_asset_name", ""),
600
+ "audio_src": audio.get("audio_src", ""),
601
+ "spoken_text": audio.get("spoken_text", ""),
602
+ "speech_runtime_metadata": audio.get("runtime_metadata", _runtime_metadata()),
603
+ "asr_transcript": asr.get("transcript", ""),
604
+ "asr_runtime_metadata": asr.get("runtime_metadata", _runtime_metadata()),
605
  }
606
 
607
 
 
631
  images = [_fallback_image_result(goal, index, blocker) for index, goal in enumerate(goals)]
632
  metadata = _runtime_metadata(fallback_used=True, fallback_reason=blocker)
633
 
634
+ text_blocker = ""
635
+ minicpm_results = [_fallback_card_text(goal, tone, "Live text generation is disabled.") for goal in goals]
636
+ nemotron3_results = [_fallback_card_text(goal, tone, "Live text generation is disabled.") for goal in goals]
637
+ text_metadata: dict[str, Any] = {}
638
+ if os.environ.get("EPIC_ENABLE_LIVE_GENERATION") == "1":
639
+ try:
640
+ minicpm_results, text_metadata["minicpm"] = _call_text_model(goals, "minicpm")
641
+ except (OSError, RuntimeError, ValueError, urllib.error.URLError, TimeoutError) as exc:
642
+ text_blocker = f"MiniCPM {type(exc).__name__}: {exc}"
643
+ minicpm_results = [_fallback_card_text(goal, tone, text_blocker) for goal in goals]
644
+ try:
645
+ nemotron3_results, text_metadata["nemotron3"] = _call_text_model(goals, "nemotron3")
646
+ except (OSError, RuntimeError, ValueError, urllib.error.URLError, TimeoutError) as exc:
647
+ extra = f"Nemotron3 {type(exc).__name__}: {exc}"
648
+ text_blocker = f"{text_blocker}; {extra}" if text_blocker else extra
649
+ nemotron3_results = [_fallback_card_text(goal, tone, extra) for goal in goals]
650
+
651
+ minicpm_by_id = {item["goal_id"]: item for item in minicpm_results}
652
+ nemotron3_by_id = {item["goal_id"]: item for item in nemotron3_results}
653
+
654
+ audio_blocker = ""
655
+ audio_results = [_fallback_audio_result(goal, index, "Live speech generation is disabled.") for index, goal in enumerate(goals)]
656
+ speech_metadata: dict[str, Any] = {}
657
+ if os.environ.get("EPIC_ENABLE_LIVE_GENERATION") == "1":
658
+ primary_card_texts = {
659
+ goal_id: result.get("card_text") or {}
660
+ for goal_id, result in minicpm_by_id.items()
661
+ if isinstance(result, dict)
662
+ }
663
+ try:
664
+ audio_results, speech_metadata["voxcpm2"] = _call_voxcpm2(goals, primary_card_texts)
665
+ except (OSError, RuntimeError, ValueError, urllib.error.URLError, TimeoutError) as exc:
666
+ audio_blocker = f"VoxCPM2 {type(exc).__name__}: {exc}"
667
+ audio_results = [_fallback_audio_result(goal, index, audio_blocker) for index, goal in enumerate(goals)]
668
+
669
+ asr_results: list[dict[str, Any]] = []
670
+ asr_blocker = ""
671
+ if os.environ.get("EPIC_ENABLE_LIVE_GENERATION") == "1" and any(item.get("audio_wav_base64") for item in audio_results):
672
+ try:
673
+ asr_results, speech_metadata["nemotron_speech"] = _call_nemotron_speech(audio_results)
674
+ except (OSError, RuntimeError, ValueError, urllib.error.URLError, TimeoutError) as exc:
675
+ asr_blocker = f"Nemotron Speech {type(exc).__name__}: {exc}"
676
+
677
+ audio_by_id = {item["goal_id"]: item for item in audio_results}
678
+ asr_by_id = {
679
+ item["goal_id"]: {
680
+ **item,
681
+ "runtime_metadata": speech_metadata.get("nemotron_speech", _runtime_metadata(
682
+ model_backend="modal_http",
683
+ model_id=NEMOTRON_SPEECH_MODEL_ID,
684
+ fallback_used=True,
685
+ fallback_reason=asr_blocker or "Nemotron Speech ASR was not run.",
686
+ )),
687
+ }
688
+ for item in asr_results
689
+ if isinstance(item, dict) and item.get("goal_id")
690
+ }
691
+
692
+ quests = [
693
+ _quest_for_goal(
694
+ goal,
695
+ images[index],
696
+ index,
697
+ tone,
698
+ text_results=minicpm_by_id,
699
+ secondary_text_results=nemotron3_by_id,
700
+ audio_results=audio_by_id,
701
+ asr_results=asr_by_id,
702
+ )
703
+ for index, goal in enumerate(goals)
704
+ ]
705
+ any_text_fallback = any(
706
+ bool(item.get("runtime_metadata", {}).get("fallback_used", False))
707
+ or bool(item.get("validation_error"))
708
+ for item in [*minicpm_results, *nemotron3_results]
709
+ if isinstance(item, dict)
710
+ )
711
+ any_audio_fallback = any(
712
+ bool(item.get("runtime_metadata", {}).get("fallback_used", False))
713
+ for item in audio_results
714
+ if isinstance(item, dict)
715
+ )
716
+ any_asr_fallback = bool(asr_blocker) or (
717
+ os.environ.get("EPIC_ENABLE_LIVE_GENERATION") == "1"
718
+ and bool(audio_results)
719
+ and not asr_results
720
+ )
721
+
722
  return {
723
  "goals": goals,
724
  "quests": quests,
725
  "runtime_metadata": metadata,
726
+ "model_runs": {
727
+ "image": metadata,
728
+ "text": text_metadata,
729
+ "speech": speech_metadata,
730
+ },
731
+ "fallback_used": (
732
+ any(bool(quest["runtime_metadata"].get("fallback_used", True)) for quest in quests)
733
+ or any_text_fallback
734
+ or any_audio_fallback
735
+ or any_asr_fallback
736
+ ),
737
+ "blocker": "; ".join(part for part in [blocker, text_blocker, audio_blocker, asr_blocker] if part),
738
  "generated_at": datetime.now(timezone.utc).isoformat(),
739
  }
740
 
frontend/assets/generated-audio-1-clean-up-my-room-before-dinner-56693c13.wav ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:52b5cef22f4556f7cb181a6dec7383f5df848cf7bb4d8b97780a0ffe8d91682b
3
+ size 445484
frontend/assets/generated-audio-2-finish-my-class-project-outline-62a0674b.wav ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:d91ef4c89158b69585ce0d457a784fd9664e0c5b1f8889159678d9e9259fd69b
3
+ size 568364
frontend/assets/generated-audio-3-read-for-20-minutes-5e944b16.wav ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:425f6bc70db9b75fdd6b4c0209737ea59181dfd72fd50fc6a1cea249f764490b
3
+ size 537644
frontend/assets/generated-hero-1-clean-up-my-room-before-dinner-07b300bc.png ADDED

Git LFS Details

  • SHA256: 5f0b9c1f82957d455bc48b6a282d9301cc7ef0e06952f8acd9c2d2443c10722c
  • Pointer size: 132 Bytes
  • Size of remote file: 1.09 MB
frontend/assets/generated-hero-2-finish-my-class-project-outline-4a8a06bc.png ADDED

Git LFS Details

  • SHA256: ac4265cbb71302d549e104b928ae654c2efe5a53d3f41fc0585535399cb11975
  • Pointer size: 132 Bytes
  • Size of remote file: 1.09 MB
frontend/assets/generated-hero-3-read-for-20-minutes-3781199a.png ADDED

Git LFS Details

  • SHA256: 08a72b2e8461a36a8ceda90cd1b43535c79d8547630222c46ecf07a2253ca455
  • Pointer size: 131 Bytes
  • Size of remote file: 939 kB
frontend/assets/generated-hero-manifest.json CHANGED
The diff for this file is too large to render. See raw diff