import json import os import random import re from typing import Any import gradio as gr from huggingface_hub import InferenceClient MODEL_ID = os.getenv("HF_MODEL_ID", "meta-llama/Llama-3.1-8B-Instruct") HF_TOKEN = os.getenv("HF_TOKEN") TOTAL_ROUNDS = 10 LETTERS = ["A", "B", "C", "D"] SYSTEM_PROMPT = """You are the game master for "I See", a playful multiple-choice I Spy quiz. Each round has one clue and four possible answers. Exactly one answer is correct. Keep clues short, imaginative, and fair. Keep answers family-friendly and concrete.""" FALLBACK_ROUNDS = [ { "clue": "I see something that's hollow inside", "answer": "A tree with an empty trunk", "options": [ "A tree with an empty trunk", "A mountain with a doorbell", "A cloud with pockets", "A river that forgot to flow", ], "explanation": "A hollow tree trunk is empty inside.", }, { "clue": "I see something that tells time without saying a word", "answer": "A clock on the wall", "options": [ "A quiet drum", "A clock on the wall", "A sleeping notebook", "A candle in a cup", ], "explanation": "A clock shows time visually.", }, { "clue": "I see something that carries rain above your head", "answer": "An umbrella", "options": [ "A ladder", "A suitcase", "An umbrella", "A mirror", ], "explanation": "An umbrella blocks rain overhead.", }, { "clue": "I see something that opens with teeth but never bites", "answer": "A zipper", "options": [ "A zipper", "A spoon", "A window", "A shoelace", ], "explanation": "A zipper has teeth that join and separate.", }, { "clue": "I see something that gets smaller every time it helps", "answer": "A pencil", "options": [ "A chair", "A pencil", "A plate", "A blanket", ], "explanation": "A pencil gets shorter as it is sharpened and used.", }, ] def get_client() -> InferenceClient | None: if not HF_TOKEN: return None return InferenceClient(model=MODEL_ID, token=HF_TOKEN) def call_llama(messages: list[dict[str, str]], max_tokens: int = 1800) -> str: client = get_client() if not client: raise RuntimeError("HF_TOKEN is not configured.") response = client.chat_completion( messages=messages, max_tokens=max_tokens, temperature=0.9, top_p=0.92, ) return response.choices[0].message.content.strip() def extract_json(text: str) -> dict[str, Any]: match = re.search(r"\{.*\}", text, flags=re.DOTALL) if not match: raise ValueError("No JSON object found in model response.") return json.loads(match.group(0)) def clean_text(value: Any) -> str: return re.sub(r"\s+", " ", str(value)).strip() def fallback_round(round_number: int) -> dict[str, Any]: data = dict(FALLBACK_ROUNDS[(round_number - 1) % len(FALLBACK_ROUNDS)]) options = list(data["options"]) random.shuffle(options) data["options"] = options return data def validate_round(data: dict[str, Any]) -> dict[str, Any]: clue = clean_text(data["clue"]) answer = clean_text(data["answer"]) options = [clean_text(option) for option in data["options"]] explanation = clean_text(data.get("explanation", "")) if len(options) != 4 or answer not in options: raise ValueError("Round must have four options and include the answer.") random.shuffle(options) return { "clue": clue, "answer": answer, "options": options, "explanation": explanation or f"The answer is {answer}.", } def generate_game() -> list[dict[str, Any]]: try: model_text = call_llama( [ {"role": "system", "content": SYSTEM_PROMPT}, { "role": "user", "content": ( f"Create all {TOTAL_ROUNDS} rounds for one I See demo game. " "Each round needs a short I Spy-style clue, four answer options, " "one correct answer that appears exactly in options, and one short explanation. " "Use varied everyday objects. Do not repeat correct answers. " "Return only JSON with this exact shape: " '{"rounds":[{"clue":"I see something that...","answer":"An umbrella",' '"options":["An umbrella","A chair","A candle","A spoon"],' '"explanation":"The umbrella keeps rain off you."}]}' ), }, ] ) data = extract_json(model_text) rounds = [validate_round(round_data) for round_data in data["rounds"][:TOTAL_ROUNDS]] if len(rounds) != TOTAL_ROUNDS: raise ValueError("Game must include exactly 10 rounds.") return rounds except Exception: return [fallback_round(round_number) for round_number in range(1, TOTAL_ROUNDS + 1)] def option_text(round_data: dict[str, Any], index: int) -> str: option = round_data["options"][index] return f"{LETTERS[index]}\n\n{option}" def render_question(state: dict[str, Any] | None) -> tuple[str, str, str, str, str, str, str]: if not state: empty = "Start Game" return "I See", "ROUND 0 / 10", "SCORE: 0", empty, empty, empty, empty round_data = state["round"] return ( f'## I Spy...\n\n### "{round_data["clue"]}"', f"ROUND {state['round_number']} / {TOTAL_ROUNDS}", f"SCORE: {state['score']}", option_text(round_data, 0), option_text(round_data, 1), option_text(round_data, 2), option_text(round_data, 3), ) def start_game() -> tuple[dict[str, Any], str, str, str, str, str, str, str, str]: rounds = generate_game() state = { "round_number": 1, "score": 0, "answered": False, "rounds": rounds, "round": rounds[0], } return state, *render_question(state), "", gr.update(visible=False) def choose(index: int, state: dict[str, Any] | None) -> tuple[dict[str, Any] | None, str, str, Any]: if not state: return state, "Start the game first.", "SCORE: 0", gr.update(visible=False) if state["answered"]: return state, "You already answered this round. Hit Next Round.", f"SCORE: {state['score']}", gr.update(visible=True) selected = state["round"]["options"][index] answer = state["round"]["answer"] correct = selected == answer state["answered"] = True if correct: state["score"] += 1 result = f"Correct. The answer is {answer}." else: result = f"Not quite. The answer is {answer}." explanation = state["round"].get("explanation", "") if explanation: result = f"{result}\n\n{explanation}" button_label = "Show Results" if state["round_number"] >= TOTAL_ROUNDS else "Next Round" return state, result, f"SCORE: {state['score']}", gr.update(value=button_label, visible=True) def next_round(state: dict[str, Any] | None) -> tuple[dict[str, Any] | None, str, str, str, str, str, str, str, str, Any]: if not state: return None, "I See", "ROUND 0 / 10", "SCORE: 0", "Start Game", "Start Game", "Start Game", "Start Game", "", gr.update(visible=False) if state["round_number"] >= TOTAL_ROUNDS: score = state["score"] message = f"Game complete.\n\nFinal score: {score} / {TOTAL_ROUNDS}" return state, message, "FINISHED", f"SCORE: {score}", "A", "B", "C", "D", "Start a new game to play again.", gr.update(visible=False) round_number = state["round_number"] + 1 state.update( { "round_number": round_number, "answered": False, "round": state["rounds"][round_number - 1], } ) return state, *render_question(state), "", gr.update(visible=False) APP_CSS = """ body, .gradio-container { background: #050505 !important; color: #f7f7f7 !important; } .gradio-container { max-width: 980px !important; min-height: 100vh; padding: 22px 18px 34px !important; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; } #stage { max-width: 700px; margin: 0 auto; } #title { margin: 0 0 28px; } #title h1 { text-align: center; margin-bottom: 0; font-size: 1.85rem; font-weight: 850; letter-spacing: 0; } #round-pill { text-align: center; width: fit-content; margin: 0 auto 18px; padding: 6px 14px; border: 1px solid #262626; border-radius: 999px; color: #f2f2f2; font-weight: 700; letter-spacing: 0.15em; text-transform: uppercase; font-size: 0.72rem; } #score-pill { text-align: center; color: #f4f4f4; font-weight: 750; letter-spacing: 0.16em; text-transform: uppercase; font-size: 0.8rem; margin: 18px 0 18px; } #question { text-align: center; white-space: pre-line; min-height: 136px; display: flex; align-items: center; justify-content: center; flex-direction: column; } #question h2 { font-size: clamp(1.8rem, 4vw, 2.7rem); font-style: italic; margin: 0.2rem 0 0.7rem; line-height: 1.05; letter-spacing: 0; } #question h3 { max-width: 760px; margin: 0 auto; color: #f7f7f7; font-size: clamp(1.05rem, 2.1vw, 1.35rem); font-style: italic; line-height: 1.45; font-weight: 800; letter-spacing: 0; } #result { min-height: 92px; text-align: center; white-space: pre-line; color: #d8d8d8; line-height: 1.45; padding-top: 8px; } #controls { max-width: 700px; margin: 0 auto 20px; } #answers { max-width: 700px; margin: 0 auto; } #answers .gr-row { gap: 18px; } #answers button { min-height: 158px; padding: 24px 26px !important; border: 1px solid #2a2a2a !important; border-radius: 22px !important; background: #050505 !important; color: #f7f7f7 !important; box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.015); justify-content: flex-start !important; align-items: flex-start !important; text-align: left !important; white-space: pre-line !important; font-weight: 800 !important; line-height: 1.35 !important; transition: border-color 140ms ease, transform 140ms ease, background 140ms ease; } #answers button:hover { border-color: #f5f5f5 !important; background: #0b0b0b !important; transform: translateY(-1px); } #answers button:active { transform: translateY(0); } #answers button::first-line { font-size: 0.78rem; letter-spacing: 0.14em; } #start, #next { min-height: 52px; } #start button, #next button { border-radius: 999px !important; font-weight: 800 !important; min-height: 52px; background: #ff6a13 !important; border: 0 !important; color: white !important; } #next button { background: #f7f7f7 !important; color: #080808 !important; } #footnote { max-width: 700px; margin: 28px auto 0; text-align: center; color: #8f8f8f; font-size: 0.82rem; } @media (max-width: 680px) { .gradio-container { padding: 18px 12px 28px !important; } #question { min-height: 126px; } #answers .gr-row { flex-direction: column; gap: 12px; } #answers button { min-height: 118px; padding: 20px !important; } } """ with gr.Blocks(title="I See", css=APP_CSS) as demo: state = gr.State() with gr.Column(elem_id="stage"): gr.Markdown("# I See", elem_id="title") round_label = gr.Markdown("ROUND 0 / 10", elem_id="round-pill") question = gr.Markdown("## I Spy...\n\n### Start a game to generate your first question.", elem_id="question") score = gr.Markdown("SCORE: 0", elem_id="score-pill") with gr.Row(elem_id="controls"): start = gr.Button("Start Game", variant="primary", elem_id="start") next_button = gr.Button("Next Round", visible=False, elem_id="next") with gr.Column(elem_id="answers"): with gr.Row(): choice_a = gr.Button("A") choice_b = gr.Button("B") with gr.Row(): choice_c = gr.Button("C") choice_d = gr.Button("D") result = gr.Markdown("", elem_id="result") gr.Markdown( "Powered by `meta-llama/Llama-3.1-8B-Instruct` on Hugging Face when `HF_TOKEN` is configured.", elem_id="footnote", ) outputs = [state, question, round_label, score, choice_a, choice_b, choice_c, choice_d, result, next_button] start.click(start_game, outputs=outputs) next_button.click(next_round, inputs=state, outputs=outputs) choice_a.click(lambda current: choose(0, current), inputs=state, outputs=[state, result, score, next_button]) choice_b.click(lambda current: choose(1, current), inputs=state, outputs=[state, result, score, next_button]) choice_c.click(lambda current: choose(2, current), inputs=state, outputs=[state, result, score, next_button]) choice_d.click(lambda current: choose(3, current), inputs=state, outputs=[state, result, score, next_button]) if __name__ == "__main__": demo.launch( server_name="0.0.0.0", server_port=int(os.getenv("PORT", "7860")), )