| """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 |
|
|
| |
| |
| |
| |
| DISPLAY_MODEL_ID = "lastmass/Qwen3.5-Medical-GSPO" |
| |
| 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 = """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. |
| """ |
|
|
| |
| |
| |
| 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 = { |
| "问病史": "我想进一步问病史。请给我一个关键但不直接泄底的病史线索。", |
| "查体": "我想做体格检查。请给我一个关键但不直接泄底的查体线索。", |
| "实验室": "我想申请实验室检查。请给我一个关键但不直接泄底的检验线索。", |
| "影像/心电": "我想看影像或心电图。请给我一个关键但不直接泄底的检查线索。", |
| "提示": "我卡住了。请给我一个分层提示,但不要直接说出诊断。", |
| } |
|
|
| |
| |
| |
|
|
|
|
| @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}" |
| ) |
|
|
|
|
| |
| |
| |
|
|
|
|
| def normalize_text(value: str) -> str: |
| return re.sub(r"\s+", " ", value or "").strip() |
|
|
|
|
| def strip_thinking(text: str) -> str: |
| text = re.sub(r"<think>.*?</think>", "", text, flags=re.DOTALL | re.IGNORECASE) |
| text = text.replace("<think>", "").replace("</think>", "") |
| return text.strip() |
|
|
|
|
| |
| |
| |
|
|
|
|
| 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" |
| "房间里安静了一秒。这个线索不像答案,但它像一把钥匙。" |
| ) |
|
|
|
|
| |
| |
| |
| |
| |
| |
| |
|
|
| _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 |
|
|
| 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 |
|
|
|
|
| |
| |
| |
| 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 = """\ |
| /* ===== 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; |
| } |
| """ |
|
|
| |
| |
| |
| CUSTOM_HEAD = """\ |
| <link rel="preconnect" href="https://fonts.googleapis.com"> |
| <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> |
| <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700;800&family=Noto+Sans+SC:wght@400;500;700&display=swap" rel="stylesheet"> |
| """ |
|
|
| |
| |
| |
| with gr.Blocks( |
| title="Case Lantern 🏮", |
| ) as demo: |
| game_state = gr.State(GameState()) |
|
|
| |
| gr.HTML( |
| f""" |
| <div id="hero-banner"> |
| <div class="hero-title">🏮 Case Lantern</div> |
| <p>一个由小型中文医疗推理模型驱动的虚构病例侦探游戏。查线索、避开误导、在 6 回合内破案。</p> |
| <p>模型:<a href="https://huggingface.co/{DISPLAY_MODEL_ID}" target="_blank" rel="noopener">{DISPLAY_MODEL_ID}</a> · ~4.66B 参数 · llama.cpp 本地推理</p> |
| </div> |
| """, |
| ) |
| gr.Markdown(f"⚠️ {DISCLAIMER}", elem_id="safety-note") |
|
|
| with gr.Row(): |
| |
| with gr.Column(scale=3): |
| chatbot = gr.Chatbot( |
| label="案件记录", |
| height=560, |
| elem_id="case-chat", |
| ) |
|
|
| |
| 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") |
|
|
| |
| 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", |
| ) |
|
|
| |
| 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], |
| ) |
|
|
|
|
| |
| |
| |
| 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) |
|
|