"""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)