"""Case Lantern — a fictional medical mystery game powered by a small Chinese medical reasoning model. Backend : llama-cpp-python (GGUF, runs on free CPU Spaces) Frontend : fully custom dark theme with glassmorphism & micro-animations Model : lastmass/Qwen3.5-Medical-GSPO (~4.66 B params, Q4_K_M quant) """ import os import random import re import textwrap from dataclasses import dataclass, field from functools import lru_cache from typing import Dict, List, Optional import gradio as gr # --------------------------------------------------------------------------- # Configuration # --------------------------------------------------------------------------- # Display model (shown in UI) DISPLAY_MODEL_ID = "lastmass/Qwen3.5-Medical-GSPO" # GGUF repo used for actual inference (quantised by mradermacher) GGUF_REPO = "mradermacher/Qwen3.5-Medical-GSPO-GGUF" GGUF_FILE = "Qwen3.5-Medical-GSPO.Q4_K_M.gguf" DEMO_MODE = os.getenv("DEMO_MODE", "auto").lower() MAX_NEW_TOKENS = int(os.getenv("MAX_NEW_TOKENS", "420")) DISCLAIMER = ( "Fictional training game only. This app does not provide medical advice, " "diagnosis, triage, or treatment guidance for real people." ) # --------------------------------------------------------------------------- # System prompt # --------------------------------------------------------------------------- SYSTEM_PROMPT = """You are Case Lantern, a playful but careful medical mystery game master. Create and run fictional Chinese medical reasoning puzzles for education and entertainment. Rules: - Never present output as real medical advice. - Keep all patients fictional. - Do not ask users to share real personal health information. - Make the game delightful, concise, and clue-driven. - The player should reason from clues; avoid revealing the final answer unless asked to score. - Use simplified Chinese by default, with crisp section headers. - When scoring, be honest but friendly and include one memorable teaching pearl. """ # --------------------------------------------------------------------------- # Seed cases # --------------------------------------------------------------------------- CASE_SEEDS = [ { "title": "凌晨两点的胸痛电报", "genre": "急诊悬疑", "opening": "65岁男性,凌晨突发胸痛,额头冒汗,坚持说只是晚饭吃坏了。护士递来一张还热乎的心电图。", "secret": "下壁ST段抬高型心肌梗死", "clues": [ "疼痛位于胸骨后,持续超过30分钟,伴冷汗。", "II、III、aVF导联ST段抬高,I、aVL可见对应性改变。", "血压略低,心率偏慢,提示可能累及右冠供血区域。", "硝酸甘油后症状改善不明显。", ], "red_herring": "反流性食管炎", }, { "title": "雨夜里的右下腹脚印", "genre": "妇产科侦探", "opening": "28岁女性,停经8周,右下腹剧痛后晕厥。诊室灯光一闪,血压计读数像坏消息一样低。", "secret": "输卵管妊娠破裂导致腹腔内出血", "clues": [ "停经8周,突发一侧下腹痛。", "血压80/50 mmHg,面色苍白,提示休克。", "后穹窿穿刺抽出不凝血。", "尿/血HCG阳性,床旁超声宫内未见明确孕囊。", ], "red_herring": "急性阑尾炎", }, { "title": "会变形的蝴蝶影子", "genre": "内分泌谜题", "opening": "32岁女性近两个月怕热、心悸、手抖,朋友说她的眼神像一直在追赶一列迟到的火车。", "secret": "Graves病所致甲状腺功能亢进", "clues": [ "怕热、多汗、体重下降但食欲增加。", "心率快,双手细颤。", "甲状腺弥漫性肿大,可闻及血管杂音。", "TSH降低,FT3/FT4升高,TRAb阳性。", ], "red_herring": "焦虑障碍", }, { "title": "沉默的蓝色嘴唇", "genre": "呼吸科小剧场", "opening": "70岁男性长期咳嗽咳痰,今天走三步就喘,口唇发绀,却还惦记着没下完的一盘棋。", "secret": "慢性阻塞性肺疾病急性加重", "clues": [ "长期吸烟史,慢性咳嗽咳痰多年。", "活动后气促明显加重,双肺可闻及哮鸣音。", "血气提示二氧化碳潴留倾向。", "近期有受凉或感染诱因。", ], "red_herring": "单纯支气管哮喘", }, ] ACTION_PRESETS = { "问病史": "我想进一步问病史。请给我一个关键但不直接泄底的病史线索。", "查体": "我想做体格检查。请给我一个关键但不直接泄底的查体线索。", "实验室": "我想申请实验室检查。请给我一个关键但不直接泄底的检验线索。", "影像/心电": "我想看影像或心电图。请给我一个关键但不直接泄底的检查线索。", "提示": "我卡住了。请给我一个分层提示,但不要直接说出诊断。", } # --------------------------------------------------------------------------- # Game state # --------------------------------------------------------------------------- @dataclass class GameState: title: str = "" genre: str = "" opening: str = "" secret: str = "" red_herring: str = "" clues: List[str] = field(default_factory=list) used_clues: List[str] = field(default_factory=list) turns: int = 0 score: int = 100 solved: bool = False def public_context(self) -> str: clue_text = "\n".join(f" • {c}" for c in self.used_clues) or " 暂无线索" return ( f"📁 案件:{self.title}\n" f"🏷️ 类型:{self.genre}\n" f"📖 开场:{self.opening}\n\n" f"🔍 已公开线索:\n{clue_text}\n\n" f"⏱️ 回合:{self.turns}/6\n" f"⭐ 分数:{self.score}" ) # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def normalize_text(value: str) -> str: return re.sub(r"\s+", " ", value or "").strip() def strip_thinking(text: str) -> str: text = re.sub(r".*?", "", text, flags=re.DOTALL | re.IGNORECASE) text = text.replace("", "").replace("", "") return text.strip() # --------------------------------------------------------------------------- # Demo / fallback replies (no model needed) # --------------------------------------------------------------------------- def demo_reply(prompt: str, state: GameState, mode: str) -> str: unused = [c for c in state.clues if c not in state.used_clues] next_clue = unused[0] if unused else random.choice(state.clues) if mode == "score": guess = prompt.lower() secret_terms = [state.secret.lower()] if "心肌梗死" in state.secret: secret_terms += ["心梗", "stemi", "梗死"] if "输卵管" in state.secret: secret_terms += ["宫外孕", "异位妊娠", "破裂"] if "graves" in state.secret.lower(): secret_terms += ["甲亢", "graves"] if "慢性阻塞" in state.secret: secret_terms += ["copd", "慢阻肺"] hit = any(t in guess for t in secret_terms) if hit: return ( "### 🎯 判定\n" "你抓住了核心诊断。推理链条成立,关键是把症状、危险信号和特异检查连起来。\n\n" f"### 🔓 真相\n{state.secret}\n\n" "### 💡 记忆钉\n" "好诊断不是猜谜底,而是让每条线索都有地方安放。" ) return ( "### ❌ 判定\n" "这个答案有一点影子,但还没有解释最关键的危险线索。\n\n" f"### 🔄 反向提示\n别被「{state.red_herring}」带偏,重新看最急、最能改变处理路径的证据。\n\n" "### 💡 记忆钉\n" "先处理能致命的可能,再处理看起来像的可能。" ) if mode == "hint": return ( "### 💡 分层提示\n" f"把注意力放在这条线索上:{next_clue}\n\n" "### 🤔 小问题\n" "它更支持哪个系统的问题?有没有一个诊断能同时解释时间、症状和检查?" ) return ( "### 🔍 新线索\n" f"{next_clue}\n\n" "### 📝 案件旁白\n" "房间里安静了一秒。这个线索不像答案,但它像一把钥匙。" ) # --------------------------------------------------------------------------- # Model loading — llama-cpp-python (GGUF) on CPU # --------------------------------------------------------------------------- # Hugging Face ZeroGPU is designed primarily for PyTorch workloads. The CUDA # wheel of llama-cpp-python requires system CUDA runtime libraries such as # libcudart.so.12, which are not available in the normal Space container and can # fail before inference starts. Use the CPU wheel for reliable Spaces startup. _llm_instance = None def get_llm(): """Load the GGUF model. Raises RuntimeError when DEMO_MODE is forced.""" global _llm_instance if _llm_instance is not None: return _llm_instance if DEMO_MODE in {"1", "true", "yes", "on"}: raise RuntimeError("DEMO_MODE is enabled — skipping model load.") from llama_cpp import Llama # noqa: delayed import print("[Case Lantern] Loading GGUF model …") _llm_instance = Llama.from_pretrained( repo_id=GGUF_REPO, filename=GGUF_FILE, n_ctx=2048, n_threads=int(os.getenv("LLAMA_THREADS", "4")), n_gpu_layers=0, verbose=True, ) print("[Case Lantern] Model loaded successfully.") return _llm_instance def _call_model_inner( messages: List[Dict[str, str]], state: GameState, fallback_mode: str ) -> str: if DEMO_MODE in {"1", "true", "yes", "on"}: return demo_reply(messages[-1]["content"], state, fallback_mode) try: llm = get_llm() response = llm.create_chat_completion( messages=messages, max_tokens=MAX_NEW_TOKENS, temperature=0.85, top_p=0.92, repeat_penalty=1.05, stop=["<|im_end|>", "<|endoftext|>"], ) raw = response["choices"][0]["message"]["content"] or "" return strip_thinking(raw) except Exception as exc: import traceback traceback.print_exc() if DEMO_MODE == "off": raise return ( demo_reply(messages[-1]["content"], state, fallback_mode) + f"\n\n_演示模式:模型暂未加载({type(exc).__name__}: {exc})。_" ) call_model = _call_model_inner # --------------------------------------------------------------------------- # Game logic # --------------------------------------------------------------------------- ChatHistory = List[Dict[str, str]] def new_case(): seed = random.choice(CASE_SEEDS) state = GameState( title=seed["title"], genre=seed["genre"], opening=seed["opening"], secret=seed["secret"], red_herring=seed["red_herring"], clues=list(seed["clues"]), used_clues=[], ) first_message = { "role": "assistant", "content": ( f"### 🏮 {state.title}\n" f"**{state.genre}**\n\n" f"{state.opening}\n\n" "你有 **6 个回合** 调查。选择一个行动,或直接输入你的诊断假设。" ), } return [first_message], state, state.public_context(), status_line(state) def status_line(state: GameState) -> str: icon = "🏆" if state.solved else "🔎" label = "已破案" if state.solved else "调查中" return f"{icon} {label} · 回合 {state.turns}/6 · ⭐ {state.score}" def reveal_clue(state: GameState) -> Optional[str]: unused = [c for c in state.clues if c not in state.used_clues] if not unused: return None clue = unused[0] state.used_clues.append(clue) return clue def build_messages( state: GameState, instruction: str, mode: str ) -> List[Dict[str, str]]: return [ {"role": "system", "content": SYSTEM_PROMPT}, { "role": "user", "content": textwrap.dedent(f"""\ 你正在主持一个虚构医学推理小游戏。 隐藏真相:{state.secret} 红鲱鱼:{state.red_herring} 当前公开状态: {state.public_context()} 玩家动作: {instruction} 输出要求: - 不要给真实医疗建议。 - 不要要求玩家提供真实个人健康信息。 - 如果 mode={mode} 且不是评分,不要直接泄露隐藏真相。 - 保持中文,短小、有戏剧感。 """), }, ] def diagnosis_terms(secret: str) -> List[str]: terms = [secret.lower()] mapping = { "心肌梗死": ["心梗", "stemi", "梗死"], "输卵管": ["宫外孕", "异位妊娠", "破裂"], "Graves": ["graves", "甲亢", "甲状腺功能亢进"], "慢性阻塞": ["copd", "慢阻肺"], } for key, values in mapping.items(): if key.lower() in secret.lower(): terms.extend(values) return terms def act(action, custom_action, chat, state): if not state or not state.title: chat, state, context, status = new_case() if state.solved: chat.append( { "role": "assistant", "content": "案件已经结案。点击 **新案件** 开始下一个挑战。", } ) return chat, state, state.public_context(), status_line(state), "" instruction = normalize_text(custom_action) or ACTION_PRESETS.get( action, ACTION_PRESETS["提示"] ) mode = "hint" if action == "提示" else "clue" state.turns += 1 state.score = max(20, state.score - (6 if mode == "hint" else 4)) reveal_clue(state) reply = call_model(build_messages(state, instruction, mode), state, mode) chat.append({"role": "user", "content": f"🎬 {action}:{instruction}"}) chat.append({"role": "assistant", "content": reply}) return chat, state, state.public_context(), status_line(state), "" def submit_guess(guess, chat, state): if not state or not state.title: chat, state, context, status = new_case() cleaned = normalize_text(guess) if not cleaned: chat.append({"role": "assistant", "content": "先写下你的诊断假设,再按提交。"}) return chat, state, state.public_context(), status_line(state), "" state.turns += 1 messages = build_messages( state, f"玩家最终诊断是:{cleaned}。请评分并揭示真相。", "score", ) reply = call_model(messages, state, "score") state.solved = True if any(t in cleaned.lower() for t in diagnosis_terms(state.secret)): state.score = min(100, state.score + 12) else: state.score = max(20, state.score - 15) chat.append({"role": "user", "content": f"🩺 最终诊断:{cleaned}"}) chat.append({"role": "assistant", "content": reply}) return chat, state, state.public_context(), status_line(state), "" # --------------------------------------------------------------------------- # Custom CSS — dark medical-mystery theme with glassmorphism # --------------------------------------------------------------------------- CUSTOM_CSS = """\ /* ===== GLOBAL DARK OVERRIDE ===== */ :root { --cl-bg-deep: #0b0f1a; --cl-bg-panel: rgba(15, 22, 42, 0.72); --cl-glass: rgba(255, 255, 255, 0.04); --cl-glass-edge: rgba(255, 255, 255, 0.08); --cl-ruby: #e03e5e; --cl-ruby-glow: rgba(224, 62, 94, 0.35); --cl-gold: #f0b429; --cl-gold-dim: #c6931b; --cl-mint: #34d399; --cl-text: #e2e8f0; --cl-text-dim: #94a3b8; --cl-border: rgba(255, 255, 255, 0.06); --cl-radius: 14px; } /* Force dark everywhere */ body, .gradio-container, .main, .contain, .gradio-container .main .wrap { background: var(--cl-bg-deep) !important; color: var(--cl-text) !important; } .gradio-container { max-width: 1200px !important; font-family: 'Inter', 'Noto Sans SC', system-ui, -apple-system, sans-serif !important; } /* ===== HEADER BANNER ===== */ #hero-banner { background: linear-gradient(135deg, rgba(224,62,94,0.13) 0%, rgba(15,22,42,0.95) 50%, rgba(52,211,153,0.08) 100%); border: 1px solid var(--cl-glass-edge); border-radius: var(--cl-radius); padding: 48px 32px 24px; margin-bottom: 8px; backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px); position: relative; overflow: visible; } #hero-banner::before { content: ''; position: absolute; top: -80%; right: -10%; width: 260px; height: 260px; border-radius: 50%; background: radial-gradient(circle, var(--cl-ruby-glow) 0%, transparent 70%); animation: hero-pulse 5s ease-in-out infinite; pointer-events: none; } @keyframes hero-pulse { 0%, 100% { opacity: 0.3; transform: scale(1); } 50% { opacity: 0.6; transform: scale(1.15); } } .hero-title { font-size: 2.4rem; font-weight: 800; background: linear-gradient(135deg, #ff5c7c, #ffd166); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; margin: 0 0 12px 0; line-height: 1.35; position: relative; z-index: 1; } #hero-banner p, #hero-banner .prose p { color: var(--cl-text-dim) !important; font-size: 0.92rem !important; margin: 0 !important; line-height: 1.5 !important; } #hero-banner a { color: var(--cl-gold) !important; text-decoration: underline; } /* Prevent Gradio wrapper clipping inside hero banner */ #hero-banner > div, #hero-banner .prose, #hero-banner .md, #hero-banner .wrap, #hero-banner .block { overflow: visible !important; } /* ===== SAFETY NOTE ===== */ #safety-note { background: rgba(224, 62, 94, 0.08) !important; border: 1px solid rgba(224, 62, 94, 0.18) !important; border-radius: 10px !important; padding: 10px 14px !important; margin-bottom: 12px !important; } #safety-note p, #safety-note .prose p { color: #fca5a5 !important; font-size: 0.82rem !important; margin: 0 !important; } /* ===== GLASSMORPHISM PANELS ===== */ .glass-panel, .glass-panel > .block { background: var(--cl-bg-panel) !important; border: 1px solid var(--cl-glass-edge) !important; border-radius: var(--cl-radius) !important; backdrop-filter: blur(16px) !important; -webkit-backdrop-filter: blur(16px) !important; } /* ===== CHATBOT ===== */ #case-chat { border: 1px solid var(--cl-glass-edge) !important; border-radius: var(--cl-radius) !important; background: rgba(15, 22, 42, 0.55) !important; backdrop-filter: blur(12px) !important; } /* Force ALL chatbot message text to be bright */ #case-chat .message-row .message, #case-chat .bot .message-bubble, #case-chat .user .message-bubble, #case-chat .message, #case-chat .message-bubble, #case-chat [data-testid="bot"], #case-chat [data-testid="user"], #case-chat .bot, #case-chat .user, #case-chat .prose, #case-chat .md, #case-chat .message p, #case-chat .message span, #case-chat .message li, #case-chat .message h1, #case-chat .message h2, #case-chat .message h3, #case-chat .message h4, #case-chat .message strong, #case-chat .message em, #case-chat .message-bubble p, #case-chat .message-bubble span, #case-chat .message-bubble li, #case-chat .message-bubble h1, #case-chat .message-bubble h2, #case-chat .message-bubble h3, #case-chat .message-bubble h4, #case-chat .message-bubble strong, #case-chat .message-bubble em, #case-chat .prose p, #case-chat .prose span, #case-chat .prose li, #case-chat .prose h1, #case-chat .prose h2, #case-chat .prose h3, #case-chat .prose h4, #case-chat .prose strong { color: #f1f5f9 !important; } #case-chat .message-row .message, #case-chat .message-bubble, #case-chat .bot .message-bubble, #case-chat [data-testid="bot"] { border-radius: 12px !important; font-size: 0.93rem !important; line-height: 1.65 !important; background: rgba(30, 41, 70, 0.85) !important; border: 1px solid var(--cl-glass-edge) !important; } /* user bubble - red tinted */ #case-chat .message-row.user-row .message, #case-chat .user .message-bubble, #case-chat [data-testid="user"] { background: linear-gradient(135deg, rgba(224,62,94,0.22), rgba(224,62,94,0.10)) !important; border: 1px solid rgba(224,62,94,0.25) !important; } /* bot bubble - dark glass */ #case-chat .message-row.bot-row .message, #case-chat .bot .message-bubble, #case-chat [data-testid="bot"] { background: rgba(30, 41, 70, 0.85) !important; border: 1px solid var(--cl-glass-edge) !important; } /* Chatbot wrapper and scroll area dark */ #case-chat .chatbot, #case-chat .wrap, #case-chat > div { background: transparent !important; } /* ===== TEXTBOX / INPUT FIELDS ===== */ textarea, input[type="text"], .textbox textarea, .textbox input { background: rgba(15, 22, 42, 0.7) !important; border: 1px solid var(--cl-glass-edge) !important; border-radius: 10px !important; color: var(--cl-text) !important; transition: border-color 0.3s, box-shadow 0.3s !important; } textarea:focus, input[type="text"]:focus { border-color: var(--cl-ruby) !important; box-shadow: 0 0 0 3px var(--cl-ruby-glow) !important; outline: none !important; } /* Labels */ label, .label-wrap span, .block label span { color: var(--cl-text-dim) !important; font-weight: 600 !important; font-size: 0.85rem !important; text-transform: uppercase !important; letter-spacing: 0.5px !important; } /* ===== RADIO BUTTONS ===== */ .radio-group label, .wrap label.selected { background: var(--cl-glass) !important; border: 1px solid var(--cl-glass-edge) !important; border-radius: 8px !important; color: var(--cl-text) !important; transition: all 0.25s !important; } .radio-group label:hover { border-color: var(--cl-ruby) !important; background: rgba(224, 62, 94, 0.08) !important; } .radio-group label.selected, .radio-group input:checked + label { border-color: var(--cl-ruby) !important; background: rgba(224, 62, 94, 0.15) !important; box-shadow: 0 0 12px var(--cl-ruby-glow) !important; } /* ===== BUTTONS ===== */ button.primary, button.primary:hover { background: linear-gradient(135deg, var(--cl-ruby), #c2294a) !important; border: none !important; color: #fff !important; border-radius: 10px !important; font-weight: 700 !important; letter-spacing: 0.3px !important; box-shadow: 0 4px 20px var(--cl-ruby-glow) !important; transition: transform 0.2s, box-shadow 0.3s !important; } button.primary:hover { transform: translateY(-1px) !important; box-shadow: 0 6px 28px rgba(224,62,94,0.5) !important; } button.primary:active { transform: translateY(0) !important; } button.secondary, button.secondary:hover { background: var(--cl-glass) !important; border: 1px solid var(--cl-glass-edge) !important; color: var(--cl-text) !important; border-radius: 10px !important; font-weight: 600 !important; transition: all 0.25s !important; } button.secondary:hover { border-color: var(--cl-gold-dim) !important; color: var(--cl-gold) !important; background: rgba(240,180,41,0.08) !important; } /* ===== STATUS PILL ===== */ #status-pill textarea { font-weight: 700 !important; color: var(--cl-gold) !important; font-size: 0.95rem !important; background: rgba(240,180,41,0.06) !important; border: 1px solid rgba(240,180,41,0.18) !important; border-radius: 10px !important; text-align: center !important; } /* ===== CASE BOARD ===== */ #case-board textarea { background: rgba(15, 22, 42, 0.65) !important; border: 1px solid var(--cl-glass-edge) !important; border-radius: 10px !important; color: var(--cl-text-dim) !important; font-size: 0.88rem !important; line-height: 1.7 !important; } /* ===== EXAMPLES ===== */ .examples-table button { background: var(--cl-glass) !important; border: 1px solid var(--cl-glass-edge) !important; color: var(--cl-text-dim) !important; border-radius: 8px !important; transition: all 0.2s !important; } .examples-table button:hover { border-color: var(--cl-mint) !important; color: var(--cl-mint) !important; } /* ===== FOOTER ===== */ #footer-info p, #footer-info .prose p { color: var(--cl-text-dim) !important; font-size: 0.78rem !important; text-align: center !important; } /* ===== SCROLL BAR ===== */ ::-webkit-scrollbar { width: 6px; } ::-webkit-scrollbar-track { background: transparent; } ::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.1); border-radius: 3px; } ::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,0.2); } /* ===== ANIMATIONS ===== */ @keyframes fade-in { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } } .glass-panel, #case-chat, #hero-banner { animation: fade-in 0.5s ease-out; } /* ===== RESPONSIVE ===== */ @media (max-width: 768px) { #hero-banner { padding: 18px 16px 14px; } #hero-banner h1 { font-size: 1.5rem !important; } .gradio-container { padding: 8px !important; } } /* ===== ACCORDION / GROUP borders ===== */ .block, .form, .wrap, .panel, .gap, .gr-group, .gr-box { border-color: var(--cl-border) !important; } /* ===== OVERRIDE light-mode remnants ===== */ /* Force Gradio CSS variables everywhere */ *, *::before, *::after, .dark, [data-testid], .gradio-container, .gradio-container * { --background-fill-primary: var(--cl-bg-deep) !important; --background-fill-secondary: rgba(15, 22, 42, 0.7) !important; --background-fill-primary-dark: var(--cl-bg-deep) !important; --border-color-primary: var(--cl-glass-edge) !important; --body-text-color: var(--cl-text) !important; --body-text-color-subdued: var(--cl-text-dim) !important; --block-background-fill: var(--cl-bg-panel) !important; --block-border-color: var(--cl-glass-edge) !important; --block-label-text-color: var(--cl-text-dim) !important; --input-background-fill: rgba(15, 22, 42, 0.7) !important; --input-border-color: var(--cl-glass-edge) !important; --color-accent: var(--cl-ruby) !important; --chatbot-text-color: #f1f5f9 !important; } /* Global: any text inside the app must be bright */ .gradio-container p, .gradio-container span, .gradio-container li, .gradio-container td, .gradio-container th, .gradio-container div, .gradio-container h1, .gradio-container h2, .gradio-container h3, .gradio-container h4, .gradio-container h5, .gradio-container h6, .gradio-container strong, .gradio-container em, .gradio-container label { color: var(--cl-text) !important; } /* Re-apply specific colors after the global rule */ #hero-banner .hero-title { -webkit-text-fill-color: transparent !important; } #status-pill textarea { color: var(--cl-gold) !important; } #safety-note p, #safety-note .prose p { color: #fca5a5 !important; } #footer-info p, #footer-info .prose p { color: var(--cl-text-dim) !important; } #hero-banner p { color: var(--cl-text-dim) !important; } #hero-banner a { color: var(--cl-gold) !important; } """ # --------------------------------------------------------------------------- # Google Fonts injection # --------------------------------------------------------------------------- CUSTOM_HEAD = """\ """ # --------------------------------------------------------------------------- # Build the Gradio app # --------------------------------------------------------------------------- with gr.Blocks( title="Case Lantern 🏮", ) as demo: game_state = gr.State(GameState()) # --- Hero banner (raw HTML for full rendering control) --- gr.HTML( f"""
🏮 Case Lantern

