NANI-Nithin commited on
Commit
3ee7cb0
·
1 Parent(s): e9fc2fc

Phase 3: add live session intelligence

Browse files
Files changed (7) hide show
  1. .gitignore +4 -0
  2. app.py +455 -73
  3. app/services/journal.py +294 -18
  4. app/services/scoring.py +207 -11
  5. app/services/story.py +342 -14
  6. app/services/tracing.py +161 -16
  7. test_phase3.py +83 -0
.gitignore CHANGED
@@ -48,6 +48,10 @@ env/
48
  Thumbs.db
49
  .DS_Store
50
  .AppleDouble
 
 
 
 
51
  .LSOverride
52
 
53
  # Logs and temporary files
 
48
  Thumbs.db
49
  .DS_Store
50
  .AppleDouble
51
+
52
+ # Runtime logs (generated per session — never commit)
53
+ app/logs/*.jsonl
54
+ app/logs/trace_*.json
55
  .LSOverride
56
 
57
  # Logs and temporary files
app.py CHANGED
@@ -1,4 +1,5 @@
1
  import gradio as gr
 
2
 
3
  try:
4
  import spaces # only available on Hugging Face Spaces
@@ -9,6 +10,13 @@ from app.services.retrieval import load_games_dataset, normalize_game_record, re
9
  from app.services.generator import generate_game
10
  from app.services.validator import validate_game, repair_game
11
  from app.services.schema_validator import create_minimal_game_template
 
 
 
 
 
 
 
12
 
13
 
14
  # ── Load dataset once on startup ──────────────────────────────────────────────
@@ -22,6 +30,10 @@ except FileNotFoundError:
22
  print(f"⚠ Dataset not found at {DATASET_PATH}, retrieval will be empty")
23
 
24
 
 
 
 
 
25
  # ── Pipeline entry point ──────────────────────────────────────────────────────
26
  def run_pipeline(
27
  game_type: str,
@@ -36,20 +48,22 @@ def run_pipeline(
36
  ):
37
  """Run the full AI generation pipeline end-to-end."""
38
 
 
 
39
  config = {
40
  "game_type": game_type,
41
  "city": city or "Paris",
42
  "area": area or "Downtown",
43
  "location_type": location_type,
44
- "duration_minutes": duration_minutes,
45
- "num_players": num_players,
46
  "difficulty": difficulty,
47
  "age_group": age_group,
48
  "energy_level": energy_level,
49
  "photo_enabled": True,
50
  }
51
 
52
- state = {} # collect logs for the UI trace
53
 
54
  # 1 ── Retrieval
55
  state["num_retrieved"] = 0
@@ -75,7 +89,6 @@ def run_pipeline(
75
  if not is_valid:
76
  repaired = repair_game(game, failures, config)
77
  state["repair_applied"] = True
78
- # re-validate repaired version
79
  is_valid2, failures2 = validate_game(repaired, config)
80
  state["repair_valid"] = is_valid2
81
  state["remaining_failures"] = failures2
@@ -84,16 +97,270 @@ def run_pipeline(
84
 
85
  final_game = repaired if repaired is not None else game
86
 
87
- # 5 ── Build summary text
88
- summary = build_summary(final_game, state)
89
- return summary, final_game
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
90
 
 
 
 
 
91
 
92
- def build_summary(game: dict, state: dict) -> str:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
93
  """Format a human-readable game summary for the UI."""
94
  lines = []
95
  lines.append(f"# 🎮 {game.get('title', 'Untitled')}")
96
  lines.append("")
 
 
 
97
  lines.append("## 📋 Setup")
98
  setup = game.get("setup", {})
99
  lines.append(f"- **Location:** {setup.get('city', '?')} — {setup.get('area', '?')}")
@@ -170,83 +437,198 @@ with gr.Blocks(title="CityQuest-AI – Game Generator") as demo:
170
  gr.Markdown(
171
  """
172
  # 🌍 CityQuest-AI — AI Game Generator
173
- Configure your location-based game below and let AI create it.
174
  """
175
  )
176
 
177
- with gr.Row():
178
- with gr.Column(scale=1):
179
- gr.Markdown("### 🎮 Game Configuration")
180
-
181
- game_type = gr.Dropdown(
182
- label="Game Type",
183
- choices=["scavenger_hunt", "hide_and_seek", "tag"],
184
- value="scavenger_hunt",
185
- )
186
- city = gr.Textbox(label="City", value="Paris", info="Default: Paris")
187
- area = gr.Textbox(
188
- label="Area", value="Le Marais", info="Neighbourhood or district"
189
- )
190
- location_type = gr.Radio(
191
- label="Location Type",
192
- choices=["park", "street", "landmark", "mixed"],
193
- value="mixed",
194
- )
195
- duration_minutes = gr.Slider(
196
- label="Duration (minutes)",
197
- minimum=15,
198
- maximum=120,
199
- value=60,
200
- step=5,
201
- )
202
- num_players = gr.Slider(
203
- label="Number of Players", minimum=2, maximum=10, value=4, step=1
204
- )
205
- difficulty = gr.Dropdown(
206
- label="Difficulty",
207
- choices=["easy", "medium", "hard"],
208
- value="medium",
209
- )
210
- age_group = gr.Dropdown(
211
- label="Age Group",
212
- choices=["kids", "teens", "adults", "mixed"],
213
- value="adults",
214
- )
215
- energy_level = gr.Radio(
216
- label="Energy Level",
217
- choices=["low", "medium", "high"],
218
- value="medium",
219
- )
220
-
221
- generate_btn = gr.Button("🚀 Generate Game", variant="primary", size="lg")
222
-
223
- with gr.Column(scale=2):
224
- gr.Markdown("### 📄 Generated Game")
225
- output_md = gr.Markdown(
226
- value="Click **Generate Game** to create a new game!"
227
- )
228
- output_json = gr.JSON(label="Raw game JSON", visible=False)
229
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
230
  generate_btn.click(
231
  fn=run_pipeline,
232
  inputs=[
233
- game_type,
234
- city,
235
- area,
236
- location_type,
237
- duration_minutes,
238
- num_players,
239
- difficulty,
240
- age_group,
241
- energy_level,
242
  ],
243
- outputs=[output_md, output_json],
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
244
  )
245
 
246
  gr.Markdown(
247
  """
248
  ---
249
- Built with ❤️ for the **NVIDIA / HF Hackathon** · CityQuest-AI · [Nemotron 3 Nano 4B](https://huggingface.co/nvidia/NVIDIA-Nemotron-3-Nano-4B-GGUF) · [llama.cpp](https://github.com/ggerganov/llama.cpp)
 
 
250
  """
251
  )
252
 
 
1
  import gradio as gr
2
+ import uuid
3
 
4
  try:
5
  import spaces # only available on Hugging Face Spaces
 
10
  from app.services.generator import generate_game
11
  from app.services.validator import validate_game, repair_game
12
  from app.services.schema_validator import create_minimal_game_template
13
+ from app.services.tracing import log_event, log_generation_trace, load_events
14
+ from app.services.journal import (
15
+ create_journal_entry, save_journal_entry, summarize_journal,
16
+ load_journal_entries, detect_mood, assess_story_value,
17
+ )
18
+ from app.services.scoring import compute_scores
19
+ from app.services.story import build_story_packet, generate_story
20
 
21
 
22
  # ── Load dataset once on startup ──────────────────────────────────────────────
 
30
  print(f"⚠ Dataset not found at {DATASET_PATH}, retrieval will be empty")
31
 
32
 
33
+ # ── In-memory session store (resets on restart — fine for hackathon) ───────
34
+ SESSION_STORE: dict[str, dict] = {} # session_id -> {config: {...}, game: {...}, events: [...], journals: [...]}
35
+
36
+
37
  # ── Pipeline entry point ──────────────────────────────────────────────────────
38
  def run_pipeline(
39
  game_type: str,
 
48
  ):
49
  """Run the full AI generation pipeline end-to-end."""
50
 
51
+ session_id = str(uuid.uuid4())
52
+
53
  config = {
54
  "game_type": game_type,
55
  "city": city or "Paris",
56
  "area": area or "Downtown",
57
  "location_type": location_type,
58
+ "duration_minutes": int(duration_minutes),
59
+ "num_players": int(num_players),
60
  "difficulty": difficulty,
61
  "age_group": age_group,
62
  "energy_level": energy_level,
63
  "photo_enabled": True,
64
  }
65
 
66
+ state = {}
67
 
68
  # 1 ── Retrieval
69
  state["num_retrieved"] = 0
 
89
  if not is_valid:
90
  repaired = repair_game(game, failures, config)
91
  state["repair_applied"] = True
 
92
  is_valid2, failures2 = validate_game(repaired, config)
93
  state["repair_valid"] = is_valid2
94
  state["remaining_failures"] = failures2
 
97
 
98
  final_game = repaired if repaired is not None else game
99
 
100
+ # 5 ── Log generation trace
101
+ log_generation_trace(
102
+ session_id=session_id,
103
+ config=config,
104
+ retrieved_examples=retrieved,
105
+ game=final_game,
106
+ validation_passed=is_valid or (repaired is not None and state.get("repair_valid", False)),
107
+ validation_failures=failures,
108
+ repaired_game=repaired,
109
+ )
110
+
111
+ # 6 ── Reveal all tasks as events
112
+ for task in final_game.get("tasks", []):
113
+ log_event(session_id, "task_revealed", {
114
+ "task_id": task["task_id"],
115
+ "title": task["title"],
116
+ "points": task["points"],
117
+ })
118
+
119
+ # 7 ── Store session
120
+ SESSION_STORE[session_id] = {
121
+ "config": config,
122
+ "game": final_game,
123
+ "events": [],
124
+ "journals": [],
125
+ }
126
+
127
+ # 8 ── Build summary text
128
+ summary = build_summary(final_game, state, session_id)
129
+ return summary, final_game, session_id
130
+
131
+
132
+ # ── Phase 3: Gameplay helpers ─────────────────────────────────────────────────
133
+
134
+ def complete_task(session_id: str, task_id: str, team_id: str = "team-a"):
135
+ """Log a task completion event and return updated scoreboard."""
136
+ if session_id not in SESSION_STORE:
137
+ return "⚠ Unknown session"
138
+ ev = log_event(session_id, "task_completed", {
139
+ "task_id": task_id,
140
+ "summary": f"Team {team_id} completed {task_id}",
141
+ }, team_id=team_id)
142
+ SESSION_STORE[session_id]["events"].append(ev)
143
+ return f"✅ Task {task_id} completed!"
144
+
145
+
146
+ def skip_task(session_id: str, task_id: str, team_id: str = "team-a"):
147
+ """Log a task skip event."""
148
+ if session_id not in SESSION_STORE:
149
+ return "⚠ Unknown session"
150
+ ev = log_event(session_id, "task_skipped", {
151
+ "task_id": task_id,
152
+ "summary": f"Team {team_id} skipped {task_id}",
153
+ }, team_id=team_id)
154
+ SESSION_STORE[session_id]["events"].append(ev)
155
+ return f"⏭️ Task {task_id} skipped."
156
+
157
+
158
+ def use_hint(session_id: str, task_id: str, team_id: str = "team-a"):
159
+ """Log a hint usage event."""
160
+ if session_id not in SESSION_STORE:
161
+ return "⚠ Unknown session"
162
+ ev = log_event(session_id, "hint_used", {
163
+ "task_id": task_id,
164
+ "summary": f"Team {team_id} used a hint for {task_id}",
165
+ }, team_id=team_id)
166
+ SESSION_STORE[session_id]["events"].append(ev)
167
+ return f"💡 Hint used for {task_id} (−5 pts)"
168
+
169
+
170
+ def record_journal(
171
+ session_id: str,
172
+ transcript: str,
173
+ task_id: str = "",
174
+ location_note: str = "",
175
+ team_id: str = "team-a",
176
+ ):
177
+ """Record a text journal entry, summarize it, and return the result."""
178
+ if session_id not in SESSION_STORE:
179
+ return "⚠ Unknown session", ""
180
+
181
+ # Build full journal entry
182
+ entry = create_journal_entry(
183
+ transcript=transcript,
184
+ session_id=session_id,
185
+ team_id=team_id,
186
+ task_id=task_id or None,
187
+ location_note=location_note,
188
+ )
189
+
190
+ # Summarize
191
+ summary = summarize_journal(transcript, task_id=task_id or None, location_note=location_note)
192
+ entry["moment_summary"] = summary["moment_summary"]
193
+ entry["tags"] = summary["tags"]
194
+ entry["story_value"] = summary["story_value"]
195
+
196
+ # Persist
197
+ save_journal_entry(entry)
198
+
199
+ # Log event
200
+ ev = log_event(session_id, "journal_recorded", {
201
+ "journal_id": entry["journal_id"],
202
+ "mood": entry["mood"],
203
+ "story_value": summary["story_value"],
204
+ "summary": summary["moment_summary"],
205
+ }, team_id=team_id)
206
+ SESSION_STORE[session_id]["events"].append(ev)
207
+ SESSION_STORE[session_id]["journals"].append(entry)
208
+
209
+ # Build display text
210
+ display = (
211
+ f"🎙️ **Journal recorded!**\n"
212
+ f"- Mood: *{entry['mood']}*\n"
213
+ f"- Story value: **{summary['story_value']}**\n"
214
+ f"- Tags: {', '.join(summary['tags'])}\n"
215
+ f"- Summary: {summary['moment_summary']}"
216
+ )
217
+ return display, entry["journal_id"]
218
+
219
+
220
+ def upload_photo(
221
+ session_id: str,
222
+ photo_file,
223
+ caption: str = "",
224
+ task_id: str = "",
225
+ team_id: str = "team-a",
226
+ ):
227
+ """Log a photo upload event and return a confirmation."""
228
+ if session_id not in SESSION_STORE:
229
+ return "⚠ Unknown session", []
230
+
231
+ photo_name = ""
232
+ if photo_file is not None:
233
+ # Gradio 4+ returns a filepath string or a PIL image
234
+ if isinstance(photo_file, str):
235
+ photo_name = photo_file.split("/")[-1].split("\\")[-1]
236
+ else:
237
+ photo_name = getattr(photo_file, "name", "photo")
238
+
239
+ photo_id = f"photo-{uuid.uuid4().hex[:8]}"
240
+
241
+ payload = {
242
+ "photo_id": photo_id,
243
+ "photo_name": photo_name,
244
+ "caption": caption,
245
+ "summary": f"Team {team_id} uploaded photo for {task_id or 'general'}",
246
+ }
247
+ if task_id:
248
+ payload["task_id"] = task_id
249
+
250
+ ev = log_event(session_id, "photo_uploaded", payload, team_id=team_id)
251
+ SESSION_STORE[session_id]["events"].append(ev)
252
+
253
+ # Track photo in session store for recap
254
+ if "photos" not in SESSION_STORE[session_id]:
255
+ SESSION_STORE[session_id]["photos"] = []
256
+ SESSION_STORE[session_id]["photos"].append({
257
+ "photo_id": photo_id,
258
+ "photo_name": photo_name,
259
+ "caption": caption,
260
+ "task_id": task_id,
261
+ })
262
+
263
+ # Build gallery list
264
+ photos = SESSION_STORE[session_id]["photos"]
265
+ gallery_lines = [f"📸 **{p['photo_id']}** — {p['caption'] or '(no caption)'} [{p['task_id'] or 'general'}]" for p in photos]
266
+
267
+ display = (
268
+ f"📸 **Photo uploaded!**\n"
269
+ f"- ID: `{photo_id}`\n"
270
+ f"- Caption: {caption or '(none)'}\n"
271
+ f"- Related task: {task_id or 'general'}\n\n"
272
+ f"**All photos ({len(photos)}):**\n" + "\n".join(gallery_lines)
273
+ )
274
+ return display
275
+
276
+
277
+ def end_game(session_id: str, team_id: str = "team-a"):
278
+ """End the game, compute scores, and return a scoreboard."""
279
+ if session_id not in SESSION_STORE:
280
+ return "⚠ Unknown session"
281
+
282
+ session = SESSION_STORE[session_id]
283
+ game = session["game"]
284
+ events = load_events(session_id=session_id)
285
+
286
+ ev = log_event(session_id, "game_finished", {
287
+ "summary": f"Game finished — session {session_id}",
288
+ }, team_id=team_id)
289
+ events.append(ev)
290
+
291
+ scores = compute_scores(events, game)
292
+ session["scores"] = scores
293
+
294
+ lines = ["# 🏆 Final Scoreboard\n"]
295
+ for ts in scores.get("team_scores", []):
296
+ marker = " 🏆" if ts["team_id"] == scores.get("winner") else ""
297
+ lines.append(f"### Team: {ts['team_id']}{marker}")
298
+ lines.append(f"- **Total points:** {ts['points']}")
299
+ lines.append(f"- Tasks completed: {ts['completed_tasks']}/{ts['total_tasks']}")
300
+ lines.append(f"- Hints used: {ts['hints_used']}")
301
+ if ts.get("bonuses"):
302
+ lines.append(f"- Bonuses: {', '.join(ts['bonuses'])}")
303
+ lines.append("")
304
+ lines.append("**Breakdown:**")
305
+ for b in ts.get("scoring_breakdown", []):
306
+ lines.append(f" • {b}")
307
+ lines.append("")
308
+
309
+ if scores.get("winner"):
310
+ lines.append(f"**Winner: {scores['winner']}** 🎉")
311
+
312
+ return "\n".join(lines), scores
313
+
314
 
315
+ def generate_recap(session_id: str):
316
+ """Generate the final story recap from all collected session data."""
317
+ if session_id not in SESSION_STORE:
318
+ return "⚠ Unknown session"
319
 
320
+ session = SESSION_STORE[session_id]
321
+ game = session["game"]
322
+ events = load_events(session_id=session_id)
323
+ journals = load_journal_entries(session_id=session_id)
324
+ scores = session.get("scores", compute_scores(events, game))
325
+ photos = session.get("photos", [])
326
+
327
+ # Build story packet
328
+ packet = build_story_packet(
329
+ game=game,
330
+ events=events,
331
+ scores=scores,
332
+ journal_entries=journals,
333
+ photo_captions=photos,
334
+ )
335
+
336
+ # Generate story
337
+ result = generate_story(packet, session_id=session_id)
338
+
339
+ # Format for display
340
+ lines = [
341
+ "# 📖 Episode Recap\n",
342
+ result["short_recap"],
343
+ "",
344
+ "---\n",
345
+ result["long_summary"],
346
+ "",
347
+ "---\n",
348
+ "### 🎨 Poster Prompt\n",
349
+ f"```{result['poster_prompt']}```",
350
+ ]
351
+
352
+ return "\n".join(lines), result
353
+
354
+
355
+ # ── Build summary text ────────────────────────────────────────────────────────
356
+ def build_summary(game: dict, state: dict, session_id: str = "") -> str:
357
  """Format a human-readable game summary for the UI."""
358
  lines = []
359
  lines.append(f"# 🎮 {game.get('title', 'Untitled')}")
360
  lines.append("")
361
+ if session_id:
362
+ lines.append(f"> Session `{session_id[:12]}…` — paste this in the **Play** tab to log progress.")
363
+ lines.append("")
364
  lines.append("## 📋 Setup")
365
  setup = game.get("setup", {})
366
  lines.append(f"- **Location:** {setup.get('city', '?')} — {setup.get('area', '?')}")
 
437
  gr.Markdown(
438
  """
439
  # 🌍 CityQuest-AI — AI Game Generator
440
+ Configure your game, play it, record journals, and get a story recap.
441
  """
442
  )
443
 
444
+ # ── Tab 1: Generate ────────────────────────────────────────────────────
445
+ with gr.Tab("🎮 Generate"):
446
+ with gr.Row():
447
+ with gr.Column(scale=1):
448
+ gr.Markdown("### Game Configuration")
449
+
450
+ game_type = gr.Dropdown(
451
+ label="Game Type",
452
+ choices=["scavenger_hunt", "hide_and_seek", "tag"],
453
+ value="scavenger_hunt",
454
+ )
455
+ city = gr.Textbox(label="City", value="Paris", info="Default: Paris")
456
+ area = gr.Textbox(
457
+ label="Area", value="Le Marais", info="Neighbourhood or district"
458
+ )
459
+ location_type = gr.Radio(
460
+ label="Location Type",
461
+ choices=["park", "street", "landmark", "mixed"],
462
+ value="mixed",
463
+ )
464
+ duration_minutes = gr.Slider(
465
+ label="Duration (minutes)",
466
+ minimum=15,
467
+ maximum=120,
468
+ value=60,
469
+ step=5,
470
+ )
471
+ num_players = gr.Slider(
472
+ label="Number of Players", minimum=2, maximum=10, value=4, step=1
473
+ )
474
+ difficulty = gr.Dropdown(
475
+ label="Difficulty",
476
+ choices=["easy", "medium", "hard"],
477
+ value="medium",
478
+ )
479
+ age_group = gr.Dropdown(
480
+ label="Age Group",
481
+ choices=["kids", "teens", "adults", "mixed"],
482
+ value="adults",
483
+ )
484
+ energy_level = gr.Radio(
485
+ label="Energy Level",
486
+ choices=["low", "medium", "high"],
487
+ value="medium",
488
+ )
489
+
490
+ generate_btn = gr.Button("🚀 Generate Game", variant="primary", size="lg")
491
+
492
+ with gr.Column(scale=2):
493
+ gr.Markdown("### 📄 Generated Game")
494
+ output_md = gr.Markdown(
495
+ value="Click **Generate Game** to create a new game!"
496
+ )
497
+ output_json = gr.JSON(label="Raw game JSON", visible=False)
498
+ session_id_box = gr.Textbox(
499
+ label="Session ID (copy to Play tab)", interactive=False, visible=True
500
+ )
501
+
502
+ # ── Tab 2: Play ────────────────────────────────────────────────────────
503
+ with gr.Tab("🎯 Play"):
504
+ gr.Markdown(
505
+ """
506
+ ### Simulate gameplay
507
+ Paste your **Session ID** from the Generate tab, then log task
508
+ completions, hints, and journal entries as you play.
509
+ """
510
+ )
511
+ with gr.Row():
512
+ play_session_id = gr.Textbox(label="Session ID", placeholder="Paste session ID here…")
513
+ play_team_id = gr.Textbox(label="Team ID", value="team-a")
514
+
515
+ with gr.Row():
516
+ with gr.Column():
517
+ gr.Markdown("#### Task Actions")
518
+ play_task_id = gr.Textbox(label="Task ID (e.g. t1, t2)", placeholder="t1")
519
+ with gr.Row():
520
+ complete_btn = gr.Button("✅ Complete Task", variant="primary")
521
+ skip_btn = gr.Button("⏭️ Skip Task")
522
+ hint_btn = gr.Button("💡 Use Hint")
523
+
524
+ task_feedback = gr.Markdown(value="")
525
+
526
+ with gr.Column():
527
+ gr.Markdown("#### 🎙️ Voice Journal")
528
+ journal_transcript = gr.Textbox(
529
+ label="Journal entry (what happened, how you feel)",
530
+ lines=4,
531
+ placeholder="We just found the mural near the canal — it was incredible!",
532
+ )
533
+ journal_task_id = gr.Textbox(
534
+ label="Related Task ID (optional)", placeholder="t1"
535
+ )
536
+ journal_location = gr.Textbox(
537
+ label="Location note", placeholder="Near the canal on Rue de Rivoli"
538
+ )
539
+ journal_btn = gr.Button("🎙️ Record Journal", variant="secondary")
540
+ journal_output = gr.Markdown(value="")
541
+
542
+ with gr.Row():
543
+ with gr.Column():
544
+ gr.Markdown("#### 📸 Photo Upload")
545
+ photo_file = gr.Image(label="Upload a photo (drag & drop or click)", type="filepath", height=200)
546
+ photo_caption = gr.Textbox(label="Caption", placeholder="The mural we just discovered!")
547
+ photo_task_id = gr.Textbox(label="Related Task ID (optional)", placeholder="t1")
548
+ photo_btn = gr.Button("📸 Upload Photo", variant="secondary")
549
+ photo_output = gr.Markdown(value="")
550
+
551
+ with gr.Row():
552
+ end_btn = gr.Button("🏁 End Game & Score", variant="primary", size="lg")
553
+ scoreboard_md = gr.Markdown(value="")
554
+
555
+ # ── Tab 3: Recap ───────────────────────────────────────────────────────
556
+ with gr.Tab("📖 Recap"):
557
+ gr.Markdown(
558
+ """
559
+ ### Generate a story recap
560
+ After ending the game, paste your **Session ID** to generate a
561
+ narrative recap from your real session data.
562
+ """
563
+ )
564
+ recap_session_id = gr.Textbox(label="Session ID", placeholder="Paste session ID here…")
565
+ recap_btn = gr.Button("📖 Generate Recap", variant="primary", size="lg")
566
+ recap_md = gr.Markdown(value="")
567
+ recap_json = gr.JSON(label="Recap data", visible=False)
568
+
569
+ # ── Wire events ────────────────────────────────────────────────────────
570
+
571
+ # Generate tab
572
  generate_btn.click(
573
  fn=run_pipeline,
574
  inputs=[
575
+ game_type, city, area, location_type,
576
+ duration_minutes, num_players, difficulty, age_group, energy_level,
 
 
 
 
 
 
 
577
  ],
578
+ outputs=[output_md, output_json, session_id_box],
579
+ )
580
+
581
+ # Play tab — task actions
582
+ complete_btn.click(
583
+ fn=complete_task,
584
+ inputs=[play_session_id, play_task_id, play_team_id],
585
+ outputs=[task_feedback],
586
+ )
587
+ skip_btn.click(
588
+ fn=skip_task,
589
+ inputs=[play_session_id, play_task_id, play_team_id],
590
+ outputs=[task_feedback],
591
+ )
592
+ hint_btn.click(
593
+ fn=use_hint,
594
+ inputs=[play_session_id, play_task_id, play_team_id],
595
+ outputs=[task_feedback],
596
+ )
597
+
598
+ # Play tab — journal
599
+ journal_btn.click(
600
+ fn=record_journal,
601
+ inputs=[play_session_id, journal_transcript, journal_task_id, journal_location, play_team_id],
602
+ outputs=[journal_output],
603
+ )
604
+
605
+ # Play tab — photo upload
606
+ photo_btn.click(
607
+ fn=upload_photo,
608
+ inputs=[play_session_id, photo_file, photo_caption, photo_task_id, play_team_id],
609
+ outputs=[photo_output],
610
+ )
611
+
612
+ # Play tab — end game
613
+ end_btn.click(
614
+ fn=end_game,
615
+ inputs=[play_session_id, play_team_id],
616
+ outputs=[scoreboard_md],
617
+ )
618
+
619
+ # Recap tab
620
+ recap_btn.click(
621
+ fn=generate_recap,
622
+ inputs=[recap_session_id],
623
+ outputs=[recap_md, recap_json],
624
  )
625
 
626
  gr.Markdown(
627
  """
628
  ---
629
+ Built with ❤️ for the **NVIDIA / HF Hackathon** · CityQuest-AI ·
630
+ [Nemotron 3 Nano 4B](https://huggingface.co/nvidia/NVIDIA-Nemotron-3-Nano-4B-GGUF) ·
631
+ [llama.cpp](https://github.com/ggerganov/llama.cpp)
632
  """
633
  )
634
 
app/services/journal.py CHANGED
@@ -1,34 +1,310 @@
1
- """Voice journal capture and summarization module."""
2
 
3
- from typing import Any
 
 
4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
 
6
  def transcribe_journal(audio_path: str) -> str:
7
  """Transcribe voice journal audio to text.
8
-
 
 
 
 
 
9
  Args:
10
- audio_path: Path to recorded audio file
11
-
12
  Returns:
13
- Transcribed text
14
  """
15
- # TODO: Implement with speech-to-text service
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
16
  return ""
17
 
18
 
19
- def summarize_journal(transcript: str, task_id: str | None = None) -> dict:
20
- """Summarize journal entry using OpenBMB model.
21
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22
  Args:
23
- transcript: Journal transcript text
24
- task_id: Optional associated task ID
25
-
 
26
  Returns:
27
- Journal summary with tags and story value
28
  """
29
- # TODO: Implement with MiniCPM or similar model
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
30
  return {
31
- "moment_summary": "",
32
- "tags": [],
33
- "story_value": "low"
34
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Voice journal capture and summarization module.
2
 
3
+ Provides two paths for journal creation:
4
+ 1. Voice path: record audio → transcribe → summarize (requires STT backend)
5
+ 2. Text path: type a journal entry directly → summarize
6
 
7
+ Summarization uses a heuristic + optional LLM approach so the pipeline
8
+ works even when no LLM is available.
9
+ """
10
+
11
+ import json
12
+ import uuid
13
+ import re
14
+ from datetime import datetime, timezone
15
+ from pathlib import Path
16
+ from typing import Optional
17
+
18
+
19
+ # ── Mood keywords for heuristic mood detection ──────────────────────────────
20
+ MOOD_KEYWORDS: dict[str, list[str]] = {
21
+ "funny": [
22
+ "hilarious", "laughed", "laughing", "funny", "silly", "ridiculous",
23
+ "comedic", "joke", "cracked up", "wheezing", "snort",
24
+ ],
25
+ "confused": [
26
+ "confused", "lost", "don't understand", "no idea", "where is",
27
+ "which way", "puzzled", "baffled", "not sure", "wait what",
28
+ ],
29
+ "excited": [
30
+ "excited", "amazing", "awesome", "incredible", "wow", "yes!",
31
+ "let's go", "so cool", "unbelievable", "found it", "nailed it",
32
+ ],
33
+ "tense": [
34
+ "nervous", "worried", "oh no", "scary", "rushed", "panicked",
35
+ "close call", "barely made it", "running out of time", "hurry",
36
+ ],
37
+ "lucky": [
38
+ "lucky", "by chance", "just happened", "stumbled", "coincidence",
39
+ "right place", "phew", "got lucky", "luckily", "close one",
40
+ ],
41
+ "chaotic": [
42
+ "chaos", "everything at once", "all over the place", "wild",
43
+ "crazy", "mayhem", "pandemonium", "disaster", "total mess", "frantic",
44
+ ],
45
+ }
46
+
47
+ # Story-value indicators
48
+ HIGH_VALUE_SIGNALS = [
49
+ "turning point", "decided to", "changed our mind", "found it",
50
+ "last second", "just in time", "unexpected", "surprise", "nobody expected",
51
+ "close call", "first time", "only team", "beat them", "won",
52
+ "everyone cheered", "high five", "best moment", "highlight",
53
+ ]
54
+
55
+ LOCATION_KEYWORDS = [
56
+ "square", "street", "corner", "park", "garden", "bridge", "cafe",
57
+ "church", "museum", "statue", "fountain", "canal", "river", "market",
58
+ "tower", "palace", "mural", "gallery", "arch", "gate", "alley",
59
+ "plaza", "staircase", "passage", "courtyard",
60
+ ]
61
+
62
+
63
+ # ── Transcription ───────────────────────────────────────────────────────────
64
 
65
  def transcribe_journal(audio_path: str) -> str:
66
  """Transcribe voice journal audio to text.
67
+
68
+ Tries (in order):
69
+ 1. ``whisper`` Python package (OpenAI Whisper, runs locally).
70
+ 2. Returns an empty string with a warning so downstream code
71
+ still works and the user can fall back to typed input.
72
+
73
  Args:
74
+ audio_path: Path to a recorded audio file (wav/mp3/m4a/ogg/webm).
75
+
76
  Returns:
77
+ Transcribed text, or ``""`` if transcription is unavailable.
78
  """
79
+ path = Path(audio_path)
80
+ if not path.exists():
81
+ raise FileNotFoundError(f"Audio file not found: {audio_path}")
82
+
83
+ # Try local Whisper
84
+ try:
85
+ import whisper # type: ignore
86
+
87
+ model = whisper.load_model("base")
88
+ result = model.transcribe(str(path))
89
+ return result.get("text", "").strip()
90
+ except ImportError:
91
+ print(
92
+ "[journal] whisper not installed — install with: "
93
+ "pip install openai-whisper. Falling back to empty transcript."
94
+ )
95
+ except Exception as exc:
96
+ print(f"[journal] Whisper transcription failed: {exc}")
97
+
98
  return ""
99
 
100
 
101
+ # ── Mood detection ──────────────────────────────────────────────────────────
102
+
103
+ def detect_mood(transcript: str) -> str:
104
+ """Detect the dominant mood from transcript text using keyword matching.
105
+
106
+ Returns one of: ``funny``, ``confused``, ``excited``, ``tense``,
107
+ ``lucky``, ``chaotic``. Defaults to ``excited`` if no clear signal.
108
+ """
109
+ lower = transcript.lower()
110
+ scores: dict[str, int] = {}
111
+
112
+ for mood, keywords in MOOD_KEYWORDS.items():
113
+ count = sum(1 for kw in keywords if kw in lower)
114
+ if count > 0:
115
+ scores[mood] = count
116
+
117
+ if not scores:
118
+ return "excited" # safe default
119
+
120
+ return max(scores, key=scores.get)
121
+
122
+
123
+ # ── Tag extraction ──────────────────────────────────────────────────────────
124
+
125
+ def extract_tags(transcript: str, task_id: Optional[str] = None) -> list[str]:
126
+ """Extract meaningful tags from the transcript.
127
+
128
+ Tags include:
129
+ - The associated ``task_id`` if provided.
130
+ - Detected mood.
131
+ - Any mentioned locations / landmarks.
132
+ - Any detected story-value signal words.
133
+ """
134
+ lower = transcript.lower()
135
+ tags: list[str] = []
136
+
137
+ if task_id:
138
+ tags.append(task_id)
139
+
140
+ tags.append(detect_mood(transcript))
141
+
142
+ for loc in LOCATION_KEYWORDS:
143
+ if loc in lower:
144
+ tags.append(loc)
145
+
146
+ return tags
147
+
148
+
149
+ # ── Story-value scoring ─────────────────────────────────────────────────────
150
+
151
+ def assess_story_value(transcript: str) -> str:
152
+ """Rate the story value of a journal entry as ``low``, ``medium``, or ``high``.
153
+
154
+ Heuristic: count the number of high-value signals present in the text.
155
+ """
156
+ lower = transcript.lower()
157
+ hits = sum(1 for sig in HIGH_VALUE_SIGNALS if sig in lower)
158
+
159
+ word_count = len(transcript.split())
160
+
161
+ # Very short entries are low value regardless
162
+ if word_count < 8:
163
+ return "low"
164
+ if hits >= 3:
165
+ return "high"
166
+ if hits >= 1 or word_count >= 30:
167
+ return "medium"
168
+ return "low"
169
+
170
+
171
+ # ── Summarization ───────────────────────────────────────────────────────────
172
+
173
+ def summarize_journal(
174
+ transcript: str,
175
+ task_id: Optional[str] = None,
176
+ location_note: str = "",
177
+ ) -> dict:
178
+ """Summarize a journal entry with tags and story value.
179
+
180
+ Uses a keyword-based heuristic that works offline. When an LLM is
181
+ available it can optionally produce a richer summary.
182
+
183
  Args:
184
+ transcript: Journal transcript text (typed or transcribed).
185
+ task_id: Optional associated task ID.
186
+ location_note: Where the entry was recorded.
187
+
188
  Returns:
189
+ Dictionary with keys ``moment_summary``, ``tags``, ``story_value``.
190
  """
191
+ if not transcript or not transcript.strip():
192
+ return {
193
+ "moment_summary": "No content recorded.",
194
+ "tags": [],
195
+ "story_value": "low",
196
+ }
197
+
198
+ mood = detect_mood(transcript)
199
+ tags = extract_tags(transcript, task_id)
200
+ story_value = assess_story_value(transcript)
201
+
202
+ # Build a short summary (first sentence or first 40 words)
203
+ sentences = re.split(r'[.!?]+', transcript)
204
+ first_sentence = sentences[0].strip() if sentences else transcript[:200]
205
+
206
+ if len(first_sentence.split()) > 40:
207
+ first_sentence = " ".join(first_sentence.split()[:40]) + "…"
208
+
209
+ moment_summary = f"[{mood}] {first_sentence}"
210
+
211
  return {
212
+ "moment_summary": moment_summary,
213
+ "tags": tags,
214
+ "story_value": story_value,
215
  }
216
+
217
+
218
+ # ── Full journal entry builder ──────────────────────────────────────────────
219
+
220
+ def create_journal_entry(
221
+ transcript: str,
222
+ session_id: str,
223
+ team_id: str = "team-a",
224
+ task_id: Optional[str] = None,
225
+ location_note: str = "",
226
+ photo_refs: Optional[list[str]] = None,
227
+ ) -> dict:
228
+ """Build a complete journal entry dict matching the journal schema.
229
+
230
+ Args:
231
+ transcript: Journal transcript text.
232
+ session_id: Game session identifier.
233
+ team_id: Team identifier.
234
+ task_id: Optional associated task ID.
235
+ location_note: Where the entry was recorded.
236
+ photo_refs: List of photo identifiers to attach.
237
+
238
+ Returns:
239
+ Full journal entry dict.
240
+ """
241
+ mood = detect_mood(transcript)
242
+
243
+ entry = {
244
+ "journal_id": str(uuid.uuid4()),
245
+ "timestamp": datetime.now(timezone.utc).isoformat(),
246
+ "session_id": session_id,
247
+ "team_id": team_id,
248
+ "transcript": transcript,
249
+ "mood": mood,
250
+ "location_note": location_note or "Unknown location",
251
+ "photo_refs": photo_refs or [],
252
+ }
253
+
254
+ if task_id:
255
+ entry["task_id"] = task_id
256
+
257
+ return entry
258
+
259
+
260
+ def save_journal_entry(entry: dict, log_dir: str = "app/logs") -> dict:
261
+ """Persist a journal entry to a JSONL file.
262
+
263
+ Args:
264
+ entry: Journal entry dict (as returned by ``create_journal_entry``).
265
+ log_dir: Directory to store logs.
266
+
267
+ Returns:
268
+ The same entry dict for chaining.
269
+ """
270
+ log_path = Path(log_dir)
271
+ log_path.mkdir(parents=True, exist_ok=True)
272
+ journal_file = log_path / "journals.jsonl"
273
+
274
+ with open(journal_file, "a", encoding="utf-8") as fh:
275
+ fh.write(json.dumps(entry, ensure_ascii=False) + "\n")
276
+
277
+ return entry
278
+
279
+
280
+ def load_journal_entries(
281
+ session_id: Optional[str] = None, log_dir: str = "app/logs"
282
+ ) -> list[dict]:
283
+ """Load journal entries from the JSONL log, optionally by session.
284
+
285
+ Args:
286
+ session_id: If provided, only return entries for this session.
287
+ log_dir: Directory containing journal logs.
288
+
289
+ Returns:
290
+ List of journal entry dicts.
291
+ """
292
+ journal_file = Path(log_dir) / "journals.jsonl"
293
+ if not journal_file.exists():
294
+ return []
295
+
296
+ entries: list[dict] = []
297
+ with open(journal_file, "r", encoding="utf-8") as fh:
298
+ for line in fh:
299
+ line = line.strip()
300
+ if not line:
301
+ continue
302
+ try:
303
+ entry = json.loads(line)
304
+ except json.JSONDecodeError:
305
+ continue
306
+ if session_id and entry.get("session_id") != session_id:
307
+ continue
308
+ entries.append(entry)
309
+
310
+ return entries
app/services/scoring.py CHANGED
@@ -1,21 +1,217 @@
1
- """Deterministic scoring module."""
2
 
3
- from typing import Any
 
 
 
4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
 
6
  def compute_scores(events: list[dict], game: dict) -> dict:
7
  """Compute final scores from gameplay events.
8
-
 
 
 
 
 
 
 
 
 
 
 
9
  Args:
10
- events: List of gameplay events
11
- game: The original game definition
12
-
13
  Returns:
14
- Scoring output with team scores and winner
 
15
  """
16
- # TODO: Implement deterministic scoring
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
17
  return {
18
- "team_scores": [],
19
- "winner": None,
20
- "scoring_explanation": []
21
  }
 
1
+ """Deterministic scoring module.
2
 
3
+ Computes team scores entirely in Python — never delegated to an LLM.
4
+ Score breakdown is transparent and stored in ``scoring_explanation`` so
5
+ it can be displayed in the UI and included in story packets.
6
+ """
7
 
8
+ from datetime import datetime, timezone
9
+ from typing import Optional
10
+
11
+
12
+ # ── Constants ───────────────────────────────────────────────────────────────
13
+ HINT_PENALTY = 5 # Points deducted per hint used
14
+ FAST_FINISH_BONUS = 20 # Bonus for completing all tasks before time expires
15
+ COMPLETION_RATIO_BONUS = 10 # Bonus for completing >75 % of tasks
16
+ MAX_TIME_BONUS = 15 # Max bonus points for finishing early
17
+
18
+
19
+ # ── Internal helpers ────────────────────────────────────────────────────────
20
+
21
+ def _build_task_map(game: dict) -> dict[str, dict]:
22
+ """Index tasks by ``task_id`` for O(1) lookups."""
23
+ return {t["task_id"]: t for t in game.get("tasks", [])}
24
+
25
+
26
+ def _parse_ts(ts_str: str) -> Optional[datetime]:
27
+ """Parse an ISO-8601 timestamp string, returning None on failure."""
28
+ try:
29
+ # Handle both Z suffix and +00:00
30
+ ts = ts_str.replace("Z", "+00:00")
31
+ return datetime.fromisoformat(ts)
32
+ except (ValueError, TypeError):
33
+ return None
34
+
35
+
36
+ def _seconds_between(start: str, end: str) -> Optional[float]:
37
+ """Return seconds between two ISO-8601 timestamps, or None."""
38
+ dt_start = _parse_ts(start)
39
+ dt_end = _parse_ts(end)
40
+ if dt_start and dt_end:
41
+ return max(0, (dt_end - dt_start).total_seconds())
42
+ return None
43
+
44
+
45
+ # ── Public API ──────────────────────────────────────────────────────────────
46
 
47
  def compute_scores(events: list[dict], game: dict) -> dict:
48
  """Compute final scores from gameplay events.
49
+
50
+ Scoring logic:
51
+ • Each ``task_completed`` awards the task's base ``points``.
52
+ • Each ``hint_used`` deducts ``HINT_PENALTY`` points.
53
+ • ``task_skipped`` awards 0 points for that task.
54
+ • ``fast_finish`` bonus: +``FAST_FINISH_BONUS`` if all tasks are
55
+ completed before the game's duration elapses.
56
+ • ``completion_ratio`` bonus: +``COMPLETION_RATIO_BONUS`` if the
57
+ team completed more than 75 % of tasks.
58
+ • ``time_bonus``: up to ``MAX_TIME_TIME_BONUS`` points for finishing
59
+ early, scaled linearly by how much time remains.
60
+
61
  Args:
62
+ events: List of gameplay event dicts.
63
+ game: The original game definition (with tasks, setup, etc.).
64
+
65
  Returns:
66
+ Scoring output dict matching the ``event_schema`` score contract:
67
+ ``{"team_scores": [...], "winner": str, "scoring_explanation": [...]}``
68
  """
69
+ task_map = _build_task_map(game)
70
+ duration_seconds = game.get("setup", {}).get("duration_minutes", 45) * 60
71
+
72
+ # ── Collect per-team data ───────────────────────────────────────────
73
+ team_data: dict[str, dict] = {}
74
+
75
+ for ev in events:
76
+ team_id = ev.get("team_id", "team-a")
77
+ if team_id not in team_data:
78
+ team_data[team_id] = {
79
+ "completed_tasks": [],
80
+ "skipped_tasks": [],
81
+ "hints_used": 0,
82
+ "photos_uploaded": 0,
83
+ "journals": [],
84
+ "first_event_ts": ev.get("timestamp"),
85
+ "last_event_ts": ev.get("timestamp"),
86
+ }
87
+
88
+ td = team_data[team_id]
89
+ ev_type = ev.get("event_type")
90
+ payload = ev.get("payload", {})
91
+ ts = ev.get("timestamp", "")
92
+
93
+ # Track time window
94
+ if ts < td["first_event_ts"]:
95
+ td["first_event_ts"] = ts
96
+ if ts > td["last_event_ts"]:
97
+ td["last_event_ts"] = ts
98
+
99
+ if ev_type == "task_completed":
100
+ task_id = payload.get("task_id")
101
+ if task_id and task_id not in td["completed_tasks"]:
102
+ td["completed_tasks"].append(task_id)
103
+
104
+ elif ev_type == "task_skipped":
105
+ task_id = payload.get("task_id")
106
+ if task_id and task_id not in td["skipped_tasks"]:
107
+ td["skipped_tasks"].append(task_id)
108
+
109
+ elif ev_type == "hint_used":
110
+ td["hints_used"] += 1
111
+
112
+ elif ev_type == "photo_uploaded":
113
+ td["photos_uploaded"] += 1
114
+
115
+ elif ev_type == "journal_recorded":
116
+ td["journals"].append(payload)
117
+
118
+ # ── Score each team ─────────────────────────────────────────────────
119
+ all_tasks = game.get("tasks", [])
120
+ total_possible_tasks = len(all_tasks)
121
+ team_scores: list[dict] = []
122
+ explanations: list[str] = []
123
+
124
+ for team_id, td in team_data.items():
125
+ base_points = 0
126
+ for task_id in td["completed_tasks"]:
127
+ task = task_map.get(task_id, {})
128
+ pts = task.get("points", 0)
129
+ base_points += pts
130
+
131
+ # Hint penalty
132
+ hint_penalty = td["hints_used"] * HINT_PENALTY
133
+
134
+ # Completion ratio
135
+ completed_count = len(td["completed_tasks"])
136
+ completion_ratio = completed_count / total_possible_tasks if total_possible_tasks else 0
137
+
138
+ # Time bonus
139
+ elapsed = _seconds_between(td["first_event_ts"], td["last_event_ts"])
140
+ time_bonus = 0
141
+ if elapsed is not None and elapsed < duration_seconds:
142
+ remaining_ratio = 1 - (elapsed / duration_seconds)
143
+ time_bonus = min(MAX_TIME_BONUS, round(remaining_ratio * MAX_TIME_BONUS))
144
+
145
+ # Fast-finish bonus
146
+ fast_finish = FAST_FINISH_BONUS if completed_count >= total_possible_tasks and elapsed is not None and elapsed < duration_seconds else 0
147
+
148
+ # Completion-ratio bonus
149
+ completion_bonus = COMPLETION_RATIO_BONUS if completion_ratio > 0.75 else 0
150
+
151
+ total_points = max(0, base_points - hint_penalty + time_bonus + fast_finish + completion_bonus)
152
+
153
+ # Build per-team explanation
154
+ team_explanation = [
155
+ f"Base points from {completed_count} completed tasks: +{base_points}",
156
+ f"Hints used: {td['hints_used']} × {HINT_PENALTY} penalty = -{hint_penalty}",
157
+ ]
158
+ if time_bonus > 0:
159
+ team_explanation.append(f"Time bonus for finishing early: +{time_bonus}")
160
+ if fast_finish > 0:
161
+ team_explanation.append(f"Fast-finish bonus (all tasks): +{fast_finish}")
162
+ if completion_bonus > 0:
163
+ team_explanation.append(f"Completion ratio bonus (>75%): +{completion_bonus}")
164
+ team_explanation.append(f"Final total: {total_points}")
165
+
166
+ bonuses = []
167
+ if fast_finish > 0:
168
+ bonuses.append("fast_finish")
169
+ if completion_bonus > 0:
170
+ bonuses.append("completion_ratio")
171
+
172
+ team_scores.append({
173
+ "team_id": team_id,
174
+ "points": total_points,
175
+ "base_points": base_points,
176
+ "hint_penalty": hint_penalty,
177
+ "time_bonus": time_bonus,
178
+ "fast_finish_bonus": fast_finish,
179
+ "completion_bonus": completion_bonus,
180
+ "completed_tasks": completed_count,
181
+ "total_tasks": total_possible_tasks,
182
+ "hints_used": td["hints_used"],
183
+ "bonuses": bonuses,
184
+ "scoring_breakdown": team_explanation,
185
+ })
186
+
187
+ explanations.append(f"Team {team_id}: {total_points} pts ({completed_count}/{total_possible_tasks} tasks)")
188
+
189
+ # ── Determine winner ────────────────────────────────────────────────
190
+ winner: Optional[str] = None
191
+ if team_scores:
192
+ # Sort descending by points; tie-break by fewer hints
193
+ ranked = sorted(
194
+ team_scores,
195
+ key=lambda t: (t["points"], -t["hints_used"]),
196
+ reverse=True,
197
+ )
198
+ winner = ranked[0]["team_id"]
199
+
200
+ if len(ranked) > 1 and ranked[0]["points"] == ranked[1]["points"]:
201
+ explanations.append(
202
+ f"⚠ Tie between teams with {ranked[0]['points']} pts — "
203
+ "tie-breaker: fewer hints used wins."
204
+ )
205
+ # Re-rank with tie-breaker
206
+ ranked_tied = sorted(
207
+ ranked,
208
+ key=lambda t: t["hints_used"],
209
+ )
210
+ winner = ranked_tied[0]["team_id"]
211
+ explanations.append(f"Tie-break winner: {winner} (fewer hints)")
212
+
213
  return {
214
+ "team_scores": team_scores,
215
+ "winner": winner,
216
+ "scoring_explanation": explanations,
217
  }
app/services/story.py CHANGED
@@ -1,22 +1,350 @@
1
- """Final story and recap generation module."""
2
 
3
- from typing import Any
 
4
 
 
 
 
5
 
6
- def generate_story(story_packet: dict) -> dict:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7
  """Generate final recap story from game data and events.
8
-
9
- Uses OpenBMB MiniCPM5-1B or similar model.
10
-
 
11
  Args:
12
- story_packet: Structured packet with game info, scores, journals, photos
13
-
 
14
  Returns:
15
- Story output with short recap, long-form summary, and poster prompt
 
16
  """
17
- # TODO: Implement with OpenBMB model
18
- return {
19
- "short_recap": "",
20
- "long_summary": "",
21
- "poster_prompt": ""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Final story and recap generation module.
2
 
3
+ Builds a compact *story packet* from verified runtime data (game config,
4
+ events, scores, journals) and produces three artefacts:
5
 
6
+ 1. ``short_recap`` – a punchy paragraph for the result screen.
7
+ 2. ``long_summary`` – a 400-600 word episode recap.
8
+ 3. ``poster_prompt`` – a visual prompt for image generation.
9
 
10
+ When no LLM is available, a high-quality template-based recap is generated
11
+ so the pipeline always returns a visible result.
12
+ """
13
+
14
+ import json
15
+ from datetime import datetime, timezone
16
+ from pathlib import Path
17
+ from typing import Optional
18
+
19
+ from app.services.tracing import log_event
20
+
21
+
22
+ # ── Story packet builder ───────────────────────────────────────────────────
23
+
24
+ def build_story_packet(
25
+ game: dict,
26
+ events: list[dict],
27
+ scores: dict,
28
+ journal_entries: Optional[list[dict]] = None,
29
+ photo_captions: Optional[list[dict]] = None,
30
+ ) -> dict:
31
+ """Assemble a compact, verified story packet from runtime data.
32
+
33
+ The packet contains **only facts** — no fabricated locations or quotes.
34
+ This is the sole input to the recap generation step.
35
+
36
+ Args:
37
+ game: The generated game definition dict.
38
+ events: Full gameplay event stream.
39
+ scores: Output of ``scoring.compute_scores()``.
40
+ journal_entries: Optional list of journal dicts (from ``journal.py``).
41
+ photo_captions: Optional list of ``{"photo_id": ..., "caption": ...}``.
42
+
43
+ Returns:
44
+ Story packet dict matching ``story_packet_schema.json``.
45
+ """
46
+ # Task outcomes — one entry per task with completion status
47
+ task_map = {t["task_id"]: t for t in game.get("tasks", [])}
48
+ completed_ids: set[str] = set()
49
+ skipped_ids: set[str] = set()
50
+
51
+ for ev in events:
52
+ ev_type = ev.get("event_type")
53
+ payload = ev.get("payload", {})
54
+ tid = payload.get("task_id")
55
+ if ev_type == "task_completed" and tid:
56
+ completed_ids.add(tid)
57
+ elif ev_type == "task_skipped" and tid:
58
+ skipped_ids.add(tid)
59
+
60
+ task_outcomes = []
61
+ for tid, task in task_map.items():
62
+ if tid in completed_ids:
63
+ status = "completed"
64
+ elif tid in skipped_ids:
65
+ status = "skipped"
66
+ else:
67
+ status = "incomplete"
68
+ task_outcomes.append({
69
+ "task_id": tid,
70
+ "title": task.get("title", ""),
71
+ "completed": tid in completed_ids,
72
+ "skipped": tid in skipped_ids,
73
+ "points": task.get("points", 0),
74
+ "status": status,
75
+ })
76
+
77
+ # Notable events — high-signal moments
78
+ notable_events = []
79
+ for ev in events:
80
+ ev_type = ev.get("event_type")
81
+ payload = ev.get("payload", {})
82
+ if ev_type in ("task_completed", "game_finished"):
83
+ notable_events.append({
84
+ "event_type": ev_type,
85
+ "team_id": ev.get("team_id"),
86
+ "timestamp": ev.get("timestamp"),
87
+ "summary": payload.get("summary", f"{ev_type}"),
88
+ })
89
+
90
+ # Journal moments — select high-value ones
91
+ journal_moments = []
92
+ if journal_entries:
93
+ value_rank = {"high": 3, "medium": 2, "low": 1}
94
+ sorted_journals = sorted(
95
+ journal_entries,
96
+ key=lambda j: value_rank.get(j.get("story_value", "low"), 0),
97
+ reverse=True,
98
+ )
99
+ for j in sorted_journals[:4]:
100
+ journal_moments.append({
101
+ "journal_id": j.get("journal_id"),
102
+ "moment_summary": j.get("moment_summary", j.get("transcript", "")[:120]),
103
+ "mood": j.get("mood"),
104
+ "tags": j.get("tags", []),
105
+ "story_value": j.get("story_value", "low"),
106
+ })
107
+
108
+ winner = scores.get("winner")
109
+
110
+ packet = {
111
+ "game_info": {
112
+ "game_id": game.get("game_id"),
113
+ "title": game.get("title"),
114
+ "theme": game.get("theme"),
115
+ "city": game.get("setup", {}).get("city"),
116
+ "area": game.get("setup", {}).get("area"),
117
+ "duration_minutes": game.get("setup", {}).get("duration_minutes"),
118
+ "num_players": game.get("setup", {}).get("num_players"),
119
+ },
120
+ "winner": winner,
121
+ "final_scores": scores.get("team_scores", []),
122
+ "task_outcomes": task_outcomes,
123
+ "journal_moments": journal_moments,
124
+ "photo_captions": photo_captions or [],
125
+ "notable_events": notable_events,
126
+ "story_style": game.get("story_seed", {}).get("recap_style", "episode_recap"),
127
+ }
128
+
129
+ return packet
130
+
131
+
132
+ # ── Template-based recap (works offline) ───────────────────────────────────
133
+
134
+ def _template_short_recap(packet: dict) -> str:
135
+ """Generate a short recap from the story packet without an LLM."""
136
+ title = packet.get("game_info", {}).get("title", "The Game")
137
+ winner = packet.get("winner", "nobody")
138
+ total_tasks = len(packet.get("task_outcomes", []))
139
+ completed = sum(
140
+ 1 for t in packet.get("task_outcomes", []) if t.get("completed")
141
+ )
142
+ score_entries = packet.get("final_scores", [])
143
+ top_score = score_entries[0]["points"] if score_entries else 0
144
+
145
+ decisive = None
146
+ for t in packet.get("task_outcomes", []):
147
+ if t.get("completed"):
148
+ decisive = t.get("title")
149
+
150
+ parts = [
151
+ f"🏆 **{title}** is in the books!",
152
+ f"Team **{winner}** took the crown with **{top_score}** points, "
153
+ f"completing {completed}/{total_tasks} tasks.",
154
+ ]
155
+
156
+ if decisive:
157
+ parts.append(f"The decisive moment came during *{decisive}*.")
158
+
159
+ moments = packet.get("journal_moments", [])
160
+ if moments:
161
+ m = moments[0]
162
+ mood = m.get("mood", "excited")
163
+ summary = m.get("moment_summary", "")[:100]
164
+ parts.append(f"A [{mood}] highlight: \"{summary}\"")
165
+
166
+ return "\n\n".join(parts)
167
+
168
+
169
+ def _template_long_summary(packet: dict) -> str:
170
+ """Generate a full episode recap from the story packet without an LLM."""
171
+ info = packet.get("game_info", {})
172
+ title = info.get("title", "The Game")
173
+ city = info.get("city", "the city")
174
+ area = info.get("area", "the area")
175
+ duration = info.get("duration_minutes", 45)
176
+ num_players = info.get("num_players", 4)
177
+ winner = packet.get("winner", "nobody")
178
+
179
+ lines = [
180
+ f"# {title}",
181
+ "",
182
+ f"In the heart of **{city}**, specifically around **{area}**, "
183
+ f"{num_players} players gathered for a {duration}-minute showdown.",
184
+ "",
185
+ "## How it went down",
186
+ "",
187
+ ]
188
+
189
+ for t in packet.get("task_outcomes", []):
190
+ status = t.get("status", "incomplete")
191
+ icon = "✅" if status == "completed" else "⏭️" if status == "skipped" else "❌"
192
+ lines.append(f"- {icon} **{t.get('title', 'Unknown')}** — {t.get('points', 0)} pts [{status}]")
193
+
194
+ moments = packet.get("journal_moments", [])
195
+ if moments:
196
+ lines.extend(["", "## Memorable moments", ""])
197
+ for m in moments:
198
+ mood = m.get("mood", "")
199
+ lines.append(f"- 🎙️ *{mood}*: \"{m.get('moment_summary', '')}\"")
200
+
201
+ lines.extend(["", "## Final standings", ""])
202
+ for s in packet.get("final_scores", []):
203
+ marker = " 🏆" if s.get("team_id") == winner else ""
204
+ lines.append(
205
+ f"- **{s.get('team_id', 'team')}**: {s.get('points', 0)} pts "
206
+ f"({s.get('completed_tasks', 0)}/{s.get('total_tasks', 0)} tasks){marker}"
207
+ )
208
+
209
+ lines.extend(["", "### Scoring breakdown"])
210
+ for s in packet.get("final_scores", []):
211
+ for line in s.get("scoring_breakdown", []):
212
+ lines.append(f" - {line}")
213
+
214
+ lines.extend([
215
+ "",
216
+ "---",
217
+ f"*Generated by GeoChase AI Pipeline — {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M UTC')}*",
218
+ ])
219
+
220
+ return "\n".join(lines)
221
+
222
+
223
+ def _template_poster_prompt(packet: dict) -> str:
224
+ """Generate an image-generation prompt for a recap poster."""
225
+ info = packet.get("game_info", {})
226
+ winner = packet.get("winner", "team-a")
227
+ title = info.get("title", "GeoChase Game")
228
+ city = info.get("city", "Paris")
229
+ area = info.get("area", "the city center")
230
+ mood = "wholesome"
231
+ moments = packet.get("journal_moments", [])
232
+ if moments:
233
+ mood = moments[0].get("mood", "wholesome")
234
+
235
+ return (
236
+ f"Cinematic recap poster for an urban real-world game called '{title}'. "
237
+ f"Scene: a group of {info.get('num_players', 4)} friends exploring "
238
+ f"{area} in {city}. Style: warm lighting, slightly nostalgic film grain, "
239
+ f"vibrant but not oversaturated. Mood: {mood}. "
240
+ f"Include subtle game UI overlays (score badge, timer). "
241
+ f"Team {winner} celebrating. No text in the image."
242
+ )
243
+
244
+
245
+ # ── LLM-based recap ────────────────────────────────────────────────────────
246
+
247
+ def _llm_recap(prompt_template: str, story_packet: dict) -> Optional[dict]:
248
+ """Attempt to generate a recap using the Nemotron LLM via llama-cpp-python.
249
+
250
+ Returns None if the model is unavailable so callers can fall back to
251
+ the template engine.
252
+ """
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,
263
+ )
264
+
265
+ packet_str = json.dumps(story_packet, indent=2, default=str)[:3000]
266
+ prompt = prompt_template.format(story_packet=packet_str)
267
+
268
+ result = llm(
269
+ f"You are a talented narrative writer. Return ONLY valid JSON.\n\n{prompt}",
270
+ max_tokens=2000,
271
+ temperature=0.8,
272
+ top_p=0.95,
273
+ stop=["```", "\n\n\n"],
274
+ )
275
+
276
+ text = result["choices"][0]["text"]
277
+ start = text.find("{")
278
+ if start == -1:
279
+ return None
280
+
281
+ depth = 0
282
+ for i in range(start, len(text)):
283
+ if text[i] == "{":
284
+ depth += 1
285
+ elif text[i] == "}":
286
+ depth -= 1
287
+ if depth == 0:
288
+ return json.loads(text[start : i + 1])
289
+
290
+ return None
291
+
292
+ except Exception as exc:
293
+ print(f"[story] LLM recap failed: {exc}")
294
+ return None
295
+
296
+
297
+ # ── Main entry point ────────────────────────────────────────────────────────
298
+
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.
307
+ session_id: Optional session ID for logging the recap event.
308
+
309
  Returns:
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"]
325
+ long_summary = llm_result["long_summary"]
326
+ poster_prompt = llm_result["poster_prompt"]
327
+ else:
328
+ short_recap = _template_short_recap(story_packet)
329
+ long_summary = _template_long_summary(story_packet)
330
+ poster_prompt = _template_poster_prompt(story_packet)
331
+
332
+ result = {
333
+ "short_recap": short_recap,
334
+ "long_summary": long_summary,
335
+ "poster_prompt": poster_prompt,
336
+ "story_packet": story_packet,
337
  }
338
+
339
+ if session_id:
340
+ log_event(
341
+ session_id=session_id,
342
+ event_type="game_finished",
343
+ payload={
344
+ "short_recap": short_recap[:200],
345
+ "winner": story_packet.get("winner"),
346
+ "total_tasks": len(story_packet.get("task_outcomes", [])),
347
+ },
348
+ )
349
+
350
+ return result
app/services/tracing.py CHANGED
@@ -1,29 +1,174 @@
1
- """Logging and tracing module for pipeline transparency."""
 
 
 
 
2
 
3
  import json
 
 
4
  from pathlib import Path
5
- from typing import Any
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7
 
8
- def log_event(session_id: str, event_type: str, payload: dict, log_dir: str = "app/logs") -> None:
9
- """Log a gameplay event to JSONL file.
10
-
11
  Args:
12
- session_id: Session identifier
13
- event_type: Type of event (task_revealed, completed, etc.)
14
- payload: Event data
15
- log_dir: Directory to store logs
 
 
 
 
 
 
 
16
  """
17
- # TODO: Implement JSONL logging
18
- pass
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
 
20
 
21
  def save_trace(trace_data: dict, output_path: str) -> None:
22
  """Save a complete pipeline trace for debugging and publication.
23
-
24
  Args:
25
- trace_data: Dictionary containing all pipeline data
26
- output_path: Where to save the trace file
27
  """
28
- # TODO: Implement trace saving
29
- pass
 
 
 
1
+ """Logging and tracing module for pipeline transparency.
2
+
3
+ Stores JSONL event logs and complete pipeline traces for debugging,
4
+ demo narration, and possible trace publication.
5
+ """
6
 
7
  import json
8
+ import uuid
9
+ from datetime import datetime, timezone
10
  from pathlib import Path
11
+ from typing import Any, Optional
12
+
13
+
14
+ # Valid event types matching the event schema
15
+ VALID_EVENT_TYPES = [
16
+ "task_revealed",
17
+ "task_completed",
18
+ "hint_used",
19
+ "task_skipped",
20
+ "photo_uploaded",
21
+ "journal_recorded",
22
+ "score_updated",
23
+ "game_finished",
24
+ ]
25
+
26
+
27
+ def _ensure_log_dir(log_dir: str) -> Path:
28
+ """Ensure the log directory exists and return its Path."""
29
+ path = Path(log_dir)
30
+ path.mkdir(parents=True, exist_ok=True)
31
+ return path
32
+
33
+
34
+ def _now_iso() -> str:
35
+ """Return current time as ISO-8601 string."""
36
+ return datetime.now(timezone.utc).isoformat()
37
+
38
+
39
+ def log_event(
40
+ session_id: str,
41
+ event_type: str,
42
+ payload: dict,
43
+ team_id: str = "team-a",
44
+ log_dir: str = "app/logs",
45
+ ) -> dict:
46
+ """Log a gameplay event to a JSONL file.
47
+
48
+ Each event is appended as a single JSON line to ``events.jsonl`` inside
49
+ *log_dir*. Returns the full event dict that was written so callers can
50
+ pass it onward (e.g. into the scoring pipeline).
51
+
52
+ Args:
53
+ session_id: Session identifier.
54
+ event_type: One of the ``VALID_EVENT_TYPES``.
55
+ payload: Event-specific data dictionary.
56
+ team_id: Team that generated the event (default ``"team-a"``).
57
+ log_dir: Directory to store logs.
58
+
59
+ Returns:
60
+ The complete event dict that was persisted.
61
+
62
+ Raises:
63
+ ValueError: If *event_type* is not in the allowed set.
64
+ """
65
+ if event_type not in VALID_EVENT_TYPES:
66
+ raise ValueError(
67
+ f"Invalid event_type '{event_type}'. "
68
+ f"Must be one of: {VALID_EVENT_TYPES}"
69
+ )
70
+
71
+ event = {
72
+ "event_id": str(uuid.uuid4()),
73
+ "timestamp": _now_iso(),
74
+ "session_id": session_id,
75
+ "team_id": team_id,
76
+ "event_type": event_type,
77
+ "payload": payload,
78
+ }
79
 
80
+ log_path = _ensure_log_dir(log_dir) / "events.jsonl"
81
+ with open(log_path, "a", encoding="utf-8") as fh:
82
+ fh.write(json.dumps(event, ensure_ascii=False) + "\n")
83
+
84
+ return event
85
+
86
+
87
+ def load_events(session_id: Optional[str] = None, log_dir: str = "app/logs") -> list[dict]:
88
+ """Load events from the JSONL log file, optionally filtered by session.
89
+
90
+ Args:
91
+ session_id: If provided, only return events for this session.
92
+ log_dir: Directory containing event logs.
93
+
94
+ Returns:
95
+ List of event dicts.
96
+ """
97
+ log_path = Path(log_dir) / "events.jsonl"
98
+ if not log_path.exists():
99
+ return []
100
+
101
+ events: list[dict] = []
102
+ with open(log_path, "r", encoding="utf-8") as fh:
103
+ for line in fh:
104
+ line = line.strip()
105
+ if not line:
106
+ continue
107
+ try:
108
+ event = json.loads(line)
109
+ except json.JSONDecodeError:
110
+ continue
111
+ if session_id and event.get("session_id") != session_id:
112
+ continue
113
+ events.append(event)
114
+
115
+ return events
116
+
117
+
118
+ def log_generation_trace(
119
+ session_id: str,
120
+ config: dict,
121
+ retrieved_examples: list[dict],
122
+ game: dict,
123
+ validation_passed: bool,
124
+ validation_failures: list[str],
125
+ repaired_game: Optional[dict] = None,
126
+ log_dir: str = "app/logs",
127
+ ) -> dict:
128
+ """Log the full generation pipeline trace for one session.
129
+
130
+ Captures config → retrieval → generation → validation → repair in a
131
+ single JSON file for debugging and publication.
132
 
 
 
 
133
  Args:
134
+ session_id: Session identifier.
135
+ config: User-provided game configuration.
136
+ retrieved_examples: Examples retrieved by the grounding module.
137
+ game: Generated game JSON.
138
+ validation_passed: Whether the game passed validation.
139
+ validation_failures: Any validation failure messages.
140
+ repaired_game: Post-repair game dict, or None if no repair was needed.
141
+ log_dir: Directory to store logs.
142
+
143
+ Returns:
144
+ The complete trace dict.
145
  """
146
+ trace = {
147
+ "session_id": session_id,
148
+ "timestamp": _now_iso(),
149
+ "config": config,
150
+ "retrieved_examples": retrieved_examples,
151
+ "generated_game": game,
152
+ "validation_passed": validation_passed,
153
+ "validation_failures": validation_failures,
154
+ "repaired_game": repaired_game,
155
+ }
156
+
157
+ trace_path = _ensure_log_dir(log_dir) / f"trace_{session_id}.json"
158
+ with open(trace_path, "w", encoding="utf-8") as fh:
159
+ json.dump(trace, fh, indent=2, ensure_ascii=False)
160
+
161
+ return trace
162
 
163
 
164
  def save_trace(trace_data: dict, output_path: str) -> None:
165
  """Save a complete pipeline trace for debugging and publication.
166
+
167
  Args:
168
+ trace_data: Dictionary containing all pipeline data.
169
+ output_path: Where to save the trace file.
170
  """
171
+ out = Path(output_path)
172
+ out.parent.mkdir(parents=True, exist_ok=True)
173
+ with open(out, "w", encoding="utf-8") as fh:
174
+ json.dump(trace_data, fh, indent=2, ensure_ascii=False)
test_phase3.py ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """End-to-end test for Phase 3: Session Intelligence."""
2
+
3
+ import uuid
4
+ from app.services.retrieval import load_games_dataset, normalize_game_record, retrieve_examples
5
+ from app.services.generator import generate_game
6
+ from app.services.validator import validate_game, repair_game
7
+ from app.services.tracing import log_event, load_events
8
+ from app.services.journal import create_journal_entry, save_journal_entry, summarize_journal, load_journal_entries
9
+ from app.services.scoring import compute_scores
10
+ from app.services.story import build_story_packet, generate_story
11
+
12
+ # 1. Load dataset
13
+ raw = load_games_dataset("app/data/games_dataset.json")
14
+ records = [normalize_game_record(r) for r in raw]
15
+ print(f"✓ Loaded {len(records)} records")
16
+
17
+ # 2. Config
18
+ config = {
19
+ "game_type": "scavenger_hunt",
20
+ "city": "Paris",
21
+ "area": "Le Marais",
22
+ "location_type": "mixed",
23
+ "duration_minutes": 60,
24
+ "num_players": 4,
25
+ "difficulty": "medium",
26
+ "age_group": "adults",
27
+ "energy_level": "medium",
28
+ "photo_enabled": True,
29
+ }
30
+
31
+ # 3. Retrieve + Generate
32
+ retrieved = retrieve_examples(config, records, k=3)
33
+ game = generate_game(config, retrieved)
34
+ print(f"✓ Generated: {game['title']} with {len(game['tasks'])} tasks")
35
+
36
+ # 4. Validate
37
+ is_valid, failures = validate_game(game, config)
38
+ status = "PASS" if is_valid else f"FAIL ({len(failures)} issues)"
39
+ print(f"✓ Validation: {status}")
40
+
41
+ # 5. Session
42
+ session_id = f"test-{uuid.uuid4().hex[:8]}"
43
+
44
+ # 6. Log events
45
+ for t in game["tasks"]:
46
+ log_event(session_id, "task_revealed", {"task_id": t["task_id"], "title": t["title"]})
47
+ log_event(session_id, "task_completed", {"task_id": "t1", "summary": "Completed first task"}, team_id="team-a")
48
+ log_event(session_id, "task_completed", {"task_id": "t2", "summary": "Completed second task"}, team_id="team-a")
49
+ log_event(session_id, "hint_used", {"task_id": "t2", "summary": "Used hint"}, team_id="team-a")
50
+ log_event(session_id, "task_skipped", {"task_id": "t3", "summary": "Skipped"}, team_id="team-a")
51
+ events = load_events(session_id)
52
+ print(f"✓ Logged {len(events)} events")
53
+
54
+ # 7. Journal
55
+ entry = create_journal_entry(
56
+ transcript="We found the mural near the canal, it was incredible! The whole team cheered.",
57
+ session_id=session_id,
58
+ team_id="team-a",
59
+ task_id="t1",
60
+ location_note="Canal area",
61
+ )
62
+ summary = summarize_journal(entry["transcript"], task_id="t1")
63
+ entry.update(summary)
64
+ save_journal_entry(entry)
65
+ journals = load_journal_entries(session_id)
66
+ print(f"✓ Journal mood={entry['mood']}, story_value={entry['story_value']}")
67
+
68
+ # 8. Score
69
+ scores = compute_scores(events, game)
70
+ print(f"✓ Winner: {scores['winner']}")
71
+ for s in scores["team_scores"]:
72
+ print(f" - {s['team_id']}: {s['points']} pts ({s['completed_tasks']}/{s['total_tasks']} tasks)")
73
+
74
+ # 9. Story
75
+ packet = build_story_packet(game, events, scores, journals)
76
+ story = generate_story(packet, session_id=session_id)
77
+ print(f"✓ Story recap: {len(story['short_recap'])} chars short, {len(story['long_summary'])} chars long")
78
+
79
+ print()
80
+ print("=== SHORT RECAP ===")
81
+ print(story["short_recap"])
82
+ print()
83
+ print("=== PIPELINE COMPLETE ✓ ===")