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

Phase 2: ship the core happy path

Browse files
NEMOTRON_GGUF_SETUP.md ADDED
@@ -0,0 +1,151 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # NVIDIA Nemotron 3 Nano 4B GGUF Setup Guide
2
+
3
+ ## Overview
4
+
5
+ The game generation system uses **NVIDIA Nemotron 3 Nano 4B** in GGUF quantized format, optimized for inference via **llama.cpp**.
6
+
7
+ ### Why this configuration?
8
+
9
+ 1. **Hackathon bonus** — llama.cpp runtime gives extra credit
10
+ 2. **Memory efficient** — GGUF 4-bit quantization reduces model size to ~2.5GB
11
+ 3. **Performance** — llama.cpp provides fast CPU and GPU inference
12
+ 4. **Quality** — Nemotron 3 Nano 4B is NVIDIA's optimized chat model
13
+ 5. **Sponsor visibility** — NVIDIA + llama.cpp integration
14
+
15
+ ## Installation
16
+
17
+ ### Step 1: Install llama-cpp-python
18
+
19
+ **For CPU inference:**
20
+ ```bash
21
+ pip install llama-cpp-python
22
+ ```
23
+
24
+ **For GPU inference (CUDA 11.8+):**
25
+ ```bash
26
+ CMAKE_ARGS="-DLLAMA_CUDA=on" pip install llama-cpp-python
27
+ ```
28
+
29
+ **For GPU (Metal on macOS):**
30
+ ```bash
31
+ CMAKE_ARGS="-DLLAMA_METAL=on" pip install llama-cpp-python
32
+ ```
33
+
34
+ ### Step 2: Verify installation
35
+
36
+ ```bash
37
+ python -c "from llama_cpp import Llama; print('✓ llama-cpp-python installed')"
38
+ ```
39
+
40
+ ## Model Details
41
+
42
+ - **Model ID:** `nvidia/NVIDIA-Nemotron-3-Nano-4B-GGUF`
43
+ - **Repository:** https://huggingface.co/nvidia/NVIDIA-Nemotron-3-Nano-4B-GGUF
44
+ - **File:** `model.gguf`
45
+ - **Size:** ~2.5GB (4-bit quantization)
46
+ - **Context:** 2048 tokens
47
+ - **Format:** GGUF (compatible with llama.cpp)
48
+
49
+ ## How it works
50
+
51
+ 1. **First run** — llama-cpp-python downloads the GGUF model from HuggingFace (~2.5GB)
52
+ 2. **Model caching** — Subsequent runs use the cached model (no re-download)
53
+ 3. **GPU acceleration** — If CUDA/Metal is available, llama.cpp uses GPU for faster inference
54
+ 4. **Fallback** — If llama-cpp-python unavailable, system uses mock generation
55
+
56
+ ## Usage in code
57
+
58
+ ```python
59
+ from app.services.generator import generate_game
60
+ from app.services.retrieval import retrieve_examples
61
+
62
+ # Generate a game
63
+ config = {
64
+ "game_type": "scavenger_hunt",
65
+ "city": "Paris",
66
+ "area": "Le Marais",
67
+ "duration_minutes": 60,
68
+ "num_players": 4,
69
+ "difficulty": "medium",
70
+ "age_group": "adults",
71
+ "location_type": "mixed"
72
+ }
73
+
74
+ # Retrieve similar games for grounding
75
+ retrieved = retrieve_examples(config, dataset, k=3)
76
+
77
+ # Generate game (uses llama.cpp if available, mock fallback otherwise)
78
+ game = generate_game(config, retrieved)
79
+ ```
80
+
81
+ ## Performance expectations
82
+
83
+ | Setup | First Run | Subsequent Runs | Speed |
84
+ |-------|-----------|-----------------|-------|
85
+ | CPU (no optimization) | 5-10 min | 2-5 min per game | Slow |
86
+ | CPU (quantized) | 5-10 min | 30-60s per game | Moderate |
87
+ | GPU (CUDA/Metal) | 5-10 min | 5-15s per game | Fast |
88
+
89
+ ## Troubleshooting
90
+
91
+ ### Issue: "llama-cpp-python not found"
92
+ **Solution:** Install with `pip install llama-cpp-python` or with GPU support.
93
+
94
+ ### Issue: CUDA compatibility errors
95
+ **Solution:** Check CUDA version compatibility:
96
+ ```bash
97
+ # Check CUDA version
98
+ nvidia-smi
99
+
100
+ # Install specific CUDA-compatible version
101
+ CMAKE_ARGS="-DLLAMA_CUDA=on -DLLAMA_CUDA_DATALAYOUT=row -DCUDAToolkit_INCLUDE_DIR=/path/to/cuda/include" \
102
+ pip install llama-cpp-python
103
+ ```
104
+
105
+ ### Issue: Model download stuck
106
+ **Solution:** Download manually from HuggingFace and place in `~/.cache/huggingface/hub/`
107
+
108
+ ### Issue: Out of memory
109
+ **Solution:** Reduce context window or use CPU-offloading in llama.cpp settings.
110
+
111
+ ## Integration with Hugging Face Spaces
112
+
113
+ For deployment in Hugging Face Spaces:
114
+
115
+ 1. **Requirements file includes:**
116
+ ```
117
+ llama-cpp-python
118
+ ```
119
+
120
+ 2. **Spaces environment** — HF Spaces has limited GPU memory
121
+ - Consider n_gpu_layers parameter in `generator.py`
122
+ - CPU fallback is always available
123
+
124
+ 3. **Caching strategy** — Model is cached after first download
125
+
126
+ ## Hackathon Integration
127
+
128
+ This setup satisfies:
129
+ - ✓ **Extra credit:** llama.cpp runtime
130
+ - ✓ **Sponsor integration:** NVIDIA Nemotron model
131
+ - ✓ **Visible pipeline:** Model usage shown in logs and prompts
132
+ - ✓ **Quality:** Small model optimized for this task
133
+
134
+ ## Code reference
135
+
136
+ See [app/services/generator.py](app/services/generator.py) for:
137
+ - `generate_game_with_model()` — llama.cpp integration
138
+ - `NEMOTRON_MODEL_ID` — Model configuration
139
+ - Model caching and initialization
140
+
141
+ ## Testing
142
+
143
+ Run the test suite:
144
+ ```bash
145
+ python test_generation_gguf.py
146
+ ```
147
+
148
+ Expected output:
149
+ - ✓ Tests pass with mock (if llama-cpp-python not installed)
150
+ - ✓ Tests pass with actual model (if llama-cpp-python installed)
151
+ - ✓ All generated games validate against schema
app.py CHANGED
@@ -1,14 +1,256 @@
1
  import gradio as gr
2
- import spaces
3
- import torch
4
 
5
- zero = torch.Tensor([0]).cuda()
6
- print(zero.device) # <-- 'cpu' 🤔
 
 
7
 
8
- @spaces.GPU
9
- def greet(n):
10
- print(zero.device) # <-- 'cuda:0' 🤗
11
- return f"Hello {zero + n} Tensor"
12
 
13
- demo = gr.Interface(fn=greet, inputs=gr.Number(), outputs=gr.Text())
14
- demo.launch()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  import gradio as gr
 
 
2
 
3
+ try:
4
+ import spaces # only available on Hugging Face Spaces
5
+ except ImportError:
6
+ spaces = None
7
 
8
+ from app.services.retrieval import load_games_dataset, normalize_game_record, retrieve_examples
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 ──────────────────────────────────────────────
15
+ DATASET_PATH = "app/data/games_dataset.json"
16
+ DATA_RECORDS = []
17
+ try:
18
+ raw = load_games_dataset(DATASET_PATH)
19
+ DATA_RECORDS = [normalize_game_record(r) for r in raw]
20
+ print(f"✓ Loaded {len(DATA_RECORDS)} game records for retrieval")
21
+ 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,
28
+ city: str,
29
+ area: str,
30
+ location_type: str,
31
+ duration_minutes: int,
32
+ num_players: int,
33
+ difficulty: str,
34
+ age_group: str,
35
+ energy_level: str,
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
56
+ if DATA_RECORDS:
57
+ retrieved = retrieve_examples(config, DATA_RECORDS, k=3)
58
+ state["num_retrieved"] = len(retrieved)
59
+ state["retrieved_ids"] = [r["id"] for r in retrieved]
60
+ else:
61
+ retrieved = []
62
+
63
+ # 2 ── Generation
64
+ game = generate_game(config, retrieved)
65
+ state["game_id"] = game["game_id"]
66
+ state["game_title"] = game["title"]
67
+
68
+ # 3 ── Validation
69
+ is_valid, failures = validate_game(game, config)
70
+ state["validation_passed"] = is_valid
71
+ state["validation_failures"] = failures
72
+
73
+ # 4 ── Repair (if needed)
74
+ repaired = None
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
82
+ else:
83
+ state["repair_applied"] = False
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', '?')}")
100
+ lines.append(f"- **Meeting point:** {setup.get('meeting_point', '?')}")
101
+ lines.append(f"- **Duration:** {setup.get('duration_minutes', '?')} min")
102
+ lines.append(f"- **Players:** {setup.get('num_players', '?')}")
103
+ lines.append("")
104
+
105
+ lines.append("## 📜 Rules")
106
+ for i, rule in enumerate(game.get("rules", []), 1):
107
+ lines.append(f" {i}. {rule}")
108
+ lines.append("")
109
+
110
+ lines.append("## 🎯 Tasks")
111
+ for t in game.get("tasks", []):
112
+ time_str = f"{t.get('time_limit_minutes', '∞')} min" if t.get("time_limit_minutes") else "No time limit"
113
+ lines.append(f" **{t.get('task_id', '?')}:** {t.get('title', '?')}")
114
+ lines.append(f" - *{t.get('description', '')[:80]}*")
115
+ lines.append(f" - 🏆 {t.get('points', 0)} pts | ⏱ {time_str} | 📸 {t.get('proof_type', '?')}")
116
+ lines.append(f" - 💡 {t.get('hint', '')[:70]}")
117
+ lines.append(f" - 🛡 {t.get('safety_note', '')[:70]}")
118
+ lines.append("")
119
+
120
+ lines.append("## 💡 Global Hints")
121
+ for h in game.get("global_hints", []):
122
+ lines.append(f" • {h}")
123
+ lines.append("")
124
+
125
+ lines.append("## 🔒 Safety")
126
+ safety = game.get("safety", {})
127
+ lines.append(f"- **Zone:** {safety.get('allowed_zone', '?')}")
128
+ lines.append(f"- **Supervision required:** {'Yes' if safety.get('adult_supervision') else 'No'}")
129
+ lines.append(f"- **Forbidden:** {', '.join(safety.get('forbidden_behaviors', []))}")
130
+ lines.append("")
131
+
132
+ lines.append("## 📊 Scoring")
133
+ for s in game.get("score_rules", []):
134
+ lines.append(f" • {s}")
135
+ lines.append(f"- **Tie-breaker:** {game.get('tie_breaker', '?')}")
136
+ lines.append("")
137
+
138
+ lines.append("## 📖 Story Seed")
139
+ seed = game.get("story_seed", {})
140
+ lines.append(f"- **Tone:** {seed.get('tone', '?')}")
141
+ lines.append(f"- **Motifs:** {', '.join(seed.get('motifs', []))}")
142
+ lines.append(f"- **Recap style:** {seed.get('recap_style', '?')}")
143
+ lines.append("")
144
+
145
+ # Pipeline trace
146
+ lines.append("---")
147
+ lines.append("### 🔍 Pipeline trace")
148
+ lines.append(f"- Retrieval: {state.get('num_retrieved', 0)} examples found")
149
+ if state.get("retrieved_ids"):
150
+ lines.append(f"- Retrieved IDs: {', '.join(state['retrieved_ids'])}")
151
+ lines.append(f"- Game ID: {state.get('game_id', '?')}")
152
+ if state.get("validation_passed"):
153
+ lines.append(f"- ✅ Validation passed")
154
+ else:
155
+ lines.append(f"- ❌ Validation failed ({len(state.get('validation_failures', []))} issues)")
156
+ if state.get("repair_applied"):
157
+ lines.append(f"- 🔧 Repair applied → {'✅ Passed' if state.get('repair_valid') else '❌ Still has issues'}")
158
+ for f in state.get("validation_failures", [])[:5]:
159
+ lines.append(f" - {f}")
160
+ leftover = state.get("remaining_failures", [])
161
+ if leftover:
162
+ for f in leftover[:5]:
163
+ lines.append(f" - ⚠ {f}")
164
+
165
+ return "\n".join(lines)
166
+
167
+
168
+ # ── Gradio UI ─────────────────────────────────────────────────────────────────
169
+ 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
+
253
+
254
+ # ── Entry point ───────────────────────────────────────────────────────────────
255
+ if __name__ == "__main__":
256
+ demo.launch(theme=gr.themes.Soft())
app/services/generator.py CHANGED
@@ -1,41 +1,465 @@
1
- """Game generation module using Nemotron or similar models."""
2
 
3
- from typing import Any
 
 
 
4
 
5
 
6
- def generate_game(config: dict, retrieved_examples: list[dict]) -> dict:
7
- """Generate a game from user config and retrieved examples.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8
 
9
- Uses NVIDIA Nemotron Nano 4B as the primary generator.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
 
11
  Args:
12
- config: Game configuration (game_type, city, duration, etc.)
13
- retrieved_examples: List of similar example games for grounding
14
 
15
  Returns:
16
- Generated game JSON matching the game schema
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
17
  """
18
- # TODO: Implement with Nemotron or mock for testing
19
- mock_game = {
20
- "game_id": "mock-001",
21
- "title": "Mock Game",
22
- "theme": "test",
23
- "setup": config,
24
- "rules": [],
25
- "tasks": [],
26
- "global_hints": [],
27
- "score_rules": [],
28
- "tie_breaker": "",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29
  "safety": {
30
- "allowed_zone": "",
31
- "forbidden_behaviors": [],
32
- "adult_supervision": False,
33
- "stop_conditions": []
 
 
 
 
 
 
 
 
 
 
34
  },
35
  "story_seed": {
36
- "tone": "playful",
37
- "motifs": [],
38
  "recap_style": "episode_recap"
39
  }
40
  }
