NANI-Nithin commited on
Commit
e18b02f
·
1 Parent(s): 3b738a4

add recap and poster genration with minicpm and bfl

Browse files
NEMOTRON_GGUF_SETUP.md CHANGED
@@ -130,7 +130,7 @@ Hugging Face Spaces **Zero GPU** (paid tier) provides on-demand GPU allocation:
130
 
131
  ### How it works in this app
132
 
133
- 1. `app/main.py` imports `spaces` gracefully (no error if missing)
134
  2. The `_generate_with_gpu()` function is wrapped with `@spaces.GPU` only at runtime on HF Spaces
135
  3. Inside that function, `torch.cuda.is_available()` returns `True`, so `generator.py` auto-detects GPU via `_get_n_gpu_layers()` and sets `n_gpu_layers=-1`
136
  4. On CPU (local dev or free Spaces tier), it falls back to `n_gpu_layers=0`
@@ -156,7 +156,7 @@ app/
156
  requirements.txt
157
  ```
158
 
159
- On Hugging Face Spaces, the app runs `app/main.py` automatically.
160
 
161
  ### GPU auto-detection logic
162
 
 
130
 
131
  ### How it works in this app
132
 
133
+ 1. `app.py` imports `spaces` gracefully (no error if missing)
134
  2. The `_generate_with_gpu()` function is wrapped with `@spaces.GPU` only at runtime on HF Spaces
135
  3. Inside that function, `torch.cuda.is_available()` returns `True`, so `generator.py` auto-detects GPU via `_get_n_gpu_layers()` and sets `n_gpu_layers=-1`
136
  4. On CPU (local dev or free Spaces tier), it falls back to `n_gpu_layers=0`
 
156
  requirements.txt
157
  ```
158
 
159
+ On Hugging Face Spaces, the app runs `app.py` automatically.
160
 
161
  ### GPU auto-detection logic
162
 
app.py CHANGED
@@ -1,5 +1,9 @@
1
- import gradio as gr
2
  import uuid
 
 
 
 
3
 
4
  try:
5
  import spaces # only available on Hugging Face Spaces runtime
@@ -23,17 +27,18 @@ from app.services.journal import (
23
  )
24
  from app.services.scoring import compute_scores
25
  from app.services.story import build_story_packet, generate_story
 
26
 
27
 
28
  # ── Load dataset once on startup ──────────────────────────────────────────────
29
- DATASET_PATH = "app/data/games_dataset.json"
30
  DATA_RECORDS = []
31
  try:
32
- raw = load_games_dataset(DATASET_PATH)
33
  DATA_RECORDS = [normalize_game_record(r) for r in raw]
34
- print(f" Loaded {len(DATA_RECORDS)} game records for retrieval")
35
  except FileNotFoundError:
36
- print(f" Dataset not found at {DATASET_PATH}, retrieval will be empty")
37
 
38
 
39
  # ── In-memory session store (resets on restart — fine for hackathon) ───────
@@ -167,7 +172,7 @@ def run_pipeline(
167
 
168
  # 8 ── Build summary text
169
  summary = build_summary(final_game, state, session_id)
170
- return summary, final_game, session_id
171
 
172
 
173
  # ── Phase 3: Gameplay helpers ─────────────────────────────────────────────────
@@ -217,7 +222,7 @@ def record_journal(
217
  ):
218
  """Record a text journal entry, summarize it, and return the result."""
219
  if session_id not in SESSION_STORE:
220
- return " Unknown session", ""
221
 
222
  # Build full journal entry
223
  entry = create_journal_entry(
@@ -255,7 +260,7 @@ def record_journal(
255
  f"- Tags: {', '.join(summary['tags'])}\n"
256
  f"- Summary: {summary['moment_summary']}"
257
  )
258
- return display, entry["journal_id"]
259
 
260
 
261
  def upload_photo(
@@ -267,7 +272,7 @@ def upload_photo(
267
  ):
268
  """Log a photo upload event and return a confirmation."""
269
  if session_id not in SESSION_STORE:
270
- return " Unknown session", []
271
 
272
  photo_name = ""
273
  if photo_file is not None:
@@ -318,7 +323,7 @@ def upload_photo(
318
  def end_game(session_id: str, team_id: str = "team-a"):
319
  """End the game, compute scores, and return a scoreboard."""
320
  if session_id not in SESSION_STORE:
321
- return " Unknown session"
322
 
323
  session = SESSION_STORE[session_id]
324
  game = session["game"]
@@ -332,9 +337,9 @@ def end_game(session_id: str, team_id: str = "team-a"):
332
  scores = compute_scores(events, game)
333
  session["scores"] = scores
334
 
335
- lines = ["# 🏆 Final Scoreboard\n"]
336
  for ts in scores.get("team_scores", []):
337
- marker = " 🏆" if ts["team_id"] == scores.get("winner") else ""
338
  lines.append(f"### Team: {ts['team_id']}{marker}")
339
  lines.append(f"- **Total points:** {ts['points']}")
340
  lines.append(f"- Tasks completed: {ts['completed_tasks']}/{ts['total_tasks']}")
@@ -344,19 +349,19 @@ def end_game(session_id: str, team_id: str = "team-a"):
344
  lines.append("")
345
  lines.append("**Breakdown:**")
346
  for b in ts.get("scoring_breakdown", []):
347
- lines.append(f" {b}")
348
  lines.append("")
349
 
350
  if scores.get("winner"):
351
- lines.append(f"**Winner: {scores['winner']}** 🎉")
352
 
353
- return "\n".join(lines), scores
354
 
355
 
356
  def generate_recap(session_id: str):
357
  """Generate the final story recap from all collected session data."""
358
  if session_id not in SESSION_STORE:
359
- return " Unknown session"
360
 
361
  session = SESSION_STORE[session_id]
362
  game = session["game"]
@@ -377,6 +382,10 @@ def generate_recap(session_id: str):
377
  # Generate story
378
  result = generate_story(packet, session_id=session_id)
379
 
 
 
 
 
380
  # Format for display
381
  lines = [
382
  "# 📖 Episode Recap\n",
@@ -386,10 +395,13 @@ def generate_recap(session_id: str):
386
  result["long_summary"],
387
  "",
388
  "---\n",
389
- "### 🎨 Poster Prompt\n",
390
- f"```{result['poster_prompt']}```",
391
  ]
392
 
 
 
 
 
 
393
  return "\n".join(lines), result
394
 
395
 
@@ -535,7 +547,6 @@ with gr.Blocks(title="CityQuest-AI – Game Generator") as demo:
535
  output_md = gr.Markdown(
536
  value="Click **Generate Game** to create a new game!"
537
  )
538
- output_json = gr.JSON(label="Raw game JSON", visible=False)
539
  session_id_box = gr.Textbox(
540
  label="Session ID (copy to Play tab)", interactive=False, visible=True
541
  )
@@ -616,7 +627,7 @@ with gr.Blocks(title="CityQuest-AI – Game Generator") as demo:
616
  game_type, city, area, location_type,
617
  duration_minutes, num_players, difficulty, age_group, energy_level,
618
  ],
619
- outputs=[output_md, output_json, session_id_box],
620
  )
621
 
622
  # Play tab — task actions
 
1
+ import os
2
  import uuid
3
+ import json
4
+ from pathlib import Path
5
+
6
+ import gradio as gr
7
 
8
  try:
9
  import spaces # only available on Hugging Face Spaces runtime
 
27
  )
28
  from app.services.scoring import compute_scores
29
  from app.services.story import build_story_packet, generate_story
30
+ from app.services.image_gen import generate_poster_sync
31
 
32
 
33
  # ── Load dataset once on startup ──────────────────────────────────────────────
34
+ DATASET_PATH = Path(__file__).resolve().parent / "app/data/games_dataset.json"
35
  DATA_RECORDS = []
36
  try:
37
+ raw = load_games_dataset(str(DATASET_PATH))
38
  DATA_RECORDS = [normalize_game_record(r) for r in raw]
39
+ print(f"[OK] Loaded {len(DATA_RECORDS)} game records for retrieval")
40
  except FileNotFoundError:
41
+ print(f"[!] Dataset not found at {DATASET_PATH}, retrieval will be empty")
42
 
43
 
44
  # ── In-memory session store (resets on restart — fine for hackathon) ───────
 
172
 
173
  # 8 ── Build summary text
174
  summary = build_summary(final_game, state, session_id)
175
+ return summary, session_id
176
 
177
 
178
  # ── Phase 3: Gameplay helpers ─────────────────────────────────────────────────
 
222
  ):
223
  """Record a text journal entry, summarize it, and return the result."""
224
  if session_id not in SESSION_STORE:
225
+ return "[!] Unknown session"
226
 
227
  # Build full journal entry
228
  entry = create_journal_entry(
 
260
  f"- Tags: {', '.join(summary['tags'])}\n"
261
  f"- Summary: {summary['moment_summary']}"
262
  )
263
+ return display
264
 
265
 
266
  def upload_photo(
 
272
  ):
273
  """Log a photo upload event and return a confirmation."""
274
  if session_id not in SESSION_STORE:
275
+ return "[!] Unknown session"
276
 
277
  photo_name = ""
278
  if photo_file is not None:
 
323
  def end_game(session_id: str, team_id: str = "team-a"):
324
  """End the game, compute scores, and return a scoreboard."""
325
  if session_id not in SESSION_STORE:
