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")) # ── JSON extraction ─────────────────────────────────────────────────────────── 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]) # ── Gemini backend ──────────────────────────────────────────────────────────── def call_gemini(prompt: str) -> str: from google import genai from google.genai import types client = genai.Client() # reads GEMINI_API_KEY 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 # ── Mock backend (no API needed) ───────────────────────────────────────────── 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 # tags from 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"] # difficulty mix 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, # auto_fix will correct "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 # noqa: E402 if __name__ == "__main__": run_cli("scavenger_hunt", sample_input, build_prompt, auto_fix, validate_example, DEFAULT_OUTPUT, call_mock)