build-small-hackathon/Case-Lantern
2
1"""Case Lantern — a fictional medical mystery game powered by a small Chinese2medical reasoning model.3 4Backend : llama-cpp-python (GGUF, runs on free CPU Spaces)5Frontend : fully custom dark theme with glassmorphism & micro-animations6Model : lastmass/Qwen3.5-Medical-GSPO (~4.66 B params, Q4_K_M quant)7"""8 9import os10import random11import re12import textwrap13from dataclasses import dataclass, field14from functools import lru_cache15from typing import Dict, List, Optional16 17import gradio as gr18 19# ---------------------------------------------------------------------------20# Configuration21# ---------------------------------------------------------------------------22# Display model (shown in UI)23DISPLAY_MODEL_ID = "lastmass/Qwen3.5-Medical-GSPO"24# GGUF repo used for actual inference (quantised by mradermacher)25GGUF_REPO = "mradermacher/Qwen3.5-Medical-GSPO-GGUF"26GGUF_FILE = "Qwen3.5-Medical-GSPO.Q4_K_M.gguf"27 28DEMO_MODE = os.getenv("DEMO_MODE", "auto").lower()29MAX_NEW_TOKENS = int(os.getenv("MAX_NEW_TOKENS", "420"))30 31DISCLAIMER = (32 "Fictional training game only. This app does not provide medical advice, "33 "diagnosis, triage, or treatment guidance for real people."34)35 36# ---------------------------------------------------------------------------37# System prompt38# ---------------------------------------------------------------------------39SYSTEM_PROMPT = """You are Case Lantern, a playful but careful medical mystery game master.40Create and run fictional Chinese medical reasoning puzzles for education and entertainment.41 42Rules:43- Never present output as real medical advice.44- Keep all patients fictional.45- Do not ask users to share real personal health information.46- Make the game delightful, concise, and clue-driven.47- The player should reason from clues; avoid revealing the final answer unless asked to score.48- Use simplified Chinese by default, with crisp section headers.49- When scoring, be honest but friendly and include one memorable teaching pearl.50"""51 52# ---------------------------------------------------------------------------53# Seed cases54# ---------------------------------------------------------------------------55CASE_SEEDS = [56 {57 "title": "凌晨两点的胸痛电报",58 "genre": "急诊悬疑",59 "opening": "65岁男性,凌晨突发胸痛,额头冒汗,坚持说只是晚饭吃坏了。护士递来一张还热乎的心电图。",60 "secret": "下壁ST段抬高型心肌梗死",61 "clues": [62 "疼痛位于胸骨后,持续超过30分钟,伴冷汗。",63 "II、III、aVF导联ST段抬高,I、aVL可见对应性改变。",64 "血压略低,心率偏慢,提示可能累及右冠供血区域。",65 "硝酸甘油后症状改善不明显。",66 ],67 "red_herring": "反流性食管炎",68 },69 {70 "title": "雨夜里的右下腹脚印",71 "genre": "妇产科侦探",72 "opening": "28岁女性,停经8周,右下腹剧痛后晕厥。诊室灯光一闪,血压计读数像坏消息一样低。",73 "secret": "输卵管妊娠破裂导致腹腔内出血",74 "clues": [75 "停经8周,突发一侧下腹痛。",76 "血压80/50 mmHg,面色苍白,提示休克。",77 "后穹窿穿刺抽出不凝血。",78 "尿/血HCG阳性,床旁超声宫内未见明确孕囊。",79 ],80 "red_herring": "急性阑尾炎",81 },82 {83 "title": "会变形的蝴蝶影子",84 "genre": "内分泌谜题",85 "opening": "32岁女性近两个月怕热、心悸、手抖,朋友说她的眼神像一直在追赶一列迟到的火车。",86 "secret": "Graves病所致甲状腺功能亢进",87 "clues": [88 "怕热、多汗、体重下降但食欲增加。",89 "心率快,双手细颤。",90 "甲状腺弥漫性肿大,可闻及血管杂音。",91 "TSH降低,FT3/FT4升高,TRAb阳性。",92 ],93 "red_herring": "焦虑障碍",94 },95 {96 "title": "沉默的蓝色嘴唇",97 "genre": "呼吸科小剧场",98 "opening": "70岁男性长期咳嗽咳痰,今天走三步就喘,口唇发绀,却还惦记着没下完的一盘棋。",99 "secret": "慢性阻塞性肺疾病急性加重",100 "clues": [101 "长期吸烟史,慢性咳嗽咳痰多年。",102 "活动后气促明显加重,双肺可闻及哮鸣音。",103 "血气提示二氧化碳潴留倾向。",104 "近期有受凉或感染诱因。",105 ],106 "red_herring": "单纯支气管哮喘",107 },108]109 110ACTION_PRESETS = {111 "问病史": "我想进一步问病史。请给我一个关键但不直接泄底的病史线索。",112 "查体": "我想做体格检查。请给我一个关键但不直接泄底的查体线索。",113 "实验室": "我想申请实验室检查。请给我一个关键但不直接泄底的检验线索。",114 "影像/心电": "我想看影像或心电图。请给我一个关键但不直接泄底的检查线索。",115 "提示": "我卡住了。请给我一个分层提示,但不要直接说出诊断。",116}117 118# ---------------------------------------------------------------------------119# Game state120# ---------------------------------------------------------------------------121 122 123@dataclass124class GameState:125 title: str = ""126 genre: str = ""127 opening: str = ""128 secret: str = ""129 red_herring: str = ""130 clues: List[str] = field(default_factory=list)131 used_clues: List[str] = field(default_factory=list)132 turns: int = 0133 score: int = 100134 solved: bool = False135 136 def public_context(self) -> str:137 clue_text = "\n".join(f" • {c}" for c in self.used_clues) or " 暂无线索"138 return (139 f"📁 案件:{self.title}\n"140 f"🏷️ 类型:{self.genre}\n"141 f"📖 开场:{self.opening}\n\n"142 f"🔍 已公开线索:\n{clue_text}\n\n"143 f"⏱️ 回合:{self.turns}/6\n"144 f"⭐ 分数:{self.score}"145 )146 147 148# ---------------------------------------------------------------------------149# Helpers150# ---------------------------------------------------------------------------151 152 153def normalize_text(value: str) -> str:154 return re.sub(r"\s+", " ", value or "").strip()155 156 157def strip_thinking(text: str) -> str:158 text = re.sub(r"<think>.*?</think>", "", text, flags=re.DOTALL | re.IGNORECASE)159 text = text.replace("<think>", "").replace("</think>", "")160 return text.strip()161 162 163# ---------------------------------------------------------------------------164# Demo / fallback replies (no model needed)165# ---------------------------------------------------------------------------166 167 168def demo_reply(prompt: str, state: GameState, mode: str) -> str:169 unused = [c for c in state.clues if c not in state.used_clues]170 next_clue = unused[0] if unused else random.choice(state.clues)171 172 if mode == "score":173 guess = prompt.lower()174 secret_terms = [state.secret.lower()]175 if "心肌梗死" in state.secret:176 secret_terms += ["心梗", "stemi", "梗死"]177 if "输卵管" in state.secret:178 secret_terms += ["宫外孕", "异位妊娠", "破裂"]179 if "graves" in state.secret.lower():180 secret_terms += ["甲亢", "graves"]181 if "慢性阻塞" in state.secret:182 secret_terms += ["copd", "慢阻肺"]183 hit = any(t in guess for t in secret_terms)184 if hit:185 return (186 "### 🎯 判定\n"187 "你抓住了核心诊断。推理链条成立,关键是把症状、危险信号和特异检查连起来。\n\n"188 f"### 🔓 真相\n{state.secret}\n\n"189 "### 💡 记忆钉\n"190 "好诊断不是猜谜底,而是让每条线索都有地方安放。"191 )192 return (193 "### ❌ 判定\n"194 "这个答案有一点影子,但还没有解释最关键的危险线索。\n\n"195 f"### 🔄 反向提示\n别被「{state.red_herring}」带偏,重新看最急、最能改变处理路径的证据。\n\n"196 "### 💡 记忆钉\n"197 "先处理能致命的可能,再处理看起来像的可能。"198 )199 200 if mode == "hint":201 return (202 "### 💡 分层提示\n"203 f"把注意力放在这条线索上:{next_clue}\n\n"204 "### 🤔 小问题\n"205 "它更支持哪个系统的问题?有没有一个诊断能同时解释时间、症状和检查?"206 )207 208 return (209 "### 🔍 新线索\n"210 f"{next_clue}\n\n"211 "### 📝 案件旁白\n"212 "房间里安静了一秒。这个线索不像答案,但它像一把钥匙。"213 )214 215 216# ---------------------------------------------------------------------------217# Model loading — llama-cpp-python (GGUF) on CPU218# ---------------------------------------------------------------------------219# Hugging Face ZeroGPU is designed primarily for PyTorch workloads. The CUDA220# wheel of llama-cpp-python requires system CUDA runtime libraries such as221# libcudart.so.12, which are not available in the normal Space container and can222# fail before inference starts. Use the CPU wheel for reliable Spaces startup.223 224_llm_instance = None225 226 227def get_llm():228 """Load the GGUF model. Raises RuntimeError when DEMO_MODE is forced."""229 global _llm_instance230 if _llm_instance is not None:231 return _llm_instance232 if DEMO_MODE in {"1", "true", "yes", "on"}:233 raise RuntimeError("DEMO_MODE is enabled — skipping model load.")234 235 from llama_cpp import Llama # noqa: delayed import236 237 print("[Case Lantern] Loading GGUF model …")238 _llm_instance = Llama.from_pretrained(239 repo_id=GGUF_REPO,240 filename=GGUF_FILE,241 n_ctx=2048,242 n_threads=int(os.getenv("LLAMA_THREADS", "4")),243 n_gpu_layers=0,244 verbose=True,245 )246 print("[Case Lantern] Model loaded successfully.")247 return _llm_instance248 249 250def _call_model_inner(251 messages: List[Dict[str, str]], state: GameState, fallback_mode: str252) -> str:253 if DEMO_MODE in {"1", "true", "yes", "on"}:254 return demo_reply(messages[-1]["content"], state, fallback_mode)255 256 try:257 llm = get_llm()258 response = llm.create_chat_completion(259 messages=messages,260 max_tokens=MAX_NEW_TOKENS,261 temperature=0.85,262 top_p=0.92,263 repeat_penalty=1.05,264 stop=["<|im_end|>", "<|endoftext|>"],265 )266 raw = response["choices"][0]["message"]["content"] or ""267 return strip_thinking(raw)268 except Exception as exc:269 import traceback270 271 traceback.print_exc()272 if DEMO_MODE == "off":273 raise274 return (275 demo_reply(messages[-1]["content"], state, fallback_mode)276 + f"\n\n_演示模式:模型暂未加载({type(exc).__name__}: {exc})。_"277 )278 279 280call_model = _call_model_inner281 282 283# ---------------------------------------------------------------------------284# Game logic285# ---------------------------------------------------------------------------286ChatHistory = List[Dict[str, str]]287 288 289def new_case():290 seed = random.choice(CASE_SEEDS)291 state = GameState(292 title=seed["title"],293 genre=seed["genre"],294 opening=seed["opening"],295 secret=seed["secret"],296 red_herring=seed["red_herring"],297 clues=list(seed["clues"]),298 used_clues=[],299 )300 first_message = {301 "role": "assistant",302 "content": (303 f"### 🏮 {state.title}\n"304 f"**{state.genre}**\n\n"305 f"{state.opening}\n\n"306 "你有 **6 个回合** 调查。选择一个行动,或直接输入你的诊断假设。"307 ),308 }309 return [first_message], state, state.public_context(), status_line(state)310 311 312def status_line(state: GameState) -> str:313 icon = "🏆" if state.solved else "🔎"314 label = "已破案" if state.solved else "调查中"315 return f"{icon} {label} · 回合 {state.turns}/6 · ⭐ {state.score}"316 317 318def reveal_clue(state: GameState) -> Optional[str]:319 unused = [c for c in state.clues if c not in state.used_clues]320 if not unused:321 return None322 clue = unused[0]323 state.used_clues.append(clue)324 return clue325 326 327def build_messages(328 state: GameState, instruction: str, mode: str329) -> List[Dict[str, str]]:330 return [331 {"role": "system", "content": SYSTEM_PROMPT},332 {333 "role": "user",334 "content": textwrap.dedent(f"""\335 你正在主持一个虚构医学推理小游戏。336 337 隐藏真相:{state.secret}338 红鲱鱼:{state.red_herring}339 340 当前公开状态:341 {state.public_context()}342 343 玩家动作:344 {instruction}345 346 输出要求:347 - 不要给真实医疗建议。348 - 不要要求玩家提供真实个人健康信息。349 - 如果 mode={mode} 且不是评分,不要直接泄露隐藏真相。350 - 保持中文,短小、有戏剧感。351 """),352 },353 ]354 355 356def diagnosis_terms(secret: str) -> List[str]:357 terms = [secret.lower()]358 mapping = {359 "心肌梗死": ["心梗", "stemi", "梗死"],360 "输卵管": ["宫外孕", "异位妊娠", "破裂"],361 "Graves": ["graves", "甲亢", "甲状腺功能亢进"],362 "慢性阻塞": ["copd", "慢阻肺"],363 }364 for key, values in mapping.items():365 if key.lower() in secret.lower():366 terms.extend(values)367 return terms368 369 370def act(action, custom_action, chat, state):371 if not state or not state.title:372 chat, state, context, status = new_case()373 374 if state.solved:375 chat.append(376 {377 "role": "assistant",378 "content": "案件已经结案。点击 **新案件** 开始下一个挑战。",379 }380 )381 return chat, state, state.public_context(), status_line(state), ""382 383 instruction = normalize_text(custom_action) or ACTION_PRESETS.get(384 action, ACTION_PRESETS["提示"]385 )386 mode = "hint" if action == "提示" else "clue"387 state.turns += 1388 state.score = max(20, state.score - (6 if mode == "hint" else 4))389 reveal_clue(state)390 391 reply = call_model(build_messages(state, instruction, mode), state, mode)392 chat.append({"role": "user", "content": f"🎬 {action}:{instruction}"})393 chat.append({"role": "assistant", "content": reply})394 return chat, state, state.public_context(), status_line(state), ""395 396 397def submit_guess(guess, chat, state):398 if not state or not state.title:399 chat, state, context, status = new_case()400 401 cleaned = normalize_text(guess)402 if not cleaned:403 chat.append({"role": "assistant", "content": "先写下你的诊断假设,再按提交。"})404 return chat, state, state.public_context(), status_line(state), ""405 406 state.turns += 1407 messages = build_messages(408 state,409 f"玩家最终诊断是:{cleaned}。请评分并揭示真相。",410 "score",411 )412 reply = call_model(messages, state, "score")413 state.solved = True414 if any(t in cleaned.lower() for t in diagnosis_terms(state.secret)):415 state.score = min(100, state.score + 12)416 else:417 state.score = max(20, state.score - 15)418 419 chat.append({"role": "user", "content": f"🩺 最终诊断:{cleaned}"})420 chat.append({"role": "assistant", "content": reply})421 return chat, state, state.public_context(), status_line(state), ""422 423 424# ---------------------------------------------------------------------------425# Custom CSS — dark medical-mystery theme with glassmorphism426# ---------------------------------------------------------------------------427CUSTOM_CSS = """\428/* ===== GLOBAL DARK OVERRIDE ===== */429:root {430 --cl-bg-deep: #0b0f1a;431 --cl-bg-panel: rgba(15, 22, 42, 0.72);432 --cl-glass: rgba(255, 255, 255, 0.04);433 --cl-glass-edge: rgba(255, 255, 255, 0.08);434 --cl-ruby: #e03e5e;435 --cl-ruby-glow: rgba(224, 62, 94, 0.35);436 --cl-gold: #f0b429;437 --cl-gold-dim: #c6931b;438 --cl-mint: #34d399;439 --cl-text: #e2e8f0;440 --cl-text-dim: #94a3b8;441 --cl-border: rgba(255, 255, 255, 0.06);442 --cl-radius: 14px;443}444 445/* Force dark everywhere */446body, .gradio-container, .main, .contain,447.gradio-container .main .wrap {448 background: var(--cl-bg-deep) !important;449 color: var(--cl-text) !important;450}451 452.gradio-container {453 max-width: 1200px !important;454 font-family: 'Inter', 'Noto Sans SC', system-ui, -apple-system, sans-serif !important;455}456 457/* ===== HEADER BANNER ===== */458#hero-banner {459 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%);460 border: 1px solid var(--cl-glass-edge);461 border-radius: var(--cl-radius);462 padding: 48px 32px 24px;463 margin-bottom: 8px;464 backdrop-filter: blur(20px);465 -webkit-backdrop-filter: blur(20px);466 position: relative;467 overflow: visible;468}469 470#hero-banner::before {471 content: '';472 position: absolute;473 top: -80%;474 right: -10%;475 width: 260px;476 height: 260px;477 border-radius: 50%;478 background: radial-gradient(circle, var(--cl-ruby-glow) 0%, transparent 70%);479 animation: hero-pulse 5s ease-in-out infinite;480 pointer-events: none;481}482 483@keyframes hero-pulse {484 0%, 100% { opacity: 0.3; transform: scale(1); }485 50% { opacity: 0.6; transform: scale(1.15); }486}487 488.hero-title {489 font-size: 2.4rem;490 font-weight: 800;491 background: linear-gradient(135deg, #ff5c7c, #ffd166);492 -webkit-background-clip: text;493 -webkit-text-fill-color: transparent;494 background-clip: text;495 margin: 0 0 12px 0;496 line-height: 1.35;497 position: relative;498 z-index: 1;499}500 501#hero-banner p, #hero-banner .prose p {502 color: var(--cl-text-dim) !important;503 font-size: 0.92rem !important;504 margin: 0 !important;505 line-height: 1.5 !important;506}507 508#hero-banner a { color: var(--cl-gold) !important; text-decoration: underline; }509 510/* Prevent Gradio wrapper clipping inside hero banner */511#hero-banner > div,512#hero-banner .prose,513#hero-banner .md,514#hero-banner .wrap,515#hero-banner .block {516 overflow: visible !important;517}518 519/* ===== SAFETY NOTE ===== */520#safety-note {521 background: rgba(224, 62, 94, 0.08) !important;522 border: 1px solid rgba(224, 62, 94, 0.18) !important;523 border-radius: 10px !important;524 padding: 10px 14px !important;525 margin-bottom: 12px !important;526}527#safety-note p, #safety-note .prose p {528 color: #fca5a5 !important;529 font-size: 0.82rem !important;530 margin: 0 !important;531}532 533/* ===== GLASSMORPHISM PANELS ===== */534.glass-panel, .glass-panel > .block {535 background: var(--cl-bg-panel) !important;536 border: 1px solid var(--cl-glass-edge) !important;537 border-radius: var(--cl-radius) !important;538 backdrop-filter: blur(16px) !important;539 -webkit-backdrop-filter: blur(16px) !important;540}541 542/* ===== CHATBOT ===== */543#case-chat {544 border: 1px solid var(--cl-glass-edge) !important;545 border-radius: var(--cl-radius) !important;546 background: rgba(15, 22, 42, 0.55) !important;547 backdrop-filter: blur(12px) !important;548}549 550/* Force ALL chatbot message text to be bright */551#case-chat .message-row .message,552#case-chat .bot .message-bubble,553#case-chat .user .message-bubble,554#case-chat .message,555#case-chat .message-bubble,556#case-chat [data-testid="bot"],557#case-chat [data-testid="user"],558#case-chat .bot,559#case-chat .user,560#case-chat .prose,561#case-chat .md,562#case-chat .message p,563#case-chat .message span,564#case-chat .message li,565#case-chat .message h1,566#case-chat .message h2,567#case-chat .message h3,568#case-chat .message h4,569#case-chat .message strong,570#case-chat .message em,571#case-chat .message-bubble p,572#case-chat .message-bubble span,573#case-chat .message-bubble li,574#case-chat .message-bubble h1,575#case-chat .message-bubble h2,576#case-chat .message-bubble h3,577#case-chat .message-bubble h4,578#case-chat .message-bubble strong,579#case-chat .message-bubble em,580#case-chat .prose p,581#case-chat .prose span,582#case-chat .prose li,583#case-chat .prose h1,584#case-chat .prose h2,585#case-chat .prose h3,586#case-chat .prose h4,587#case-chat .prose strong {588 color: #f1f5f9 !important;589}590 591#case-chat .message-row .message,592#case-chat .message-bubble,593#case-chat .bot .message-bubble,594#case-chat [data-testid="bot"] {595 border-radius: 12px !important;596 font-size: 0.93rem !important;597 line-height: 1.65 !important;598 background: rgba(30, 41, 70, 0.85) !important;599 border: 1px solid var(--cl-glass-edge) !important;600}601 602/* user bubble - red tinted */603#case-chat .message-row.user-row .message,604#case-chat .user .message-bubble,605#case-chat [data-testid="user"] {606 background: linear-gradient(135deg, rgba(224,62,94,0.22), rgba(224,62,94,0.10)) !important;607 border: 1px solid rgba(224,62,94,0.25) !important;608}609 610/* bot bubble - dark glass */611#case-chat .message-row.bot-row .message,612#case-chat .bot .message-bubble,613#case-chat [data-testid="bot"] {614 background: rgba(30, 41, 70, 0.85) !important;615 border: 1px solid var(--cl-glass-edge) !important;616}617 618/* Chatbot wrapper and scroll area dark */619#case-chat .chatbot,620#case-chat .wrap,621#case-chat > div {622 background: transparent !important;623}624 625/* ===== TEXTBOX / INPUT FIELDS ===== */626textarea, input[type="text"],627.textbox textarea, .textbox input {628 background: rgba(15, 22, 42, 0.7) !important;629 border: 1px solid var(--cl-glass-edge) !important;630 border-radius: 10px !important;631 color: var(--cl-text) !important;632 transition: border-color 0.3s, box-shadow 0.3s !important;633}634 635textarea:focus, input[type="text"]:focus {636 border-color: var(--cl-ruby) !important;637 box-shadow: 0 0 0 3px var(--cl-ruby-glow) !important;638 outline: none !important;639}640 641/* Labels */642label, .label-wrap span, .block label span {643 color: var(--cl-text-dim) !important;644 font-weight: 600 !important;645 font-size: 0.85rem !important;646 text-transform: uppercase !important;647 letter-spacing: 0.5px !important;648}649 650/* ===== RADIO BUTTONS ===== */651.radio-group label, .wrap label.selected {652 background: var(--cl-glass) !important;653 border: 1px solid var(--cl-glass-edge) !important;654 border-radius: 8px !important;655 color: var(--cl-text) !important;656 transition: all 0.25s !important;657}658 659.radio-group label:hover {660 border-color: var(--cl-ruby) !important;661 background: rgba(224, 62, 94, 0.08) !important;662}663 664.radio-group label.selected, .radio-group input:checked + label {665 border-color: var(--cl-ruby) !important;666 background: rgba(224, 62, 94, 0.15) !important;667 box-shadow: 0 0 12px var(--cl-ruby-glow) !important;668}669 670/* ===== BUTTONS ===== */671button.primary, button.primary:hover {672 background: linear-gradient(135deg, var(--cl-ruby), #c2294a) !important;673 border: none !important;674 color: #fff !important;675 border-radius: 10px !important;676 font-weight: 700 !important;677 letter-spacing: 0.3px !important;678 box-shadow: 0 4px 20px var(--cl-ruby-glow) !important;679 transition: transform 0.2s, box-shadow 0.3s !important;680}681button.primary:hover {682 transform: translateY(-1px) !important;683 box-shadow: 0 6px 28px rgba(224,62,94,0.5) !important;684}685button.primary:active {686 transform: translateY(0) !important;687}688 689button.secondary, button.secondary:hover {690 background: var(--cl-glass) !important;691 border: 1px solid var(--cl-glass-edge) !important;692 color: var(--cl-text) !important;693 border-radius: 10px !important;694 font-weight: 600 !important;695 transition: all 0.25s !important;696}697button.secondary:hover {698 border-color: var(--cl-gold-dim) !important;699 color: var(--cl-gold) !important;700 background: rgba(240,180,41,0.08) !important;701}702 703/* ===== STATUS PILL ===== */704#status-pill textarea {705 font-weight: 700 !important;706 color: var(--cl-gold) !important;707 font-size: 0.95rem !important;708 background: rgba(240,180,41,0.06) !important;709 border: 1px solid rgba(240,180,41,0.18) !important;710 border-radius: 10px !important;711 text-align: center !important;712}713 714/* ===== CASE BOARD ===== */715#case-board textarea {716 background: rgba(15, 22, 42, 0.65) !important;717 border: 1px solid var(--cl-glass-edge) !important;718 border-radius: 10px !important;719 color: var(--cl-text-dim) !important;720 font-size: 0.88rem !important;721 line-height: 1.7 !important;722}723 724/* ===== EXAMPLES ===== */725.examples-table button {726 background: var(--cl-glass) !important;727 border: 1px solid var(--cl-glass-edge) !important;728 color: var(--cl-text-dim) !important;729 border-radius: 8px !important;730 transition: all 0.2s !important;731}732.examples-table button:hover {733 border-color: var(--cl-mint) !important;734 color: var(--cl-mint) !important;735}736 737/* ===== FOOTER ===== */738#footer-info p, #footer-info .prose p {739 color: var(--cl-text-dim) !important;740 font-size: 0.78rem !important;741 text-align: center !important;742}743 744/* ===== SCROLL BAR ===== */745::-webkit-scrollbar { width: 6px; }746::-webkit-scrollbar-track { background: transparent; }747::-webkit-scrollbar-thumb {748 background: rgba(255,255,255,0.1);749 border-radius: 3px;750}751::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,0.2); }752 753/* ===== ANIMATIONS ===== */754@keyframes fade-in {755 from { opacity: 0; transform: translateY(8px); }756 to { opacity: 1; transform: translateY(0); }757}758 759.glass-panel, #case-chat, #hero-banner {760 animation: fade-in 0.5s ease-out;761}762 763/* ===== RESPONSIVE ===== */764@media (max-width: 768px) {765 #hero-banner { padding: 18px 16px 14px; }766 #hero-banner h1 { font-size: 1.5rem !important; }767 .gradio-container { padding: 8px !important; }768}769 770/* ===== ACCORDION / GROUP borders ===== */771.block, .form, .wrap, .panel, .gap, .gr-group, .gr-box {772 border-color: var(--cl-border) !important;773}774 775/* ===== OVERRIDE light-mode remnants ===== */776/* Force Gradio CSS variables everywhere */777*, *::before, *::after,778.dark, [data-testid],779.gradio-container, .gradio-container * {780 --background-fill-primary: var(--cl-bg-deep) !important;781 --background-fill-secondary: rgba(15, 22, 42, 0.7) !important;782 --background-fill-primary-dark: var(--cl-bg-deep) !important;783 --border-color-primary: var(--cl-glass-edge) !important;784 --body-text-color: var(--cl-text) !important;785 --body-text-color-subdued: var(--cl-text-dim) !important;786 --block-background-fill: var(--cl-bg-panel) !important;787 --block-border-color: var(--cl-glass-edge) !important;788 --block-label-text-color: var(--cl-text-dim) !important;789 --input-background-fill: rgba(15, 22, 42, 0.7) !important;790 --input-border-color: var(--cl-glass-edge) !important;791 --color-accent: var(--cl-ruby) !important;792 --chatbot-text-color: #f1f5f9 !important;793}794 795/* Global: any text inside the app must be bright */796.gradio-container p,797.gradio-container span,798.gradio-container li,799.gradio-container td,800.gradio-container th,801.gradio-container div,802.gradio-container h1,803.gradio-container h2,804.gradio-container h3,805.gradio-container h4,806.gradio-container h5,807.gradio-container h6,808.gradio-container strong,809.gradio-container em,810.gradio-container label {811 color: var(--cl-text) !important;812}813 814/* Re-apply specific colors after the global rule */815#hero-banner .hero-title {816 -webkit-text-fill-color: transparent !important;817}818#status-pill textarea {819 color: var(--cl-gold) !important;820}821#safety-note p, #safety-note .prose p {822 color: #fca5a5 !important;823}824#footer-info p, #footer-info .prose p {825 color: var(--cl-text-dim) !important;826}827#hero-banner p {828 color: var(--cl-text-dim) !important;829}830#hero-banner a {831 color: var(--cl-gold) !important;832}833"""834 835# ---------------------------------------------------------------------------836# Google Fonts injection837# ---------------------------------------------------------------------------838CUSTOM_HEAD = """\839<link rel="preconnect" href="https://fonts.googleapis.com">840<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>841<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">842"""843 844# ---------------------------------------------------------------------------845# Build the Gradio app846# ---------------------------------------------------------------------------847with gr.Blocks(848 title="Case Lantern 🏮",849) as demo:850 game_state = gr.State(GameState())851 852 # --- Hero banner (raw HTML for full rendering control) ---853 gr.HTML(854 f"""855 <div id="hero-banner">856 <div class="hero-title">🏮 Case Lantern</div>857 <p>一个由小型中文医疗推理模型驱动的虚构病例侦探游戏。查线索、避开误导、在 6 回合内破案。</p>858 <p>模型:<a href="https://huggingface.co/{DISPLAY_MODEL_ID}" target="_blank" rel="noopener">{DISPLAY_MODEL_ID}</a> · ~4.66B 参数 · llama.cpp 本地推理</p>859 </div>860 """,861 )862 gr.Markdown(f"⚠️ {DISCLAIMER}", elem_id="safety-note")863 864 with gr.Row():865 # --- LEFT: Chat ---866 with gr.Column(scale=3):867 chatbot = gr.Chatbot(868 label="案件记录",869 height=560,870 elem_id="case-chat",871 )872 873 # --- RIGHT: Control panel ---874 with gr.Column(scale=2, elem_classes=["glass-panel"]):875 status = gr.Textbox(876 label="状态",877 elem_id="status-pill",878 interactive=False,879 )880 context = gr.Textbox(881 label="📋 案件板",882 lines=10,883 interactive=False,884 elem_id="case-board",885 )886 887 gr.Markdown("#### 🎯 调查行动", elem_id="action-title")888 action = gr.Radio(889 label="选择行动",890 choices=list(ACTION_PRESETS.keys()),891 value="问病史",892 )893 custom = gr.Textbox(894 label="自定义行动",895 placeholder="例如:我想追问疼痛性质和伴随症状…",896 lines=2,897 )898 with gr.Row():899 act_button = gr.Button("🔍 调查", variant="primary")900 new_button = gr.Button("🆕 新案件", variant="secondary")901 902 gr.Markdown("#### 🩺 最终诊断")903 guess = gr.Textbox(904 label="你的诊断",905 placeholder="写下你的诊断假设,然后提交破案",906 lines=2,907 )908 guess_button = gr.Button("💊 提交诊断", variant="primary")909 910 # --- Examples ---911 gr.Examples(912 examples=[913 ["我想询问发病时间、诱因和伴随症状"],914 ["我想查看最能排除危险诊断的检查"],915 ["请给我一个不会直接泄底的鉴别诊断提示"],916 ],917 inputs=custom,918 label="💡 行动灵感",919 )920 921 gr.Markdown(922 f"Case Lantern · Build Small Hackathon 2026 · Powered by "923 f"[{DISPLAY_MODEL_ID}](https://huggingface.co/{DISPLAY_MODEL_ID})"924 f" via llama.cpp",925 elem_id="footer-info",926 )927 928 # --- Wiring ---929 new_button.click(new_case, outputs=[chatbot, game_state, context, status])930 demo.load(new_case, outputs=[chatbot, game_state, context, status], queue=False)931 act_button.click(932 act,933 inputs=[action, custom, chatbot, game_state],934 outputs=[chatbot, game_state, context, status, custom],935 )936 guess_button.click(937 submit_guess,938 inputs=[guess, chatbot, game_state],939 outputs=[chatbot, game_state, context, status, guess],940 )941 942 943# ---------------------------------------------------------------------------944# Launch945# ---------------------------------------------------------------------------946if __name__ == "__main__":947 launch_kwargs = {948 "share": os.getenv("GRADIO_SHARE", "false").lower()949 in {"1", "true", "yes", "on"},950 "theme": gr.themes.Base(951 primary_hue="rose",952 secondary_hue="teal",953 neutral_hue="slate",954 radius_size="lg",955 font=[956 gr.themes.GoogleFont("Inter"),957 "Noto Sans SC",958 "system-ui",959 "sans-serif",960 ],961 ),962 "css": CUSTOM_CSS,963 "head": CUSTOM_HEAD,964 }965 if os.getenv("GRADIO_SERVER_NAME"):966 launch_kwargs["server_name"] = os.getenv("GRADIO_SERVER_NAME")967 if os.getenv("GRADIO_SERVER_PORT"):968 launch_kwargs["server_port"] = int(os.getenv("GRADIO_SERVER_PORT", "7860"))969 demo.queue(max_size=24).launch(**launch_kwargs)970 