326
+ return "[!] Unknown session"
327
 
328
  session = SESSION_STORE[session_id]
329
  game = session["game"]
 
337
  scores = compute_scores(events, game)
338
  session["scores"] = scores
339
 
340
+ lines = ["# Final Scoreboard\n"]
341
  for ts in scores.get("team_scores", []):
342
+ marker = " [WINNER]" if ts["team_id"] == scores.get("winner") else ""
343
  lines.append(f"### Team: {ts['team_id']}{marker}")
344
  lines.append(f"- **Total points:** {ts['points']}")
345
  lines.append(f"- Tasks completed: {ts['completed_tasks']}/{ts['total_tasks']}")
 
349
  lines.append("")
350
  lines.append("**Breakdown:**")
351
  for b in ts.get("scoring_breakdown", []):
352
+ lines.append(f" * {b}")
353
  lines.append("")
354
 
355
  if scores.get("winner"):
356
+ lines.append(f"**Winner: {scores['winner']}**")
357
 
358
+ return "\n".join(lines)
359
 
360
 
361
  def generate_recap(session_id: str):
362
  """Generate the final story recap from all collected session data."""
363
  if session_id not in SESSION_STORE:
364
+ return "[!] Unknown session", {}
365
 
366
  session = SESSION_STORE[session_id]
367
  game = session["game"]
 
382
  # Generate story
383
  result = generate_story(packet, session_id=session_id)
384
 
385
+ # Try to generate a poster image via BFL FLUX
386
+ poster_url, poster_status = generate_poster_sync(session_id, result["poster_prompt"])
387
+ session["poster_url"] = poster_url
388
+
389
  # Format for display
390
  lines = [
391
  "# 📖 Episode Recap\n",
 
395
  result["long_summary"],
396
  "",
397
  "---\n",
 
 
398
  ]
399
 
400
+ if poster_url:
401
+ lines.append(f"### 🖼️ Poster\n\n![Recap Poster]({poster_url})\n")
402
+ else:
403
+ lines.append(f"### 🎨 Poster Prompt\n```{result['poster_prompt']}```\n\n_{poster_status}_\n")
404
+
405
  return "\n".join(lines), result
406
 
407
 
 
547
  output_md = gr.Markdown(
548
  value="Click **Generate Game** to create a new game!"
549
  )
 
550
  session_id_box = gr.Textbox(
551
  label="Session ID (copy to Play tab)", interactive=False, visible=True
552
  )
 
627
  game_type, city, area, location_type,
628
  duration_minutes, num_players, difficulty, age_group, energy_level,
629
  ],
630
+ outputs=[output_md, session_id_box],
631
  )
632
 
633
  # Play tab — task actions
