oracle / discovery.py
vivek gangadharan
base1
112812c
Raw
History Blame Contribute Delete
16.3 kB
"""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": []}