一个由小型中文医疗推理模型驱动的虚构病例侦探游戏。查线索、避开误导、在 6 回合内破案。

模型:{DISPLAY_MODEL_ID} · ~4.66B 参数 · llama.cpp 本地推理

""", ) gr.Markdown(f"⚠️ {DISCLAIMER}", elem_id="safety-note") with gr.Row(): # --- LEFT: Chat --- with gr.Column(scale=3): chatbot = gr.Chatbot( label="案件记录", height=560, elem_id="case-chat", ) # --- RIGHT: Control panel --- with gr.Column(scale=2, elem_classes=["glass-panel"]): status = gr.Textbox( label="状态", elem_id="status-pill", interactive=False, ) context = gr.Textbox( label="📋 案件板", lines=10, interactive=False, elem_id="case-board", ) gr.Markdown("#### 🎯 调查行动", elem_id="action-title") action = gr.Radio( label="选择行动", choices=list(ACTION_PRESETS.keys()), value="问病史", ) custom = gr.Textbox( label="自定义行动", placeholder="例如:我想追问疼痛性质和伴随症状…", lines=2, ) with gr.Row(): act_button = gr.Button("🔍 调查", variant="primary") new_button = gr.Button("🆕 新案件", variant="secondary") gr.Markdown("#### 🩺 最终诊断") guess = gr.Textbox( label="你的诊断", placeholder="写下你的诊断假设,然后提交破案", lines=2, ) guess_button = gr.Button("💊 提交诊断", variant="primary") # --- Examples --- gr.Examples( examples=[ ["我想询问发病时间、诱因和伴随症状"], ["我想查看最能排除危险诊断的检查"], ["请给我一个不会直接泄底的鉴别诊断提示"], ], inputs=custom, label="💡 行动灵感", ) gr.Markdown( f"Case Lantern · Build Small Hackathon 2026 · Powered by " f"[{DISPLAY_MODEL_ID}](https://huggingface.co/{DISPLAY_MODEL_ID})" f" via llama.cpp", elem_id="footer-info", ) # --- Wiring --- new_button.click(new_case, outputs=[chatbot, game_state, context, status]) demo.load(new_case, outputs=[chatbot, game_state, context, status], queue=False) act_button.click( act, inputs=[action, custom, chatbot, game_state], outputs=[chatbot, game_state, context, status, custom], ) guess_button.click( submit_guess, inputs=[guess, chatbot, game_state], outputs=[chatbot, game_state, context, status, guess], ) # --------------------------------------------------------------------------- # Launch # --------------------------------------------------------------------------- if __name__ == "__main__": launch_kwargs = { "share": os.getenv("GRADIO_SHARE", "false").lower() in {"1", "true", "yes", "on"}, "theme": gr.themes.Base( primary_hue="rose", secondary_hue="teal", neutral_hue="slate", radius_size="lg", font=[ gr.themes.GoogleFont("Inter"), "Noto Sans SC", "system-ui", "sans-serif", ], ), "css": CUSTOM_CSS, "head": CUSTOM_HEAD, } if os.getenv("GRADIO_SERVER_NAME"): launch_kwargs["server_name"] = os.getenv("GRADIO_SERVER_NAME") if os.getenv("GRADIO_SERVER_PORT"): launch_kwargs["server_port"] = int(os.getenv("GRADIO_SERVER_PORT", "7860")) demo.queue(max_size=24).launch(**launch_kwargs)