| r""" |
| generator.py β scavenger_hunt dataset generator |
| |
| One Gemini call per example. Failed examples retry up to 2x with the |
| validation errors fed back into the prompt. Arithmetic fields are |
| auto-fixed before validation so the model is never penalised for math. |
| Saves after every successful example (safe to Ctrl-C and resume). |
| |
| Usage (PowerShell / Windows): |
| $env:GEMINI_API_KEY = "your-key" |
| python generator.py --n 10 --output ..\..\app\data\scavenger_hunt\dataset.json |
| |
| Usage (bash / Mac / Linux): |
| export GEMINI_API_KEY="your-key" |
| python3 generator.py --n 10 --output ../../app/data/scavenger_hunt/dataset.json |
| |
| Flags: |
| --n INT examples to generate this run (default 10) |
| --output PATH output JSON file (default app/data/scavenger_hunt/dataset.json) |
| --sleep FLOAT seconds between API calls (default 8) |
| --seed INT fix random seed for sampling (optional) |
| --mock skip Gemini, use built-in stub (pipeline smoke-test) |
| """ |
| import os, re, json, time, random, logging, argparse, sys |
| from sampler import sample_input, build_prompt |
| from validator import validate_example, auto_fix |
|
|
| HERE = os.path.dirname(os.path.abspath(__file__)) |
| sys.path.insert(0, os.path.join(HERE, "..", "common")) |
| from cq_common import has_risk_indicators |
|
|
| logging.basicConfig(level=logging.INFO, format="%(asctime)s %(message)s") |
| log = logging.getLogger("cq") |
|
|
| DEFAULT_OUTPUT = os.path.normpath(os.path.join( |
| os.path.dirname(os.path.abspath(__file__)), "..", "..", "app", "data", "scavenger_hunt", "dataset.json")) |
|
|
|
|
| |
| def extract_json(text: str) -> dict: |
| """Pull JSON object from model output, stripping markdown fences if present.""" |
| text = re.sub(r"^```(?:json)?\s*", "", text.strip()) |
| text = re.sub(r"\s*```$", "", text) |
| start, end = text.find("{"), text.rfind("}") |
| if start == -1 or end == -1: |
| raise ValueError("no JSON object found in model output") |
| return json.loads(text[start : end + 1]) |
|
|
|
|
| |
| def call_gemini(prompt: str) -> str: |
| from google import genai |
| from google.genai import types |
| client = genai.Client() |
| resp = client.models.generate_content( |
| model = "gemini-2.5-flash", |
| contents = prompt, |
| config = types.GenerateContentConfig( |
| response_mime_type = "application/json", |
| temperature = 0.8, |
| ), |
| ) |
| return resp.text |
|
|
|
|
| |
| def call_mock(prompt: str) -> str: |
| """Deterministic stub β builds a valid-ish output from info already in the prompt.""" |
| import re as _re |
|
|
| def _find(pattern, default): |
| m = _re.search(pattern, prompt) |
| return m.group(1) if m else default |
|
|
| n_tasks = int(_find(r"exactly (\d+)", "4")) |
| method = _find(r'scoring_method\s*:\s*"(\w+)"', "point_accumulation") |
| dur = int(_find(r"time_limit_minutes\s*:\s*(\d+)", "60")) |
| tb_raw = _find(r"time_bonus_per_minute\s*:\s*(\d+|null)", "null") |
| tb = None if tb_raw == "null" else int(tb_raw) |
| agg_raw = _find(r'team_aggregation\s*:\s*(null|"[^"]+")', "null") |
| agg = None if agg_raw == "null" else agg_raw.strip('"') |
| bonus_ok = "bonus_task_eligible : true" in prompt |
|
|
| |
| tm = _re.search(r'"landscape_tags":\s*\[([^\]]+)\]', prompt) |
| tags = [t.strip().strip('"') for t in tm.group(1).split(",")] if tm else ["plaza_or_square"] |
|
|
| |
| if "ZERO hard tasks" in prompt: |
| diffs = ["easy"] * n_tasks |
| elif "β₯40 % hard" in prompt or ">=40% hard" in prompt: |
| n_hard = max(2, round(n_tasks * 0.45)) |
| n_med = max(1, round(n_tasks * 0.35)) |
| n_easy = max(0, n_tasks - n_hard - n_med) |
| diffs = (["hard"] * n_hard + ["medium"] * n_med + ["easy"] * n_easy)[:n_tasks] |
| else: |
| diffs = (["medium", "easy"] * n_tasks)[:n_tasks] |
|
|
| pts_map = {"easy": 10, "medium": 20, "hard": 30} |
| types_cycle = ["observe_and_answer","find_and_photograph","reach_and_verify", |
| "social_interaction","timed_challenge","collect_and_return"] |
| per_task = max(3, int(dur * 0.78) // n_tasks) |
|
|
| tasks = [] |
| for i in range(n_tasks): |
| tag = tags[i % len(tags)] |
| description = f"Within the zone characterised by {tag.replace('_',' ')}, locate and document the most structurally distinct feature visible from a public pedestrian path." |
| flags = ["uneven paving may be slippery when wet", "watch for cyclists sharing the path"] if has_risk_indicators(description) else [] |
| tasks.append({ |
| "task_id": f"T{i+1:02d}", |
| "title": f"Zone Study {i+1}", |
| "description": description, |
| "landscape_tags_used": [tag], |
| "task_type": types_cycle[i % len(types_cycle)], |
| "difficulty_contribution": diffs[i], |
| "points": pts_map[diffs[i]], |
| "completion_proof": "photograph submitted via app", |
| "estimated_time_minutes": per_task, |
| "hints": { |
| "hint_1": "Scan the area from open space.", |
| "hint_2": f"Move toward the {tag.replace('_',' ')} zone and compare features.", |
| "hint_3": f"Within the {tag.replace('_',' ')} zone, find the feature that differs most from its neighbours in scale or colour.", |
| }, |
| "safety_flags": flags, |
| }) |
|
|
| bonus = ( |
| {"description": "Reach the highest publicly accessible viewpoint in the area and return before the window closes.", |
| "points": 50, "risk": "β20 points if not completed within 15 minutes", |
| "completion_proof": "geotagged photo from viewpoint"} |
| if bonus_ok else |
| {"description": None, "points": None, "risk": None, "completion_proof": None} |
| ) |
|
|
| out = { |
| "game_title": "The Urban Field Survey", |
| "rules": { |
| "objective": "Score the most points before the time limit expires.", |
| "scoring_method": method, |
| "task_reveal_mode": "sequential", |
| "team_rules": "Teams move together and submit jointly." if agg else None, |
| "time_limit_minutes": dur, |
| "disqualification_conditions": ["entering an exclusion zone","using prohibited transport"], |
| }, |
| "safety_constraints": { |
| "exclusion_zones": ["private_property","active_roadway","construction_sites","restricted_government_buildings"], |
| "physical_limits": ["no climbing","no jumping","no water entry","no entering buildings"], |
| "adult_supervision_required": False, |
| "notes": "Standard urban precautions apply.", |
| }, |
| "tasks": tasks, |
| "task_count": n_tasks, |
| "total_possible_points": 0, |
| "max_deductible_points": 0, |
| "minimum_possible_points": 0, |
| "bonus_task_eligible": bonus_ok, |
| "bonus_task": bonus, |
| "scoring_summary": { |
| "base_points_available": 0, |
| "time_bonus_per_minute_early": tb, |
| "hint_cost_tier_1": 5, |
| "hint_cost_tier_2": 10, |
| "team_aggregation_method": agg, |
| "winning_condition_detail": "The competitor with the highest total points when the time limit expires wins; ties broken by earliest final task submission.", |
| }, |
| "estimated_total_time_minutes": 0, |
| "quality_score": 4.0, |
| } |
| return json.dumps(out) |
|
|
|
|
| from cli import run_cli |
|
|
|
|
| if __name__ == "__main__": |
| run_cli("scavenger_hunt", sample_input, build_prompt, auto_fix, |
| validate_example, DEFAULT_OUTPUT, call_mock) |