""" sampler.py — scavenger_hunt Samples ONE input config in Python (distributions guaranteed here, not by the LLM) and builds a prompt asking Gemini to write only the output for that input. """ import json import os import random import sys HERE = os.path.dirname(os.path.abspath(__file__)) sys.path.insert(0, os.path.join(HERE, "..", "common")) from cq_common import CITY_BANK, DENSITY, AREA_TYPES, AGE_GROUPS, compute_exclusions, climate_note # (duration_minutes, difficulty) → task count TASK_COUNT = { (30,"easy"):3, (30,"medium"):4, (30,"hard"):4, (45,"easy"):4, (45,"medium"):5, (45,"hard"):5, (60,"easy"):5, (60,"medium"):6, (60,"hard"):7, (90,"easy"):6, (90,"medium"):8, (90,"hard"):9, (120,"easy"):8, (120,"medium"):10,(120,"hard"):12, } THEMES = ["observation","history","social","nature","urban_exploration","photography","logic"] TASK_TYPES = ["find_and_photograph","observe_and_answer","collect_and_return", "reach_and_verify","social_interaction","timed_challenge"] def _scoring_method(age_group, team_count, difficulty, duration): """Deterministic decision tree — pre-computed in Python, given to the LLM as fact.""" if age_group in ("children_only", "mixed_family"): return "point_accumulation" if team_count > 2: return "point_accumulation" if team_count <= 1: if difficulty == "hard" and duration >= 60: return "timed_bonus" if difficulty == "easy" and duration <= 45: return "first_to_finish" return "point_accumulation" # team_count == 2 if difficulty == "hard" and duration >= 90: return "timed_bonus" return "point_accumulation" def sample_input(index: int, rng=None) -> dict: """Return one fully-sampled input record.""" rng = rng or random city = rng.choice(list(CITY_BANK.keys())) country, code, tags, climate = CITY_BANK[city] n_tags = rng.randint(3, min(6, len(tags))) chosen_tags = rng.sample(tags, n_tags) difficulty = rng.choices(["easy","medium","hard"], weights=[30,40,30])[0] duration = rng.choices([30,45,60,90,120], weights=[15,20,35,20,10])[0] age_group = rng.choice(AGE_GROUPS) team_count = rng.choices([1,2,3,4,5], weights=[40,25,15,12,8])[0] count = rng.randint(max(2, team_count), 20) return { "id": f"SH-{code}-{index:04d}", "input": { "game_type": "scavenger_hunt", "location": { "city": city, "country": country, "city_code": code, "landscape_tags": chosen_tags, "urban_density": DENSITY[city], "climate_zone": climate, "area_type": rng.choice(AREA_TYPES), }, "players": { "count": count, "team_count": team_count, "age_group": age_group, "mobility": rng.choices(["standard","limited"], weights=[85,15])[0], }, "preferences": { "duration_minutes": duration, "difficulty": difficulty, "theme": rng.choice(THEMES), "allow_transport": rng.random() < 0.3, }, }, } def build_prompt(record: dict) -> str: """Compact, precise prompt. All decisions are pre-computed and stated as facts.""" inp = record["input"] loc, players, prefs = inp["location"], inp["players"], inp["preferences"] diff = prefs["difficulty"] dur = prefs["duration_minutes"] age = players["age_group"] tc = players["team_count"] n_tasks = TASK_COUNT[(dur, diff)] method = _scoring_method(age, tc, diff, dur) time_bonus = {"easy":2,"medium":3,"hard":5}[diff] if method == "timed_bonus" else None agg = "sum_all_members" if tc > 1 else None bonus_ok = diff == "hard" and n_tasks >= 7 and age != "children_only" supervise = age in ("children_only","mixed_family") max_task_time = int(dur * 0.80 // n_tasks) # generous per-task ceiling exclusions = compute_exclusions(loc["landscape_tags"]) climate_note_txt = climate_note(loc["climate_zone"], duration_minutes=dur, unit="task", unit_activity="walk") diff_rule = { "easy": f"ALL {n_tasks} tasks easy (10 pts each). ZERO hard tasks.", "medium": f"Mix across {n_tasks} tasks: roughly 40 % easy, 45 % medium, ≤1 hard.", "hard": f"Mix across {n_tasks} tasks: ≤1 easy, ~35 % medium, ≥40 % hard.", }[diff] bonus_instruction = ( "bonus_task: one OPTIONAL timed_challenge worth exactly 50 pts. " "risk = '−20 points if not completed within its time window'. " "NO logic puzzles. description must obey the no-proper-noun rule." if bonus_ok else "bonus_task: set ALL four fields (description, points, risk, completion_proof) to null." ) return f"""You are a JSON dataset generator. Return ONLY a valid JSON object — no markdown, no commentary, no extra text. Generate the OUTPUT section of one scavenger-hunt training example. ═══ FIXED INPUT (do not alter) ═══ {json.dumps(inp, indent=1)} ═══ PRE-COMPUTED DECISIONS (use these exact values) ═══ task_count : exactly {n_tasks} (task_ids T01 … T{n_tasks:02d}) scoring_method : "{method}" time_bonus_per_minute : {json.dumps(time_bonus)} team_aggregation : {json.dumps(agg)} bonus_task_eligible : {json.dumps(bonus_ok)} time_limit_minutes : {dur} difficulty mix : {diff_rule} points scale : easy=10, medium=20, hard=30 max per-task time : {max_task_time} min (total task time MUST be ≤ {int(dur*0.85)} min) adult_supervision : {json.dumps(supervise)} exclusion_zones : {json.dumps(exclusions)} climate note : {climate_note_txt} ═══ ABSOLUTE RULES ═══ 1. NO proper nouns anywhere — no city names, landmark names, street names, brand names. Tasks must work purely from the landscape_tags. ALSO BANNED everywhere (description, title, hints, completion_proof — these leak city-specific knowledge the player shouldn't need): "central", "main", "largest", "most famous", "the famous", "best-known", "best known", "downtown", and "iconic" (unless followed by the word "landmark", e.g. "iconic landmark" is fine). ✓ GOOD: "Find the tallest structure visible from the open square and photograph its base." ✗ BAD: "Find the Eiffel Tower." / "...the main square..." / "...the most famous fountain..." 2. Each task's landscape_tags_used must be a strict subset of: {json.dumps(loc["landscape_tags"])} 3. Use at least 3 different task_type values from: {json.dumps(TASK_TYPES)} 4. Hints per task — HARD character limits (count characters, not words): hint_1 = a short directional nudge, AT MOST 50 characters total. hint_2 = describes the feature to look for, AT MOST 80 characters total, longer than hint_1. hint_3 = a near-explicit walkthrough, AT MOST 120 characters total, longer than hint_2. All three hints must be meaningfully distinct from each other. hint_3 must NOT use spoiler phrases that give away exact spatial position relative to another object, e.g. avoid "just before", "next to", "to the left of", "to the right of", "near the corner of", "across from", "adjacent to", "directly behind", "right after", "just past". Describe the feature itself instead of its position relative to something else. 5. No task may require: climbing, jumping, entering water, entering any building (exception: social_interaction tasks may enter public-facing shops). 6. winning_condition_detail: one precise, unambiguous sentence. 7. {bonus_instruction} 8. social_interaction tasks: completion_proof must require a photo, receipt, or other physical/digital artifact — NEVER a verbal-only response (no "tell us", "ask them", "verbal answer", etc.). 9. If a task's landscape_tags_used or description touches water, elevation/slopes/stairs, alleys/narrow passages, crowded areas, or dark/low-light areas, that task's safety_flags must contain AT LEAST 2 specific hazard mentions (e.g. naming the slippery surface, the blind corner, the steep step, the low visibility) — not generic phrases like "be careful" or "stay safe". ═══ OUTPUT SHAPE ═══ {{ "game_title": "string", "rules": {{ "objective": "string", "scoring_method": "{method}", "task_reveal_mode": "sequential" | "all_at_once" | "gated_by_points", "team_rules": "string or null", "time_limit_minutes": {dur}, "disqualification_conditions": ["string"] }}, "safety_constraints": {{ "exclusion_zones": {json.dumps(exclusions)}, "physical_limits": ["no climbing","no jumping","no water entry","no entering buildings"], "adult_supervision_required": {json.dumps(supervise)}, "notes": "string — include climate advisory here" }}, "tasks": [ {{ "task_id": "T01", "title": "string", "description": "string — NO proper nouns", "landscape_tags_used": ["subset of input tags"], "task_type": "string", "difficulty_contribution": "easy|medium|hard", "points": 10 | 20 | 30, "completion_proof": "string", "estimated_time_minutes": "integer ≤ {max_task_time}", "hints": {{ "hint_1": "5–15 words", "hint_2": "15–30 words", "hint_3": "30–50 words" }}, "safety_flags": ["string"] }} ], "task_count": {n_tasks}, "total_possible_points": "integer", "max_deductible_points": "integer", "minimum_possible_points": "integer", "bonus_task_eligible": {json.dumps(bonus_ok)}, "bonus_task": {{ "description": "string or null", "points": 50 | null, "risk": "string or null", "completion_proof": "string or null" }}, "scoring_summary": {{ "base_points_available": "integer", "time_bonus_per_minute_early": {json.dumps(time_bonus)}, "hint_cost_tier_1": 5, "hint_cost_tier_2": 10, "team_aggregation_method": {json.dumps(agg)}, "winning_condition_detail": "string" }}, "estimated_total_time_minutes": "integer", "quality_score": "float 1.0–5.0" }}""" if __name__ == "__main__": rec = sample_input(1, rng=random.Random(42)) print(json.dumps(rec, indent=2)) print("\n--- PROMPT PREVIEW (first 1500 chars) ---\n") print(build_prompt(rec)[:1500])