Spaces:
Runtime error
Runtime error
| """Discovery mode — teach the Oracle a new item it doesn't know yet. | |
| When the engine runs out of candidates (or guesses wrong), the player tells us | |
| what they were thinking of. We then DERIVE that item's attributes and append it | |
| to the JSON database, so the Oracle gets smarter every game. | |
| Two enrichment sources, both optional and offline-safe: | |
| 1. Web context — a short Wikipedia summary (skipped silently if offline). | |
| 2. LLM — fills the category's attribute table as yes/no/unknown, | |
| using the web context (if any) as grounding. | |
| If both are unavailable, we still save the item with an empty attribute set, | |
| so at minimum the name is added and can be guessed once it's the last candidate. | |
| """ | |
| from __future__ import annotations | |
| import json | |
| import os | |
| import re | |
| import urllib.parse | |
| import urllib.request | |
| import engine | |
| LLAMA_URL = os.environ.get("ORACLE_LLAMA_URL", "http://localhost:8080/v1/chat/completions") | |
| MODEL = os.environ.get("ORACLE_LLAMA_MODEL", "Llama-3.2-3B-Instruct") # Built with Llama 🦙 | |
| USE_LLM = os.environ.get("ORACLE_QUESTION_LLM", "1") == "1" # ON by default; falls back if no model | |
| USE_WEB = os.environ.get("ORACLE_DISCOVERY_WEB", "1") == "1" | |
| # The canonical attribute set the engine reasons over, per category. | |
| CATEGORY_ATTRS = { | |
| "animal": ["mammal", "bird", "water", "carnivore", "big", | |
| "domestic", "can_fly", "stripes", "horns", | |
| "climbs", "hops", "wool", "hump", "black_white", "long_tail", "pack"], | |
| "fruit": ["sweet", "has_pit", "tree", "tropical", "big", "peel", "red", | |
| "green", "small", "seeds_outside", "hard_shell", "spiky"], | |
| "vegetable": ["root", "leafy", "green", "underground", | |
| "raw", "long", "spicy", "starchy", | |
| "round", "white", "cooked", "pod", "thin", "knobbly"], | |
| } | |
| # Human-readable meaning for each attribute, so the LLM answers the right thing. | |
| ATTR_MEANING = { | |
| "mammal": "is a mammal", "bird": "is a bird", | |
| "water": "lives mainly in water", "carnivore": "mainly eats meat", | |
| "big": "is bigger than a human (for animals) / large for its type (for produce)", | |
| "domestic": "is a pet or farm animal", "can_fly": "can fly", | |
| "stripes": "has stripes", "horns": "has horns or antlers", | |
| "sweet": "tastes sweet", "has_pit": "has a hard pit or stone inside", | |
| "tree": "grows on a tree", "tropical": "is a tropical fruit", | |
| "peel": "is usually peeled before eating", "red": "is red", | |
| "root": "is a root vegetable", "leafy": "is a leafy vegetable", | |
| "green": "is green", "underground": "grows underground", | |
| "raw": "is usually eaten raw", "long": "is long in shape", | |
| "spicy": "is spicy or pungent", "starchy": "is starchy", | |
| # animal (added) | |
| "climbs": "can climb trees well", "hops": "hops or jumps to move around", | |
| "wool": "has wool", "hump": "has a hump on its back", | |
| "black_white": "is black and white in colour", "long_tail": "has a long tail", | |
| "pack": "lives in a group or pack", | |
| # fruit (added) | |
| "small": "is small or bite-sized", "seeds_outside": "has seeds on the outside", | |
| "hard_shell": "has a hard shell", "spiky": "has a spiky skin", | |
| # vegetable (added) | |
| "round": "is round in shape", "white": "is white in colour", | |
| "cooked": "is usually cooked before eating", "pod": "grows in a pod", | |
| "thin": "is thin and slender", "knobbly": "is knobbly or bumpy", | |
| } | |
| # Natural fact sentence per attribute, for both polarities. "{n}" = the item name. | |
| # Used to explain WHY a player's answer was wrong (e.g. "a potato is starchy"). | |
| ATTR_REASON = { | |
| "mammal": ("a {n} is a mammal", "a {n} is not a mammal"), | |
| "bird": ("a {n} is a bird", "a {n} is not a bird"), | |
| "water": ("a {n} lives mainly in water", "a {n} does not live in water"), | |
| "carnivore": ("a {n} mainly eats meat", "a {n} does not mainly eat meat"), | |
| "big": ("a {n} is big", "a {n} is not big"), | |
| "domestic": ("a {n} is a pet or farm animal", "a {n} is a wild animal"), | |
| "can_fly": ("a {n} can fly", "a {n} cannot fly"), | |
| "stripes": ("a {n} has stripes", "a {n} does not have stripes"), | |
| "horns": ("a {n} has horns or antlers", "a {n} has no horns or antlers"), | |
| "sweet": ("a {n} is sweet", "a {n} is not sweet"), | |
| "has_pit": ("a {n} has a hard pit inside", "a {n} has no hard pit"), | |
| "tree": ("a {n} grows on a tree", "a {n} does not grow on a tree"), | |
| "tropical": ("a {n} is a tropical fruit", "a {n} is not tropical"), | |
| "peel": ("a {n} is usually peeled before eating", "a {n} is not usually peeled"), | |
| "red": ("a {n} is red", "a {n} is not red"), | |
| "root": ("a {n} is a root vegetable", "a {n} is not a root vegetable"), | |
| "leafy": ("a {n} is a leafy vegetable", "a {n} is not leafy"), | |
| "green": ("a {n} is green", "a {n} is not green"), | |
| "underground": ("a {n} grows underground", "a {n} does not grow underground"), | |
| "raw": ("a {n} is usually eaten raw", "a {n} is usually cooked"), | |
| "long": ("a {n} is long in shape", "a {n} is not long in shape"), | |
| "spicy": ("a {n} is spicy or pungent", "a {n} is not spicy"), | |
| "starchy": ("a {n} is starchy", "a {n} is not starchy"), | |
| # animal (added) | |
| "climbs": ("a {n} climbs well", "a {n} does not climb well"), | |
| "hops": ("a {n} hops or jumps to move", "a {n} does not hop"), | |
| "wool": ("a {n} has wool", "a {n} does not have wool"), | |
| "hump": ("a {n} has a hump", "a {n} has no hump"), | |
| "black_white": ("a {n} is black and white", "a {n} is not black and white"), | |
| "long_tail": ("a {n} has a long tail", "a {n} does not have a long tail"), | |
| "pack": ("a {n} lives in a group or pack", "a {n} lives alone"), | |
| # fruit (added) | |
| "small": ("a {n} is small", "a {n} is not small"), | |
| "seeds_outside": ("a {n} has seeds on the outside", "a {n} has no seeds on the outside"), | |
| "hard_shell": ("a {n} has a hard shell", "a {n} has no hard shell"), | |
| "spiky": ("a {n} has a spiky skin", "a {n} does not have a spiky skin"), | |
| # vegetable (added) | |
| "round": ("a {n} is round", "a {n} is not round"), | |
| "white": ("a {n} is white", "a {n} is not white"), | |
| "cooked": ("a {n} is usually cooked", "a {n} is not usually cooked"), | |
| "pod": ("a {n} grows in a pod", "a {n} does not grow in a pod"), | |
| "thin": ("a {n} is thin and slender", "a {n} is not thin"), | |
| "knobbly": ("a {n} is knobbly or bumpy", "a {n} is not knobbly"), | |
| } | |
| # Common alternate spellings / regional names -> the canonical name in our DB, | |
| # so "chilli" matches "chili pepper" instead of creating a junk duplicate. | |
| ALIASES = { | |
| "chilli": "chili pepper", "chili": "chili pepper", "chillies": "chili pepper", | |
| "chillis": "chili pepper", "green chilli": "chili pepper", "pepper": "chili pepper", | |
| "capsicum": "bell pepper", "brinjal": "eggplant", "aubergine": "eggplant", | |
| "ladyfinger": "okra", "lady finger": "okra", "lady's finger": "okra", | |
| "courgette": "zucchini", "beet": "beetroot", "beets": "beetroot", | |
| "spring onion": "leek", "scallion": "leek", "maize": "corn", | |
| "coriander": "spinach", # leafy fallback | |
| "brocolli": "broccoli", "colliflower": "cauliflower", | |
| } | |
| def _slug(name: str) -> str: | |
| return re.sub(r"\s+", " ", name.strip()).lower() | |
| def _canonical(name: str) -> str: | |
| """Resolve aliases and a couple of spelling variants to a canonical name.""" | |
| s = _slug(name) | |
| if s in ALIASES: | |
| return ALIASES[s] | |
| # collapse the chilli/chili spelling so both hit the same record | |
| s2 = s.replace("chilli", "chili") | |
| return ALIASES.get(s2, s) | |
| def _reason(attr: str, name: str, truth: bool) -> str: | |
| pos, neg = ATTR_REASON.get(attr, (f"a {{n}} matches '{attr}'", f"a {{n}} does not match '{attr}'")) | |
| return (pos if truth else neg).format(n=name) | |
| def find_contradictions(name: str, attributes: dict, history: list) -> list: | |
| """Compare the player's answers to an item's TRUE attributes. | |
| Returns one entry per wrong answer: | |
| {attribute, question, your_answer, correct_answer, reason} | |
| Unknown answers and attributes we don't know are skipped (no contradiction). | |
| """ | |
| out = [] | |
| for h in history or []: | |
| attr = h.get("attribute") | |
| ans = str(h.get("answer", "")).strip().lower() | |
| truth = attributes.get(attr) | |
| if attr is None or truth is None or ans not in ("yes", "no"): | |
| continue | |
| if (ans == "yes") != truth: # the player and reality disagree | |
| out.append({ | |
| "attribute": attr, | |
| "question": h.get("question", ""), | |
| "your_answer": "Yes" if ans == "yes" else "No", | |
| "correct_answer": "Yes" if truth else "No", | |
| "reason": _reason(attr, name, truth), | |
| }) | |
| return out | |
| # --- web enrichment -------------------------------------------------------- | |
| def fetch_web_context(name: str) -> str: | |
| """Short Wikipedia summary for grounding. Returns '' on any failure/offline.""" | |
| if not USE_WEB: | |
| return "" | |
| try: | |
| title = urllib.parse.quote(name.strip().replace(" ", "_")) | |
| url = f"https://en.wikipedia.org/api/rest_v1/page/summary/{title}" | |
| req = urllib.request.Request(url, headers={"User-Agent": "OracleGame/1.0"}) | |
| with urllib.request.urlopen(req, timeout=8) as resp: | |
| data = json.loads(resp.read().decode("utf-8")) | |
| return (data.get("extract") or "").strip() | |
| except Exception as exc: # noqa: BLE001 — never break discovery on network | |
| print(f"[discovery] web context unavailable for {name!r}: {exc}") | |
| return "" | |
| # --- LLM attribute derivation --------------------------------------------- | |
| def _llm_attributes(category: str, name: str, attrs: list, context: str) -> dict: | |
| sys_msg = ("You are a careful encyclopedia. Answer only with facts you are " | |
| "confident about. Output strict JSON.") | |
| lines = "\n".join(f'- "{a}": true if the {category} {ATTR_MEANING.get(a, a)}, ' | |
| f"else false" for a in attrs) | |
| ctx = f"\nReference text:\n{context}\n" if context else "" | |
| prompt = ( | |
| f'The {category} is: "{name}".{ctx}\n' | |
| f"For each key below, answer true, false, or null (null = genuinely unsure):\n" | |
| f"{lines}\n\n" | |
| f'Reply with ONLY a JSON object using these exact keys, e.g. ' | |
| f'{{"{attrs[0]}": true}}. No prose.' | |
| ) | |
| import llm # local module; runs the model through llama.cpp | |
| text = llm.chat( | |
| [{"role": "system", "content": sys_msg}, | |
| {"role": "user", "content": prompt}], | |
| temperature=0, max_tokens=400) | |
| if "</think>" in text: | |
| text = text.split("</think>")[-1] | |
| m = re.search(r"\{.*\}", text, re.DOTALL) | |
| raw = json.loads(m.group(0)) if m else {} | |
| out = {} | |
| for a in attrs: | |
| v = raw.get(a) | |
| if isinstance(v, bool): | |
| out[a] = v # null / missing -> omit (unknown) | |
| return out | |
| def derive_attributes(category: str, name: str) -> dict: | |
| """Best-effort attribute dict for a new item. Empty dict if nothing known.""" | |
| attrs = CATEGORY_ATTRS.get(category, []) | |
| if not attrs or not USE_LLM: | |
| return {} | |
| context = fetch_web_context(name) | |
| try: | |
| return _llm_attributes(category, name, attrs, context) | |
| except Exception as exc: # noqa: BLE001 | |
| print(f"[discovery] LLM derivation failed for {name!r}: {exc}") | |
| return {} | |
| # --- persistence ----------------------------------------------------------- | |
| def attributes_from_history(category: str, history: list) -> dict: | |
| """Turn the player's in-game yes/no answers into attribute facts. | |
| For a brand-new item the player is the ground truth about what they pictured, | |
| so this guarantees a freshly-taught item is never saved with zero attributes | |
| (even fully offline) and is immediately guessable next time. | |
| """ | |
| valid = set(CATEGORY_ATTRS.get(category, [])) | |
| out = {} | |
| for h in history or []: | |
| attr = h.get("attribute") | |
| ans = str(h.get("answer", "")).strip().lower() | |
| if attr in valid and ans in ("yes", "no"): | |
| out[attr] = ans == "yes" | |
| return out | |
| def complete_attributes(category: str, known: dict) -> dict: | |
| """Fill EVERY remaining attribute so a learned item is never incomplete. | |
| Source: the existing DB. We take the items most similar to what we already | |
| know (those consistent with the known answers) and fill each missing | |
| attribute by majority vote among them. Fully offline and deterministic. | |
| """ | |
| attrs = CATEGORY_ATTRS.get(category, []) | |
| out = {a: bool(known[a]) for a in attrs if a in known} | |
| missing = [a for a in attrs if a not in out] | |
| if not missing: | |
| return out | |
| items = engine.load_items(category) | |
| # neighbours = items consistent with what we already know | |
| facts = [{"attribute": a, "answer": "yes" if v else "no"} for a, v in out.items()] | |
| neighbours = engine.filter_candidates(items, facts) or list(items) | |
| for a in missing: | |
| yes = sum(1 for it in neighbours if it["attributes"].get(a) is True) | |
| no = sum(1 for it in neighbours if it["attributes"].get(a) is False) | |
| out[a] = yes > no # tie -> False (the safer default for most traits) | |
| return out | |
| def item_exists(category: str, name: str) -> bool: | |
| target = _canonical(name) | |
| return any(_canonical(it["name"]) == target for it in engine.load_items(category)) | |
| def add_item(category: str, name: str, attributes: dict) -> dict: | |
| """Append a new item to the category's JSON DB and refresh the engine cache.""" | |
| fname = engine._FILES.get(category) | |
| if not fname: | |
| raise ValueError(f"unknown category: {category}") | |
| engine.load_items(category) # ensure the DB is seeded into DATA_DIR first | |
| path = os.path.join(engine.DATA_DIR, fname) | |
| with open(path, "r", encoding="utf-8") as f: | |
| items = json.load(f) | |
| record = {"name": _slug(name), "category": category, "attributes": attributes} | |
| items.append(record) | |
| with open(path, "w", encoding="utf-8") as f: | |
| json.dump(items, f, indent=2, ensure_ascii=False) | |
| engine.load_items.cache_clear() # so the next game sees the new item | |
| return record | |
| def _lookup(category: str, name: str) -> dict | None: | |
| target = _canonical(name) | |
| for it in engine.load_items(category): | |
| if _canonical(it["name"]) == target: | |
| return it | |
| return None | |
| def learn_item(category: str, name: str, history: list | None = None) -> dict: | |
| """Full discovery flow: identify `name`, and explain any wrong answers. | |
| - If already known (incl. aliases like chilli->chili pepper): compare the | |
| player's answers to its true attributes and return the contradictions. | |
| - If new: seed attributes from the player's own answers, enrich with the LLM, | |
| save it, and explain against the merged attributes. | |
| Returns {status, name, attributes, learned:int, contradictions:[...]}. | |
| status: 'known' | 'learned' | 'error'. | |
| """ | |
| history = history or [] | |
| if not _slug(name): | |
| return {"status": "error", "message": "no name given"} | |
| existing = _lookup(category, name) | |
| if existing is not None: | |
| canon = _slug(existing["name"]) | |
| contradictions = find_contradictions(canon, existing["attributes"], history) | |
| return {"status": "known", "name": canon, "attributes": existing["attributes"], | |
| "learned": 0, "contradictions": contradictions} | |
| name = _slug(name) | |
| # Three sources, in order of trust: | |
| # 1. the player's in-game answers (ground truth for their own item), | |
| # 2. the LLM (if enabled) — overlays/corrects on top, | |
| # 3. the existing DB — fills any attribute still unknown, by similarity, | |
| # so the learned item is always COMPLETE (passes check_db immediately). | |
| attributes = attributes_from_history(category, history) | |
| attributes.update(derive_attributes(category, name)) | |
| attributes = complete_attributes(category, attributes) | |
| add_item(category, name, attributes) | |
| print(f"[discovery] learned {category} {name!r} with {len(attributes)} attrs", flush=True) | |
| # for a new item, the player defined it -> nothing to contradict | |
| return {"status": "learned", "name": name, "attributes": attributes, | |
| "learned": len(attributes), "contradictions": []} | |