Commit ·
3ee7cb0
1
Parent(s): e9fc2fc
Phase 3: add live session intelligence
Browse files- .gitignore +4 -0
- app.py +455 -73
- app/services/journal.py +294 -18
- app/services/scoring.py +207 -11
- app/services/story.py +342 -14
- app/services/tracing.py +161 -16
- 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 = {}
|
| 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 ──
|
| 88 |
-
|
| 89 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 90 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 91 |
|
| 92 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 174 |
"""
|
| 175 |
)
|
| 176 |
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
label="
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 230 |
generate_btn.click(
|
| 231 |
fn=run_pipeline,
|
| 232 |
inputs=[
|
| 233 |
-
game_type,
|
| 234 |
-
|
| 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 ·
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
return ""
|
| 17 |
|
| 18 |
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
Args:
|
| 23 |
-
transcript: Journal transcript text
|
| 24 |
-
task_id: Optional associated task ID
|
| 25 |
-
|
|
|
|
| 26 |
Returns:
|
| 27 |
-
|
| 28 |
"""
|
| 29 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
return {
|
| 31 |
-
"moment_summary":
|
| 32 |
-
"tags":
|
| 33 |
-
"story_value":
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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
|
| 11 |
-
game: The original game definition
|
| 12 |
-
|
| 13 |
Returns:
|
| 14 |
-
Scoring output
|
|
|
|
| 15 |
"""
|
| 16 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
return {
|
| 18 |
-
"team_scores":
|
| 19 |
-
"winner":
|
| 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
|
|
|
|
| 4 |
|
|
|
|
|
|
|
|
|
|
| 5 |
|
| 6 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
"""Generate final recap story from game data and events.
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
|
|
|
| 11 |
Args:
|
| 12 |
-
story_packet: Structured packet with game info, scores, journals, photos
|
| 13 |
-
|
|
|
|
| 14 |
Returns:
|
| 15 |
-
Story output with
|
|
|
|
| 16 |
"""
|
| 17 |
-
#
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 14 |
-
|
| 15 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
"""
|
| 17 |
-
|
| 18 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 29 |
-
|
|
|
|
|
|
|
|
|
| 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 ✓ ===")
|