"""The Oracle — custom frontend via gradio.Server. Architecture (deterministic core + LLM only for phrasing): 1. engine.filter_candidates — narrow the JSON candidate list by the answers 2. engine.choose_attribute — pick the attribute that best splits the set 3. question_maker.make_question — LLM turns that attribute into a natural question The LLM never decides elimination; the engine does, so filtering is always exact. Outcomes: 1 left -> guess; 0 left -> "I don't know it yet" (discovery hook); "I am not sure" answers don't filter and aren't re-asked. Run: python server.py ORACLE_QUESTION_LLM=1 python server.py # natural questions via local LLM """ from __future__ import annotations import json import os from gradio import Server from fastapi.responses import HTMLResponse import engine import discovery import question_maker import subprocess subprocess.run("pip install -V llama_cpp_python==0.3.0", shell=True) MAX_QUESTIONS = int(os.environ.get("ORACLE_MAX_QUESTIONS", "20")) app = Server() HERE = os.path.dirname(os.path.abspath(__file__)) try: from fastapi.staticfiles import StaticFiles app.mount("/images", StaticFiles(directory=os.path.join(HERE, "images")), name="images") except Exception as exc: # noqa: BLE001 print(f"[oracle] could not mount /images: {exc}") # At boot (background): load the model, then pre-generate & cache every question # once. After this, gameplay is instant dict lookups — no per-turn model calls — # so it stays snappy even on a weak CPU. No-op if the LLM is disabled/unavailable. if os.environ.get("ORACLE_QUESTION_LLM", "1") == "1": import threading def _boot_warm(): import llm import question_maker llm.warmup() question_maker.prewarm_questions() threading.Thread(target=_boot_warm, daemon=True).start() @app.api(name="next") def next_turn(category: str = "animal", history_json: str = "[]", asked: int = 0) -> dict: try: history = json.loads(history_json) if history_json else [] except (json.JSONDecodeError, ValueError): history = [] # STEP 1 — deterministic filter by the answers so far facts = [{"attribute": h.get("attribute"), "answer": h.get("answer")} for h in history] items = engine.filter_candidates(engine.load_items(category), facts) names = [it["name"] for it in items] print(f"[oracle] {category}: {len(names)} remain -> {names[:20]}", flush=True) base = {"asked": asked, "max": MAX_QUESTIONS, "remaining": len(names), "items": names} # STEP 2 — outcomes decided in code if not names: # discovery: nothing in the DB matches -> ask the player to teach us return {"action": "giveup", "text": "Hmm, I don't know this one yet. What were you thinking of?", **base} if len(names) == 1 or asked >= MAX_QUESTIONS: yes_attrs = [h.get("attribute") for h in history if str(h.get("answer", "")).strip().lower() == "yes" and h.get("attribute")] reveal = question_maker.make_reveal(category, yes_attrs) return {"action": "guess", "text": names[0], "reveal": reveal, **base} # STEP 3 — pick the best attribute, then have the LLM phrase the question asked_attrs = [h.get("attribute") for h in history if h.get("attribute")] attr = engine.choose_attribute(category, items, asked_attrs) if attr is None: return {"action": "guess", "text": names[0], **base} # can't split further question = question_maker.make_question(category, attr, asked_attrs) return {"action": "ask", "attribute": attr, "text": question, "options": ["Yes", "No"], **base} @app.api(name="learn") def learn(category: str = "animal", name: str = "", history_json: str = "[]") -> dict: """Discovery mode: the player tells us what it was; we derive its attributes, add it to the JSON DB, and explain any answers that contradicted reality.""" try: history = json.loads(history_json) if history_json else [] except (json.JSONDecodeError, ValueError): history = [] try: return discovery.learn_item(category, name, history) except Exception as exc: # noqa: BLE001 — never crash the game on a teach print(f"[oracle] learn failed: {exc}") return {"status": "error", "message": str(exc)} @app.get("/", response_class=HTMLResponse) async def homepage(): with open(os.path.join(HERE, "index.html"), "r", encoding="utf-8") as f: return f.read() if __name__ == "__main__": # On Hugging Face Spaces the app must listen on 0.0.0.0:7860. Gradio reads # these env vars; setdefault keeps local runs unchanged. os.environ.setdefault("GRADIO_SERVER_NAME", "0.0.0.0") os.environ.setdefault("GRADIO_SERVER_PORT", "7860") app.launch(show_error=True)