41
- return mock_game
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Game generation module using NVIDIA Nemotron 3 Nano 4B via llama.cpp."""
2
 
3
+ import json
4
+ import uuid
5
+ from typing import Optional
6
+ from pathlib import Path
7
 
8
 
9
+ # Model initialization cache
10
+ _model_cache = {}
11
+
12
+ # Model configuration
13
+ NEMOTRON_MODEL_ID = "nvidia/NVIDIA-Nemotron-3-Nano-4B-GGUF"
14
+ NEMOTRON_GGUF_FILE = "model.gguf" # The GGUF file name in the HF repo
15
+
16
+
17
+ def build_generation_prompt(config: dict, retrieved_examples: list[dict]) -> str:
18
+ """Build the game generation prompt with context and examples.
19
+
20
+ Args:
21
+ config: Game configuration from user
22
+ retrieved_examples: Retrieved similar games for grounding
23
+
24
+ Returns:
25
+ Formatted prompt string
26
+ """
27
+ # Format retrieved examples
28
+ examples_str = ""
29
+ if retrieved_examples:
30
+ examples_str = "\n## Retrieved Similar Examples:\n"
31
+ for i, ex in enumerate(retrieved_examples[:3], 1):
32
+ examples_str += f"\n### Example {i}: {ex.get('id')}\n"
33
+ examples_str += f"- Type: {ex.get('game_type')}\n"
34
+ examples_str += f"- Area: {ex.get('area')}\n"
35
+ examples_str += f"- Difficulty: {ex.get('difficulty')}\n"
36
+ examples_str += f"- Rules: {', '.join(ex.get('rules_summary', [])[:2])}\n"
37
+ examples_str += f"- Task Patterns:\n"
38
+ for task in ex.get('task_patterns', [])[:2]:
39
+ examples_str += f" • {task.get('task_id')}: {task.get('points')} pts ({task.get('proof_type')})\n"
40
+
41
+ # Load prompt template
42
+ template_path = Path("app/prompts/game_generation.txt")
43
+ if template_path.exists():
44
+ with open(template_path, 'r', encoding='utf-8') as f:
45
+ template = f.read()
46
+ else:
47
+ template = "{instruction}"
48
+
49
+ # Load game schema for reference
50
+ schema_path = Path("app/schemas/game_schema.json")
51
+ schema_str = ""
52
+ if schema_path.exists():
53
+ with open(schema_path, 'r', encoding='utf-8') as f:
54
+ schema_obj = json.load(f)
55
+ schema_str = json.dumps(schema_obj, indent=2)
56
 
57
+ # Build prompt with all context
58
+ prompt = template.format(
59
+ city=config.get('city', 'Paris'),
60
+ area=config.get('area', 'downtown'),
61
+ game_type=config.get('game_type', 'scavenger_hunt'),
62
+ duration_minutes=config.get('duration_minutes', 45),
63
+ num_players=config.get('num_players', 4),
64
+ difficulty=config.get('difficulty', 'medium'),
65
+ age_group=config.get('age_group', 'adults'),
66
+ location_type=config.get('location_type', 'mixed'),
67
+ retrieved_examples=examples_str,
68
+ output_schema=schema_str
69
+ )
70
+
71
+ return prompt
72
+
73
+
74
+ def generate_game_with_model(prompt: str, model_name: str = "nemotron") -> Optional[str]:
75
+ """Generate game JSON using NVIDIA Nemotron 3 Nano 4B via llama.cpp.
76
+
77
+ Uses llama-cpp-python for optimal performance with GGUF quantization.
78
 
79
  Args:
80
+ prompt: Generation prompt
81
+ model_name: Model to use ("nemotron" for Nemotron 3 Nano 4B GGUF)
82
 
83
  Returns:
84
+ Generated game JSON string or None if model unavailable
85
+ """
86
+ try:
87
+ from llama_cpp import Llama
88
+
89
+ # Use NVIDIA Nemotron 3 Nano 4B GGUF model
90
+ model_id = NEMOTRON_MODEL_ID
91
+
92
+ # Cache key for this model
93
+ cache_key = f"llama_cpp_{model_id}"
94
+
95
+ if cache_key not in _model_cache:
96
+ print(f"Initializing llama.cpp with: {model_id}/{NEMOTRON_GGUF_FILE}")
97
+
98
+ # Initialize llama.cpp with the GGUF model
99
+ # The model will be downloaded from HuggingFace automatically via llama-cpp-python
100
+ _model_cache[cache_key] = Llama.from_pretrained(
101
+ repo_id=model_id,
102
+ filename=NEMOTRON_GGUF_FILE,
103
+ verbose=False,
104
+ n_gpu_layers=-1, # Use GPU if available
105
+ n_ctx=2048, # Context window
106
+ )
107
+
108
+ llm = _model_cache[cache_key]
109
+
110
+ # Build strict JSON prompt
111
+ system_prompt = "You are an expert game designer. Return ONLY valid JSON, no other text."
112
+ full_prompt = f"{system_prompt}\n\n{prompt}"
113
+
114
+ # Generate with llama.cpp
115
+ result = llm(
116
+ full_prompt,
117
+ max_tokens=2000,
118
+ temperature=0.7,
119
+ top_p=0.95,
120
+ stop=["```", "\n\n"]
121
+ )
122
+
123
+ generated_text = result['choices'][0]['text']
124
+
125
+ # Extract JSON from generated text
126
+ json_str = extract_json(generated_text)
127
+ return json_str
128
+
129
+ except ImportError:
130
+ print("llama-cpp-python not available. Install with: pip install llama-cpp-python")
131
+ return None
132
+ except Exception as e:
133
+ print(f"llama.cpp generation failed: {type(e).__name__}: {e}")
134
+ return None
135
+
136
+
137
+ def extract_json(text: str) -> Optional[str]:
138
+ """Extract JSON object from generated text.
139
+
140
+ Args:
141
+ text: Generated text that may contain JSON
142
+
143
+ Returns:
144
+ JSON string or None if not found
145
+ """
146
+ # Find JSON block
147
+ start_idx = text.find('{')
148
+ if start_idx == -1:
149
+ return None
150
+
151
+ # Find matching closing brace
152
+ depth = 0
153
+ for i in range(start_idx, len(text)):
154
+ if text[i] == '{':
155
+ depth += 1
156
+ elif text[i] == '}':
157
+ depth -= 1
158
+ if depth == 0:
159
+ return text[start_idx:i+1]
160
+
161
+ return None
162
+
163
+
164
+ def generate_game_mock(config: dict, retrieved_examples: list[dict]) -> dict:
165
+ """Generate a game with mock data for testing.
166
+
167
+ Creates a realistic game structure based on config and examples.
168
+
169
+ Args:
170
+ config: Game configuration
171
+ retrieved_examples: Retrieved examples for reference
172
+
173
+ Returns:
174
+ Mock game JSON matching schema
175
  """
176
+ game_type = config.get('game_type', 'scavenger_hunt')
177
+ area = config.get('area', 'Downtown')
178
+ duration = config.get('duration_minutes', 45)
179
+ num_players = config.get('num_players', 4)
180
+ difficulty = config.get('difficulty', 'medium')
181
+ age_group = config.get('age_group', 'adults')
182
+ city = config.get('city', 'Paris')
183
+
184
+ # ── Diverse task templates inspired by dataset ──
185
+ scavenger_tasks = [
186
+ {
187
+ "title": "Find the Memorial",
188
+ "desc": f"Locate and photograph the historical memorial plaque in the main square of {area}",
189
+ "loc_hint": "Look near the central square, check walls at eye level for commemorative markers",
190
+ "pts": 20, "proof": "photo",
191
+ "hint": "The plaque is brass or stone, mounted on a wall at street level",
192
+ "safety": "Stay on the pavement — do not step into the road"
193
+ },
194
+ {
195
+ "title": "Architecture Hunt",
196
+ "desc": f"Find a structure with a distinctive facade — count the arches or decorative columns visible from the street",
197
+ "loc_hint": f"Walk along the main pedestrian street of {area} and observe the upper levels of structures",
198
+ "pts": 25, "proof": "observation",
199
+ "hint": "Look for repeating patterns like arches, columns, or decorative windows",
200
+ "safety": "Stay on public footpaths and do not obstruct doorways"
201
+ },
202
+ {
203
+ "title": "Street Sign Discovery",
204
+ "desc": f"Locate the street sign at a historic intersection in {area} and photograph it",
205
+ "loc_hint": f"Follow the oldest-looking street toward the edge of {area}",
206
+ "pts": 15, "proof": "photo",
207
+ "hint": "Classic street signs are often green rectangular plaques on building corners",
208
+ "safety": "Use crosswalks when crossing streets"
209
+ },
210
+ {
211
+ "title": "Hidden Detail",
212
+ "desc": "Find a small artistic detail — a mosaic, mural, or unusual carving — visible from a public path",
213
+ "loc_hint": "Check side alleys and passageways branching off the main route",
214
+ "pts": 20, "proof": "photo",
215
+ "hint": "Look for colourful tiles, unexpected sculptures, or decorative ironwork",
216
+ "safety": "Do not enter private courtyards or gated areas"
217
+ },
218
+ {
219
+ "title": "Time Capsule",
220
+ "desc": f"Find the oldest visible date or year inscribed on a building in {area}",
221
+ "loc_hint": "Look at the upper frieze or pediment above building entrances",
222
+ "pts": 15, "proof": "text",
223
+ "hint": "Dates are often carved in Roman numerals on older buildings",
224
+ "safety": "Look up from the pavement — no need to approach buildings closely"
225
+ },
226
+ {
227
+ "title": "Green Spot",
228
+ "desc": f"Locate a pocket garden or park that is open to the public in {area}",
229
+ "loc_hint": "Look for tree canopies visible above the rooflines",
230
+ "pts": 25, "proof": "photo",
231
+ "hint": "Small squares and hidden gardens are often behind large wooden gates",
232
+ "safety": "Stay on marked paths within any garden or park"
233
+ },
234
+ {
235
+ "title": "Street Art Trail",
236
+ "desc": f"Find a piece of public street art or a mural on an exterior wall in {area}",
237
+ "loc_hint": "Check the walls facing side streets and small plazas",
238
+ "pts": 20, "proof": "photo",
239
+ "hint": "Look for large-scale paintings on blank building walls",
240
+ "safety": "Stay on the public sidewalk"
241
+ },
242
+ {
243
+ "title": "The Unusual Detail",
244
+ "desc": f"Observe and describe something unexpected in {area} — a quirky statue, odd sign, or unique feature",
245
+ "loc_hint": "Explore the less-travelled corners of the area",
246
+ "pts": 30, "proof": "text",
247
+ "hint": "The most interesting details are often slightly hidden or easy to walk past",
248
+ "safety": "Stay aware of your surroundings"
249
+ },
250
+ ]
251
+
252
+ hide_seek_tasks = [
253
+ {
254
+ "title": "Find Cover",
255
+ "desc": "Locate a good hiding spot in the designated play zone — behind a bush, bench, or wall — without going near restricted areas",
256
+ "loc_hint": "Stay on the large open area, look for clusters of bushes or trees",
257
+ "pts": 10, "proof": "observation",
258
+ "hint": "Look for large bushes near the path, trees with low branches",
259
+ "safety": "No hiding near water edges, locked gates, or steep drops"
260
+ },
261
+ {
262
+ "title": "Sweep the Zone",
263
+ "desc": "Systematically search the play area to find all hidden players using teamwork",
264
+ "loc_hint": "Begin at the center and move outward in a spiral pattern",
265
+ "pts": 30, "proof": "observation",
266
+ "hint": "Start with obvious spots near the entrance, then move deeper",
267
+ "safety": "Stay visible and call out when you find someone"
268
+ },
269
+ {
270
+ "title": "Silent Sprint",
271
+ "desc": "Move quietly between three designated checkpoints without being seen",
272
+ "loc_hint": "Checkpoints are at the main entrance, the far end, and the central shelter",
273
+ "pts": 20, "proof": "observation",
274
+ "hint": "Use tree cover and benches to move discreetly",
275
+ "safety": "Walk, do not run — maintain awareness of others nearby"
276
+ },
277
+ ]
278
+
279
+ tag_tasks = [
280
+ {
281
+ "title": "Capture the Flag",
282
+ "desc": "Tag as many other players as possible within the time limit while staying in the play zone",
283
+ "loc_hint": "Play zone spans the main open area — use the edges to your advantage",
284
+ "pts": 5, "proof": "observation",
285
+ "hint": "Use the paths and corners to cut off escape routes",
286
+ "safety": "Light touch only on the back or shoulder — no pushing or grabbing"
287
+ },
288
+ {
289
+ "title": "Zone Control",
290
+ "desc": "Hold control of a specific zone by staying inside it and tagging anyone who enters",
291
+ "loc_hint": "The control zone is the raised platform or central open area",
292
+ "pts": 15, "proof": "observation",
293
+ "hint": "Stand near the center so you can see all approaches",
294
+ "safety": "No running on elevated surfaces or stairs"
295
+ },
296
+ {
297
+ "title": "Evasion Run",
298
+ "desc": "Avoid being tagged for the full round by using obstacles and terrain strategically",
299
+ "loc_hint": "Use the entire play zone — stay mobile and unpredictable",
300
+ "pts": 25, "proof": "observation",
301
+ "hint": "Keep moving and use other players as shields",
302
+ "safety": "Watch your footing on uneven ground or steps"
303
+ },
304
+ ]
305
+
306
+ # Select task pool
307
+ if game_type == "hide_and_seek":
308
+ task_pool = hide_seek_tasks
309
+ theme = "hide and seek adventure"
310
+ elif game_type == "tag":
311
+ task_pool = tag_tasks
312
+ theme = "fast-paced chase"
313
+ else:
314
+ task_pool = scavenger_tasks
315
+ theme = "urban discovery"
316
+
317
+ num_tasks = max(3, min(duration // 12, len(task_pool)))
318
+
319
+ # Build diverse tasks from templates
320
+ tasks = []
321
+ diff_mult = {'easy': 0.8, 'medium': 1.0, 'hard': 1.25}
322
+ mult = diff_mult.get(difficulty, 1.0)
323
+
324
+ for i in range(num_tasks):
325
+ tpl = task_pool[i]
326
+ task_id = f"t{i+1}"
327
+ time_limit = (duration - 5) // num_tasks if i < num_tasks - 1 else None
328
+ points = max(5, int(round(tpl["pts"] * mult / 5) * 5))
329
+
330
+ tasks.append({
331
+ "task_id": task_id,
332
+ "title": tpl["title"],
333
+ "description": tpl["desc"],
334
+ "location_hint": tpl["loc_hint"],
335
+ "points": points,
336
+ "time_limit_minutes": time_limit,
337
+ "proof_type": tpl["proof"],
338
+ "hint": tpl["hint"],
339
+ "safety_note": tpl["safety"]
340
+ })
341
+
342
+ # ── Rules ──
343
+ rules_pool = {
344
+ "scavenger_hunt": [
345
+ f"Stay within the {area} boundaries defined at game start",
346
+ "Take a photo as proof of each completed task where required",
347
+ "No running on narrow streets or busy roads",
348
+ "Players may not split up during timed segments",
349
+ "Hints may be requested but cost 5 points each",
350
+ "Respect private property — observe from public spaces only",
351
+ "No entering buildings, shops, or restricted areas"
352
+ ],
353
+ "hide_and_seek": [
354
+ f"Stay within the {area} boundaries at all times",
355
+ "No hiding near water edges, locked gates, or steep drops",
356
+ "Seeker counts to 30 at the designated starting point",
357
+ "Call out 'Found!' loudly when you locate a hider",
358
+ "Adults must remain within line-of-sight of all children",
359
+ "Game ends when all players are found or time elapses"
360
+ ],
361
+ "tag": [
362
+ f"Play zone is restricted to {area} — not surrounding areas",
363
+ "No running on wet, slippery, or uneven surfaces",
364
+ "Tagged players sit out briefly before rejoining",
365
+ "No physical contact beyond a light touch on the back",
366
+ "Game runs in timed rounds with rest breaks between"
367
+ ]
368
+ }
369
+ rules = rules_pool.get(game_type, rules_pool["scavenger_hunt"])
370
+
371
+ # ── Global hints ──
372
+ hint_pool = {
373
+ "scavenger_hunt": [
374
+ "Work together to cover more ground efficiently",
375
+ "Use the location hints to narrow your search radius",
376
+ "Look for landmarks like statues, fountains, or unusual doors",
377
+ "Check both sides of the street as you walk"
378
+ ],
379
+ "hide_and_seek": [
380
+ "Choose hiding spots with good visibility of the seeker's approach",
381
+ "Stay quiet and still — sudden movements give you away",
382
+ "Switch hiding spots between rounds if possible"
383
+ ],
384
+ "tag": [
385
+ "Stay aware of all escape routes at all times",
386
+ "Use terrain — steps, slopes, corners — to your advantage",
387
+ "Communicate with teammates to corner opponents"
388
+ ]
389
+ }
390
+ global_hints = hint_pool.get(game_type, hint_pool["scavenger_hunt"])[:3]
391
+
392
+ # ── Build game dict ──
393
+ game = {
394
+ "game_id": f"game-{uuid.uuid4().hex[:8]}",
395
+ "title": f"{area} {game_type.replace('_', ' ').title()}",
396
+ "theme": theme,
397
+ "setup": {"city": city, "area": area, "meeting_point": f"Center of {area}", "duration_minutes": duration, "num_players": num_players},
398
+ "rules": rules,
399
+ "tasks": tasks,
400
+ "global_hints": global_hints,
401
+ "score_rules": [
402
+ "Complete each task within the time limit for full points",
403
+ "Each hint used costs 5 points",
404
+ "Bonus: +20 points for completing all tasks before time expires"
405
+ ],
406
+ "tie_breaker": "Team with the most points wins. If tied, the team that finished first wins.",
407
  "safety": {
408
+ "allowed_zone": f"Public areas within {area}",
409
+ "forbidden_behaviors": [
410
+ "Entering private buildings or restricted areas",
411
+ "Crossing busy roads unsafely",
412
+ "Climbing on structures",
413
+ "Interacting with strangers",
414
+ "Photographing people without consent"
415
+ ],
416
+ "adult_supervision": age_group in ['kids', 'mixed'],
417
+ "stop_conditions": [
418
+ "Player injury or emergency",
419
+ "Severe weather conditions",
420
+ "Any safety concern raised by participants"
421
+ ]
422
  },
423
  "story_seed": {
424
+ "tone": "playful" if difficulty == "easy" else "cinematic" if difficulty == "hard" else "wholesome",
425
+ "motifs": ["exploration", "teamwork", "discovery"],
426
  "recap_style": "episode_recap"
427
  }
428
  }
429
+
430
+ return game
431
+
432
+
433
+ def generate_game(config: dict, retrieved_examples: list[dict]) -> dict:
434
+ """Generate a game from user config and retrieved examples.
435
+
436
+ Uses NVIDIA Nemotron 3 Nano 4B via llama.cpp for optimal performance.
437
+ Falls back to mock generation if model unavailable.
438
+
439
+ Args:
440
+ config: Game configuration (game_type, city, duration, etc.)
441
+ retrieved_examples: List of similar example games for grounding
442
+
443
+ Returns:
444
+ Generated game JSON matching the game schema
445
+ """
446
+ # Build prompt
447
+ prompt = build_generation_prompt(config, retrieved_examples)
448
+
449
+ # Try model-based generation first with Nemotron 3 Nano 4B GGUF
450
+ json_str = generate_game_with_model(prompt, model_name="nemotron")
451
+
452
+ if json_str:
453
+ try:
454
+ # Validate JSON
455
+ game = json.loads(json_str)
456
+
457
+ # Ensure required fields exist
458
+ if all(field in game for field in ["game_id", "title", "setup", "tasks", "safety"]):
459
+ return game
460
+ except json.JSONDecodeError:
461
+ print("Failed to parse generated JSON, using mock")
462
+
463
+ # Fallback: Use mock generation
464
+ print("Using mock generation (model unavailable or generation failed)")
465
+ return generate_game_mock(config, retrieved_examples)
app/services/validator.py CHANGED
@@ -1,10 +1,362 @@
1
  """Game validation and repair module."""
2
 
3
- from typing import Any
4
 
5
 
6
- def validate_game(game: dict, config: dict) -> tuple[bool, list[str]]:
7
- """Validate generated game against hard rules.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8
 
