Spaces:
Runtime error
Runtime error
| """LLM question-maker: attribute name -> one natural yes/no question. | |
| The model ONLY phrases questions — it never decides elimination (the engine does). | |
| Performance note: there are only ~42 possible questions (one per attribute per | |
| category), so we generate them with the model ONCE (at boot, via prewarm_questions) | |
| and cache them to disk (persisted in the bucket). During gameplay make_question | |
| is a pure dict lookup — instant, even on a weak CPU — yet the questions are still | |
| genuinely model-written. Falls back to built-in phrasing until the cache fills. | |
| """ | |
| from __future__ import annotations | |
| import json | |
| import os | |
| import engine | |
| from engine import ATTR_QUESTIONS | |
| from discovery import ATTR_MEANING | |
| 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" # model writes the questions | |
| USE_REVEAL_LLM = os.environ.get("ORACLE_REVEAL_LLM", "0") == "1" # off: instant templated reveal | |
| SYSTEM = ("You write simple, clear, kid-friendly yes/no questions for a guessing game.") | |
| PROMPT = """Turn the fact below into ONE simple yes/no question for a kids' guessing game. | |
| Keep it short, clear, and natural — plain everyday words, nothing weird or confusing. | |
| Mention "this {category}" or "the {category}". Do NOT use the word "{attribute}". | |
| Output only the question, nothing else. | |
| The {category} either {meaning} — or not. Ask about exactly that. | |
| Examples of the style: | |
| - Does this animal have a long tail? | |
| - Do you usually peel the fruit before eating it? | |
| - Does this animal eat meat? | |
| Now write the question:""" | |
| # --- question cache (persisted next to the DB, e.g. in the bucket) ---------- | |
| CACHE_VERSION = "v3" # bump when the prompt/style changes to auto-regenerate | |
| _qcache: dict | None = None | |
| _qcache_path: str | None = None | |
| def _cache_file() -> str: | |
| return os.path.join(engine.DATA_DIR, "questions_cache.json") | |
| def _cache() -> dict: | |
| global _qcache, _qcache_path | |
| path = _cache_file() | |
| if _qcache is not None and _qcache_path == path: | |
| return _qcache | |
| try: | |
| with open(path, "r", encoding="utf-8") as f: | |
| data = json.load(f) | |
| except Exception: # noqa: BLE001 — no cache yet | |
| data = {} | |
| if data.get("__version__") != CACHE_VERSION: # stale style -> start fresh | |
| data = {"__version__": CACHE_VERSION} | |
| _qcache = data | |
| _qcache_path = path | |
| return _qcache | |
| def _save_cache() -> None: | |
| try: | |
| with open(_cache_file(), "w", encoding="utf-8") as f: | |
| json.dump(_qcache or {}, f, ensure_ascii=False, indent=0) | |
| except Exception as exc: # noqa: BLE001 | |
| print(f"[question_maker] cache save failed: {exc}") | |
| # --- generation (only runs at boot/prewarm, never during a turn) ------------ | |
| def _llm_question(category: str, attribute: str) -> str | None: | |
| import llm # runs the model through llama.cpp | |
| meaning = ATTR_MEANING.get(attribute, f"relates to '{attribute}'") | |
| messages = [ | |
| {"role": "system", "content": SYSTEM}, | |
| {"role": "user", "content": PROMPT.format( | |
| category=category, attribute=attribute, meaning=meaning)}, | |
| ] | |
| text = llm.chat(messages, temperature=0.3, max_tokens=60).strip() | |
| if "</think>" in text: | |
| text = text.split("</think>")[-1].strip() | |
| text = text.splitlines()[-1].strip().strip('"') if text else "" | |
| return text or None | |
| def prewarm_questions() -> None: | |
| """Generate every category/attribute question once and cache it. Safe to run | |
| in a background thread at startup; persists progress so restarts are instant.""" | |
| if not USE_LLM: | |
| return | |
| try: | |
| from discovery import CATEGORY_ATTRS | |
| except Exception: # noqa: BLE001 | |
| return | |
| import time | |
| cache = _cache() | |
| made = 0 | |
| t_start = time.time() | |
| for cat, attrs in CATEGORY_ATTRS.items(): | |
| for attr in attrs: | |
| key = f"{cat}:{attr}" | |
| if cache.get(key): | |
| continue | |
| t0 = time.time() | |
| try: | |
| q = _llm_question(cat, attr) | |
| except Exception as exc: # noqa: BLE001 | |
| print(f"[question_maker] prewarm {key} failed: {exc}") | |
| q = None | |
| dt = time.time() - t0 | |
| if q and q.upper() != "SKIP" and "?" in q: | |
| cache[key] = q | |
| _save_cache() | |
| made += 1 | |
| print(f"[question_maker] {key} ({dt:.1f}s): {q}", flush=True) | |
| else: | |
| print(f"[question_maker] {key} ({dt:.1f}s): no question -> fallback", flush=True) | |
| total = sum(1 for k in cache if ":" in k) | |
| elapsed = time.time() - t_start | |
| if made: | |
| print(f"[question_maker] generated {made} questions in {elapsed:.1f}s " | |
| f"({elapsed / made:.1f}s each)", flush=True) | |
| print(f"[question_maker] question cache ready: {total} questions (+{made} new), style {CACHE_VERSION}", | |
| flush=True) | |
| # --- used during gameplay: instant lookup, never blocks --------------------- | |
| def make_question(category: str, attribute: str, asked: list | None = None) -> str: | |
| """Return a natural yes/no question — a cached model-written one if available, | |
| otherwise the built-in phrasing. Never calls the model (so it's instant).""" | |
| fallback = ATTR_QUESTIONS.get(attribute, f"Is your {category} related to '{attribute}'?") | |
| return _cache().get(f"{category}:{attribute}", fallback) | |
| def make_reveal(category: str, yes_attrs: list) -> str: | |
| """A short, theatrical line said just before guessing, built from the traits | |
| the player confirmed. Templated (instant) by default; set ORACLE_REVEAL_LLM=1 | |
| to have the model write it. Never names the item (keeps the suspense).""" | |
| short = {"big": "is large", "carnivore": "eats meat", | |
| "domestic": "is a pet or farm creature", "can_fly": "can fly"} | |
| traits = [short.get(a, ATTR_MEANING[a]) for a in (yes_attrs or []) if a in ATTR_MEANING] | |
| if traits: | |
| fallback = "I sense something that " + ", ".join(traits[:3]) + "…" | |
| else: | |
| fallback = "The mists are clearing… I see it now…" | |
| if not USE_REVEAL_LLM: | |
| return fallback | |
| try: | |
| import llm | |
| desc = ", ".join(traits) if traits else "a mysterious thing" | |
| messages = [ | |
| {"role": "system", "content": "You are a theatrical crystal-ball fortune teller."}, | |
| {"role": "user", "content": | |
| f"In ONE short sentence (max 18 words), tease that you are about to " | |
| f"reveal a {category} that {desc}. Be mystical and playful. Do NOT name it."}, | |
| ] | |
| line = llm.chat(messages, temperature=0.8, max_tokens=60, timeout=8).strip() | |
| if "</think>" in line: | |
| line = line.split("</think>")[-1].strip() | |
| line = line.splitlines()[-1].strip().strip('"') | |
| return line or fallback | |
| except Exception as exc: # noqa: BLE001 | |
| print(f"[question_maker] reveal LLM failed, using fallback: {exc}") | |
| return fallback | |