lastmass commited on
Commit
adff921
·
0 Parent(s):

Case Lantern: GGUF backend + custom dark frontend for Build Small Hackathon

Browse files
Files changed (3) hide show
  1. README.md +70 -0
  2. app.py +934 -0
  3. requirements.txt +3 -0
README.md ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Case Lantern
3
+ colorFrom: pink
4
+ colorTo: blue
5
+ sdk: gradio
6
+ sdk_version: 4.44.1
7
+ app_file: app.py
8
+ pinned: false
9
+ license: apache-2.0
10
+ models:
11
+ - lastmass/Qwen3.5-Medical-GSPO
12
+ ---
13
+
14
+ # 🏮 Case Lantern
15
+
16
+ Case Lantern is a fictional medical mystery game for the
17
+ [Build Small Hackathon](https://huggingface.co/build-small-hackathon).
18
+ Players investigate a short Chinese case, request clues, avoid red herrings, and
19
+ submit a diagnosis within six turns.
20
+
21
+ The experience uses [`lastmass/Qwen3.5-Medical-GSPO`](https://huggingface.co/lastmass/Qwen3.5-Medical-GSPO),
22
+ a small Chinese medical reasoning model with roughly 4.66B parameters, as the
23
+ game master and scorer. Inference runs locally via **llama.cpp** (GGUF Q4_K_M).
24
+
25
+ ## Track & Merit Badges
26
+
27
+ | Item | Detail |
28
+ |------|--------|
29
+ | Track | An Adventure in Thousand Token Wood |
30
+ | AI role | Load-bearing game master, clue writer, and scoring judge |
31
+ | Constraint | Small model under 32B parameters |
32
+ | UI | Gradio Space with custom dark frontend |
33
+
34
+ | Badge | Status |
35
+ |-------|--------|
36
+ | 🏕️ Off the Grid (LOCAL-FIRST) | ✅ Model runs locally in the Space |
37
+ | 🎸 Well-Tuned (FINE-TUNED) | ✅ Uses fine-tuned model published on HF |
38
+ | 🦙 Llama Champion | ✅ Runs via llama.cpp runtime |
39
+ | 🎨 Off-Brand (CUSTOM UI) | ✅ Dark glassmorphism theme, custom CSS |
40
+
41
+ ## Safety framing
42
+
43
+ This is not a diagnosis or treatment tool. It only uses fictional cases for
44
+ learning-oriented gameplay. Users are explicitly asked not to provide personal
45
+ health information.
46
+
47
+ ## Deployment notes
48
+
49
+ The app is designed for **free CPU Spaces** on Hugging Face. It does not require
50
+ a GPU. The GGUF model (~2.78 GB, Q4_K_M) is downloaded from the Hub at first
51
+ launch and cached.
52
+
53
+ - Set `DEMO_MODE=auto` (default) to allow a graceful scripted fallback if the
54
+ model cannot load.
55
+ - Set `DEMO_MODE=true` to skip model loading entirely (instant UI-only demo).
56
+ - Set `DEMO_MODE=off` if you want model-loading failures to surface immediately.
57
+
58
+ ## Local run
59
+
60
+ ```bash
61
+ pip install -r requirements.txt
62
+ DEMO_MODE=true python app.py
63
+ ```
64
+
65
+ On Windows PowerShell:
66
+
67
+ ```powershell
68
+ $env:DEMO_MODE="true"
69
+ python app.py
70
+ ```
app.py ADDED
@@ -0,0 +1,934 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Case Lantern — a fictional medical mystery game powered by a small Chinese
2
+ medical reasoning model.
3
+
4
+ Backend : llama-cpp-python (GGUF, runs on free CPU Spaces)
5
+ Frontend : fully custom dark theme with glassmorphism & micro-animations
6
+ Model : lastmass/Qwen3.5-Medical-GSPO (~4.66 B params, Q4_K_M quant)
7
+ """
8
+
9
+ import os
10
+ import random
11
+ import re
12
+ import textwrap
13
+ from dataclasses import dataclass, field
14
+ from functools import lru_cache
15
+ from typing import Dict, List, Optional
16
+
17
+ import gradio as gr
18
+
19
+ # ---------------------------------------------------------------------------
20
+ # Configuration
21
+ # ---------------------------------------------------------------------------
22
+ # Display model (shown in UI)
23
+ DISPLAY_MODEL_ID = "lastmass/Qwen3.5-Medical-GSPO"
24
+ # GGUF repo used for actual inference (quantised by mradermacher)
25
+ GGUF_REPO = "mradermacher/Qwen3.5-Medical-GSPO-GGUF"
26
+ GGUF_FILE = "Qwen3.5-Medical-GSPO.Q4_K_M.gguf"
27
+
28
+ DEMO_MODE = os.getenv("DEMO_MODE", "auto").lower()
29
+ MAX_NEW_TOKENS = int(os.getenv("MAX_NEW_TOKENS", "420"))
30
+
31
+ DISCLAIMER = (
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 prompt
38
+ # ---------------------------------------------------------------------------
39
+ SYSTEM_PROMPT = """You are Case Lantern, a playful but careful medical mystery game master.
40
+ Create and run fictional Chinese medical reasoning puzzles for education and entertainment.
41
+
42
+ Rules:
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 cases
54
+ # ---------------------------------------------------------------------------
55
+ CASE_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
+
110
+ ACTION_PRESETS = {
111
+ "问病史": "我想进一步问病史。请给我一个关键但不直接泄底的病史线索。",
112
+ "查体": "我想做体格检查。请给我一个关键但不直接泄底的查体线索。",
113
+ "实验室": "我想申请实验室检查。请给我一个关键但不直接泄底的检验线索。",
114
+ "���像/心电": "我想看影像或心电图。请给我一个关键但不直接泄底的检查线索。",
115
+ "提示": "我卡住了。请给我一个分层提示,但不要直接说出诊断。",
116
+ }
117
+
118
+ # ---------------------------------------------------------------------------
119
+ # Game state
120
+ # ---------------------------------------------------------------------------
121
+
122
+ @dataclass
123
+ class GameState:
124
+ title: str = ""
125
+ genre: str = ""
126
+ opening: str = ""
127
+ secret: str = ""
128
+ red_herring: str = ""
129
+ clues: List[str] = field(default_factory=list)
130
+ used_clues: List[str] = field(default_factory=list)
131
+ turns: int = 0
132
+ score: int = 100
133
+ solved: bool = False
134
+
135
+ def public_context(self) -> str:
136
+ clue_text = "\n".join(f" • {c}" for c in self.used_clues) or " 暂无线索"
137
+ return (
138
+ f"📁 案件:{self.title}\n"
139
+ f"🏷️ 类型:{self.genre}\n"
140
+ f"📖 开场:{self.opening}\n\n"
141
+ f"🔍 已公开线索:\n{clue_text}\n\n"
142
+ f"⏱️ 回合:{self.turns}/6\n"
143
+ f"⭐ 分数:{self.score}"
144
+ )
145
+
146
+
147
+ # ---------------------------------------------------------------------------
148
+ # Helpers
149
+ # ---------------------------------------------------------------------------
150
+
151
+ def normalize_text(value: str) -> str:
152
+ return re.sub(r"\s+", " ", value or "").strip()
153
+
154
+
155
+ def strip_thinking(text: str) -> str:
156
+ text = re.sub(r"<think>.*?</think>", "", text, flags=re.DOTALL | re.IGNORECASE)
157
+ text = text.replace("<think>", "").replace("</think>", "")
158
+ return text.strip()
159
+
160
+
161
+ # ---------------------------------------------------------------------------
162
+ # Demo / fallback replies (no model needed)
163
+ # ---------------------------------------------------------------------------
164
+
165
+ def demo_reply(prompt: str, state: GameState, mode: str) -> str:
166
+ unused = [c for c in state.clues if c not in state.used_clues]
167
+ next_clue = unused[0] if unused else random.choice(state.clues)
168
+
169
+ if mode == "score":
170
+ guess = prompt.lower()
171
+ secret_terms = [state.secret.lower()]
172
+ if "心肌梗死" in state.secret:
173
+ secret_terms += ["心梗", "stemi", "梗死"]
174
+ if "输卵管" in state.secret:
175
+ secret_terms += ["宫外孕", "异位妊娠", "破裂"]
176
+ if "graves" in state.secret.lower():
177
+ secret_terms += ["甲亢", "graves"]
178
+ if "慢性阻塞" in state.secret:
179
+ secret_terms += ["copd", "慢阻肺"]
180
+ hit = any(t in guess for t in secret_terms)
181
+ if hit:
182
+ return (
183
+ "### 🎯 判定\n"
184
+ "你抓住了核心诊断。推理链条成立,关键是把症状、危险信号和特异检查连起来。\n\n"
185
+ f"### 🔓 真相\n{state.secret}\n\n"
186
+ "### 💡 记忆钉\n"
187
+ "好诊断不是猜谜底,而是让每条线索都有地方安放。"
188
+ )
189
+ return (
190
+ "### ❌ 判定\n"
191
+ "这个答案有一点影子,但还没有解释最关键的危险线索。\n\n"
192
+ f"### 🔄 反向提示\n别被「{state.red_herring}」带偏,重新看最急、最能改变处理路径的证据。\n\n"
193
+ "### 💡 记忆钉\n"
194
+ "先处理能致命的可能,再处理看起来像的可能。"
195
+ )
196
+
197
+ if mode == "hint":
198
+ return (
199
+ "### 💡 分层提示\n"
200
+ f"把注意力放在这条线索上:{next_clue}\n\n"
201
+ "### 🤔 小问题\n"
202
+ "它更支持哪个系统的问题?有没有一个诊断能同时解释时间、症状和检查?"
203
+ )
204
+
205
+ return (
206
+ "### 🔍 新线索\n"
207
+ f"{next_clue}\n\n"
208
+ "### 📝 案件旁白\n"
209
+ "房间里安静了一秒。这个线索不像答案,但它像一把钥匙。"
210
+ )
211
+
212
+
213
+ # ---------------------------------------------------------------------------
214
+ # Model loading — llama-cpp-python (GGUF)
215
+ # ---------------------------------------------------------------------------
216
+
217
+ @lru_cache(maxsize=1)
218
+ def get_llm():
219
+ """Load the GGUF model. Raises RuntimeError when DEMO_MODE is forced."""
220
+ if DEMO_MODE in {"1", "true", "yes", "on"}:
221
+ raise RuntimeError("DEMO_MODE is enabled — skipping model load.")
222
+
223
+ from llama_cpp import Llama # noqa: delayed import
224
+
225
+ return Llama.from_pretrained(
226
+ repo_id=GGUF_REPO,
227
+ filename=GGUF_FILE,
228
+ n_ctx=2048,
229
+ n_threads=2,
230
+ verbose=False,
231
+ )
232
+
233
+
234
+ def call_model(messages: List[Dict[str, str]], state: GameState, fallback_mode: str) -> str:
235
+ if DEMO_MODE in {"1", "true", "yes", "on"}:
236
+ return demo_reply(messages[-1]["content"], state, fallback_mode)
237
+
238
+ try:
239
+ llm = get_llm()
240
+ response = llm.create_chat_completion(
241
+ messages=messages,
242
+ max_tokens=MAX_NEW_TOKENS,
243
+ temperature=0.85,
244
+ top_p=0.92,
245
+ repeat_penalty=1.05,
246
+ stop=["<|im_end|>", "<|endoftext|>"],
247
+ )
248
+ raw = response["choices"][0]["message"]["content"] or ""
249
+ return strip_thinking(raw)
250
+ except Exception as exc:
251
+ if DEMO_MODE == "off":
252
+ raise
253
+ return (
254
+ demo_reply(messages[-1]["content"], state, fallback_mode)
255
+ + f"\n\n_演示模式:模型暂未加载({type(exc).__name__})。_"
256
+ )
257
+
258
+
259
+ # ---------------------------------------------------------------------------
260
+ # Game logic
261
+ # ---------------------------------------------------------------------------
262
+ ChatHistory = List[Dict[str, str]]
263
+
264
+
265
+ def new_case():
266
+ seed = random.choice(CASE_SEEDS)
267
+ state = GameState(
268
+ title=seed["title"],
269
+ genre=seed["genre"],
270
+ opening=seed["opening"],
271
+ secret=seed["secret"],
272
+ red_herring=seed["red_herring"],
273
+ clues=list(seed["clues"]),
274
+ used_clues=[],
275
+ )
276
+ first_message = {
277
+ "role": "assistant",
278
+ "content": (
279
+ f"### 🏮 {state.title}\n"
280
+ f"**{state.genre}**\n\n"
281
+ f"{state.opening}\n\n"
282
+ "你有 **6 个回合** 调查。选择一个行动,或直接输入你的诊断假设。"
283
+ ),
284
+ }
285
+ return [first_message], state, state.public_context(), status_line(state)
286
+
287
+
288
+ def status_line(state: GameState) -> str:
289
+ icon = "🏆" if state.solved else "🔎"
290
+ label = "已破案" if state.solved else "调查中"
291
+ return f"{icon} {label} · 回合 {state.turns}/6 · ⭐ {state.score}"
292
+
293
+
294
+ def reveal_clue(state: GameState) -> Optional[str]:
295
+ unused = [c for c in state.clues if c not in state.used_clues]
296
+ if not unused:
297
+ return None
298
+ clue = unused[0]
299
+ state.used_clues.append(clue)
300
+ return clue
301
+
302
+
303
+ def build_messages(state: GameState, instruction: str, mode: str) -> List[Dict[str, str]]:
304
+ return [
305
+ {"role": "system", "content": SYSTEM_PROMPT},
306
+ {
307
+ "role": "user",
308
+ "content": textwrap.dedent(f"""\
309
+ 你正在主持一个虚构医学推理小游戏。
310
+
311
+ 隐藏真相:{state.secret}
312
+ 红鲱鱼:{state.red_herring}
313
+
314
+ 当前公开状态:
315
+ {state.public_context()}
316
+
317
+ 玩家动作:
318
+ {instruction}
319
+
320
+ 输出要求:
321
+ - 不要给真实医疗建议。
322
+ - 不要要求玩家提供真实个人健康信息。
323
+ - 如果 mode={mode} 且不是评分,不要直接泄露隐藏真相。
324
+ - 保持中文,短小、有戏剧感。
325
+ """),
326
+ },
327
+ ]
328
+
329
+
330
+ def diagnosis_terms(secret: str) -> List[str]:
331
+ terms = [secret.lower()]
332
+ mapping = {
333
+ "心肌梗死": ["心梗", "stemi", "梗死"],
334
+ "输卵管": ["宫外孕", "异位妊娠", "破裂"],
335
+ "Graves": ["graves", "甲亢", "甲状腺功能亢进"],
336
+ "慢性阻塞": ["copd", "慢阻肺"],
337
+ }
338
+ for key, values in mapping.items():
339
+ if key.lower() in secret.lower():
340
+ terms.extend(values)
341
+ return terms
342
+
343
+
344
+ def act(action, custom_action, chat, state):
345
+ if not state or not state.title:
346
+ chat, state, context, status = new_case()
347
+
348
+ if state.solved:
349
+ chat.append({"role": "assistant", "content": "案件已经结案。点击 **新案件** 开始下一个挑战。"})
350
+ return chat, state, state.public_context(), status_line(state), ""
351
+
352
+ instruction = normalize_text(custom_action) or ACTION_PRESETS.get(action, ACTION_PRESETS["提示"])
353
+ mode = "hint" if action == "提示" else "clue"
354
+ state.turns += 1
355
+ state.score = max(20, state.score - (6 if mode == "hint" else 4))
356
+ reveal_clue(state)
357
+
358
+ reply = call_model(build_messages(state, instruction, mode), state, mode)
359
+ chat.append({"role": "user", "content": f"🎬 {action}:{instruction}"})
360
+ chat.append({"role": "assistant", "content": reply})
361
+ return chat, state, state.public_context(), status_line(state), ""
362
+
363
+
364
+ def submit_guess(guess, chat, state):
365
+ if not state or not state.title:
366
+ chat, state, context, status = new_case()
367
+
368
+ cleaned = normalize_text(guess)
369
+ if not cleaned:
370
+ chat.append({"role": "assistant", "content": "先写下你的诊断假设,再按提交。"})
371
+ return chat, state, state.public_context(), status_line(state), ""
372
+
373
+ state.turns += 1
374
+ messages = build_messages(
375
+ state,
376
+ f"玩家最终诊断是:{cleaned}。请评分并揭示真相。",
377
+ "score",
378
+ )
379
+ reply = call_model(messages, state, "score")
380
+ state.solved = True
381
+ if any(t in cleaned.lower() for t in diagnosis_terms(state.secret)):
382
+ state.score = min(100, state.score + 12)
383
+ else:
384
+ state.score = max(20, state.score - 15)
385
+
386
+ chat.append({"role": "user", "content": f"🩺 最终诊断:{cleaned}"})
387
+ chat.append({"role": "assistant", "content": reply})
388
+ return chat, state, state.public_context(), status_line(state), ""
389
+
390
+
391
+ # ---------------------------------------------------------------------------
392
+ # Custom CSS — dark medical-mystery theme with glassmorphism
393
+ # ---------------------------------------------------------------------------
394
+ CUSTOM_CSS = """\
395
+ /* ===== GLOBAL DARK OVERRIDE ===== */
396
+ :root {
397
+ --cl-bg-deep: #0b0f1a;
398
+ --cl-bg-panel: rgba(15, 22, 42, 0.72);
399
+ --cl-glass: rgba(255, 255, 255, 0.04);
400
+ --cl-glass-edge: rgba(255, 255, 255, 0.08);
401
+ --cl-ruby: #e03e5e;
402
+ --cl-ruby-glow: rgba(224, 62, 94, 0.35);
403
+ --cl-gold: #f0b429;
404
+ --cl-gold-dim: #c6931b;
405
+ --cl-mint: #34d399;
406
+ --cl-text: #e2e8f0;
407
+ --cl-text-dim: #94a3b8;
408
+ --cl-border: rgba(255, 255, 255, 0.06);
409
+ --cl-radius: 14px;
410
+ }
411
+
412
+ /* Force dark everywhere */
413
+ body, .gradio-container, .main, .contain,
414
+ .gradio-container .main .wrap {
415
+ background: var(--cl-bg-deep) !important;
416
+ color: var(--cl-text) !important;
417
+ }
418
+
419
+ .gradio-container {
420
+ max-width: 1200px !important;
421
+ font-family: 'Inter', 'Noto Sans SC', system-ui, -apple-system, sans-serif !important;
422
+ }
423
+
424
+ /* ===== HEADER BANNER ===== */
425
+ #hero-banner {
426
+ 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%);
427
+ border: 1px solid var(--cl-glass-edge);
428
+ border-radius: var(--cl-radius);
429
+ padding: 48px 32px 24px;
430
+ margin-bottom: 8px;
431
+ backdrop-filter: blur(20px);
432
+ -webkit-backdrop-filter: blur(20px);
433
+ position: relative;
434
+ overflow: visible;
435
+ }
436
+
437
+ #hero-banner::before {
438
+ content: '';
439
+ position: absolute;
440
+ top: -80%;
441
+ right: -10%;
442
+ width: 260px;
443
+ height: 260px;
444
+ border-radius: 50%;
445
+ background: radial-gradient(circle, var(--cl-ruby-glow) 0%, transparent 70%);
446
+ animation: hero-pulse 5s ease-in-out infinite;
447
+ pointer-events: none;
448
+ }
449
+
450
+ @keyframes hero-pulse {
451
+ 0%, 100% { opacity: 0.3; transform: scale(1); }
452
+ 50% { opacity: 0.6; transform: scale(1.15); }
453
+ }
454
+
455
+ .hero-title {
456
+ font-size: 2.4rem;
457
+ font-weight: 800;
458
+ background: linear-gradient(135deg, #ff5c7c, #ffd166);
459
+ -webkit-background-clip: text;
460
+ -webkit-text-fill-color: transparent;
461
+ background-clip: text;
462
+ margin: 0 0 12px 0;
463
+ line-height: 1.35;
464
+ position: relative;
465
+ z-index: 1;
466
+ }
467
+
468
+ #hero-banner p, #hero-banner .prose p {
469
+ color: var(--cl-text-dim) !important;
470
+ font-size: 0.92rem !important;
471
+ margin: 0 !important;
472
+ line-height: 1.5 !important;
473
+ }
474
+
475
+ #hero-banner a { color: var(--cl-gold) !important; text-decoration: underline; }
476
+
477
+ /* Prevent Gradio wrapper clipping inside hero banner */
478
+ #hero-banner > div,
479
+ #hero-banner .prose,
480
+ #hero-banner .md,
481
+ #hero-banner .wrap,
482
+ #hero-banner .block {
483
+ overflow: visible !important;
484
+ }
485
+
486
+ /* ===== SAFETY NOTE ===== */
487
+ #safety-note {
488
+ background: rgba(224, 62, 94, 0.08) !important;
489
+ border: 1px solid rgba(224, 62, 94, 0.18) !important;
490
+ border-radius: 10px !important;
491
+ padding: 10px 14px !important;
492
+ margin-bottom: 12px !important;
493
+ }
494
+ #safety-note p, #safety-note .prose p {
495
+ color: #fca5a5 !important;
496
+ font-size: 0.82rem !important;
497
+ margin: 0 !important;
498
+ }
499
+
500
+ /* ===== GLASSMORPHISM PANELS ===== */
501
+ .glass-panel, .glass-panel > .block {
502
+ background: var(--cl-bg-panel) !important;
503
+ border: 1px solid var(--cl-glass-edge) !important;
504
+ border-radius: var(--cl-radius) !important;
505
+ backdrop-filter: blur(16px) !important;
506
+ -webkit-backdrop-filter: blur(16px) !important;
507
+ }
508
+
509
+ /* ===== CHATBOT ===== */
510
+ #case-chat {
511
+ border: 1px solid var(--cl-glass-edge) !important;
512
+ border-radius: var(--cl-radius) !important;
513
+ background: rgba(15, 22, 42, 0.55) !important;
514
+ backdrop-filter: blur(12px) !important;
515
+ }
516
+
517
+ /* Force ALL chatbot message text to be bright */
518
+ #case-chat .message-row .message,
519
+ #case-chat .bot .message-bubble,
520
+ #case-chat .user .message-bubble,
521
+ #case-chat .message,
522
+ #case-chat .message-bubble,
523
+ #case-chat [data-testid="bot"],
524
+ #case-chat [data-testid="user"],
525
+ #case-chat .bot,
526
+ #case-chat .user,
527
+ #case-chat .prose,
528
+ #case-chat .md,
529
+ #case-chat .message p,
530
+ #case-chat .message span,
531
+ #case-chat .message li,
532
+ #case-chat .message h1,
533
+ #case-chat .message h2,
534
+ #case-chat .message h3,
535
+ #case-chat .message h4,
536
+ #case-chat .message strong,
537
+ #case-chat .message em,
538
+ #case-chat .message-bubble p,
539
+ #case-chat .message-bubble span,
540
+ #case-chat .message-bubble li,
541
+ #case-chat .message-bubble h1,
542
+ #case-chat .message-bubble h2,
543
+ #case-chat .message-bubble h3,
544
+ #case-chat .message-bubble h4,
545
+ #case-chat .message-bubble strong,
546
+ #case-chat .message-bubble em,
547
+ #case-chat .prose p,
548
+ #case-chat .prose span,
549
+ #case-chat .prose li,
550
+ #case-chat .prose h1,
551
+ #case-chat .prose h2,
552
+ #case-chat .prose h3,
553
+ #case-chat .prose h4,
554
+ #case-chat .prose strong {
555
+ color: #f1f5f9 !important;
556
+ }
557
+
558
+ #case-chat .message-row .message,
559
+ #case-chat .message-bubble,
560
+ #case-chat .bot .message-bubble,
561
+ #case-chat [data-testid="bot"] {
562
+ border-radius: 12px !important;
563
+ font-size: 0.93rem !important;
564
+ line-height: 1.65 !important;
565
+ background: rgba(30, 41, 70, 0.85) !important;
566
+ border: 1px solid var(--cl-glass-edge) !important;
567
+ }
568
+
569
+ /* user bubble - red tinted */
570
+ #case-chat .message-row.user-row .message,
571
+ #case-chat .user .message-bubble,
572
+ #case-chat [data-testid="user"] {
573
+ background: linear-gradient(135deg, rgba(224,62,94,0.22), rgba(224,62,94,0.10)) !important;
574
+ border: 1px solid rgba(224,62,94,0.25) !important;
575
+ }
576
+
577
+ /* bot bubble - dark glass */
578
+ #case-chat .message-row.bot-row .message,
579
+ #case-chat .bot .message-bubble,
580
+ #case-chat [data-testid="bot"] {
581
+ background: rgba(30, 41, 70, 0.85) !important;
582
+ border: 1px solid var(--cl-glass-edge) !important;
583
+ }
584
+
585
+ /* Chatbot wrapper and scroll area dark */
586
+ #case-chat .chatbot,
587
+ #case-chat .wrap,
588
+ #case-chat > div {
589
+ background: transparent !important;
590
+ }
591
+
592
+ /* ===== TEXTBOX / INPUT FIELDS ===== */
593
+ textarea, input[type="text"],
594
+ .textbox textarea, .textbox input {
595
+ background: rgba(15, 22, 42, 0.7) !important;
596
+ border: 1px solid var(--cl-glass-edge) !important;
597
+ border-radius: 10px !important;
598
+ color: var(--cl-text) !important;
599
+ transition: border-color 0.3s, box-shadow 0.3s !important;
600
+ }
601
+
602
+ textarea:focus, input[type="text"]:focus {
603
+ border-color: var(--cl-ruby) !important;
604
+ box-shadow: 0 0 0 3px var(--cl-ruby-glow) !important;
605
+ outline: none !important;
606
+ }
607
+
608
+ /* Labels */
609
+ label, .label-wrap span, .block label span {
610
+ color: var(--cl-text-dim) !important;
611
+ font-weight: 600 !important;
612
+ font-size: 0.85rem !important;
613
+ text-transform: uppercase !important;
614
+ letter-spacing: 0.5px !important;
615
+ }
616
+
617
+ /* ===== RADIO BUTTONS ===== */
618
+ .radio-group label, .wrap label.selected {
619
+ background: var(--cl-glass) !important;
620
+ border: 1px solid var(--cl-glass-edge) !important;
621
+ border-radius: 8px !important;
622
+ color: var(--cl-text) !important;
623
+ transition: all 0.25s !important;
624
+ }
625
+
626
+ .radio-group label:hover {
627
+ border-color: var(--cl-ruby) !important;
628
+ background: rgba(224, 62, 94, 0.08) !important;
629
+ }
630
+
631
+ .radio-group label.selected, .radio-group input:checked + label {
632
+ border-color: var(--cl-ruby) !important;
633
+ background: rgba(224, 62, 94, 0.15) !important;
634
+ box-shadow: 0 0 12px var(--cl-ruby-glow) !important;
635
+ }
636
+
637
+ /* ===== BUTTONS ===== */
638
+ button.primary, button.primary:hover {
639
+ background: linear-gradient(135deg, var(--cl-ruby), #c2294a) !important;
640
+ border: none !important;
641
+ color: #fff !important;
642
+ border-radius: 10px !important;
643
+ font-weight: 700 !important;
644
+ letter-spacing: 0.3px !important;
645
+ box-shadow: 0 4px 20px var(--cl-ruby-glow) !important;
646
+ transition: transform 0.2s, box-shadow 0.3s !important;
647
+ }
648
+ button.primary:hover {
649
+ transform: translateY(-1px) !important;
650
+ box-shadow: 0 6px 28px rgba(224,62,94,0.5) !important;
651
+ }
652
+ button.primary:active {
653
+ transform: translateY(0) !important;
654
+ }
655
+
656
+ button.secondary, button.secondary:hover {
657
+ background: var(--cl-glass) !important;
658
+ border: 1px solid var(--cl-glass-edge) !important;
659
+ color: var(--cl-text) !important;
660
+ border-radius: 10px !important;
661
+ font-weight: 600 !important;
662
+ transition: all 0.25s !important;
663
+ }
664
+ button.secondary:hover {
665
+ border-color: var(--cl-gold-dim) !important;
666
+ color: var(--cl-gold) !important;
667
+ background: rgba(240,180,41,0.08) !important;
668
+ }
669
+
670
+ /* ===== STATUS PILL ===== */
671
+ #status-pill textarea {
672
+ font-weight: 700 !important;
673
+ color: var(--cl-gold) !important;
674
+ font-size: 0.95rem !important;
675
+ background: rgba(240,180,41,0.06) !important;
676
+ border: 1px solid rgba(240,180,41,0.18) !important;
677
+ border-radius: 10px !important;
678
+ text-align: center !important;
679
+ }
680
+
681
+ /* ===== CASE BOARD ===== */
682
+ #case-board textarea {
683
+ background: rgba(15, 22, 42, 0.65) !important;
684
+ border: 1px solid var(--cl-glass-edge) !important;
685
+ border-radius: 10px !important;
686
+ color: var(--cl-text-dim) !important;
687
+ font-size: 0.88rem !important;
688
+ line-height: 1.7 !important;
689
+ }
690
+
691
+ /* ===== EXAMPLES ===== */
692
+ .examples-table button {
693
+ background: var(--cl-glass) !important;
694
+ border: 1px solid var(--cl-glass-edge) !important;
695
+ color: var(--cl-text-dim) !important;
696
+ border-radius: 8px !important;
697
+ transition: all 0.2s !important;
698
+ }
699
+ .examples-table button:hover {
700
+ border-color: var(--cl-mint) !important;
701
+ color: var(--cl-mint) !important;
702
+ }
703
+
704
+ /* ===== FOOTER ===== */
705
+ #footer-info p, #footer-info .prose p {
706
+ color: var(--cl-text-dim) !important;
707
+ font-size: 0.78rem !important;
708
+ text-align: center !important;
709
+ }
710
+
711
+ /* ===== SCROLL BAR ===== */
712
+ ::-webkit-scrollbar { width: 6px; }
713
+ ::-webkit-scrollbar-track { background: transparent; }
714
+ ::-webkit-scrollbar-thumb {
715
+ background: rgba(255,255,255,0.1);
716
+ border-radius: 3px;
717
+ }
718
+ ::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,0.2); }
719
+
720
+ /* ===== ANIMATIONS ===== */
721
+ @keyframes fade-in {
722
+ from { opacity: 0; transform: translateY(8px); }
723
+ to { opacity: 1; transform: translateY(0); }
724
+ }
725
+
726
+ .glass-panel, #case-chat, #hero-banner {
727
+ animation: fade-in 0.5s ease-out;
728
+ }
729
+
730
+ /* ===== RESPONSIVE ===== */
731
+ @media (max-width: 768px) {
732
+ #hero-banner { padding: 18px 16px 14px; }
733
+ #hero-banner h1 { font-size: 1.5rem !important; }
734
+ .gradio-container { padding: 8px !important; }
735
+ }
736
+
737
+ /* ===== ACCORDION / GROUP borders ===== */
738
+ .block, .form, .wrap, .panel, .gap, .gr-group, .gr-box {
739
+ border-color: var(--cl-border) !important;
740
+ }
741
+
742
+ /* ===== OVERRIDE light-mode remnants ===== */
743
+ /* Force Gradio CSS variables everywhere */
744
+ *, *::before, *::after,
745
+ .dark, [data-testid],
746
+ .gradio-container, .gradio-container * {
747
+ --background-fill-primary: var(--cl-bg-deep) !important;
748
+ --background-fill-secondary: rgba(15, 22, 42, 0.7) !important;
749
+ --background-fill-primary-dark: var(--cl-bg-deep) !important;
750
+ --border-color-primary: var(--cl-glass-edge) !important;
751
+ --body-text-color: var(--cl-text) !important;
752
+ --body-text-color-subdued: var(--cl-text-dim) !important;
753
+ --block-background-fill: var(--cl-bg-panel) !important;
754
+ --block-border-color: var(--cl-glass-edge) !important;
755
+ --block-label-text-color: var(--cl-text-dim) !important;
756
+ --input-background-fill: rgba(15, 22, 42, 0.7) !important;
757
+ --input-border-color: var(--cl-glass-edge) !important;
758
+ --color-accent: var(--cl-ruby) !important;
759
+ --chatbot-text-color: #f1f5f9 !important;
760
+ }
761
+
762
+ /* Global: any text inside the app must be bright */
763
+ .gradio-container p,
764
+ .gradio-container span,
765
+ .gradio-container li,
766
+ .gradio-container td,
767
+ .gradio-container th,
768
+ .gradio-container div,
769
+ .gradio-container h1,
770
+ .gradio-container h2,
771
+ .gradio-container h3,
772
+ .gradio-container h4,
773
+ .gradio-container h5,
774
+ .gradio-container h6,
775
+ .gradio-container strong,
776
+ .gradio-container em,
777
+ .gradio-container label {
778
+ color: var(--cl-text) !important;
779
+ }
780
+
781
+ /* Re-apply specific colors after the global rule */
782
+ #hero-banner .hero-title {
783
+ -webkit-text-fill-color: transparent !important;
784
+ }
785
+ #status-pill textarea {
786
+ color: var(--cl-gold) !important;
787
+ }
788
+ #safety-note p, #safety-note .prose p {
789
+ color: #fca5a5 !important;
790
+ }
791
+ #footer-info p, #footer-info .prose p {
792
+ color: var(--cl-text-dim) !important;
793
+ }
794
+ #hero-banner p {
795
+ color: var(--cl-text-dim) !important;
796
+ }
797
+ #hero-banner a {
798
+ color: var(--cl-gold) !important;
799
+ }
800
+ """
801
+
802
+ # ---------------------------------------------------------------------------
803
+ # Google Fonts injection
804
+ # ---------------------------------------------------------------------------
805
+ CUSTOM_HEAD = """\
806
+ <link rel="preconnect" href="https://fonts.googleapis.com">
807
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
808
+ <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">
809
+ """
810
+
811
+ # ---------------------------------------------------------------------------
812
+ # Build the Gradio app
813
+ # ---------------------------------------------------------------------------
814
+ with gr.Blocks(
815
+ title="Case Lantern 🏮",
816
+ theme=gr.themes.Base(
817
+ primary_hue="rose",
818
+ secondary_hue="teal",
819
+ neutral_hue="slate",
820
+ radius_size="lg",
821
+ font=[gr.themes.GoogleFont("Inter"), "Noto Sans SC", "system-ui", "sans-serif"],
822
+ ),
823
+ css=CUSTOM_CSS,
824
+ head=CUSTOM_HEAD,
825
+ ) as demo:
826
+ game_state = gr.State(GameState())
827
+
828
+ # --- Hero banner (raw HTML for full rendering control) ---
829
+ gr.HTML(
830
+ f"""
831
+ <div id="hero-banner">
832
+ <div class="hero-title">🏮 Case Lantern</div>
833
+ <p>一个由小型中文医疗推理模型驱动的虚构病例侦探游戏。查线索、避开误导、在 6 回合内破案。</p>
834
+ <p>模型:<a href="https://huggingface.co/{DISPLAY_MODEL_ID}" target="_blank" rel="noopener">{DISPLAY_MODEL_ID}</a> · ~4.66B 参数 · llama.cpp 本地推理</p>
835
+ </div>
836
+ """,
837
+ )
838
+ gr.Markdown(f"⚠️ {DISCLAIMER}", elem_id="safety-note")
839
+
840
+ with gr.Row():
841
+ # --- LEFT: Chat ---
842
+ with gr.Column(scale=3):
843
+ chatbot = gr.Chatbot(
844
+ label="案件记录",
845
+ type="messages",
846
+ height=560,
847
+ show_copy_button=True,
848
+ avatar_images=(None, None),
849
+ elem_id="case-chat",
850
+ )
851
+
852
+ # --- RIGHT: Control panel ---
853
+ with gr.Column(scale=2, elem_classes=["glass-panel"]):
854
+ status = gr.Textbox(
855
+ label="状态",
856
+ elem_id="status-pill",
857
+ interactive=False,
858
+ )
859
+ context = gr.Textbox(
860
+ label="📋 案件板",
861
+ lines=10,
862
+ interactive=False,
863
+ elem_id="case-board",
864
+ )
865
+
866
+ gr.Markdown("#### 🎯 调查行动", elem_id="action-title")
867
+ action = gr.Radio(
868
+ label="选择行动",
869
+ choices=list(ACTION_PRESETS.keys()),
870
+ value="问病史",
871
+ )
872
+ custom = gr.Textbox(
873
+ label="自定义行动",
874
+ placeholder="例如:我想追问疼痛性质和伴随症状…",
875
+ lines=2,
876
+ )
877
+ with gr.Row():
878
+ act_button = gr.Button("🔍 调查", variant="primary")
879
+ new_button = gr.Button("🆕 新案件", variant="secondary")
880
+
881
+ gr.Markdown("#### 🩺 最终诊断")
882
+ guess = gr.Textbox(
883
+ label="你的诊断",
884
+ placeholder="写下你的诊断假设,然后提交破案",
885
+ lines=2,
886
+ )
887
+ guess_button = gr.Button("💊 提交诊断", variant="primary")
888
+
889
+ # --- Examples ---
890
+ gr.Examples(
891
+ examples=[
892
+ ["我想询问发病时间、诱因和伴随症状"],
893
+ ["我想查看最能排除危险诊断的检查"],
894
+ ["请给我一个不会直接泄底的鉴别诊断提示"],
895
+ ],
896
+ inputs=custom,
897
+ label="💡 行动灵感",
898
+ )
899
+
900
+ gr.Markdown(
901
+ f"Case Lantern · Build Small Hackathon 2026 · Powered by "
902
+ f"[{DISPLAY_MODEL_ID}](https://huggingface.co/{DISPLAY_MODEL_ID})"
903
+ f" via llama.cpp",
904
+ elem_id="footer-info",
905
+ )
906
+
907
+ # --- Wiring ---
908
+ new_button.click(new_case, outputs=[chatbot, game_state, context, status])
909
+ demo.load(new_case, outputs=[chatbot, game_state, context, status], queue=False)
910
+ act_button.click(
911
+ act,
912
+ inputs=[action, custom, chatbot, game_state],
913
+ outputs=[chatbot, game_state, context, status, custom],
914
+ )
915
+ guess_button.click(
916
+ submit_guess,
917
+ inputs=[guess, chatbot, game_state],
918
+ outputs=[chatbot, game_state, context, status, guess],
919
+ )
920
+
921
+
922
+ # ---------------------------------------------------------------------------
923
+ # Launch
924
+ # ---------------------------------------------------------------------------
925
+ if __name__ == "__main__":
926
+ launch_kwargs = {
927
+ "show_api": False,
928
+ "share": os.getenv("GRADIO_SHARE", "false").lower() in {"1", "true", "yes", "on"},
929
+ }
930
+ if os.getenv("GRADIO_SERVER_NAME"):
931
+ launch_kwargs["server_name"] = os.getenv("GRADIO_SERVER_NAME")
932
+ if os.getenv("GRADIO_SERVER_PORT"):
933
+ launch_kwargs["server_port"] = int(os.getenv("GRADIO_SERVER_PORT", "7860"))
934
+ demo.queue(max_size=24).launch(**launch_kwargs)
requirements.txt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ gradio==4.44.1
2
+ llama-cpp-python==0.3.22
3
+ huggingface_hub>=0.24.0