9
  Args:
10
  game: Generated game JSON
@@ -13,34 +365,41 @@ def validate_game(game: dict, config: dict) -> tuple[bool, list[str]]:
13
  Returns:
14
  Tuple of (is_valid, list of failure messages)
15
  """
16
- failures = []
17
-
18
- # Hard validation rules
19
- hard_checks = [
20
- ("title", lambda g: g.get("title")),
21
- ("theme", lambda g: g.get("theme")),
22
- ("setup", lambda g: g.get("setup")),
23
- ("rules", lambda g: isinstance(g.get("rules"), list) and len(g.get("rules", [])) > 0),
24
- ("tasks", lambda g: isinstance(g.get("tasks"), list) and len(g.get("tasks", [])) > 0),
25
- ("no_buildings", lambda g: not any("building" in str(t).lower() for t in g.get("tasks", []))),
26
- ("no_private_areas", lambda g: not any("private" in str(t).lower() for t in g.get("tasks", []))),
27
- ("global_hints", lambda g: isinstance(g.get("global_hints"), list)),
28
- ("score_rules", lambda g: isinstance(g.get("score_rules"), list)),
29
- ("safety", lambda g: g.get("safety")),
30
  ]
31
 
32
- for check_name, check_fn in hard_checks:
33
  try:
34
- if not check_fn(game):
35
- failures.append(f"Failed check: {check_name}")
 
36
  except Exception as e:
37
- failures.append(f"Error in {check_name}: {str(e)}")
38
 
39
- return len(failures) == 0, failures
40
 
41
 
42
  def repair_game(game: dict, failures: list[str], config: dict) -> dict:
43
- """Repair a game that failed validation.
 
 
 
 
 
 
 
44
 
45
  Args:
46
  game: Failed game JSON
@@ -48,7 +407,332 @@ def repair_game(game: dict, failures: list[str], config: dict) -> dict:
48
  config: Original game configuration
49
 
50
  Returns:
51
- Repaired game JSON
52
  """
53
- # TODO: Implement repair logic with minimal modifications
54
- return game
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  """Game validation and repair module."""
2
 
3
+ from typing import Tuple
4
 
5
 
6
+ # Hard safety constraints derived from dataset patterns
7
+ FORBIDDEN_BEHAVIORS = [
8
+ "building", "shop", "roof", "rooftop", "enter building", "private",
9
+ "private courtyard", "fenced", "fence", "gate", "locked",
10
+ "river edge", "canal edge", "water edge", "edge of water",
11
+ "traffic", "road crossing", "railroad", "rail line",
12
+ "fountain", "climbing", "climb", "purchase", "buy",
13
+ "stranger", "staff", "permission needed"
14
+ ]
15
+
16
+ WATER_HAZARDS = [
17
+ "river", "canal", "pond", "lake", "fountain", "water",
18
+ "drowning", "swimming"
19
+ ]
20
+
21
+ ROAD_HAZARDS = [
22
+ "traffic", "road", "street crossing", "highway", "busy road",
23
+ "intersection", "rail", "railroad", "railway"
24
+ ]
25
+
26
+ BUILDING_RESTRICTIONS = [
27
+ "enter", "inside", "building", "shop", "store", "house",
28
+ "apartment", "private", "restricted", "no entry"
29
+ ]
30
+
31
+
32
+ def check_forbidden_behaviors(game: dict) -> Tuple[bool, list[str]]:
33
+ """Check that task descriptions don't describe forbidden behaviors.
34
+
35
+ Uses action-context analysis: "building" in "photograph the building" is safe,
36
+ but "enter the building" or "go inside the shop" is forbidden.
37
+
38
+ Args:
39
+ game: Game to check
40
+
41
+ Returns:
42
+ Tuple of (is_valid, list of violations)
43
+ """
44
+ # Forbidden action patterns — require a verb + noun combination
45
+ # The verb implies the player must DO something unsafe
46
+ FORBIDDEN_ACTION_PATTERNS = [
47
+ # Entering buildings/shops
48
+ ('enter', ['building', 'shop', 'store', 'house', 'courtyard']),
49
+ ('go inside', ['building', 'shop', 'store', 'house']),
50
+ ('go into', ['building', 'shop', 'store', 'house']),
51
+ ('step inside', ['building', 'shop']),
52
+ ('walk inside', ['building', 'shop']),
53
+ # Climbing
54
+ ('climb', ['wall', 'fence', 'roof', 'structure', 'gate', 'railing']),
55
+ ('climb onto', ['roof', 'wall', 'structure']),
56
+ # Interacting with strangers
57
+ ('ask', ['stranger', 'staff', 'employee']),
58
+ ('talk to', ['stranger', 'staff']),
59
+ ('approach', ['stranger', 'staff']),
60
+ # Purchasing
61
+ ('buy', ['item', 'ticket', 'food', 'drink', 'something']),
62
+ ('purchase', ['item', 'ticket']),
63
+ # Water proximity
64
+ ('enter', ['water', 'river', 'canal', 'pond', 'lake', 'fountain']),
65
+ ('step into', ['water', 'river', 'canal']),
66
+ ('go near', ['river edge', 'canal edge', 'water edge']),
67
+ ]
68
+
69
+ violations = []
70
+ tasks = game.get("tasks", [])
71
+
72
+ for task in tasks:
73
+ desc = (task.get('description', '') + ' ' + task.get('location_hint', '')).lower()
74
+
75
+ # Skip if description already forbids the action itself
76
+ if any(sk in desc for sk in ['forbidden', 'do not', "don't", 'avoid', 'stay away', 'no entering', 'no climbing', 'no hiding']):
77
+ continue
78
+
79
+ for verb, nouns in FORBIDDEN_ACTION_PATTERNS:
80
+ for noun in nouns:
81
+ pattern = f"{verb} {noun}"
82
+ if pattern in desc:
83
+ violations.append(f"Task {task.get('task_id')}: describes forbidden behavior '{pattern}'")
84
+
85
+ return len(violations) == 0, violations
86
+
87
+
88
+ def check_water_hazards(game: dict) -> Tuple[bool, list[str]]:
89
+ """Check that tasks mentioning water proximity have explicit safety restrictions.
90
+
91
+ Args:
92
+ game: Game to check
93
+
94
+ Returns:
95
+ Tuple of (is_valid, list of violations)
96
+ """
97
+ violations = []
98
+ tasks = game.get("tasks", [])
99
+
100
+ WATER_ACTION_WORDS = [
101
+ 'cross the river', 'cross the canal', 'go near water',
102
+ 'enter water', 'enter river', 'enter canal', 'enter lake', 'enter pond',
103
+ 'step into water', 'swim', 'boat', 'kayak', 'paddle',
104
+ ]
105
+
106
+ for task in tasks:
107
+ desc = f"{task.get('description', '')} {task.get('location_hint', '')}".lower()
108
+ safety_note = task.get('safety_note', '').lower()
109
+
110
+ # Only flag if the water proximity is an ACTION, not a location reference
111
+ is_water_action = any(aw in desc for aw in WATER_ACTION_WORDS)
112
+
113
+ if is_water_action:
114
+ has_restriction = any(r in safety_note for r in
115
+ ["no", "forbidden", "stay away", "do not", "don't", "exclude"])
116
+ if not has_restriction:
117
+ violations.append(f"Task {task.get('task_id')}: mentions water activity but lacks explicit safety restriction")
118
+
119
+ return len(violations) == 0, violations
120
+
121
+
122
+ def check_road_hazards(game: dict) -> Tuple[bool, list[str]]:
123
+ """Check that tasks involving road crossing have explicit safety guidance.
124
+
125
+ Args:
126
+ game: Game to check
127
+
128
+ Returns:
129
+ Tuple of (is_valid, list of violations)
130
+ """
131
+ violations = []
132
+ tasks = game.get("tasks", [])
133
+
134
+ ROAD_ACTION_WORDS = [
135
+ 'cross the road', 'cross the street', 'cross traffic',
136
+ 'run across', 'dash across', 'jump across',
137
+ ]
138
+
139
+ for task in tasks:
140
+ desc = f"{task.get('description', '')} {task.get('location_hint', '')}".lower()
141
+ safety_note = task.get('safety_note', '').lower()
142
+
143
+ # Only flag road-crossing actions, not passive mentions like "street corner"
144
+ is_road_action = any(rw in desc for rw in ROAD_ACTION_WORDS)
145
+
146
+ if is_road_action:
147
+ has_restriction = any(r in safety_note for r in
148
+ ["safe", "safely", "careful", "caution", "avoid", "crosswalk", "zebra"])
149
+ if not has_restriction:
150
+ violations.append(f"Task {task.get('task_id')}: mentions road crossing but lacks safety guidance")
151
+
152
+ return len(violations) == 0, violations
153
+
154
+
155
+ def check_building_access(game: dict) -> Tuple[bool, list[str]]:
156
+ """Check that tasks don't require entering private buildings.
157
+
158
+ "Find the building" or "photograph the facade" are safe.
159
+ "Enter the building" or "go inside" are not.
160
+
161
+ Args:
162
+ game: Game to check
163
+
164
+ Returns:
165
+ Tuple of (is_valid, list of violations)
166
+ """
167
+ violations = []
168
+ tasks = game.get("tasks", [])
169
+
170
+ ENTRY_PHRASES = [
171
+ 'enter the building', 'enter building', 'enter the shop', 'enter shop',
172
+ 'enter the store', 'enter store', 'go inside the', 'step inside',
173
+ 'walk inside', 'go into the building', 'go into the shop',
174
+ ]
175
+
176
+ for task in tasks:
177
+ desc = f"{task.get('description', '')} {task.get('location_hint', '')}".lower()
178
+
179
+ for phrase in ENTRY_PHRASES:
180
+ if phrase in desc:
181
+ violations.append(f"Task {task.get('task_id')}: '{phrase}' requires entering a building")
182
+
183
+ return len(violations) == 0, violations
184
+
185
+
186
+ def check_task_requirements(game: dict) -> Tuple[bool, list[str]]:
187
+ """Check that all tasks have required fields with valid values.
188
+
189
+ Args:
190
+ game: Game to check
191
+
192
+ Returns:
193
+ Tuple of (is_valid, list of violations)
194
+ """
195
+ violations = []
196
+ tasks = game.get("tasks", [])
197
+
198
+ required_fields = {
199
+ 'task_id': str,
200
+ 'title': str,
201
+ 'description': str,
202
+ 'location_hint': str,
203
+ 'points': int,
204
+ 'proof_type': str,
205
+ 'hint': str,
206
+ 'safety_note': str
207
+ }
208
+
209
+ for task in tasks:
210
+ # Check required fields exist
211
+ for field, field_type in required_fields.items():
212
+ if field not in task:
213
+ violations.append(f"Task {task.get('task_id')}: missing required field '{field}'")
214
+ elif task[field] is None or (isinstance(task[field], str) and len(task[field].strip()) == 0):
215
+ violations.append(f"Task {task.get('task_id')}: '{field}' is empty")
216
+
217
+ # Validate proof_type enum
218
+ if task.get('proof_type') not in ['photo', 'observation', 'text']:
219
+ violations.append(f"Task {task.get('task_id')}: invalid proof_type '{task.get('proof_type')}'")
220
+
221
+ # Validate points are positive
222
+ if task.get('points', 0) <= 0:
223
+ violations.append(f"Task {task.get('task_id')}: points must be positive, got {task.get('points')}")
224
+
225
+ # Check hint is meaningful
226
+ if len(task.get('hint', '').split()) < 2:
227
+ violations.append(f"Task {task.get('task_id')}: hint too short or missing")
228
+
229
+ # Check safety_note is meaningful
230
+ if len(task.get('safety_note', '').split()) < 3:
231
+ violations.append(f"Task {task.get('task_id')}: safety_note too brief")
232
+
233
+ return len(violations) == 0, violations
234
+
235
+
236
+ def check_task_count_realism(game: dict, config: dict) -> Tuple[bool, list[str]]:
237
+ """Check that task count is realistic for the duration.
238
+
239
+ Args:
240
+ game: Game to check
241
+ config: Original configuration
242
+
243
+ Returns:
244
+ Tuple of (is_valid, list of violations)
245
+ """
246
+ violations = []
247
+ tasks = game.get("tasks", [])
248
+ duration = config.get('duration_minutes', 45)
249
+
250
+ # Estimate: 10-15 minutes per task (including travel, decision time)
251
+ min_tasks = max(1, duration // 20)
252
+ max_tasks = duration // 8
253
+
254
+ task_count = len(tasks)
255
+
256
+ if task_count < min_tasks:
257
+ violations.append(f"Too few tasks ({task_count}) for {duration} min duration (recommend {min_tasks}-{max_tasks})")
258
+ elif task_count > max_tasks:
259
+ violations.append(f"Too many tasks ({task_count}) for {duration} min duration (recommend {min_tasks}-{max_tasks})")
260
+
261
+ return len(violations) == 0, violations
262
+
263
+
264
+ def check_age_appropriate_supervision(game: dict, config: dict) -> Tuple[bool, list[str]]:
265
+ """Check that kids and mixed-age games require adult supervision.
266
+
267
+ Args:
268
+ game: Game to check
269
+ config: Original configuration
270
+
271
+ Returns:
272
+ Tuple of (is_valid, list of violations)
273
+ """
274
+ violations = []
275
+ age_group = config.get('age_group', 'adults')
276
+ adult_supervision = game.get('safety', {}).get('adult_supervision', False)
277
+
278
+ if age_group in ['kids', 'mixed'] and not adult_supervision:
279
+ violations.append(f"Age group '{age_group}' requires adult_supervision=true")
280
+
281
+ # Check rules mention supervision for kids
282
+ if age_group in ['kids', 'mixed']:
283
+ rules_text = ' '.join(game.get('rules', [])).lower()
284
+ if 'adult' not in rules_text and 'supervision' not in rules_text:
285
+ violations.append(f"Age group '{age_group}' should mention adult supervision in rules")
286
+
287
+ return len(violations) == 0, violations
288
+
289
+
290
+ def check_win_condition(game: dict) -> Tuple[bool, list[str]]:
291
+ """Check that game has a clear win condition.
292
+
293
+ Args:
294
+ game: Game to check
295
+
296
+ Returns:
297
+ Tuple of (is_valid, list of violations)
298
+ """
299
+ violations = []
300
+
301
+ # Check tie_breaker exists (indicates clear win condition)
302
+ tie_breaker = game.get('tie_breaker', '').strip()
303
+ if not tie_breaker:
304
+ violations.append("Game must have a clear tie_breaker/win condition")
305
+
306
+ # Check score_rules exist
307
+ score_rules = game.get('score_rules', [])
308
+ if not score_rules or len(score_rules) == 0:
309
+ violations.append("Game must have scoring rules")
310
+
311
+ return len(violations) == 0, violations
312
+
313
+
314
+ def check_safety_structure(game: dict) -> Tuple[bool, list[str]]:
315
+ """Check that safety object is complete and sensible.
316
+
317
+ Args:
318
+ game: Game to check
319
+
320
+ Returns:
321
+ Tuple of (is_valid, list of violations)
322
+ """
323
+ violations = []
324
+ safety = game.get('safety', {})
325
+
326
+ required_fields = ['allowed_zone', 'forbidden_behaviors', 'adult_supervision', 'stop_conditions']
327
+
328
+ for field in required_fields:
329
+ if field not in safety:
330
+ violations.append(f"Safety missing required field: '{field}'")
331
+
332
+ # Check forbidden_behaviors is non-empty
333
+ forbidden = safety.get('forbidden_behaviors', [])
334
+ if not forbidden or len(forbidden) < 2:
335
+ violations.append("Safety.forbidden_behaviors should have at least 2 items")
336
+
337
+ # Check stop_conditions is non-empty
338
+ stops = safety.get('stop_conditions', [])
339
+ if not stops or len(stops) < 2:
340
+ violations.append("Safety.stop_conditions should have at least 2 items")
341
+
342
+ # Check allowed_zone is meaningful
343
+ zone = safety.get('allowed_zone', '').strip()
344
+ if not zone or len(zone) < 5:
345
+ violations.append("Safety.allowed_zone should be clearly defined")
346
+
347
+ return len(violations) == 0, violations
348
+
349
+
350
+ def validate_game(game: dict, config: dict) -> Tuple[bool, list[str]]:
351
+ """Validate generated game against hard rules derived from dataset patterns.
352
+
353
+ Checks:
354
+ - Basic structure (required fields, correct types)
355
+ - Task requirements (proofs, hints, safety notes)
356
+ - Safety constraints (no buildings, water/road hazards)
357
+ - Age appropriateness (adult supervision)
358
+ - Realism (task count vs duration)
359
+ - Win conditions and scoring
360
 
361
  Args:
362
  game: Generated game JSON
 
365
  Returns:
366
  Tuple of (is_valid, list of failure messages)
367
  """
368
+ all_failures = []
369
+
370
+ # Run all validation checks
371
+ checks = [
372
+ ("Structure", lambda: check_task_requirements(game)),
373
+ ("Safety structure", lambda: check_safety_structure(game)),
374
+ ("Forbidden behaviors", lambda: check_forbidden_behaviors(game)),
375
+ ("Water hazards", lambda: check_water_hazards(game)),
376
+ ("Road hazards", lambda: check_road_hazards(game)),
377
+ ("Building access", lambda: check_building_access(game)),
378
+ ("Task realism", lambda: check_task_count_realism(game, config)),
379
+ ("Age appropriateness", lambda: check_age_appropriate_supervision(game, config)),
380
+ ("Win condition", lambda: check_win_condition(game)),
 
381
  ]
382
 
383
+ for check_name, check_fn in checks:
384
  try:
385
+ is_valid, violations = check_fn()
386
+ if not is_valid:
387
+ all_failures.extend([f"[{check_name}] {v}" for v in violations])
388
  except Exception as e:
389
+ all_failures.append(f"[{check_name}] Error: {str(e)}")
390
 
391
+ return len(all_failures) == 0, all_failures
392
 
393
 
394
  def repair_game(game: dict, failures: list[str], config: dict) -> dict:
395
+ """Repair a game that failed validation with minimal modifications.
396
+
397
+ The repair strategy applies targeted fixes only to failing fields,
398
+ preserving all valid content. Uses progressive repair:
399
+ 1. Structural fixes (missing objects, fields)
400
+ 2. Content fixes (hints, safety notes, forbidden content)
401
+ 3. Config-alignment fixes (duration, age group, difficulty)
402
+ 4. Smart fallback generation if config mismatch.
403
 
404
  Args:
405
  game: Failed game JSON
 
407
  config: Original game configuration
408
 
409
  Returns:
410
+ Repaired game JSON (only failing fields modified)
411
  """
412
+ import uuid
413
+
414
+ # Deep copy to avoid mutating original
415
+ repaired = __import__('json').loads(__import__('json').dumps(game))
416
+
417
+ # Categorize failures for targeted repair
418
+ failure_categories = {
419
+ 'structure': [],
420
+ 'safety': [],
421
+ 'task_field': [],
422
+ 'task_content': [],
423
+ 'rules': [],
424
+ 'forbidden': [],
425
+ 'age': [],
426
+ 'win_condition': [],
427
+ 'realism': [],
428
+ }
429
+
430
+ for failure in failures:
431
+ if '[Structure]' in failure or 'missing required field' in failure.lower() or 'empty' in failure.lower():
432
+ failure_categories['structure'].append(failure)
433
+ elif '[Safety' in failure or 'forbidden_behaviors' in failure or 'stop_conditions' in failure or 'allowed_zone' in failure:
434
+ failure_categories['safety'].append(failure)
435
+ elif 'adult_supervision' in failure or 'age group' in failure.lower():
436
+ failure_categories['age'].append(failure)
437
+ elif '[Forbidden' in failure or 'forbidden behavior' in failure.lower():
438
+ failure_categories['forbidden'].append(failure)
439
+ elif '[Task realism' in failure or 'realism' in failure.lower() or 'task count' in failure.lower() or 'duration' in failure.lower():
440
+ failure_categories['realism'].append(failure)
441
+ elif '[Task' in failure and ('missing' in failure.lower() or 'empty' in failure.lower()):
442
+ failure_categories['task_field'].append(failure)
443
+ elif '[Task' in failure:
444
+ failure_categories['task_content'].append(failure)
445
+ elif '[Rules' in failure or 'rules' in failure.lower():
446
+ failure_categories['rules'].append(failure)
447
+ elif '[Water' in failure or '[Road' in failure or '[Building' in failure:
448
+ failure_categories['forbidden'].append(failure)
449
+ elif 'win' in failure.lower() or 'tie_breaker' in failure.lower() or 'scoring' in failure.lower():
450
+ failure_categories['win_condition'].append(failure)
451
+ elif 'realism' in failure.lower() or 'task count' in failure.lower() or 'duration' in failure.lower():
452
+ failure_categories['realism'].append(failure)
453
+ else:
454
+ failure_categories['structure'].append(failure)
455
+
456
+ # ---- 1. STRUCTURAL REPAIRS ----
457
+
458
+ # Ensure game has an ID
459
+ if not repaired.get('game_id'):
460
+ repaired['game_id'] = f"game-{uuid.uuid4().hex[:8]}"
461
+
462
+ # Ensure title uses config if missing
463
+ if not repaired.get('title'):
464
+ area = config.get('area', 'City')
465
+ game_type = config.get('game_type', 'game').replace('_', ' ').title()
466
+ repaired['title'] = f"{area} {game_type}"
467
+
468
+ # Ensure theme from config
469
+ if not repaired.get('theme') or len(repaired.get('theme', '').strip()) < 3:
470
+ if 'scavenger' in config.get('game_type', ''):
471
+ repaired['theme'] = 'urban discovery'
472
+ elif 'hide' in config.get('game_type', ''):
473
+ repaired['theme'] = 'hide and seek'
474
+ elif 'tag' in config.get('game_type', ''):
475
+ repaired['theme'] = 'fast-paced chase'
476
+ else:
477
+ repaired['theme'] = 'exploration and fun'
478
+
479
+ # Ensure setup uses config values
480
+ if 'setup' not in repaired or not isinstance(repaired.get('setup'), dict):
481
+ repaired['setup'] = {}
482
+ if not repaired['setup'].get('city'):
483
+ repaired['setup']['city'] = config.get('city', 'Paris')
484
+ if not repaired['setup'].get('area'):
485
+ repaired['setup']['area'] = config.get('area', 'Downtown')
486
+ if not repaired['setup'].get('meeting_point'):
487
+ repaired['setup']['meeting_point'] = f"Central point of {repaired['setup'].get('area', 'the area')}"
488
+ if not repaired['setup'].get('duration_minutes'):
489
+ repaired['setup']['duration_minutes'] = config.get('duration_minutes', 45)
490
+ if not repaired['setup'].get('num_players'):
491
+ repaired['setup']['num_players'] = config.get('num_players', 4)
492
+
493
+ # Ensure story_seed
494
+ if 'story_seed' not in repaired:
495
+ repaired['story_seed'] = {
496
+ "tone": "playful",
497
+ "motifs": ["exploration", "teamwork"],
498
+ "recap_style": "episode_recap"
499
+ }
500
+ if not repaired['story_seed'].get('tone'):
501
+ repaired['story_seed']['tone'] = 'playful'
502
+ if not repaired['story_seed'].get('motifs'):
503
+ repaired['story_seed']['motifs'] = ["exploration", "teamwork"]
504
+ if not repaired['story_seed'].get('recap_style'):
505
+ repaired['story_seed']['recap_style'] = "episode_recap"
506
+
507
+ # ---- 2. SAFETY REPAIRS ----
508
+
509
+ if 'safety' not in repaired or not isinstance(repaired.get('safety'), dict):
510
+ repaired['safety'] = {}
511
+
512
+ safety = repaired['safety']
513
+
514
+ # Fix forbidden_behaviors
515
+ if 'forbidden_behaviors' not in safety or not isinstance(safety.get('forbidden_behaviors'), list) or len(safety.get('forbidden_behaviors', [])) < 2:
516
+ safety['forbidden_behaviors'] = [
517
+ "Entering private buildings or restricted areas",
518
+ "Crossing busy roads unsafely",
519
+ "Climbing on structures",
520
+ "Interacting with strangers",
521
+ "Photographing people without consent"
522
+ ]
523
+
524
+ # Fix stop_conditions
525
+ if 'stop_conditions' not in safety or not isinstance(safety.get('stop_conditions'), list) or len(safety.get('stop_conditions', [])) < 2:
526
+ safety['stop_conditions'] = [
527
+ "Player injury or medical emergency",
528
+ "Severe weather conditions",
529
+ "Any safety concern raised by participant"
530
+ ]
531
+
532
+ # Fix allowed_zone
533
+ if not safety.get('allowed_zone') or len(str(safety.get('allowed_zone', '')).strip()) < 5:
534
+ area_name = config.get('area', repaired['setup'].get('area', 'the area'))
535
+ safety['allowed_zone'] = f"Public streets, parks, and pedestrian areas within {area_name}"
536
+
537
+ # Fix adult_supervision - enforce for kids/mixed regardless of current value
538
+ age_group = config.get('age_group', 'adults')
539
+ safety['adult_supervision'] = age_group in ['kids', 'mixed']
540
+
541
+ # ---- 3. TASK REPAIRS ----
542
+
543
+ tasks = repaired.get('tasks', [])
544
+
545
+ # Fix task count for duration if needed
546
+ duration = config.get('duration_minutes', repaired['setup'].get('duration_minutes', 45))
547
+
548
+ # Recalculate appropriate task count
549
+ min_tasks = max(1, duration // 20)
550
+ max_tasks = duration // 8
551
+
552
+ realism_failures = [f for f in failures if 'Too few tasks' in f or 'Too many tasks' in f]
553
+
554
+ if realism_failures or len(tasks) < min_tasks:
555
+ # Add tasks to reach minimum
556
+ proof_types = ['photo', 'observation', 'text']
557
+ difficulty = config.get('difficulty', 'medium')
558
+ points_map = {'easy': [10, 15], 'medium': [15, 20, 25], 'hard': [20, 25, 30]}
559
+ start_idx = len(tasks)
560
+ while len(tasks) < min_tasks:
561
+ idx = len(tasks)
562
+ pts = points_map.get(difficulty, [15, 20, 25])[idx % 3]
563
+ time_limit = (duration - 5) // min_tasks if idx < min_tasks - 1 else None
564
+
565
+ tasks.append({
566
+ "task_id": f"t{idx + 1}",
567
+ "title": f"Task {idx + 1}",
568
+ "description": f"Explore and discover something interesting in the area",
569
+ "location_hint": f"Look around the central area",
570
+ "points": pts,
571
+ "time_limit_minutes": time_limit,
572
+ "proof_type": proof_types[idx % 3],
573
+ "hint": "Check visible landmarks and signs for clues",
574
+ "safety_note": "Stay on public paths and respect local rules"
575
+ })
576
+ elif len(tasks) > max_tasks:
577
+ # Remove excess tasks (keep first max_tasks)
578
+ tasks = tasks[:max_tasks]
579
+
580
+ # Fix individual task fields
581
+ for idx, task in enumerate(tasks):
582
+ # Add task_id if missing
583
+ if 'task_id' not in task or not task.get('task_id'):
584
+ task['task_id'] = f"t{tasks.index(task) + 1}"
585
+
586
+ # Add title if missing
587
+ if 'title' not in task or not task.get('title'):
588
+ task['title'] = f"Task {tasks.index(task) + 1}"
589
+
590
+ # Add description if missing
591
+ if 'description' not in task or not task.get('description'):
592
+ area = config.get('area', 'the area')
593
+ task['description'] = f"Find and document a notable feature in {area}"
594
+
595
+ # Add location_hint if missing
596
+ if 'location_hint' not in task or not task.get('location_hint'):
597
+ task['location_hint'] = f"Search the public areas"
598
+
599
+ # Fix proof_type
600
+ if 'proof_type' not in task or task.get('proof_type') not in ['photo', 'observation', 'text']:
601
+ proof_options = ['photo', 'observation', 'text']
602
+ task['proof_type'] = proof_options[tasks.index(task) % 3]
603
+
604
+ # Fix points
605
+ if 'points' not in task or not isinstance(task.get('points'), int) or task.get('points', 0) <= 0:
606
+ difficulty = config.get('difficulty', 'medium')
607
+ points_map = {'easy': [10, 15], 'medium': [15, 20, 25], 'hard': [20, 25, 30]}
608
+ idx = tasks.index(task) % 3
609
+ task['points'] = points_map.get(difficulty, [15, 20, 25])[idx]
610
+
611
+ # Fix time_limit_minutes if None or missing
612
+ if 'time_limit_minutes' not in task:
613
+ task['time_limit_minutes'] = max(5, duration // len(tasks))
614
+ elif task['time_limit_minutes'] is None:
615
+ task['time_limit_minutes'] = None # Valid null for last task
616
+
617
+ # Fix hint
618
+ if 'hint' not in task or not task.get('hint') or len(str(task.get('hint', '')).strip().split()) < 2:
619
+ task['hint'] = "Look for visible landmarks or signs in the area"
620
+
621
+ # Fix safety_note
622
+ if 'safety_note' not in task or not task.get('safety_note') or len(str(task.get('safety_note', '')).strip().split()) < 3:
623
+ if age_group in ['kids', 'mixed']:
624
+ task['safety_note'] = "Stay with your adult supervisor at all times"
625
+ else:
626
+ task['safety_note'] = "Stay in public areas and follow local regulations"
627
+
628
+ repaired['tasks'] = tasks
629
+
630
+ # ---- 4. RULES REPAIRS ----
631
+
632
+ rules = repaired.get('rules', [])
633
+ if not rules or len(rules) < 3:
634
+ area = config.get('area', repaired['setup'].get('area', 'the area'))
635
+ game_type = config.get('game_type', 'scavenger_hunt')
636
+
637
+ base_rules = [
638
+ f"Stay within the {area} boundaries defined at game start",
639
+ "Respect private property — no entering buildings",
640
+ "No running on narrow streets or busy roads",
641
+ "Players must stay together as a team",
642
+ "Hints may be requested but cost points"
643
+ ]
644
+
645
+ if game_type == 'hide_and_seek':
646
+ base_rules = [
647
+ f"Stay within {area} boundaries at all times",
648
+ "No hiding in locked or restricted areas",
649
+ "Seeker counts to 30 at the meeting point",
650
+ "Adults must maintain line-of-sight of children",
651
+ "Game ends when all players are found or time elapses"
652
+ ]
653
+ elif game_type == 'tag':
654
+ base_rules = [
655
+ f"Play zone is restricted to {area}",
656
+ "No running on wet or hazardous surfaces",
657
+ "Tagged players sit out briefly before rejoining",
658
+ "No physical contact beyond a light touch",
659
+ "Game runs in timed rounds"
660
+ ]
661
+
662
+ # Keep existing good rules, fill gaps with base rules
663
+ existing_rules = [r for r in rules if len(r.strip()) > 10]
664
+ # Merge: keep existing valid rules, add from base until we have at least 3
665
+ merged_rules = existing_rules[:]
666
+ for br in base_rules:
667
+ if br not in merged_rules and len(merged_rules) < 5:
668
+ merged_rules.append(br)
669
+
670
+ rules = merged_rules[:5] # Max 5 rules
671
+
672
+ repaired['rules'] = rules
673
+
674
+ # ---- 5. WIN CONDITION REPAIRS ----
675
+
676
+ if not repaired.get('tie_breaker') or len(str(repaired.get('tie_breaker', '')).strip()) < 5:
677
+ repaired['tie_breaker'] = "Team with the most tasks completed wins. If tied, team that finished first wins."
678
+
679
+ score_rules = repaired.get('score_rules', [])
680
+ if not score_rules or len(score_rules) < 2:
681
+ repaired['score_rules'] = [
682
+ "Complete each task within the time limit for full points",
683
+ "Each hint used costs 5 points",
684
+ "Bonus: +20 points for completing all tasks before time expires"
685
+ ]
686
+
687
+ global_hints = repaired.get('global_hints', [])
688
+ if not global_hints or len(global_hints) == 0:
689
+ repaired['global_hints'] = [
690
+ "Work together as a team to cover more ground",
691
+ "Use the location hints to narrow your search",
692
+ "Keep track of time for each task"
693
+ ]
694
+
695
+ # ---- 6. FORBIDDEN CONTENT REPAIRS ----
696
+ # Rewrite task descriptions/location_hints that describe forbidden activities.
697
+
698
+ if failure_categories['forbidden']:
699
+ for task in repaired.get('tasks', []):
700
+ desc = task.get('description', '')
701
+ hint = task.get('location_hint', '')
702
+ safety_note = task.get('safety_note', '')
703
+
704
+ # Rewrite: "enter/inside [place]" -> "observe [place] from outside"
705
+ desc = desc.replace('enter the building', 'observe the building from outside')
706
+ desc = desc.replace('enter building', 'observe the building from outside')
707
+ desc = desc.replace('enter the shop', 'observe the shop from outside')
708
+ desc = desc.replace('go inside the', 'approach the')
709
+ desc = desc.replace('go inside', 'observe')
710
+ desc = desc.replace('inside the building', 'outside the building')
711
+ desc = desc.replace('inside the shop', 'outside the shop')
712
+
713
+ # Rewrite: "ask staff/strangers" -> "observe"
714
+ desc = desc.replace('ask staff', 'look for public signs')
715
+ desc = desc.replace('ask the staff', 'look for public signs')
716
+ desc = desc.replace('ask a stranger', 'observe')
717
+ desc = desc.replace('ask strangers', 'observe')
718
+ desc = desc.replace('talk to staff', 'look for posted information')
719
+
720
+ # Rewrite location_hint
721
+ hint = hint.replace('inside the building', 'near the building')
722
+ hint = hint.replace('inside building', 'near the building')
723
+ hint = hint.replace('inside the shop', 'near the shop')
724
+ hint = hint.replace('inside the entrance', 'near the entrance')
725
+ hint = hint.replace('in the building', 'near the building')
726
+
727
+ task['description'] = desc
728
+ task['location_hint'] = hint
729
+
730
+ # After rewriting, if any forbidden keywords remain, note it in safety_note
731
+ remaining_forbidden = [kw for kw in FORBIDDEN_BEHAVIORS
732
+ if kw in f"{desc} {hint}".lower()]
733
+ if remaining_forbidden and 'forbidden' not in safety_note.lower():
734
+ forbid_str = ', '.join(remaining_forbidden[:3])
735
+ task['safety_note'] = f"Forbidden: entering {forbid_str}. {safety_note}" if safety_note else \
736
+ f"Forbidden: entering {forbid_str}. Stay in public areas only."
737
+
738
+ return repaired
requirements.txt CHANGED
@@ -1,2 +1,4 @@
1
  gradio
2
  torch
 
 
 
1
  gradio
2
  torch
3
+ llama-cpp-python
4
+ jsonschema
scripts/extract_task_patterns.py ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ with open('app/data/games_dataset.json') as f:
3
+ data = json.load(f)
4
+
5
+ for rec in data[:10]:
6
+ i = rec['input']
7
+ o = rec['expected_output']
8
+ print(f"=== {rec['id']} ({i['game_type']}) ===")
9
+ for t in o['tasks']:
10
+ desc = t['description'][:90]
11
+ loc = t.get('location_hint','')[:70]
12
+ pts = t['points']
13
+ tm = t.get('time_limit_minutes','None')
14
+ print(f" desc: {desc}")
15
+ if loc: print(f" loc: {loc}")
16
+ print(f" pts={pts} time={tm}")
17
+ print()
18
+ print("---")
scripts/quick_test.py ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Quick test for diverse mock generation."""
2
+ import sys; sys.path.insert(0, '.')
3
+ from app.services.generator import generate_game_mock
4
+ from app.services.retrieval import load_games_dataset, normalize_game_record
5
+
6
+ raw = load_games_dataset('app/data/games_dataset.json')
7
+ norm = [normalize_game_record(r) for r in raw]
8
+
9
+ cfg = {
10
+ 'game_type': 'scavenger_hunt', 'city': 'Paris', 'area': 'Le Marais',
11
+ 'duration_minutes': 60, 'num_players': 4, 'difficulty': 'medium', 'age_group': 'adults'
12
+ }
13
+ g = generate_game_mock(cfg, norm[:3])
14
+ print(g['title'])
15
+ for t in g['tasks']:
16
+ print(f" {t['task_id']}: {t['title']} | {t['points']}pts | {t['proof_type']}")
scripts/quick_test_all.py ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Test all 3 game types for diverse generation."""
2
+ import sys; sys.path.insert(0, '.')
3
+ from app.services.generator import generate_game_mock
4
+ from app.services.retrieval import load_games_dataset, normalize_game_record
5
+
6
+ raw = load_games_dataset('app/data/games_dataset.json')
7
+ norm = [normalize_game_record(r) for r in raw]
8
+
9
+ for gt, area, diff, age in [
10
+ ('scavenger_hunt', 'Le Marais', 'medium', 'adults'),
11
+ ('hide_and_seek', 'Parc des Buttes-Chaumont', 'easy', 'kids'),
12
+ ('tag', 'Jardins du Trocadéro', 'hard', 'teens'),
13
+ ]:
14
+ cfg = {'game_type': gt, 'city': 'Paris', 'area': area, 'duration_minutes': 45,
15
+ 'num_players': 4, 'difficulty': diff, 'age_group': age}
16
+ g = generate_game_mock(cfg, norm[:3])
17
+ print(f"=== {g['title']} ===")
18
+ print(f" Theme: {g['theme']}")
19
+ print(f" Supervision: {g['safety']['adult_supervision']}")
20
+ for t in g['tasks']:
21
+ print(f" {t['task_id']}: {t['title']} | {t['points']}pts | {t['proof_type']}")
22
+ print(f" Rules: {len(g['rules'])} | Hints: {len(g['global_hints'])}")
23
+ for r in g['rules'][:2]:
24
+ print(f" - {r}")
25
+ print()
test_generation.py ADDED
@@ -0,0 +1,171 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Test the game generation pipeline with retrieval and generation.
3
+
4
+ Run this script to:
5
+ 1. Load and normalize the dataset
6
+ 2. Retrieve similar games for a sample config
7
+ 3. Generate a game using the generator
8
+ 4. Validate the generated game against the schema
9
+ 5. Display the results
10
+ """
11
+
12
+ import json
13
+ from app.services.retrieval import load_games_dataset, normalize_game_record, retrieve_examples
14
+ from app.services.generator import generate_game, build_generation_prompt
15
+ from app.services.schema_validator import validate_game_schema
16
+
17
+
18
+ def main():
19
+ print("\n" + "=" * 80)
20
+ print("PHASE 2, TASK 6: GAME GENERATION TEST")
21
+ print("=" * 80)
22
+
23
+ # Load dataset
24
+ print("\n1. Loading dataset...")
25
+ raw_records = load_games_dataset("app/data/games_dataset.json")
26
+ normalized_records = [normalize_game_record(r) for r in raw_records]
27
+ print(f"✓ Loaded {len(normalized_records)} records")
28
+
29
+ # Test cases with different configs
30
+ test_configs = [
31
+ {
32
+ "name": "Scavenger Hunt - Adults - Medium - Le Marais",
33
+ "config": {
34
+ "game_type": "scavenger_hunt",
35
+ "city": "Paris",
36
+ "area": "Le Marais",
37
+ "location_type": "mixed",
38
+ "duration_minutes": 60,
39
+ "num_players": 4,
40
+ "difficulty": "medium",
41
+ "age_group": "adults",
42
+ "photo_enabled": True
43
+ }
44
+ },
45
+ {
46
+ "name": "Hide & Seek - Kids - Easy - Park",
47
+ "config": {
48
+ "game_type": "hide_and_seek",
49
+ "city": "Paris",
50
+ "area": "Parc des Buttes-Chaumont",
51
+ "location_type": "park",
52
+ "duration_minutes": 45,
53
+ "num_players": 5,
54
+ "difficulty": "easy",
55
+ "age_group": "kids",
56
+ "photo_enabled": False
57
+ }
58
+ },
59
+ {
60
+ "name": "Tag - Teens - Hard - Trocadéro",
61
+ "config": {
62
+ "game_type": "tag",
63
+ "city": "Paris",
64
+ "area": "Jardins du Trocadéro",
65
+ "location_type": "park",
66
+ "duration_minutes": 30,
67
+ "num_players": 8,
68
+ "difficulty": "hard",
69
+ "age_group": "teens",
70
+ "photo_enabled": False
71
+ }
72
+ }
73
+ ]
74
+
75
+ # Test each config
76
+ results = []
77
+ for test in test_configs:
78
+ print("\n" + "=" * 80)
79
+ print(f"TEST: {test['name']}")
80
+ print("=" * 80)
81
+
82
+ config = test['config']
83
+
84
+ # Step 1: Retrieve similar games
85
+ print("\n2. Retrieving similar games...")
86
+ retrieved = retrieve_examples(config, normalized_records, k=3)
87
+ print(f"✓ Retrieved {len(retrieved)} similar games:")
88
+ for i, ex in enumerate(retrieved, 1):
89
+ print(f" {i}. {ex['id']} (score: {ex['retrieval_score']:.1f})")
90
+
91
+ # Step 2: Show prompt snippet
92
+ print("\n3. Building generation prompt...")
93
+ prompt = build_generation_prompt(config, retrieved)
94
+ print(f"✓ Prompt built ({len(prompt)} chars)")
95
+ print(f"\nPrompt preview:")
96
+ print(prompt[:300] + "...\n")
97
+
98
+ # Step 3: Generate game
99
+ print("4. Generating game...")
100
+ try:
101
+ game = generate_game(config, retrieved)
102
+ print(f"✓ Game generated: {game['game_id']}")
103
+
104
+ # Step 4: Validate against schema
105
+ print("\n5. Validating against schema...")
106
+ is_valid, errors = validate_game_schema(game)
107
+
108
+ if is_valid:
109
+ print("✓ Game VALID against schema")
110
+ else:
111
+ print(f"✗ Game INVALID - {len(errors)} errors:")
112
+ for error in errors[:3]:
113
+ print(f" - {error}")
114
+
115
+ # Step 5: Display game details
116
+ print("\n6. Generated Game Details:")
117
+ print(f" ID: {game['game_id']}")
118
+ print(f" Title: {game['title']}")
119
+ print(f" Theme: {game['theme']}")
120
+ print(f" Area: {game['setup']['area']}")
121
+ print(f" Duration: {game['setup']['duration_minutes']} min | Players: {game['setup']['num_players']}")
122
+ print(f" Rules: {len(game['rules'])} rules")
123
+ print(f" Tasks: {len(game['tasks'])} tasks")
124
+ print(f" Safety: Adult supervision = {game['safety']['adult_supervision']}")
125
+ print(f" Story tone: {game['story_seed']['tone']}")
126
+
127
+ # Show first 2 tasks
128
+ print(f"\n First 2 Tasks:")
129
+ for task in game['tasks'][:2]:
130
+ print(f" • {task['task_id']}: {task['title']}")
131
+ print(f" Points: {task['points']} | Proof: {task['proof_type']} | Time: {task['time_limit_minutes']} min")
132
+
133
+ results.append({
134
+ 'config_name': test['name'],
135
+ 'valid': is_valid,
136
+ 'game_id': game['game_id'],
137
+ 'error_count': len(errors)
138
+ })
139
+
140
+ except Exception as e:
141
+ print(f"✗ Generation failed: {e}")
142
+ results.append({
143
+ 'config_name': test['name'],
144
+ 'valid': False,
145
+ 'game_id': None,
146
+ 'error_count': 1
147
+ })
148
+
149
+ # Summary
150
+ print("\n" + "=" * 80)
151
+ print("TEST SUMMARY")
152
+ print("=" * 80)
153
+
154
+ for result in results:
155
+ status = "✓ PASS" if result['valid'] else "✗ FAIL"
156
+ print(f"{status}: {result['config_name']}")
157
+ if result['game_id']:
158
+ print(f" Generated: {result['game_id']}")
159
+ if result['error_count'] > 0:
160
+ print(f" Errors: {result['error_count']}")
161
+
162
+ total_pass = sum(1 for r in results if r['valid'])
163
+ print(f"\nTotal: {total_pass}/{len(results)} tests passed")
164
+
165
+ print("\n" + "=" * 80)
166
+ print("GENERATION READY FOR PHASE 2 TASK 7: VALIDATION")
167
+ print("=" * 80)
168
+
169
+
170
+ if __name__ == "__main__":
171
+ main()
test_generation_gguf.py ADDED
@@ -0,0 +1,180 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Test the game generation pipeline with llama.cpp and NVIDIA Nemotron 3 Nano 4B GGUF.
3
+
4
+ Run this script to:
5
+ 1. Test model availability (llama-cpp-python)
6
+ 2. Demonstrate GGUF model loading from HuggingFace
7
+ 3. Generate games with the Nemotron model
8
+ 4. Fall back to mock generation if model unavailable
9
+ 5. Validate all outputs against schema
10
+ """
11
+
12
+ import json
13
+ from pathlib import Path
14
+ from app.services.retrieval import load_games_dataset, normalize_game_record, retrieve_examples
15
+ from app.services.generator import generate_game, build_generation_prompt, NEMOTRON_MODEL_ID, NEMOTRON_GGUF_FILE
16
+ from app.services.schema_validator import validate_game_schema
17
+
18
+
19
+ def check_llama_cpp_availability():
20
+ """Check if llama-cpp-python is installed."""
21
+ print("\n" + "=" * 80)
22
+ print("CHECKING ENVIRONMENT")
23
+ print("=" * 80)
24
+
25
+ try:
26
+ import llama_cpp
27
+ print(f"✓ llama-cpp-python is installed")
28
+ print(f" Version: {llama_cpp.__version__ if hasattr(llama_cpp, '__version__') else 'unknown'}")
29
+ return True
30
+ except ImportError:
31
+ print("✗ llama-cpp-python not found")
32
+ print(" Install with: pip install llama-cpp-python")
33
+ print(" Or for GPU support: pip install llama-cpp-python[cuda]")
34
+ return False
35
+
36
+
37
+ def main():
38
+ print("\n" + "=" * 80)
39
+ print("PHASE 2, TASK 6: GAME GENERATION WITH NEMOTRON 3 NANO 4B GGUF")
40
+ print("=" * 80)
41
+
42
+ # Check environment
43
+ llama_cpp_available = check_llama_cpp_availability()
44
+
45
+ print(f"\nModel Configuration:")
46
+ print(f" Repository: {NEMOTRON_MODEL_ID}")
47
+ print(f" File: {NEMOTRON_GGUF_FILE}")
48
+ print(f" Runtime: llama.cpp (GGUF quantized)")
49
+ print(f" Benefits:")
50
+ print(f" • GGUF quantization: 4-bit, memory efficient")
51
+ print(f" • llama.cpp: Fast CPU/GPU inference")
52
+ print(f" • Hackathon bonus: Extra credit for llama.cpp runtime")
53
+
54
+ # Load dataset
55
+ print("\n1. Loading dataset...")
56
+ raw_records = load_games_dataset("app/data/games_dataset.json")
57
+ normalized_records = [normalize_game_record(r) for r in raw_records]
58
+ print(f"✓ Loaded {len(normalized_records)} records")
59
+
60
+ # Test cases
61
+ test_configs = [
62
+ {
63
+ "name": "Scavenger Hunt - Adults - Medium",
64
+ "config": {
65
+ "game_type": "scavenger_hunt",
66
+ "city": "Paris",
67
+ "area": "Le Marais",
68
+ "location_type": "mixed",
69
+ "duration_minutes": 60,
70
+ "num_players": 4,
71
+ "difficulty": "medium",
72
+ "age_group": "adults"
73
+ }
74
+ },
75
+ {
76
+ "name": "Hide & Seek - Kids - Easy",
77
+ "config": {
78
+ "game_type": "hide_and_seek",
79
+ "city": "Paris",
80
+ "area": "Parc des Buttes-Chaumont",
81
+ "location_type": "park",
82
+ "duration_minutes": 45,
83
+ "num_players": 5,
84
+ "difficulty": "easy",
85
+ "age_group": "kids"
86
+ }
87
+ }
88
+ ]
89
+
90
+ # Test generation
91
+ results = []
92
+ for test in test_configs:
93
+ print("\n" + "=" * 80)
94
+ print(f"TEST: {test['name']}")
95
+ print("=" * 80)
96
+
97
+ config = test['config']
98
+
99
+ # Retrieve similar games
100
+ print("\n2. Retrieving similar games...")
101
+ retrieved = retrieve_examples(config, normalized_records, k=3)
102
+ print(f"✓ Retrieved {len(retrieved)} examples")
103
+
104
+ # Build prompt
105
+ print("\n3. Building generation prompt...")
106
+ prompt = build_generation_prompt(config, retrieved)
107
+ print(f"✓ Prompt ready ({len(prompt)} chars)")
108
+
109
+ # Generate game
110
+ print("\n4. Generating game...")
111
+ print(f" (Using: NVIDIA Nemotron 3 Nano 4B GGUF via llama.cpp)")
112
+
113
+ try:
114
+ game = generate_game(config, retrieved)
115
+ print(f"✓ Game generated: {game['game_id']}")
116
+
117
+ # Validate
118
+ print("\n5. Validating against schema...")
119
+ is_valid, errors = validate_game_schema(game)
120
+
121
+ if is_valid:
122
+ print("✓ Game VALID against schema")
123
+ else:
124
+ print(f"✗ Validation errors: {len(errors)}")
125
+
126
+ # Display summary
127
+ print("\n6. Game Summary:")
128
+ print(f" Title: {game['title']}")
129
+ print(f" Area: {game['setup']['area']}")
130
+ print(f" Duration: {game['setup']['duration_minutes']} min | Players: {game['setup']['num_players']}")
131
+ print(f" Tasks: {len(game['tasks'])} | Rules: {len(game['rules'])}")
132
+ print(f" Tone: {game['story_seed']['tone']}")
133
+
134
+ results.append({
135
+ 'name': test['name'],
136
+ 'valid': is_valid,
137
+ 'game_id': game['game_id']
138
+ })
139
+
140
+ except Exception as e:
141
+ print(f"✗ Generation failed: {e}")
142
+ results.append({
143
+ 'name': test['name'],
144
+ 'valid': False,
145
+ 'game_id': None
146
+ })
147
+
148
+ # Summary
149
+ print("\n" + "=" * 80)
150
+ print("TEST SUMMARY")
151
+ print("=" * 80)
152
+
153
+ passed = sum(1 for r in results if r['valid'])
154
+ print(f"\nResults: {passed}/{len(results)} tests passed")
155
+
156
+ for result in results:
157
+ status = "✓ PASS" if result['valid'] else "✗ FAIL"
158
+ print(f"{status}: {result['name']}")
159
+ if result['game_id']:
160
+ print(f" {result['game_id']}")
161
+
162
+ print("\n" + "=" * 80)
163
+ print("NOTES")
164
+ print("=" * 80)
165
+ if not llama_cpp_available:
166
+ print("⚠ llama-cpp-python not installed - using mock generation")
167
+ print(" For actual model-based generation, install:")
168
+ print(" pip install llama-cpp-python[cuda] # For GPU")
169
+ print(" or")
170
+ print(" pip install llama-cpp-python # For CPU")
171
+ else:
172
+ print("✓ llama-cpp-python available")
173
+ print("✓ NVIDIA Nemotron 3 Nano 4B GGUF ready for download from HuggingFace")
174
+ print("✓ Hackathon extra credit: llama.cpp runtime ✓")
175
+
176
+ print("\n" + "=" * 80)
177
+
178
+
179
+ if __name__ == "__main__":
180
+ main()
test_repair.py ADDED
@@ -0,0 +1,462 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Test the repair pass logic for Phase 2, Task 8.
3
+
4
+ Tests that repair_game() can:
5
+ 1. Fix missing fields and structure
6
+ 2. Add safety constraints
7
+ 3. Repair task content issues
8
+ 4. Handle multiple failures simultaneously
9
+ 5. Preserve valid content when fixing
10
+ """
11
+
12
+ from app.services.validator import validate_game, repair_game
13
+
14
+
15
+ def print_result(label: str, valid: bool, failures: list[str]):
16
+ status = "✓ PASS" if valid else "✗ FAIL"
17
+ print(f" {status}: {label}")
18
+ if failures:
19
+ for f in failures[:3]:
20
+ print(f" → {f}")
21
+ if len(failures) > 3:
22
+ print(f" → ... and {len(failures) - 3} more")
23
+
24
+
25
+ def test_repair_minimal_game():
26
+ """Repair a game with almost nothing filled in."""
27
+ print("\n" + "=" * 80)
28
+ print("TEST 1: Repair minimal (empty) game")
29
+ print("=" * 80)
30
+
31
+ bad_game = {
32
+ "game_id": "",
33
+ "title": "",
34
+ "theme": "",
35
+ "setup": {},
36
+ "rules": [],
37
+ "tasks": [],
38
+ "global_hints": [],
39
+ "score_rules": [],
40
+ "tie_breaker": "",
41
+ "safety": {},
42
+ "story_seed": {}
43
+ }
44
+
45
+ config = {
46
+ "game_type": "scavenger_hunt",
47
+ "city": "Paris",
48
+ "area": "Le Marais",
49
+ "duration_minutes": 60,
50
+ "num_players": 4,
51
+ "difficulty": "medium",
52
+ "age_group": "adults"
53
+ }
54
+
55
+ # Show validation before repair
56
+ is_valid, failures = validate_game(bad_game, config)
57
+ print_result("Before repair", is_valid, failures)
58
+
59
+ # Apply repair
60
+ repaired = repair_game(bad_game, failures, config)
61
+
62
+ # Validate repaired game
63
+ is_valid, failures = validate_game(repaired, config)
64
+ print_result("After repair", is_valid, failures)
65
+
66
+ # Show what was fixed
67
+ print(f"\n Repaired game ID: {repaired['game_id']}")
68
+ print(f" Title: {repaired['title']}")
69
+ print(f" Theme: {repaired['theme']}")
70
+ print(f" Tasks: {len(repaired['tasks'])}")
71
+ print(f" Rules: {len(repaired['rules'])}")
72
+ print(f" Score rules: {len(repaired['score_rules'])}")
73
+ print(f" Safety zone: {repaired['safety']['allowed_zone'][:50]}...")
74
+ print(f" Supervision: {repaired['safety']['adult_supervision']}")
75
+ print(f" Forbidden behaviors: {len(repaired['safety']['forbidden_behaviors'])}")
76
+ print(f" Stop conditions: {len(repaired['safety']['stop_conditions'])}")
77
+
78
+ return is_valid
79
+
80
+
81
+ def test_repair_missing_task_fields():
82
+ """Repair tasks with missing critical fields."""
83
+ print("\n" + "=" * 80)
84
+ print("TEST 2: Repair tasks with missing fields")
85
+ print("=" * 80)
86
+
87
+ bad_game = {
88
+ "game_id": "game-001",
89
+ "title": "Test Game",
90
+ "theme": "discovery",
91
+ "setup": {
92
+ "city": "Paris",
93
+ "area": "Montmartre",
94
+ "meeting_point": "Sacré-Cœur steps",
95
+ "duration_minutes": 45,
96
+ "num_players": 4
97
+ },
98
+ "rules": ["Rule 1", "Rule 2"],
99
+ "tasks": [
100
+ {
101
+ "task_id": "t1",
102
+ "title": "Find the vineyard",
103
+ "description": "Locate the vineyard sign",
104
+ "location_hint": "North slope near Lapin Agile",
105
+ "points": 20,
106
+ "time_limit_minutes": 15,
107
+ # missing proof_type
108
+ # missing hint
109
+ # missing safety_note
110
+ }
111
+ ],
112
+ "global_hints": [],
113
+ "score_rules": [],
114
+ "tie_breaker": "",
115
+ "safety": {
116
+ "allowed_zone": "Montmartre public areas",
117
+ "forbidden_behaviors": ["No entering buildings"],
118
+ # missing adult_supervision
119
+ # missing stop_conditions
120
+ },
121
+ "story_seed": {
122
+ "tone": "playful",
123
+ "motifs": [],
124
+ "recap_style": "episode_recap"
125
+ }
126
+ }
127
+
128
+ config = {
129
+ "game_type": "scavenger_hunt",
130
+ "city": "Paris",
131
+ "area": "Montmartre",
132
+ "duration_minutes": 45,
133
+ "num_players": 4,
134
+ "difficulty": "hard",
135
+ "age_group": "adults"
136
+ }
137
+
138
+ # Before repair
139
+ is_valid, failures = validate_game(bad_game, config)
140
+ print_result("Before repair", is_valid, failures)
141
+
142
+ # Repair
143
+ repaired = repair_game(bad_game, failures, config)
144
+
145
+ # After repair
146
+ is_valid, failures = validate_game(repaired, config)
147
+ print_result("After repair", is_valid, failures)
148
+
149
+ # Show fixed task
150
+ task = repaired['tasks'][0]
151
+ print(f"\n Fixed task fields:")
152
+ print(f" proof_type: {task.get('proof_type')}")
153
+ print(f" hint: {task.get('hint')[:40]}...")
154
+ print(f" safety_note: {task.get('safety_note')[:40]}...")
155
+ print(f" Fixed safety:")
156
+ print(f" adult_supervision: {repaired['safety'].get('adult_supervision')}")
157
+ print(f" stop_conditions: {len(repaired['safety'].get('stop_conditions', []))}")
158
+
159
+ return is_valid
160
+
161
+
162
+ def test_repair_kids_game_no_supervision():
163
+ """Repair a kids game missing adult supervision."""
164
+ print("\n" + "=" * 80)
165
+ print("TEST 3: Repair kids game missing adult supervision")
166
+ print("=" * 80)
167
+
168
+ bad_game = {
169
+ "game_id": "game-kids",
170
+ "title": "Kids Hunt",
171
+ "theme": "play",
172
+ "setup": {
173
+ "city": "Paris",
174
+ "area": "Parc des Buttes-Chaumont",
175
+ "meeting_point": "Main entrance",
176
+ "duration_minutes": 30,
177
+ "num_players": 6
178
+ },
179
+ "rules": ["Stay in park"],
180
+ "tasks": [
181
+ {
182
+ "task_id": "t1",
183
+ "title": "Find the temple",
184
+ "description": "Find the temple viewpoint",
185
+ "location_hint": "At the top of the hill",
186
+ "points": 15,
187
+ "time_limit_minutes": 10,
188
+ "proof_type": "observation",
189
+ "hint": "Look up",
190
+ "safety_note": "Stay safe"
191
+ }
192
+ ],
193
+ "global_hints": [],
194
+ "score_rules": [],
195
+ "tie_breaker": "",
196
+ "safety": {
197
+ "allowed_zone": "Park",
198
+ "forbidden_behaviors": ["No climbing"],
199
+ "adult_supervision": False, # Should be True for kids!
200
+ "stop_conditions": []
201
+ },
202
+ "story_seed": {
203
+ "tone": "playful",
204
+ "motifs": [],
205
+ "recap_style": "episode_recap"
206
+ }
207
+ }
208
+
209
+ config = {
210
+ "game_type": "hide_and_seek",
211
+ "city": "Paris",
212
+ "area": "Parc des Buttes-Chaumont",
213
+ "duration_minutes": 30,
214
+ "num_players": 6,
215
+ "difficulty": "easy",
216
+ "age_group": "kids"
217
+ }
218
+
219
+ # Before repair
220
+ is_valid, failures = validate_game(bad_game, config)
221
+ print_result("Before repair", is_valid, failures)
222
+
223
+ # Repair
224
+ repaired = repair_game(bad_game, failures, config)
225
+
226
+ # After repair
227
+ is_valid, failures = validate_game(repaired, config)
228
+ print_result("After repair", is_valid, failures)
229
+
230
+ print(f"\n Adult supervision: {repaired['safety']['adult_supervision']} (was False)")
231
+ print(f" Rules: {len(repaired['rules'])} (was 1)")
232
+ print(f" Safety note: {repaired['tasks'][0]['safety_note'][:50]}...")
233
+
234
+ return is_valid
235
+
236
+
237
+ def test_repair_content_with_forbidden_keywords():
238
+ """Repair tasks mentioning forbidden locations."""
239
+ print("\n" + "=" * 80)
240
+ print("TEST 4: Repair content with forbidden keyword mentions")
241
+ print("=" * 80)
242
+
243
+ bad_game = {
244
+ "game_id": "game-004",
245
+ "title": "Building Hunt",
246
+ "theme": "urban",
247
+ "setup": {
248
+ "city": "Paris",
249
+ "area": "Centre",
250
+ "meeting_point": "Plaza",
251
+ "duration_minutes": 45,
252
+ "num_players": 3,
253
+ "time_limit_minutes": 40
254
+ },
255
+ "rules": ["Rule 1", "Rule 2", "Rule 3"],
256
+ "tasks": [
257
+ {
258
+ "task_id": "t1",
259
+ "title": "Enter the shop",
260
+ "description": "Go inside the shop on Rue de Rivoli and ask staff for directions",
261
+ "location_hint": "Inside the building entrance",
262
+ "points": 20,
263
+ "time_limit_minutes": 10,
264
+ "proof_type": "photo",
265
+ "hint": "Check behind the counter",
266
+ "safety_note": "Be careful"
267
+ }
268
+ ],
269
+ "global_hints": [],
270
+ "score_rules": ["Complete tasks"],
271
+ "tie_breaker": "First to finish",
272
+ "safety": {
273
+ "allowed_zone": "City center",
274
+ "forbidden_behaviors": [],
275
+ "adult_supervision": False,
276
+ "stop_conditions": []
277
+ },
278
+ "story_seed": {
279
+ "tone": "playful",
280
+ "motifs": [],
281
+ "recap_style": "episode_recap"
282
+ }
283
+ }
284
+
285
+ config = {
286
+ "game_type": "scavenger_hunt",
287
+ "city": "Paris",
288
+ "area": "Centre",
289
+ "duration_minutes": 45,
290
+ "num_players": 3,
291
+ "difficulty": "medium",
292
+ "age_group": "adults"
293
+ }
294
+
295
+ # Before repair
296
+ is_valid, failures = validate_game(bad_game, config)
297
+ print_result("Before repair", is_valid, failures)
298
+
299
+ # Repair
300
+ repaired = repair_game(bad_game, failures, config)
301
+
302
+ # After repair
303
+ is_valid, failures = validate_game(repaired, config)
304
+ print_result("After repair", is_valid, failures)
305
+
306
+ print(f"\n Task safety note: {repaired['tasks'][0]['safety_note'][:70]}...")
307
+ print(f" Forbidden behaviors: {len(repaired['safety']['forbidden_behaviors'])} items")
308
+
309
+ return is_valid
310
+
311
+
312
+ def test_repair_wrong_task_count():
313
+ """Repair games with too few or too many tasks for duration."""
314
+ print("\n" + "=" * 80)
315
+ print("TEST 5: Repair unrealistic task count for duration")
316
+ print("=" * 80)
317
+
318
+ # 90 min game with only 1 task
319
+ bad_game = {
320
+ "game_id": "game-005",
321
+ "title": "Long Game",
322
+ "theme": "epic",
323
+ "setup": {
324
+ "city": "Paris",
325
+ "area": "Le Marais",
326
+ "meeting_point": "Centre",
327
+ "duration_minutes": 90,
328
+ "num_players": 4
329
+ },
330
+ "rules": ["Rule 1", "Rule 2", "Rule 3", "Rule 4", "Rule 5"],
331
+ "tasks": [
332
+ {
333
+ "task_id": "t1",
334
+ "title": "One task",
335
+ "description": "Walk around",
336
+ "location_hint": "Around",
337
+ "points": 30,
338
+ "time_limit_minutes": None,
339
+ "proof_type": "photo",
340
+ "hint": "Look around",
341
+ "safety_note": "Stay in public areas"
342
+ }
343
+ ],
344
+ "global_hints": [],
345
+ "score_rules": ["Complete all"],
346
+ "tie_breaker": "First",
347
+ "safety": {
348
+ "allowed_zone": "Le Marais",
349
+ "forbidden_behaviors": ["No buildings", "No roads"],
350
+ "adult_supervision": False,
351
+ "stop_conditions": ["Injury", "Weather", "Emergency"]
352
+ },
353
+ "story_seed": {"tone": "playful", "motifs": [], "recap_style": "episode_recap"}
354
+ }
355
+
356
+ config = {
357
+ "game_type": "scavenger_hunt",
358
+ "city": "Paris",
359
+ "area": "Le Marais",
360
+ "duration_minutes": 90,
361
+ "num_players": 4,
362
+ "difficulty": "medium",
363
+ "age_group": "adults"
364
+ }
365
+
366
+ # Before repair
367
+ is_valid, failures = validate_game(bad_game, config)
368
+ print_result("Before repair", is_valid, failures)
369
+
370
+ # Repair
371
+ repaired = repair_game(bad_game, failures, config)
372
+
373
+ # After repair
374
+ is_valid, failures = validate_game(repaired, config)
375
+ print_result("After repair", is_valid, failures)
376
+
377
+ print(f"\n Tasks: {len(repaired['tasks'])} (was 1)")
378
+ print(f" Task IDs: {', '.join(t['task_id'] for t in repaired['tasks'])}")
379
+ print(f" Task points: {[t['points'] for t in repaired['tasks']]}")
380
+
381
+ return is_valid
382
+
383
+
384
+ def test_full_pipeline_repair():
385
+ """Test full pipeline: generate → validate → repair → re-validate."""
386
+ print("\n" + "=" * 80)
387
+ print("TEST 6: Full pipeline - generate, validate, repair, re-validate")
388
+ print("=" * 80)
389
+
390
+ from app.services.generator import generate_game
391
+ from app.services.retrieval import load_games_dataset, normalize_game_record, retrieve_examples
392
+
393
+ # Load dataset
394
+ raw = load_games_dataset("app/data/games_dataset.json")
395
+ normalized = [normalize_game_record(r) for r in raw]
396
+
397
+ config = {
398
+ "game_type": "scavenger_hunt",
399
+ "city": "Paris",
400
+ "area": "Le Marais",
401
+ "location_type": "mixed",
402
+ "duration_minutes": 60,
403
+ "num_players": 4,
404
+ "difficulty": "medium",
405
+ "age_group": "adults",
406
+ "photo_enabled": True
407
+ }
408
+
409
+ # Generate
410
+ retrieved = retrieve_examples(config, normalized, k=3)
411
+ game = generate_game(config, retrieved)
412
+ print(f" Generated: {game['game_id']} ({len(game['tasks'])} tasks)")
413
+
414
+ # Validate
415
+ is_valid, failures = validate_game(game, config)
416
+ print_result("Initial validation", is_valid, failures)
417
+
418
+ # Repair if needed
419
+ if not is_valid:
420
+ repaired = repair_game(game, failures, config)
421
+ is_valid, failures = validate_game(repaired, config)
422
+ print_result("After repair", is_valid, failures)
423
+
424
+ print(f"\n Originally: {len(game['tasks'])} tasks, {len(game['rules'])} rules")
425
+ print(f" Repaired: {len(repaired['tasks'])} tasks, {len(repaired['rules'])} rules")
426
+ else:
427
+ print(" No repair needed (game already valid)")
428
+
429
+ return is_valid
430
+
431
+
432
+ def main():
433
+ print("\n" + "=" * 80)
434
+ print("PHASE 2, TASK 8: REPAIR PASS LOGIC TESTS")
435
+ print("=" * 80)
436
+
437
+ results = []
438
+
439
+ # Run all tests
440
+ results.append(("Test 1: Minimal game repair", test_repair_minimal_game()))
441
+ results.append(("Test 2: Missing task fields", test_repair_missing_task_fields()))
442
+ results.append(("Test 3: Kids game supervision", test_repair_kids_game_no_supervision()))
443
+ results.append(("Test 4: Forbidden content", test_repair_content_with_forbidden_keywords()))
444
+ results.append(("Test 5: Task count realism", test_repair_wrong_task_count()))
445
+ results.append(("Test 6: Full pipeline", test_full_pipeline_repair()))
446
+
447
+ # Summary
448
+ print("\n" + "=" * 80)
449
+ print("REPAIR PASS TEST SUMMARY")
450
+ print("=" * 80)
451
+
452
+ passed = sum(1 for _, ok in results if ok)
453
+ for name, ok in results:
454
+ status = "✓ PASS" if ok else "✗ FAIL"
455
+ print(f" {status}: {name}")
456
+
457
+ print(f"\n Total: {passed}/{len(results)} tests passed")
458
+ print("\n" + "=" * 80)
459
+
460
+
461
+ if __name__ == "__main__":
462
+ main()
test_validation.py ADDED
@@ -0,0 +1,229 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Test the game validation and repair pipeline.
3
+
4
+ Run this script to:
5
+ 1. Test validation against hard rules
6
+ 2. Test repair logic for failed validations
7
+ 3. Validate generated games from the generator
8
+ 4. Display validation results
9
+ """
10
+
11
+ import json
12
+ from app.services.retrieval import load_games_dataset, normalize_game_record, retrieve_examples
13
+ from app.services.generator import generate_game
14
+ from app.services.validator import validate_game, repair_game
15
+
16
+
17
+ def main():
18
+ print("\n" + "=" * 80)
19
+ print("PHASE 2, TASK 7: GAME VALIDATION AND REPAIR")
20
+ print("=" * 80)
21
+
22
+ # Load dataset
23
+ print("\n1. Loading dataset...")
24
+ raw_records = load_games_dataset("app/data/games_dataset.json")
25
+ normalized_records = [normalize_game_record(r) for r in raw_records]
26
+ print(f"✓ Loaded {len(normalized_records)} records")
27
+
28
+ # Test configs
29
+ test_configs = [
30
+ {
31
+ "name": "Valid: Scavenger Hunt - Adults - Medium",
32
+ "config": {
33
+ "game_type": "scavenger_hunt",
34
+ "city": "Paris",
35
+ "area": "Le Marais",
36
+ "location_type": "mixed",
37
+ "duration_minutes": 60,
38
+ "num_players": 4,
39
+ "difficulty": "medium",
40
+ "age_group": "adults"
41
+ },
42
+ "expect_valid": True
43
+ },
44
+ {
45
+ "name": "Valid: Hide & Seek - Kids - Easy",
46
+ "config": {
47
+ "game_type": "hide_and_seek",
48
+ "city": "Paris",
49
+ "area": "Parc des Buttes-Chaumont",
50
+ "location_type": "park",
51
+ "duration_minutes": 45,
52
+ "num_players": 5,
53
+ "difficulty": "easy",
54
+ "age_group": "kids"
55
+ },
56
+ "expect_valid": True
57
+ },
58
+ {
59
+ "name": "Valid: Tag - Teens - Hard",
60
+ "config": {
61
+ "game_type": "tag",
62
+ "city": "Paris",
63
+ "area": "Jardins du Trocadéro",
64
+ "location_type": "park",
65
+ "duration_minutes": 30,
66
+ "num_players": 8,
67
+ "difficulty": "hard",
68
+ "age_group": "teens"
69
+ },
70
+ "expect_valid": True
71
+ }
72
+ ]
73
+
74
+ # Invalid game example for repair testing
75
+ invalid_game = {
76
+ "game_id": "invalid-test",
77
+ "title": "Test Game",
78
+ "theme": "test",
79
+ "setup": {
80
+ "city": "Paris",
81
+ "area": "Test Area",
82
+ "meeting_point": "Central",
83
+ "duration_minutes": 60,
84
+ "num_players": 4
85
+ },
86
+ "rules": ["Rule 1"],
87
+ "tasks": [
88
+ {
89
+ "task_id": "t1",
90
+ # Missing: title, description, location_hint, points, proof_type, hint, safety_note
91
+ "description": None,
92
+ "points": -5, # Invalid: negative points
93
+ "proof_type": "invalid_type" # Invalid: not in enum
94
+ }
95
+ ],
96
+ "global_hints": ["Hint"],
97
+ "score_rules": [], # Empty
98
+ "tie_breaker": "", # Empty
99
+ "safety": {} # Missing required fields
100
+ }
101
+
102
+ results = []
103
+
104
+ # Test 1: Generated games validation
105
+ print("\n" + "=" * 80)
106
+ print("TEST SUITE 1: VALIDATING GENERATED GAMES")
107
+ print("=" * 80)
108
+
109
+ for test in test_configs:
110
+ print(f"\n{test['name']}")
111
+ print("-" * 80)
112
+
113
+ config = test['config']
114
+
115
+ # Generate game
116
+ print("Retrieving similar games...")
117
+ retrieved = retrieve_examples(config, normalized_records, k=3)
118
+
119
+ print("Generating game...")
120
+ game = generate_game(config, retrieved)
121
+
122
+ # Validate
123
+ print("Validating game...")
124
+ is_valid, failures = validate_game(game, config)
125
+
126
+ if is_valid:
127
+ print(f"✓ VALID - Game {game['game_id']} passed all checks")
128
+ else:
129
+ print(f"✗ INVALID - {len(failures)} validation errors:")
130
+ for failure in failures[:3]:
131
+ print(f" - {failure}")
132
+ if len(failures) > 3:
133
+ print(f" ... and {len(failures) - 3} more errors")
134
+
135
+ results.append({
136
+ 'name': test['name'],
137
+ 'valid': is_valid,
138
+ 'expected_valid': test['expect_valid'],
139
+ 'failures': failures
140
+ })
141
+
142
+ # Test 2: Invalid game repair
143
+ print("\n" + "=" * 80)
144
+ print("TEST SUITE 2: REPAIR OF INVALID GAMES")
145
+ print("=" * 80)
146
+
147
+ print("\nBefore repair:")
148
+ config = test_configs[0]['config']
149
+ is_valid_before, failures_before = validate_game(invalid_game, config)
150
+ print(f"✗ Invalid with {len(failures_before)} errors:")
151
+ for failure in failures_before[:5]:
152
+ print(f" - {failure}")
153
+ if len(failures_before) > 5:
154
+ print(f" ... and {len(failures_before) - 5} more errors")
155
+
156
+ print("\nRepairing game...")
157
+ repaired_game = repair_game(invalid_game, failures_before, config)
158
+
159
+ print("After repair:")
160
+ is_valid_after, failures_after = validate_game(repaired_game, config)
161
+
162
+ if is_valid_after:
163
+ print(f"✓ VALID after repair")
164
+ print(f" Fixed {len(failures_before)} issues")
165
+ print(f" Game ID: {repaired_game['game_id']}")
166
+ print(f" Tasks: {len(repaired_game['tasks'])}")
167
+ print(f" Rules: {len(repaired_game['rules'])}")
168
+ print(f" Safety zone: {repaired_game['safety'].get('allowed_zone')}")
169
+ else:
170
+ print(f"✗ Still invalid with {len(failures_after)} errors:")
171
+ for failure in failures_after[:3]:
172
+ print(f" - {failure}")
173
+
174
+ # Summary
175
+ print("\n" + "=" * 80)
176
+ print("VALIDATION TEST SUMMARY")
177
+ print("=" * 80)
178
+
179
+ passed = sum(1 for r in results if r['valid'] == r['expected_valid'])
180
+ total = len(results)
181
+
182
+ print(f"\nGenerated games: {passed}/{total} passed expectations")
183
+ for result in results:
184
+ status = "✓ PASS" if result['valid'] == result['expected_valid'] else "✗ FAIL"
185
+ valid_str = "VALID" if result['valid'] else "INVALID"
186
+ print(f"{status}: {result['name']} → {valid_str}")
187
+
188
+ print(f"\nRepair test: {'✓ PASS' if is_valid_after else '✗ FAIL'}")
189
+ print(f" Before: {len(failures_before)} failures")
190
+ print(f" After: {len(failures_after)} failures")
191
+
192
+ print("\n" + "=" * 80)
193
+ print("VALIDATION CHECKS IMPLEMENTED")
194
+ print("=" * 80)
195
+ print("""
196
+ ✓ Structure validation
197
+ - All required fields present
198
+ - Correct field types
199
+
200
+ ✓ Task requirements
201
+ - Proof types in enum (photo|observation|text)
202
+ - Positive points
203
+ - Meaningful hints and safety notes
204
+
205
+ ✓ Safety constraints
206
+ - No forbidden behaviors (buildings, private areas)
207
+ - Water hazards have explicit restrictions
208
+ - Road hazards have explicit guidance
209
+ - No illegal building access
210
+
211
+ ✓ Age appropriateness
212
+ - Kids/mixed games require adult supervision
213
+ - Supervision mentioned in rules
214
+
215
+ ✓ Realism checks
216
+ - Task count matches duration (8-20 min per task)
217
+ - Clear win conditions
218
+ - Scoring rules present
219
+
220
+ ✓ Repair automation
221
+ - Adds missing fields with defaults
222
+ - Fixes enum violations
223
+ - Ensures positive values
224
+ - Adjusts task count for duration
225
+ """)
226
+
227
+
228
+ if __name__ == "__main__":
229
+ main()