app/main.py DELETED
@@ -1,577 +0,0 @@
1
- """Hugging Face Spaces entry point with Zero GPU support.
2
-
3
- On HF Spaces, @spaces.GPU auto-allocates a GPU when the function runs
4
- and frees it after — saving costs on zero-GPU (paid) tier.
5
- """
6
-
7
- import os
8
- import uuid
9
- import json
10
- from pathlib import Path
11
-
12
- import gradio as gr
13
-
14
- try:
15
- import spaces # only available on Hugging Face Spaces runtime
16
- except ImportError:
17
- spaces = None
18
-
19
- from app.services.retrieval import load_games_dataset, normalize_game_record, retrieve_examples
20
- from app.services.generator import generate_game, generate_game_with_model, build_generation_prompt, NEMOTRON_MODEL_ID, NEMOTRON_GGUF_FILE
21
- from app.services.validator import validate_game, repair_game
22
- from app.services.schema_validator import create_minimal_game_template
23
- from app.services.tracing import log_event, log_generation_trace, load_events
24
- from app.services.journal import (
25
- create_journal_entry, save_journal_entry, summarize_journal,
26
- load_journal_entries, detect_mood, assess_story_value,
27
- )
28
- from app.services.scoring import compute_scores
29
- from app.services.story import build_story_packet, generate_story
30
-
31
-
32
- # ── Load dataset once on startup ──────────────────────────────────────────────
33
- DATASET_PATH = Path(__file__).resolve().parent.parent / "app/data/games_dataset.json"
34
- DATA_RECORDS = []
35
- try:
36
- raw = load_games_dataset(str(DATASET_PATH))
37
- DATA_RECORDS = [normalize_game_record(r) for r in raw]
38
- print(f"✓ Loaded {len(DATA_RECORDS)} game records for retrieval")
39
- except FileNotFoundError:
40
- print(f"⚠ Dataset not found at {DATASET_PATH}, retrieval will be empty")
41
-
42
- # ── In-memory session store ───────────────────────────────────────────────────
43
- SESSION_STORE: dict[str, dict] = {}
44
-
45
-
46
- # ── Zero GPU wrappers ─────────────────────────────────────────────────────────
47
-
48
- def _generate_with_gpu(config: dict, retrieved: list[dict]) -> dict:
49
- """Call the model for generation — wrapped in @spaces.GPU on HF Spaces.
50
-
51
- The 2.84 GB GGUF model is lazily downloaded on first call (inside the GPU
52
- context) via ``hf_hub_download``. ``Llama(model_path=...)`` initialisation
53
- happens here where CUDA is available via @spaces.GPU.
54
-
55
- Inside @spaces.GPU, torch.cuda.is_available() returns True,
56
- so generator.py auto-detects GPU via _get_n_gpu_layers().
57
- """
58
- prompt = build_generation_prompt(config, retrieved)
59
- json_str = generate_game_with_model(
60
- prompt,
61
- model_name="nemotron",
62
- )
63
- if json_str:
64
- try:
65
- game = json.loads(json_str)
66
- if all(field in game for field in ["game_id", "title", "setup", "tasks", "safety"]):
67
- return game
68
- except json.JSONDecodeError:
69
- pass
70
- # Fallback — returns mock without using GPU
71
- return None
72
-
73
-
74
- # Apply @spaces.GPU only if the decorator exists (HF Spaces runtime)
75
- # duration=300 allows up to 5 minutes for first-run 2.84 GB model download + init
76
- if spaces is not None:
77
- _generate_with_gpu = spaces.GPU(duration=300)(_generate_with_gpu)
78
-
79
-
80
- def run_pipeline(
81
- game_type: str,
82
- city: str,
83
- area: str,
84
- location_type: str,
85
- duration_minutes: int,
86
- num_players: int,
87
- difficulty: str,
88
- age_group: str,
89
- energy_level: str,
90
- ):
91
- """Run the full AI generation pipeline end-to-end."""
92
- session_id = str(uuid.uuid4())
93
-
94
- config = {
95
- "game_type": game_type,
96
- "city": city or "Paris",
97
- "area": area or "Downtown",
98
- "location_type": location_type,
99
- "duration_minutes": int(duration_minutes),
100
- "num_players": int(num_players),
101
- "difficulty": difficulty,
102
- "age_group": age_group,
103
- "energy_level": energy_level,
104
- "photo_enabled": True,
105
- }
106
-
107
- state = {}
108
-
109
- # 1 ── Retrieval
110
- state["num_retrieved"] = 0
111
- if DATA_RECORDS:
112
- retrieved = retrieve_examples(config, DATA_RECORDS, k=3)
113
- state["num_retrieved"] = len(retrieved)
114
- state["retrieved_ids"] = [r["id"] for r in retrieved]
115
- else:
116
- retrieved = []
117
-
118
- # 2 ── Generation (with Zero GPU if available)
119
- game = _generate_with_gpu(config, retrieved)
120
- if game is None:
121
- from app.services.generator import generate_game_mock
122
- game = generate_game_mock(config, retrieved)
123
- print("Using mock generation (model unavailable or failed)")
124
-
125
- state["game_id"] = game["game_id"]
126
- state["game_title"] = game["title"]
127
-
128
- # 3 ── Validation
129
- is_valid, failures = validate_game(game, config)
130
- state["validation_passed"] = is_valid
131
- state["validation_failures"] = failures
132
-
133
- # 4 ── Repair (if needed)
134
- repaired = None
135
- if not is_valid:
136
- repaired = repair_game(game, failures, config)
137
- state["repair_applied"] = True
138
- is_valid2, failures2 = validate_game(repaired, config)
139
- state["repair_valid"] = is_valid2
140
- state["remaining_failures"] = failures2
141
- else:
142
- state["repair_applied"] = False
143
-
144
- final_game = repaired if repaired is not None else game
145
-
146
- # 5 ── Log generation trace
147
- log_generation_trace(
148
- session_id=session_id,
149
- config=config,
150
- retrieved_examples=retrieved,
151
- game=final_game,
152
- validation_passed=is_valid or (repaired is not None and state.get("repair_valid", False)),
153
- validation_failures=failures,
154
- repaired_game=repaired,
155
- )
156
-
157
- # 6 ── Reveal all tasks as events
158
- for task in final_game.get("tasks", []):
159
- log_event(session_id, "task_revealed", {
160
- "task_id": task["task_id"],
161
- "title": task["title"],
162
- "points": task["points"],
163
- })
164
-
165
- # 7 ── Store session
166
- SESSION_STORE[session_id] = {
167
- "config": config,
168
- "game": final_game,
169
- "events": [],
170
- "journals": [],
171
- }
172
-
173
- # 8 ── Build summary text
174
- summary = build_summary(final_game, state, session_id)
175
- return summary, final_game, session_id
176
-
177
-
178
- # ── Phase 3: Gameplay helpers ─────────────────────────────────────────────────
179
-
180
- def complete_task(session_id: str, task_id: str, team_id: str = "team-a"):
181
- if session_id not in SESSION_STORE:
182
- return "⚠ Unknown session"
183
- ev = log_event(session_id, "task_completed", {
184
- "task_id": task_id,
185
- "summary": f"Team {team_id} completed {task_id}",
186
- }, team_id=team_id)
187
- SESSION_STORE[session_id]["events"].append(ev)
188
- return f"✅ Task {task_id} completed!"
189
-
190
-
191
- def skip_task(session_id: str, task_id: str, team_id: str = "team-a"):
192
- if session_id not in SESSION_STORE:
193
- return "⚠ Unknown session"
194
- ev = log_event(session_id, "task_skipped", {
195
- "task_id": task_id,
196
- "summary": f"Team {team_id} skipped {task_id}",
197
- }, team_id=team_id)
198
- SESSION_STORE[session_id]["events"].append(ev)
199
- return f"⏭️ Task {task_id} skipped."
200
-
201
-
202
- def use_hint(session_id: str, task_id: str, team_id: str = "team-a"):
203
- if session_id not in SESSION_STORE:
204
- return "⚠ Unknown session"
205
- ev = log_event(session_id, "hint_used", {
206
- "task_id": task_id,
207
- "summary": f"Team {team_id} used a hint for {task_id}",
208
- }, team_id=team_id)
209
- SESSION_STORE[session_id]["events"].append(ev)
210
- return f"💡 Hint used for {task_id} (−5 pts)"
211
-
212
-
213
- def record_journal(
214
- session_id: str,
215
- transcript: str,
216
- task_id: str = "",
217
- location_note: str = "",
218
- team_id: str = "team-a",
219
- ):
220
- if session_id not in SESSION_STORE:
221
- return "⚠ Unknown session", ""
222
-
223
- entry = create_journal_entry(
224
- transcript=transcript,
225
- session_id=session_id,
226
- team_id=team_id,
227
- task_id=task_id or None,
228
- location_note=location_note,
229
- )
230
-
231
- summary = summarize_journal(transcript, task_id=task_id or None, location_note=location_note)
232
- entry["moment_summary"] = summary["moment_summary"]
233
- entry["tags"] = summary["tags"]
234
- entry["story_value"] = summary["story_value"]
235
-
236
- save_journal_entry(entry)
237
-
238
- ev = log_event(session_id, "journal_recorded", {
239
- "journal_id": entry["journal_id"],
240
- "mood": entry["mood"],
241
- "story_value": summary["story_value"],
242
- "summary": summary["moment_summary"],
243
- }, team_id=team_id)
244
- SESSION_STORE[session_id]["events"].append(ev)
245
- SESSION_STORE[session_id]["journals"].append(entry)
246
-
247
- display = (
248
- f"🎙️ **Journal recorded!**\n"
249
- f"- Mood: *{entry['mood']}*\n"
250
- f"- Story value: **{summary['story_value']}**\n"
251
- f"- Tags: {', '.join(summary['tags'])}\n"
252
- f"- Summary: {summary['moment_summary']}"
253
- )
254
- return display, entry["journal_id"]
255
-
256
-
257
- def upload_photo(
258
- session_id: str,
259
- photo_file,
260
- caption: str = "",
261
- task_id: str = "",
262
- team_id: str = "team-a",
263
- ):
264
- if session_id not in SESSION_STORE:
265
- return "⚠ Unknown session", []
266
-
267
- photo_name = ""
268
- if photo_file is not None:
269
- if isinstance(photo_file, str):
270
- photo_name = photo_file.split("/")[-1].split("\\")[-1]
271
- else:
272
- photo_name = getattr(photo_file, "name", "photo")
273
-
274
- photo_id = f"photo-{uuid.uuid4().hex[:8]}"
275
-
276
- payload = {
277
- "photo_id": photo_id,
278
- "photo_name": photo_name,
279
- "caption": caption,
280
- "summary": f"Team {team_id} uploaded photo for {task_id or 'general'}",
281
- }
282
- if task_id:
283
- payload["task_id"] = task_id
284
-
285
- ev = log_event(session_id, "photo_uploaded", payload, team_id=team_id)
286
- SESSION_STORE[session_id]["events"].append(ev)
287
-
288
- if "photos" not in SESSION_STORE[session_id]:
289
- SESSION_STORE[session_id]["photos"] = []
290
- SESSION_STORE[session_id]["photos"].append({
291
- "photo_id": photo_id,
292
- "photo_name": photo_name,
293
- "caption": caption,
294
- "task_id": task_id,
295
- })
296
-
297
- photos = SESSION_STORE[session_id]["photos"]
298
- gallery_lines = [f"📸 **{p['photo_id']}** — {p['caption'] or '(no caption)'} [{p['task_id'] or 'general'}]" for p in photos]
299
-
300
- display = (
301
- f"📸 **Photo uploaded!**\n"
302
- f"- ID: `{photo_id}`\n"
303
- f"- Caption: {caption or '(none)'}\n"
304
- f"- Related task: {task_id or 'general'}\n\n"
305
- f"**All photos ({len(photos)}):**\n" + "\n".join(gallery_lines)
306
- )
307
- return display
308
-
309
-
310
- def end_game(session_id: str, team_id: str = "team-a"):
311
- if session_id not in SESSION_STORE:
312
- return "⚠ Unknown session"
313
-
314
- session = SESSION_STORE[session_id]
315
- game = session["game"]
316
- events = load_events(session_id=session_id)
317
-
318
- ev = log_event(session_id, "game_finished", {
319
- "summary": f"Game finished — session {session_id}",
320
- }, team_id=team_id)
321
- events.append(ev)
322
-
323
- scores = compute_scores(events, game)
324
- session["scores"] = scores
325
-
326
- lines = ["# 🏆 Final Scoreboard\n"]
327
- for ts in scores.get("team_scores", []):
328
- marker = " 🏆" if ts["team_id"] == scores.get("winner") else ""
329
- lines.append(f"### Team: {ts['team_id']}{marker}")
330
- lines.append(f"- **Total points:** {ts['points']}")
331
- lines.append(f"- Tasks completed: {ts['completed_tasks']}/{ts['total_tasks']}")
332
- lines.append(f"- Hints used: {ts['hints_used']}")
333
- if ts.get("bonuses"):
334
- lines.append(f"- Bonuses: {', '.join(ts['bonuses'])}")
335
- lines.append("")
336
- lines.append("**Breakdown:**")
337
- for b in ts.get("scoring_breakdown", []):
338
- lines.append(f" • {b}")
339
- lines.append("")
340
-
341
- if scores.get("winner"):
342
- lines.append(f"**Winner: {scores['winner']}** 🎉")
343
-
344
- return "\n".join(lines), scores
345
-
346
-
347
- def generate_recap(session_id: str):
348
- if session_id not in SESSION_STORE:
349
- return "⚠ Unknown session"
350
-
351
- session = SESSION_STORE[session_id]
352
- game = session["game"]
353
- events = load_events(session_id=session_id)
354
- journals = load_journal_entries(session_id=session_id)
355
- scores = session.get("scores", compute_scores(events, game))
356
- photos = session.get("photos", [])
357
-
358
- packet = build_story_packet(
359
- game=game,
360
- events=events,
361
- scores=scores,
362
- journal_entries=journals,
363
- photo_captions=photos,
364
- )
365
-
366
- result = generate_story(packet, session_id=session_id)
367
-
368
- lines = [
369
- "# 📖 Episode Recap\n",
370
- result["short_recap"],
371
- "",
372
- "---\n",
373
- result["long_summary"],
374
- "",
375
- "---\n",
376
- "### 🎨 Poster Prompt\n",
377
- f"```{result['poster_prompt']}```",
378
- ]
379
-
380
- return "\n".join(lines), result
381
-
382
-
383
- # ── Build summary text ────────────────────────────────────────────────────────
384
- def build_summary(game: dict, state: dict, session_id: str = "") -> str:
385
- lines = []
386
- lines.append(f"# 🎮 {game.get('title', 'Untitled')}")
387
- lines.append("")
388
- if session_id:
389
- lines.append(f"> Session `{session_id[:12]}…` — paste this in the **Play** tab to log progress.")
390
- lines.append("")
391
- lines.append("## 📋 Setup")
392
- setup = game.get("setup", {})
393
- lines.append(f"- **Location:** {setup.get('city', '?')} — {setup.get('area', '?')}")
394
- lines.append(f"- **Meeting point:** {setup.get('meeting_point', '?')}")
395
- lines.append(f"- **Duration:** {setup.get('duration_minutes', '?')} min")
396
- lines.append(f"- **Players:** {setup.get('num_players', '?')}")
397
- lines.append("")
398
-
399
- lines.append("## 📜 Rules")
400
- for i, rule in enumerate(game.get("rules", []), 1):
401
- lines.append(f" {i}. {rule}")
402
- lines.append("")
403
-
404
- lines.append("## 🎯 Tasks")
405
- for t in game.get("tasks", []):
406
- time_str = f"{t.get('time_limit_minutes', '∞')} min" if t.get("time_limit_minutes") else "No time limit"
407
- lines.append(f" **{t.get('task_id', '?')}:** {t.get('title', '?')}")
408
- lines.append(f" - *{t.get('description', '')[:80]}*")
409
- lines.append(f" - 🏆 {t.get('points', 0)} pts | ⏱ {time_str} | 📸 {t.get('proof_type', '?')}")
410
- lines.append(f" - 💡 {t.get('hint', '')[:70]}")
411
- lines.append(f" - 🛡 {t.get('safety_note', '')[:70]}")
412
- lines.append("")
413
-
414
- lines.append("## 💡 Global Hints")
415
- for h in game.get("global_hints", []):
416
- lines.append(f" • {h}")
417
- lines.append("")
418
-
419
- lines.append("## 🔒 Safety")
420
- safety = game.get("safety", {})
421
- lines.append(f"- **Zone:** {safety.get('allowed_zone', '?')}")
422
- lines.append(f"- **Supervision required:** {'Yes' if safety.get('adult_supervision') else 'No'}")
423
- lines.append(f"- **Forbidden:** {', '.join(safety.get('forbidden_behaviors', []))}")
424
- lines.append("")
425
-
426
- lines.append("## 📊 Scoring")
427
- for s in game.get("score_rules", []):
428
- lines.append(f" • {s}")
429
- lines.append(f"- **Tie-breaker:** {game.get('tie_breaker', '?')}")
430
- lines.append("")
431
-
432
- lines.append("## 📖 Story Seed")
433
- seed = game.get("story_seed", {})
434
- lines.append(f"- **Tone:** {seed.get('tone', '?')}")
435
- lines.append(f"- **Motifs:** {', '.join(seed.get('motifs', []))}")
436
- lines.append(f"- **Recap style:** {seed.get('recap_style', '?')}")
437
- lines.append("")
438
-
439
- lines.append("---")
440
- lines.append("### 🔍 Pipeline trace")
441
- lines.append(f"- Retrieval: {state.get('num_retrieved', 0)} examples found")
442
- if state.get("retrieved_ids"):
443
- lines.append(f"- Retrieved IDs: {', '.join(state['retrieved_ids'])}")
444
- lines.append(f"- Game ID: {state.get('game_id', '?')}")
445
- if state.get("validation_passed"):
446
- lines.append(f"- ✅ Validation passed")
447
- else:
448
- lines.append(f"- ❌ Validation failed ({len(state.get('validation_failures', []))} issues)")
449
- if state.get("repair_applied"):
450
- lines.append(f"- 🔧 Repair applied → {'✅ Passed' if state.get('repair_valid') else '❌ Still has issues'}")
451
- for f in state.get("validation_failures", [])[:5]:
452
- lines.append(f" - {f}")
453
- leftover = state.get("remaining_failures", [])
454
- if leftover:
455
- for f in leftover[:5]:
456
- lines.append(f" - ⚠ {f}")
457
-
458
- return "\n".join(lines)
459
-
460
-
461
- # ── Gradio UI ─────────────────────────────────────────────────────────────────
462
- with gr.Blocks(title="CityQuest-AI – Game Generator") as demo:
463
- gr.Markdown(
464
- """
465
- # 🌍 CityQuest-AI — AI Game Generator
466
- Configure your game, play it, record journals, and get a story recap.
467
- """
468
- )
469
-
470
- # ── Tab 1: Generate ────────────────────────────────────────────────────
471
- with gr.Tab("🎮 Generate"):
472
- with gr.Row():
473
- with gr.Column(scale=1):
474
- gr.Markdown("### Game Configuration")
475
-
476
- game_type = gr.Dropdown(
477
- label="Game Type",
478
- choices=["scavenger_hunt", "hide_and_seek", "tag"],
479
- value="scavenger_hunt",
480
- )
481
- city = gr.Textbox(label="City", value="Paris", info="Default: Paris")
482
- area = gr.Textbox(label="Area", value="Le Marais", info="Neighbourhood or district")
483
- location_type = gr.Radio(
484
- label="Location Type",
485
- choices=["park", "street", "landmark", "mixed"],
486
- value="mixed",
487
- )
488
- duration_minutes = gr.Slider(label="Duration (minutes)", minimum=15, maximum=120, value=60, step=5)
489
- num_players = gr.Slider(label="Number of Players", minimum=2, maximum=10, value=4, step=1)
490
- difficulty = gr.Dropdown(label="Difficulty", choices=["easy", "medium", "hard"], value="medium")
491
- age_group = gr.Dropdown(label="Age Group", choices=["kids", "teens", "adults", "mixed"], value="adults")
492
- energy_level = gr.Radio(label="Energy Level", choices=["low", "medium", "high"], value="medium")
493
- generate_btn = gr.Button("🚀 Generate Game", variant="primary", size="lg")
494
-
495
- with gr.Column(scale=2):
496
- gr.Markdown("### 📄 Generated Game")
497
- output_md = gr.Markdown(value="Click **Generate Game** to create a new game!")
498
- output_json = gr.JSON(label="Raw game JSON", visible=False)
499
- session_id_box = gr.Textbox(label="Session ID (copy to Play tab)", interactive=False, visible=True)
500
-
501
- # ── Tab 2: Play ────────────────────────────────────────────────────────
502
- with gr.Tab("🎯 Play"):
503
- gr.Markdown("### Simulate gameplay")
504
- with gr.Row():
505
- play_session_id = gr.Textbox(label="Session ID", placeholder="Paste session ID here…")
506
- play_team_id = gr.Textbox(label="Team ID", value="team-a")
507
-
508
- with gr.Row():
509
- with gr.Column():
510
- gr.Markdown("#### Task Actions")
511
- play_task_id = gr.Textbox(label="Task ID (e.g. t1, t2)", placeholder="t1")
512
- with gr.Row():
513
- complete_btn = gr.Button("✅ Complete Task", variant="primary")
514
- skip_btn = gr.Button("⏭️ Skip Task")
515
- hint_btn = gr.Button("💡 Use Hint")
516
- task_feedback = gr.Markdown(value="")
517
-
518
- with gr.Column():
519
- gr.Markdown("#### 🎙️ Voice Journal")
520
- journal_transcript = gr.Textbox(label="Journal entry", lines=4, placeholder="We just found the mural near the canal — it was incredible!")
521
- journal_task_id = gr.Textbox(label="Related Task ID (optional)", placeholder="t1")
522
- journal_location = gr.Textbox(label="Location note", placeholder="Near the canal on Rue de Rivoli")
523
- journal_btn = gr.Button("🎙️ Record Journal", variant="secondary")
524
- journal_output = gr.Markdown(value="")
525
-
526
- with gr.Row():
527
- with gr.Column():
528
- gr.Markdown("#### 📸 Photo Upload")
529
- photo_file = gr.Image(label="Upload a photo", type="filepath", height=200)
530
- photo_caption = gr.Textbox(label="Caption", placeholder="The mural we just discovered!")
531
- photo_task_id = gr.Textbox(label="Related Task ID (optional)", placeholder="t1")
532
- photo_btn = gr.Button("📸 Upload Photo", variant="secondary")
533
- photo_output = gr.Markdown(value="")
534
-
535
- with gr.Row():
536
- end_btn = gr.Button("🏁 End Game & Score", variant="primary", size="lg")
537
- scoreboard_md = gr.Markdown(value="")
538
-
539
- # ── Tab 3: Recap ───────────────────────────────────────────────────────
540
- with gr.Tab("📖 Recap"):
541
- gr.Markdown("### Generate a story recap")
542
- recap_session_id = gr.Textbox(label="Session ID", placeholder="Paste session ID here…")
543
- recap_btn = gr.Button("📖 Generate Recap", variant="primary", size="lg")
544
- recap_md = gr.Markdown(value="")
545
- recap_json = gr.JSON(label="Recap data", visible=False)
546
-
547
- # ── Wire events ────────────────────────────────────────────────────────
548
- generate_btn.click(
549
- fn=run_pipeline,
550
- inputs=[game_type, city, area, location_type, duration_minutes, num_players, difficulty, age_group, energy_level],
551
- outputs=[output_md, output_json, session_id_box],
552
- )
553
-
554
- complete_btn.click(fn=complete_task, inputs=[play_session_id, play_task_id, play_team_id], outputs=task_feedback)
555
- skip_btn.click(fn=skip_task, inputs=[play_session_id, play_task_id, play_team_id], outputs=task_feedback)
556
- hint_btn.click(fn=use_hint, inputs=[play_session_id, play_task_id, play_team_id], outputs=task_feedback)
557
-
558
- journal_btn.click(
559
- fn=record_journal,
560
- inputs=[play_session_id, journal_transcript, journal_task_id, journal_location, play_team_id],
561
- outputs=[journal_output, gr.Textbox(visible=False)],
562
- )
563
-
564
- photo_btn.click(
565
- fn=upload_photo,
566
- inputs=[play_session_id, photo_file, photo_caption, photo_task_id, play_team_id],
567
- outputs=photo_output,
568
- )
569
-
570
- end_btn.click(fn=end_game, inputs=[play_session_id, play_team_id], outputs=[scoreboard_md, gr.JSON(visible=False)])
571
-
572
- recap_btn.click(fn=generate_recap, inputs=[recap_session_id], outputs=[recap_md, recap_json])
573
-
574
-
575
- # ── Launch ────────────────────────────────────────────────────────────────────
576
- if __name__ == "__main__":
577
- demo.launch()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/services/image_gen.py ADDED
@@ -0,0 +1,107 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Black Forest Labs FLUX poster generation module.
2
+
3
+ Generates a recap poster image from a text prompt using the BFL API.
4
+ When the API key is not set or the call fails, gracefully returns None
5
+ so the pipeline always produces a visible result.
6
+
7
+ Sponsor alignment: Black Forest Labs FLUX produces a visible demo artifact
8
+ (the recap poster) rather than being hidden in backend code.
9
+ """
10
+
11
+ import os
12
+ from typing import Optional
13
+
14
+
15
+ # ── Configuration ───────────────────────────────────────────────────────────
16
+ BFL_API_KEY_ENV = "BFL_API_KEY"
17
+
18
+
19
+ def generate_poster(
20
+ poster_prompt: str,
21
+ api_key: Optional[str] = None,
22
+ ) -> Optional[str]:
23
+ """Generate a poster image using Black Forest Labs FLUX API.
24
+
25
+ Args:
26
+ poster_prompt: Text prompt describing the desired image.
27
+ api_key: BFL API key. Falls back to ``BFL_API_KEY`` env var.
28
+
29
+ Returns:
30
+ URL string of the generated image, or ``None`` on failure.
31
+ """
32
+ key = api_key or os.environ.get(BFL_API_KEY_ENV)
33
+ if not key:
34
+ print(
35
+ "[image_gen] BFL_API_KEY not set — "
36
+ "set the environment variable to enable poster generation"
37
+ )
38
+ return None
39
+
40
+ try:
41
+ import bfl
42
+
43
+ client = bfl.Bfl(api_key=key)
44
+
45
+ # FLUX.1 Pro / FLUX.1.1 Pro — use the latest available
46
+ result = client.generate_image(
47
+ prompt=poster_prompt,
48
+ width=1024,
49
+ height=768,
50
+ steps=25,
51
+ guidance=3.5,
52
+ )
53
+
54
+ # The result object typically has a ``url`` or ``image_url`` attribute
55
+ image_url = getattr(result, "url", None) or getattr(result, "image_url", None)
56
+ if image_url:
57
+ print(f"[image_gen] Poster generated -> {image_url[:80]}...")
58
+ return image_url
59
+
60
+ print(f"[image_gen] BFL response had no URL: {result}")
61
+ return None
62
+
63
+ except ImportError:
64
+ print(
65
+ "[image_gen] 'bfl' Python package not installed. "
66
+ "Install with: pip install bfl"
67
+ )
68
+ return None
69
+ except Exception as e:
70
+ print(f"[image_gen] BFL API call failed: {type(e).__name__}: {e}")
71
+ return None
72
+
73
+
74
+ def generate_poster_sync(
75
+ session_id: str,
76
+ poster_prompt: str,
77
+ ) -> tuple[Optional[str], str]:
78
+ """Generate a poster and return (image_url, status_message).
79
+
80
+ This is a convenience wrapper for use in the pipeline entry point.
81
+ The status message explains why no image was generated if the call
82
+ fails, so the UI can display a helpful fallback.
83
+
84
+ Args:
85
+ session_id: Session identifier (for logging).
86
+ poster_prompt: Text prompt for the image.
87
+
88
+ Returns:
89
+ Tuple of (image_url_or_None, status_message).
90
+ """
91
+ image_url = generate_poster(poster_prompt)
92
+
93
+ if image_url:
94
+ return image_url, f"\ud83d\uddbc\ufe0f **Poster generated!** [View image]({image_url})"
95
+
96
+ key = os.environ.get(BFL_API_KEY_ENV)
97
+ if not key:
98
+ return None, (
99
+ "\u26a0\ufe0f Poster generation unavailable — "
100
+ "set `BFL_API_KEY` environment variable to enable "
101
+ "Black Forest Labs FLUX poster generation."
102
+ )
103
+
104
+ return None, (
105
+ "\u26a0\ufe0f Poster generation failed — "
106
+ "check the BFL API key and try again."
107
+ )
app/services/minicpm.py ADDED
@@ -0,0 +1,208 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """OpenBMB MiniCPM5-1B recap generation via llama.cpp.
2
+
3
+ Lazy-downloads the GGUF model on first call (same pattern as generator.py)
4
+ and provides a ``generate_recap()`` function that takes a story packet and
5
+ returns a recap dict with ``short_recap``, ``long_summary``, ``poster_prompt``.
6
+
7
+ Sponsor alignment: OpenBMB MiniCPM5-1B gets the *first attempt* at recap
8
+ before falling through to Nemotron → template.
9
+ """
10
+
11
+ import json
12
+ import os
13
+ from typing import Optional
14
+
15
+
16
+ # ── Model configuration ─────────────────────────────────────────────────────
17
+ MINICPM_MODEL_ID = "openbmb/MiniCPM5-1B-GGUF"
18
+ MINICPM_GGUF_FILE = "MiniCPM5-1B-Q4_K_M.gguf" # 688 MB, good quality/size
19
+
20
+
21
+ # ── Lazy model path resolution ──────────────────────────────────────────────
22
+ _model_path: Optional[str] = None
23
+ _model_download_attempted = False
24
+ _model_cache: dict[str, object] = {}
25
+
26
+
27
+ def _resolve_model_path() -> Optional[str]:
28
+ """Download the MiniCPM GGUF model to local cache and return its path.
29
+
30
+ Skips download if ``CITYQUEST_SKIP_MODEL`` environment variable is set
31
+ (used by fast-test mode).
32
+
33
+ Uses ``hf_hub_download`` which caches to ``~/.cache/huggingface/hub/``
34
+ so subsequent calls are instant (no re-download).
35
+
36
+ Returns:
37
+ Absolute path to the GGUF file, or ``None`` on failure or skip.
38
+ """
39
+ global _model_path, _model_download_attempted
40
+ if _model_download_attempted:
41
+ return _model_path
42
+ _model_download_attempted = True
43
+
44
+ if os.environ.get("CITYQUEST_SKIP_MODEL"):
45
+ print("[minicpm] CITYQUEST_SKIP_MODEL set — skipping model download")
46
+ return None
47
+
48
+ try:
49
+ os.environ.setdefault("HF_HUB_ENABLE_HF_TRANSFER", "1")
50
+
51
+ from huggingface_hub import hf_hub_download
52
+
53
+ print(f"[minicpm] Downloading {MINICPM_MODEL_ID}/{MINICPM_GGUF_FILE} ...")
54
+ _model_path = hf_hub_download(
55
+ repo_id=MINICPM_MODEL_ID,
56
+ filename=MINICPM_GGUF_FILE,
57
+ )
58
+ print(f"[minicpm] Downloaded -> {_model_path}")
59
+ except Exception as e:
60
+ print(f"[minicpm] Download failed: {type(e).__name__}: {e}")
61
+ return _model_path
62
+
63
+
64
+ def _get_n_gpu_layers() -> int:
65
+ """Auto-detect GPU availability for llama.cpp inference.
66
+
67
+ Returns:
68
+ -1 if CUDA/GPU available (all layers on GPU), 0 for CPU-only.
69
+ """
70
+ try:
71
+ import torch
72
+ if torch.cuda.is_available():
73
+ return -1
74
+ except ImportError:
75
+ pass
76
+ return 0
77
+
78
+
79
+ # ── Core inference ──────────────────────────────────────────────────────────
80
+
81
+ def generate_summary(
82
+ prompt: str,
83
+ temperature: float = 0.7,
84
+ max_tokens: int = 1024,
85
+ ) -> Optional[str]:
86
+ """Run MiniCPM5-1B inference via llama.cpp and return the generated text.
87
+
88
+ Args:
89
+ prompt: The full prompt string to send to the model.
90
+ temperature: Sampling temperature (default 0.7).
91
+ max_tokens: Maximum tokens to generate (default 1024).
92
+
93
+ Returns:
94
+ Generated text string, or ``None`` if the model is unavailable.
95
+ """
96
+ try:
97
+ from llama_cpp import Llama
98
+
99
+ cache_key = f"minicpm_llama"
100
+ if cache_key in _model_cache:
101
+ llm = _model_cache[cache_key]
102
+ else:
103
+ model_path = _resolve_model_path()
104
+ if not model_path:
105
+ print("[minicpm] No model path available")
106
+ return None
107
+
108
+ n_gpu_layers = _get_n_gpu_layers()
109
+ gpu_info = "GPU" if n_gpu_layers < 0 else "CPU"
110
+ print(f"[minicpm] Initialising llama.cpp from: {model_path} ({gpu_info})")
111
+ llm = Llama(
112
+ model_path=model_path,
113
+ verbose=False,
114
+ n_gpu_layers=n_gpu_layers,
115
+ n_ctx=4096,
116
+ )
117
+ _model_cache[cache_key] = llm
118
+
119
+ # MiniCPM5 uses standard Llama chat template
120
+ messages = [
121
+ {"role": "system", "content": "You are a talented narrative writer. Return ONLY valid JSON. No other text."},
122
+ {"role": "user", "content": prompt},
123
+ ]
124
+
125
+ result = llm.create_chat_completion(
126
+ messages=messages,
127
+ max_tokens=max_tokens,
128
+ temperature=temperature,
129
+ top_p=0.95,
130
+ stop=["```"],
131
+ )
132
+
133
+ text = result["choices"][0]["message"]["content"]
134
+ return text.strip()
135
+
136
+ except ImportError:
137
+ print("[minicpm] llama-cpp-python not available")
138
+ return None
139
+ except Exception as e:
140
+ print(f"[minicpm] Inference failed: {type(e).__name__}: {e}")
141
+ return None
142
+
143
+
144
+ def _extract_json(text: str) -> Optional[dict]:
145
+ """Extract a JSON object from generated text by tracking brace depth."""
146
+ start = text.find("{")
147
+ if start == -1:
148
+ return None
149
+
150
+ depth = 0
151
+ for i in range(start, len(text)):
152
+ if text[i] == "{":
153
+ depth += 1
154
+ elif text[i] == "}":
155
+ depth -= 1
156
+ if depth == 0:
157
+ try:
158
+ return json.loads(text[start : i + 1])
159
+ except json.JSONDecodeError:
160
+ return None
161
+ return None
162
+
163
+
164
+ # ── Recap generation ────────────────────────────────────────────────────────
165
+
166
+ def generate_recap(story_packet: dict) -> Optional[dict]:
167
+ """Generate a recap using MiniCPM5-1B.
168
+
169
+ Builds a prompt from the story packet and attempts to produce a recap
170
+ dict with ``short_recap``, ``long_summary``, ``poster_prompt``.
171
+
172
+ Args:
173
+ story_packet: Structured packet from ``story.build_story_packet()``.
174
+
175
+ Returns:
176
+ Recap dict with three keys, or ``None`` if generation fails.
177
+ """
178
+ packet_str = json.dumps(story_packet, indent=2, default=str)[:3000]
179
+
180
+ prompt = (
181
+ "You are a talented narrative writer creating engaging recaps "
182
+ "of location-based games.\n\n"
183
+ f"## Game Data\n{packet_str}\n\n"
184
+ "## Instructions\n"
185
+ "- Use ONLY facts from logs, tasks, journals, scores, and photos\n"
186
+ "- Do NOT invent locations or quotes\n"
187
+ "- Mention 2-4 concrete moments from player journals\n"
188
+ "- Highlight the decisive turning point\n"
189
+ "- Maintain a lively, memorable tone\n"
190
+ "- Keep recaps between 150-250 words for short recap, "
191
+ "400-600 words for long summary\n\n"
192
+ "## Output\n"
193
+ "Generate:\n"
194
+ "1. short_recap: Brief highlight for the result screen\n"
195
+ "2. long_summary: Full episode recap\n"
196
+ "3. poster_prompt: Visual prompt for image generation\n\n"
197
+ "Return as JSON with these three keys."
198
+ )
199
+
200
+ text = generate_summary(prompt, temperature=0.8, max_tokens=2048)
201
+ if not text:
202
+ return None
203
+
204
+ result = _extract_json(text)
205
+ if result and all(k in result for k in ("short_recap", "long_summary", "poster_prompt")):
206
+ return result
207
+
208
+ return None
app/services/story.py CHANGED
@@ -17,6 +17,7 @@ from pathlib import Path
17
  from typing import Optional
18
 
19
  from app.services.tracing import log_event
 
20
 
21
 
22
  # ── Story packet builder ───────────────────────────────────────────────────
@@ -253,10 +254,9 @@ def _llm_recap(prompt_template: str, story_packet: dict) -> Optional[dict]:
253
  try:
254
  from llama_cpp import Llama
255
 
256
- model_id = "nvidia/NVIDIA-Nemotron-3-Nano-4B-GGUF"
257
  llm = Llama.from_pretrained(
258
- repo_id=model_id,
259
- filename="model.gguf",
260
  verbose=False,
261
  n_gpu_layers=-1,
262
  n_ctx=2048,
@@ -299,8 +299,8 @@ def _llm_recap(prompt_template: str, story_packet: dict) -> Optional[dict]:
299
  def generate_story(story_packet: dict, session_id: Optional[str] = None) -> dict:
300
  """Generate final recap story from game data and events.
