oracle / app.py
vivek gangadharan
hf patch
17c0ccc
Raw
History Blame Contribute Delete
4.85 kB
"""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)