Commit ·
e9fc2fc
1
Parent(s): eaf7eca
Phase 2: ship the core happy path
Browse files- NEMOTRON_GGUF_SETUP.md +151 -0
- app.py +252 -10
- app/services/generator.py +450 -26
- app/services/validator.py +710 -26
- requirements.txt +2 -0
- scripts/extract_task_patterns.py +18 -0
- scripts/quick_test.py +16 -0
- scripts/quick_test_all.py +25 -0
- test_generation.py +171 -0
- test_generation_gguf.py +180 -0
- test_repair.py +462 -0
- test_validation.py +229 -0
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 |
-
|
| 6 |
-
|
|
|
|
|
|
|
| 7 |
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
|
| 13 |
-
|
| 14 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 2 |
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
| 4 |
|
| 5 |
|
| 6 |
-
|
| 7 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
|
| 9 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
|
| 11 |
Args:
|
| 12 |
-
|
| 13 |
-
|
| 14 |
|
| 15 |
Returns:
|
| 16 |
-
Generated game JSON
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
"""
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
"safety": {
|
| 30 |
-
"allowed_zone": "",
|
| 31 |
-
"forbidden_behaviors": [
|
| 32 |
-
|
| 33 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
},
|
| 35 |
"story_seed": {
|
| 36 |
-
"tone": "playful",
|
| 37 |
-
"motifs": [],
|
| 38 |
"recap_style": "episode_recap"
|
| 39 |
}
|
| 40 |
}
|
| 41 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 4 |
|
| 5 |
|
| 6 |
-
|
| 7 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 17 |
-
|
| 18 |
-
#
|
| 19 |
-
|
| 20 |
-
("
|
| 21 |
-
("
|
| 22 |
-
("
|
| 23 |
-
("
|
| 24 |
-
("
|
| 25 |
-
("
|
| 26 |
-
("
|
| 27 |
-
("
|
| 28 |
-
("
|
| 29 |
-
("safety", lambda g: g.get("safety")),
|
| 30 |
]
|
| 31 |
|
| 32 |
-
for check_name, check_fn in
|
| 33 |
try:
|
| 34 |
-
|
| 35 |
-
|
|
|
|
| 36 |
except Exception as e:
|
| 37 |
-
|
| 38 |
|
| 39 |
-
return len(
|
| 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 |
-
|
| 54 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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()
|