CoolFace
Apppublic

build-small-hackathon/Case-Lantern

sourceHugging Faceapache-2.0updated 4mo agoView on Hugging Face
2likes
app.py970 linesDownload Raw Back to root
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