"""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 "" in text: text = text.split("")[-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 "" in line: line = line.split("")[-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