301
 
302
- Attempts LLM-based generation first; falls back to high-quality
303
- templates so the pipeline always returns a result.
304
 
305
  Args:
306
  story_packet: Structured packet with game info, scores, journals, photos.
@@ -310,15 +310,27 @@ def generate_story(story_packet: dict, session_id: Optional[str] = None) -> dict
310
  Story output dict with keys ``short_recap``, ``long_summary``,
311
  ``poster_prompt``, ``story_packet``.
312
  """
313
- # Try LLM path
314
- template_path = Path("app/prompts/story_recap.txt")
315
- prompt_template = ""
316
- if template_path.exists():
317
- prompt_template = template_path.read_text(encoding="utf-8")
318
-
319
  llm_result = None
320
- if prompt_template:
321
- llm_result = _llm_recap(prompt_template, story_packet)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
322
 
323
  if llm_result and all(k in llm_result for k in ("short_recap", "long_summary", "poster_prompt")):
324
  short_recap = llm_result["short_recap"]
 
17
  from typing import Optional
18
 
19
  from app.services.tracing import log_event
20
+ from app.services.generator import NEMOTRON_MODEL_ID, NEMOTRON_GGUF_FILE
21
 
22
 
23
  # ── Story packet builder ───────────────────────────────────────────────────
 
254
  try:
255
  from llama_cpp import Llama
256
 
 
257
  llm = Llama.from_pretrained(
258
+ repo_id=NEMOTRON_MODEL_ID,
259
+ filename=NEMOTRON_GGUF_FILE,
260
  verbose=False,
261
  n_gpu_layers=-1,
262
  n_ctx=2048,
 
299
  def generate_story(story_packet: dict, session_id: Optional[str] = None) -> dict:
300
  """Generate final recap story from game data and events.
