"""Deterministic Oracle engine over a JSON attribute database. - load_items(category) -> list of {name, category, attributes} - filter_candidates(items, facts) -> items consistent with facts (yes/no/unknown) - choose_attribute(items, asked) -> attribute that best splits the set (info gain) All pure Python — filtering is always factually correct (the JSON is the truth). """ from __future__ import annotations import json import os import shutil HERE = os.path.dirname(os.path.abspath(__file__)) # Read-only seed shipped with the app. SEED_DIR = os.path.join(HERE, "data") # Where the live DB lives. Point ORACLE_DATA_DIR at a mounted HF Storage Bucket # (e.g. /data) so items taught during play persist across Space restarts. DATA_DIR = os.environ.get("ORACLE_DATA_DIR") or SEED_DIR # category -> json filename _FILES = {"animal": "animals.json", "fruit": "fruits.json", "vegetable": "vegetables.json"} def _seed_if_missing(path: str, fname: str) -> None: """On first run against an empty bucket, copy the bundled seed DB over.""" if os.path.exists(path): return seed = os.path.join(SEED_DIR, fname) if os.path.exists(seed) and os.path.abspath(path) != os.path.abspath(seed): os.makedirs(os.path.dirname(path) or ".", exist_ok=True) shutil.copyfile(seed, path) print(f"[engine] seeded {fname} -> {path}", flush=True) # Fallback question phrasing per attribute (used if the LLM question-maker is # unavailable or returns SKIP). Mirrors the attribute keys in the JSON. ATTR_QUESTIONS = { # animal "mammal": "Is it a mammal?", "bird": "Is it a bird?", "water": "Does it live mainly in water?", "carnivore": "Does it mainly eat meat?", "big": "Is it bigger than a human?", "domestic": "Is it a pet or farm animal?", "can_fly": "Can it fly?", "stripes": "Does it have stripes?", "horns": "Does it have horns or antlers?", # fruit "red": "Is it red?", "sweet": "Is it sweet?", "has_pit": "Does it have a hard pit or stone inside?", "tree": "Does it grow on a tree?", "tropical": "Is it a tropical fruit?", "peel": "Do you usually peel it before eating?", # vegetable "root": "Is it a root vegetable?", "leafy": "Is it a leafy vegetable?", "green": "Is it green?", "underground": "Does it grow underground?", "raw": "Is it usually eaten raw?", "long": "Is it long in shape?", "spicy": "Is it spicy or pungent?", "starchy": "Is it starchy?", # animal (added) "climbs": "Can it climb trees well?", "hops": "Does it hop or jump to move around?", "wool": "Does it have wool?", "hump": "Does it have a hump on its back?", "black_white": "Is it black and white?", "long_tail": "Does it have a long tail?", "pack": "Does it live in a group or pack?", # fruit (added) "small": "Is it small or bite-sized?", "seeds_outside": "Does it have seeds on the outside?", "hard_shell": "Does it have a hard shell?", "spiky": "Does it have a spiky skin?", # vegetable (added) "round": "Is it round in shape?", "white": "Is it white in colour?", "cooked": "Is it usually cooked before eating?", "pod": "Does it grow in a pod?", "thin": "Is it thin and slender?", "knobbly": "Is it knobbly or bumpy?", } # cache keyed by file path -> (mtime, items). Reloads automatically when the # JSON changes, so hand-edits are picked up on the next game with NO restart. _CACHE: dict = {} def load_items(category: str) -> tuple: """Load the JSON DB for a category. Cached, but auto-reloads if the file has changed on disk (compares modification time).""" fname = _FILES.get(category) if not fname: return () path = os.path.join(DATA_DIR, fname) _seed_if_missing(path, fname) try: mtime = os.path.getmtime(path) except OSError: return () cached = _CACHE.get(path) if cached and cached[0] == mtime: return cached[1] with open(path, "r", encoding="utf-8") as f: items = tuple(json.load(f)) _CACHE[path] = (mtime, items) return items def _cache_clear() -> None: """Drop the in-memory cache (used after writing the DB programmatically).""" _CACHE.clear() load_items.cache_clear = _cache_clear # keep the old API used by discovery/tests def filter_candidates(items: list, facts: list) -> list: """Keep items consistent with the facts. facts: [{"attribute": str, "answer": "yes"|"no"|"unknown"}]. Unknown answers (and missing attribute values) never eliminate an item. """ out = list(items) for fact in facts: attr = fact.get("attribute") ans = str(fact.get("answer", "")).strip().lower() if not attr or ans not in ("yes", "no"): continue # unknown / not-sure -> no filtering expected = ans == "yes" out = [it for it in out if it["attributes"].get(attr) is None or it["attributes"].get(attr) == expected] return out def choose_attribute(category: str, items: list, asked: list) -> str | None: """Pick the unused attribute whose yes/no split is closest to 50/50.""" asked = set(asked or []) best, best_score = None, -1 # consider attributes that actually appear in the data, in a stable order seen = [] for it in items: for k in it["attributes"]: if k not in seen: seen.append(k) for attr in seen: if attr in asked: continue yes = sum(1 for it in items if it["attributes"].get(attr) is True) no = sum(1 for it in items if it["attributes"].get(attr) is False) if yes == 0 or no == 0: continue score = min(yes, no) if score > best_score: best, best_score = attr, score return best