| """ |
| 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 |
|
|
| |
| 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" |
| |
| 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) |
|
|
| 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]) |