301
 
302
+ Attempts MiniCPM-based generation first, then Nemotron, then falls
303
+ back to high-quality templates so the pipeline always returns a result.
304
 
305
  Args:
306
  story_packet: Structured packet with game info, scores, journals, photos.
 
310
  Story output dict with keys ``short_recap``, ``long_summary``,
311
  ``poster_prompt``, ``story_packet``.
312
  """
313
+ # Try MiniCPM path first (OpenBMB sponsor model)
 
 
 
 
 
314
  llm_result = None
315
+ try:
316
+ from app.services.minicpm import generate_recap as minicpm_recap
317
+ llm_result = minicpm_recap(story_packet)
318
+ if llm_result:
319
+ print("[story] MiniCPM recap generated successfully")
320
+ except Exception as exc:
321
+ print(f"[story] MiniCPM recap unavailable: {exc}")
322
+
323
+ # Fall back to Nemotron LLM path
324
+ if not llm_result:
325
+ template_path = Path("app/prompts/story_recap.txt")
326
+ prompt_template = ""
327
+ if template_path.exists():
328
+ prompt_template = template_path.read_text(encoding="utf-8")
329
+
330
+ if prompt_template:
331
+ llm_result = _llm_recap(prompt_template, story_packet)
332
+ if llm_result:
333
+ print("[story] Nemotron recap generated successfully")
334
 
335
  if llm_result and all(k in llm_result for k in ("short_recap", "long_summary", "poster_prompt")):
336
  short_recap = llm_result["short_recap"]
requirements.txt CHANGED
@@ -7,4 +7,5 @@ huggingface_hub
7
  --extra-index-url https://abetlen.github.io/llama-cpp-python/whl/cu124
8
  llama-cpp-python
9
  gradio>=6.16.0
10
- jsonschema
 
 
7
  --extra-index-url https://abetlen.github.io/llama-cpp-python/whl/cu124
8
  llama-cpp-python
9
  gradio>=6.16.0
10
+ jsonschema
11
+ # modal # uncomment to deploy to Modal cloud GPU
test_end_to_end.py ADDED
@@ -0,0 +1,420 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """End-to-end test for the full CityQuest AI pipeline.
2
+
3
+ Run with ``CITYQUEST_FAST_TEST=1`` to skip GGUF model downloads
4
+ and use mock generation for LLM-dependent steps.
5
+
6
+ Usage:
7
+ python test_end_to_end.py
8
+ CITYQUEST_FAST_TEST=1 python test_end_to_end.py
9
+ """
10
+
11
+ import os
12
+ import sys
13
+ import json
14
+ import uuid
15
+ from pathlib import Path
16
+
17
+ # ── Fast-test guard ──────────────────────────────────────────────────────────
18
+ if os.environ.get("CITYQUEST_FAST_TEST"):
19
+ print("[test] FAST TEST - skipping GGUF model download and LLM inference")
20
+ os.environ["CITYQUEST_SKIP_MODEL"] = "1"
21
+
22
+ # ── Test tracking ────────────────────────────────────────────────────────────
23
+ passed = 0
24
+ failed = 0
25
+ errors: list[str] = []
26
+
27
+
28
+ def check(label: str, condition: bool, detail: str = ""):
29
+ global passed, failed
30
+ if condition:
31
+ passed += 1
32
+ print(f" ✓ PASS: {label}")
33
+ else:
34
+ failed += 1
35
+ msg = f"✗ FAIL: {label} — {detail}" if detail else f"✗ FAIL: {label}"
36
+ errors.append(msg)
37
+ print(f" {msg}")
38
+
39
+
40
+ def main():
41
+ global passed, failed, errors
42
+ print("\n" + "=" * 80)
43
+ print("CITYQUEST AI — END-TO-END PIPELINE TEST")
44
+ print("=" * 80)
45
+
46
+ # ── Imports ──────────────────────────────────────────────────────────
47
+ print("\n" + "-" * 80)
48
+ print("MODULE IMPORTS")
49
+ print("-" * 80)
50
+
51
+ try:
52
+ from app.services.retrieval import load_games_dataset, normalize_game_record, retrieve_examples
53
+ from app.services.generator import generate_game, generate_game_mock, build_generation_prompt
54
+ from app.services.validator import validate_game, repair_game
55
+ from app.services.schema_validator import validate_game_schema, create_minimal_game_template
56
+ from app.services.tracing import log_event, load_events, log_generation_trace
57
+ from app.services.journal import create_journal_entry, save_journal_entry, summarize_journal, load_journal_entries, detect_mood, assess_story_value
58
+ from app.services.scoring import compute_scores
59
+ from app.services.story import build_story_packet, generate_story, _template_short_recap, _template_long_summary
60
+ from app.services.minicpm import generate_recap as minicpm_recap, generate_summary as minicpm_summary
61
+ from app.services.image_gen import generate_poster, generate_poster_sync
62
+ check("All service modules imported", True)
63
+ except Exception as e:
64
+ check("All service modules imported", False, str(e))
65
+ # Don't continue if core imports fail
66
+ _summary()
67
+ sys.exit(1)
68
+
69
+ # ── T1: Dataset loading ──────────────────────────────────────────────
70
+ print("\n" + "-" * 80)
71
+ print("T1: DATASET LOADING & NORMALIZATION")
72
+ print("-" * 80)
73
+
74
+ try:
75
+ raw = load_games_dataset("app/data/games_dataset.json")
76
+ check("Dataset loaded", len(raw) > 0, f"Got {len(raw)} records")
77
+ check("Dataset has 70 records", len(raw) == 70, f"Got {len(raw)}")
78
+
79
+ records = [normalize_game_record(r) for r in raw]
80
+ check("Records normalized", len(records) == len(raw))
81
+
82
+ # Verify structure of first record
83
+ r0 = records[0]
84
+ check("Normalized record has id", bool(r0.get("id")))
85
+ check("Normalized record has game_type", bool(r0.get("game_type")))
86
+ check("Normalized record has tasks list", isinstance(r0.get("tasks"), list))
87
+ check("Normalized record has rules list", isinstance(r0.get("rules"), list))
88
+
89
+ # Check game type distribution
90
+ game_types = set(r["game_type"] for r in records)
91
+ check("All game types present", game_types == {"scavenger_hunt", "hide_and_seek", "tag"},
92
+ f"Got {game_types}")
93
+ except Exception as e:
94
+ check("Dataset loading", False, str(e))
95
+
96
+ # ── T2: Retrieval ────────────────────────────────────────────────────
97
+ print("\n" + "-" * 80)
98
+ print("T2: RETRIEVAL GROUNDING")
99
+ print("-" * 80)
100
+
101
+ try:
102
+ config_adults = {
103
+ "game_type": "scavenger_hunt",
104
+ "city": "Paris", "area": "Le Marais",
105
+ "location_type": "mixed", "duration_minutes": 60,
106
+ "num_players": 4, "difficulty": "medium",
107
+ "age_group": "adults", "energy_level": "medium",
108
+ }
109
+
110
+ retrieved = retrieve_examples(config_adults, records, k=3)
111
+ check("Retrieval returns k=3 results", len(retrieved) == 3)
112
+
113
+ # Verify structure
114
+ check("Retrieved has id", all(r.get("id") for r in retrieved))
115
+ check("Retrieved has retrieval_score", all("retrieval_score" in r for r in retrieved))
116
+ check("Retrieved has rules_summary", all("rules_summary" in r for r in retrieved))
117
+ check("Retrieved has task_patterns", all("task_patterns" in r for r in retrieved))
118
+
119
+ # Test different config types
120
+ config_kids = {"game_type": "hide_and_seek", "city": "Paris", "area": "Park",
121
+ "location_type": "park", "duration_minutes": 45, "num_players": 5,
122
+ "difficulty": "easy", "age_group": "kids", "energy_level": "high"}
123
+ r_kids = retrieve_examples(config_kids, records, k=5)
124
+ check("Kids config returns k=5", len(r_kids) == 5)
125
+
126
+ config_teens = {"game_type": "tag", "city": "Paris", "area": "Square",
127
+ "location_type": "mixed", "duration_minutes": 30, "num_players": 8,
128
+ "difficulty": "hard", "age_group": "teens", "energy_level": "high"}
129
+ r_teens = retrieve_examples(config_teens, records, k=3)
130
+ check("Teens config returns k=3", len(r_teens) == 3)
131
+ except Exception as e:
132
+ check("Retrieval", False, str(e))
133
+
134
+ # ── T3: Game Generation (mock) ────────────────────────────────────────
135
+ print("\n" + "-" * 80)
136
+ print("T3: GAME GENERATION (MOCK PATH)")
137
+ print("-" * 80)
138
+
139
+ try:
140
+ game = generate_game_mock(config_adults, retrieved)
141
+ check("Game generated", game is not None)
142
+ check("Game has game_id", bool(game.get("game_id")))
143
+ check("Game has title", bool(game.get("title")))
144
+ check("Game has theme", bool(game.get("theme")))
145
+ check("Game has setup with city", game.get("setup", {}).get("city") == "Paris")
146
+ check("Game has tasks list", len(game.get("tasks", [])) > 0)
147
+ check("Game has rules list", len(game.get("rules", [])) > 0)
148
+ check("Game has safety section", bool(game.get("safety")))
149
+ check("Game has story_seed", bool(game.get("story_seed")))
150
+
151
+ # Schema validation
152
+ is_valid_schema, schema_errors = validate_game_schema(game)
153
+ check("Game passes JSON schema", is_valid_schema, f"Errors: {schema_errors[:3]}")
154
+
155
+ # Build generation prompt
156
+ prompt = build_generation_prompt(config_adults, retrieved)
157
+ check("Prompt built", len(prompt) > 100, f"Got {len(prompt)} chars")
158
+ except Exception as e:
159
+ check("Game generation", False, str(e))
160
+
161
+ # ── T4: Validation ───────────────────────────────────────────────────
162
+ print("\n" + "-" * 80)
163
+ print("T4: VALIDATION CHECKS")
164
+ print("-" * 80)
165
+
166
+ try:
167
+ is_valid, failures = validate_game(game, config_adults)
168
+ check("Generated game passes validation", is_valid, f"Failures: {failures[:3]}")
169
+
170
+ # Test that invalid games are caught
171
+ bad_game = {
172
+ "game_id": "", "title": "", "theme": "",
173
+ "setup": {}, "rules": [], "tasks": [],
174
+ "global_hints": [], "score_rules": [],
175
+ "tie_breaker": "", "safety": {}, "story_seed": {},
176
+ }
177
+ is_bad_valid, bad_failures = validate_game(bad_game, config_adults)
178
+ check("Empty game fails validation", not is_bad_valid, f"Got {len(bad_failures)} issues")
179
+ check("Empty game has issues", len(bad_failures) > 5)
180
+ except Exception as e:
181
+ check("Validation", False, str(e))
182
+
183
+ # ── T5: Repair ───────────────────────────────────────────────────────
184
+ print("\n" + "-" * 80)
185
+ print("T5: REPAIR PASS")
186
+ print("-" * 80)
187
+
188
+ try:
189
+ bad_game = {
190
+ "game_id": "", "title": "", "theme": "",
191
+ "setup": {}, "rules": [], "tasks": [],
192
+ "global_hints": [], "score_rules": [],
193
+ "tie_breaker": "", "safety": {}, "story_seed": {},
194
+ }
195
+ _, failures = validate_game(bad_game, config_adults)
196
+ repaired = repair_game(bad_game, failures, config_adults)
197
+
198
+ check("Repair returns dict", isinstance(repaired, dict))
199
+ check("Repaired has game_id", bool(repaired.get("game_id")), f"Got '{repaired.get('game_id')}'")
200
+ check("Repaired has title", bool(repaired.get("title")))
201
+ check("Repaired has tasks", len(repaired.get("tasks", [])) > 0)
202
+ check("Repaired has rules", len(repaired.get("rules", [])) > 0)
203
+ check("Repaired has safety zone", bool(repaired.get("safety", {}).get("allowed_zone")))
204
+
205
+ is_repaired_valid, repaired_failures = validate_game(repaired, config_adults)
206
+ check("Repaired game passes validation", is_repaired_valid, f"Remaining: {repaired_failures[:3]}")
207
+ except Exception as e:
208
+ check("Repair", False, str(e))
209
+
210
+ # ── T6: Event Logging ────────────────────────────────────────────────
211
+ print("\n" + "-" * 80)
212
+ print("T6: EVENT LOGGING")
213
+ print("-" * 80)
214
+
215
+ try:
216
+ session_id = f"test-{uuid.uuid4().hex[:8]}"
217
+
218
+ ev1 = log_event(session_id, "task_revealed", {"task_id": "t1", "title": "Find the mural"})
219
+ check("Event logged", ev1 is not None)
220
+ check("Event has event_id", bool(ev1.get("event_id")))
221
+ check("Event has timestamp", bool(ev1.get("timestamp")))
222
+ check("Event has payload", "task_id" in ev1.get("payload", {}))
223
+
224
+ log_event(session_id, "task_completed", {"task_id": "t1", "summary": "Done"}, team_id="team-a")
225
+ log_event(session_id, "task_completed", {"task_id": "t2", "summary": "Done"}, team_id="team-a")
226
+ log_event(session_id, "hint_used", {"task_id": "t2", "summary": "Used hint"}, team_id="team-a")
227
+ log_event(session_id, "task_skipped", {"task_id": "t3", "summary": "Skipped"}, team_id="team-a")
228
+ log_event(session_id, "photo_uploaded", {"photo_id": "p1", "summary": "Selfie"}, team_id="team-a")
229
+ log_event(session_id, "journal_recorded", {"journal_id": "j1", "mood": "excited"}, team_id="team-a")
230
+
231
+ events = load_events(session_id)
232
+ check("All events loaded", len(events) >= 6, f"Got {len(events)} events")
233
+
234
+ event_types = [e["event_type"] for e in events]
235
+ check("Has task_completed", "task_completed" in event_types)
236
+ check("Has hint_used", "hint_used" in event_types)
237
+ check("Has task_skipped", "task_skipped" in event_types)
238
+ check("Has photo_uploaded", "photo_uploaded" in event_types)
239
+ except Exception as e:
240
+ check("Event logging", False, str(e))
241
+
242
+ # ── T7: Journal ──────────────────────────────────────────────────────
243
+ print("\n" + "-" * 80)
244
+ print("T7: JOURNAL PIPELINE")
245
+ print("-" * 80)
246
+
247
+ try:
248
+ transcript = "We found the mural near the canal, it was incredible! The whole team cheered. This was definitely the highlight of our game - a close call that turned into our best moment."
249
+ entry = create_journal_entry(
250
+ transcript=transcript,
251
+ session_id=session_id,
252
+ team_id="team-a",
253
+ task_id="t1",
254
+ location_note="Canal area",
255
+ )
256
+ check("Journal entry created", entry is not None)
257
+ check("Journal has journal_id", bool(entry.get("journal_id")))
258
+ check("Journal has session_id", entry.get("session_id") == session_id)
259
+
260
+ # Mood detection
261
+ mood = detect_mood(transcript)
262
+ check("Mood detected", bool(mood), f"Got '{mood}'")
263
+ check("Mood is expected", mood in ("excited", "funny", "confused", "tense", "lucky", "chaotic"),
264
+ f"Got '{mood}'")
265
+
266
+ # Story value
267
+ story_value = assess_story_value(transcript)
268
+ check("Story value assessed", bool(story_value), f"Got '{story_value}'")
269
+
270
+ # Summarization
271
+ summary = summarize_journal(transcript, task_id="t1")
272
+ check("Summary has moment_summary", bool(summary.get("moment_summary")))
273
+ check("Summary has tags", len(summary.get("tags", [])) > 0)
274
+ check("Summary has story_value", bool(summary.get("story_value")))
275
+
276
+ entry.update(summary)
277
+ save_journal_entry(entry)
278
+
279
+ journals = load_journal_entries(session_id)
280
+ check("Journal persisted", len(journals) >= 1, f"Got {len(journals)} entries")
281
+ except Exception as e:
282
+ check("Journal pipeline", False, str(e))
283
+
284
+ # ── T8: Scoring ──────────────────────────────────────────────────────
285
+ print("\n" + "-" * 80)
286
+ print("T8: DETERMINISTIC SCORING")
287
+ print("-" * 80)
288
+
289
+ try:
290
+ events = load_events(session_id)
291
+ scores = compute_scores(events, game)
292
+
293
+ check("Scores returned", scores is not None)
294
+ check("Has team_scores", len(scores.get("team_scores", [])) > 0)
295
+ check("Has winner", bool(scores.get("winner")))
296
+ check("Has scoring_explanation", len(scores.get("scoring_explanation", [])) > 0)
297
+
298
+ ts = scores["team_scores"][0]
299
+ check("Team score has points", ts.get("points", 0) >= 0)
300
+ check("Team score has completed_tasks", ts.get("completed_tasks", 0) >= 0)
301
+ check("Team score has hints_used", ts.get("hints_used", 0) >= 0)
302
+ check("Team score has total_tasks", ts.get("total_tasks", 0) > 0)
303
+
304
+ # Verify hint penalty
305
+ check("Hint penalty applied", ts["hints_used"] > 0)
306
+
307
+ # Scoring breakdown is present
308
+ has_breakdown = any(s.get("scoring_breakdown") for s in scores["team_scores"])
309
+ check("Has scoring breakdown", has_breakdown)
310
+ except Exception as e:
311
+ check("Scoring", False, str(e))
312
+
313
+ # ── T9: Story Recap (template) ──────────────────────────────────────
314
+ print("\n" + "-" * 80)
315
+ print("T9: STORY RECAP (TEMPLATE FALLBACK)")
316
+ print("-" * 80)
317
+
318
+ try:
319
+ events = load_events(session_id)
320
+ packet = build_story_packet(
321
+ game=game,
322
+ events=events,
323
+ scores=scores,
324
+ journal_entries=load_journal_entries(session_id),
325
+ )
326
+ check("Story packet built", packet is not None)
327
+ check("Packet has game_info", bool(packet.get("game_info")))
328
+ check("Packet has winner", bool(packet.get("winner")))
329
+ check("Packet has final_scores", len(packet.get("final_scores", [])) > 0)
330
+ check("Packet has task_outcomes", len(packet.get("task_outcomes", [])) > 0)
331
+
332
+ # Template recap
333
+ short = _template_short_recap(packet)
334
+ check("Template short recap generated", len(short) > 50, f"Got {len(short)} chars")
335
+
336
+ long = _template_long_summary(packet)
337
+ check("Template long summary generated", len(long) > 100, f"Got {len(long)} chars")
338
+
339
+ # Full generate_story with fallback
340
+ story = generate_story(packet, session_id=session_id)
341
+ check("generate_story() returns dict", isinstance(story, dict))
342
+ check("Has short_recap", bool(story.get("short_recap")))
343
+ check("Has long_summary", bool(story.get("long_summary")))
344
+ check("Has poster_prompt", bool(story.get("poster_prompt")))
345
+ check("Has story_packet", bool(story.get("story_packet")))
346
+ except Exception as e:
347
+ check("Story recap", False, str(e))
348
+
349
+ # ── T10: MiniCPM module ─────────────────────────────────────────────
350
+ print("\n" + "-" * 80)
351
+ print("T10: MINICPM MODULE (lazy-load only — no download in fast test)")
352
+ print("-" * 80)
353
+
354
+ try:
355
+ from app.services.minicpm import MINICPM_MODEL_ID, MINICPM_GGUF_FILE, generate_recap, generate_summary
356
+
357
+ check("MiniCPM constants loaded", bool(MINICPM_MODEL_ID) and bool(MINICPM_GGUF_FILE))
358
+ check("MiniCPM model ID is correct", "MiniCPM" in MINICPM_MODEL_ID)
359
+ check("MiniCPM GGUF file ends with .gguf", MINICPM_GGUF_FILE.endswith(".gguf"))
360
+
361
+ # In fast test, skip actual model loading
362
+ if os.environ.get("CITYQUEST_FAST_TEST"):
363
+ check("MiniCPM module imports (fast test — model skipped)", True)
364
+ else:
365
+ # Try recapping — will likely fail without model, that's OK
366
+ packet = build_story_packet(
367
+ game=game, events=events, scores=scores,
368
+ journal_entries=load_journal_entries(session_id),
369
+ )
370
+ result = generate_recap(packet)
371
+ # May be None if no model, but must not raise
372
+ check("MiniCPM generate_recap() runs without exception", True)
373
+ except Exception as e:
374
+ check("MiniCPM module", False, str(e))
375
+
376
+ # ── T11: Image Gen module ───────────────────────────────────────────
377
+ print("\n" + "-" * 80)
378
+ print("T11: IMAGE GEN MODULE")
379
+ print("-" * 80)
380
+
381
+ try:
382
+ from app.services.image_gen import generate_poster, generate_poster_sync, BFL_API_KEY_ENV
383
+
384
+ check("Image gen constants loaded", bool(BFL_API_KEY_ENV))
385
+
386
+ # Test with no API key — must return None gracefully
387
+ result = generate_poster("A test poster prompt", api_key=None)
388
+ check("generate_poster() with no key returns None", result is None)
389
+
390
+ url, status = generate_poster_sync("test-session", "A test poster prompt")
391
+ check("generate_poster_sync() returns tuple", isinstance(url, type(None)) and isinstance(status, str))
392
+ check("Status message is informative", "BFL_API_KEY" in status or "Failed" in status or "unavailable" in status)
393
+ except Exception as e:
394
+ check("Image gen module", False, str(e))
395
+
396
+ # ── Summary ──────────────────────────────────────────────────────────
397
+ _summary()
398
+
399
+
400
+ def _summary():
401
+ total = passed + failed
402
+ print(f"\nRESULTS: {passed}/{total} tests passed", end="")
403
+ if failed > 0:
404
+ print(f"{passed}/{total} tests passed - ALL CLEAR")
405
+ else:
406
+ print(" — ALL PASSED! 🎉")
407
+ print("=" * 80)
408
+
409
+ if errors:
410
+ print("\nFailed tests:")
411
+ for e in errors:
412
+ print(f" {e}")
413
+
414
+ # Exit with error code if any test failed
415
+ if failed > 0:
416
+ sys.exit(1)
417
+
418
+
419
+ if __name__ == "__main__":
420
+ main()