fix: clarify dream customs guidance UI

#10
dream_customs/models.py CHANGED
@@ -34,9 +34,9 @@ class FakeTextClient:
34
  return {
35
  "visitor_name": "迟到的电梯",
36
  "questions": [
37
- "这部电梯在阻止你,还是在替你争取一点慢下来时间?",
38
- "如果它今天只允许你带走一件东西,你会选择哪件未完成的小事?",
39
- "你愿意用一个 5 分钟动作和它交换放行章吗?",
40
  ],
41
  "tone_note": "这个来访者也许不是敌人,而是在提醒你把开始和完成分开。",
42
  }
@@ -58,8 +58,8 @@ class FakeTextClient:
58
  contraband=["未申报的焦虑", "融化的按钮", "一小袋没来得及开始的事"],
59
  risk_level="橙色:需要被安置,但不需要被害怕",
60
  alliance_reading="这个梦也许在提醒你,今天先把启动一件事和完成一件事分开。",
61
- practical_suggestion="提前 10 分钟一个最小任务,只要求开,不要求完成。",
62
- weird_task="电梯写一句道歉信:抱歉总让你替我背迟到。",
63
  bedtime_release="今日电梯已停靠,未完成事项明日再报关。",
64
  )
65
  return PactCard(
 
34
  return {
35
  "visitor_name": "迟到的电梯",
36
  "questions": [
37
+ "这个梦醒来后最明显的感觉什么:紧张、疲惫、好奇,还是的?",
38
+ "今天有没有一件现实里的小事,你希望先轻一点开始?",
39
+ "你想让我给你偏生活建议,还是偏 5 分钟怪趣任务?",
40
  ],
41
  "tone_note": "这个来访者也许不是敌人,而是在提醒你把开始和完成分开。",
42
  }
 
58
  contraband=["未申报的焦虑", "融化的按钮", "一小袋没来得及开始的事"],
59
  risk_level="橙色:需要被安置,但不需要被害怕",
60
  alliance_reading="这个梦也许在提醒你,今天先把启动一件事和完成一件事分开。",
61
+ practical_suggestion="今天先把最重要的一件事写成 10 分钟始的版本,只要求,不要求完成。",
62
+ weird_task="在纸上画一个很小的电梯按钮,给今天最小任务按一下,然后做 5 分钟。",
63
  bedtime_release="今日电梯已停靠,未完成事项明日再报关。",
64
  )
65
  return PactCard(
dream_customs/pipeline.py CHANGED
@@ -45,6 +45,71 @@ def _stamp_card_for_today(card: PactCard) -> PactCard:
45
  return stamped
46
 
47
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
48
  def intake_from_modalities(
49
  dream_text: str,
50
  image_path: Optional[str],
@@ -72,6 +137,7 @@ def generate_pact(intake: DreamIntake, answers: str, text_client) -> Tuple[PactC
72
  prompt = pact_prompt(intake, answers)
73
  card = text_client.generate_pact(prompt)
74
  merged = intake.merged_text() + "\n" + answers
 
75
  if needs_escalation(merged):
76
  card.safety_note = safety_note()
77
  card = _stamp_card_for_today(card)
 
45
  return stamped
46
 
47
 
48
+ def _looks_unclear_or_dream_literal(text: str) -> bool:
49
+ clean = (text or "").strip()
50
+ if len(clean) < 12:
51
+ return True
52
+ dream_literals = [
53
+ "电梯运行",
54
+ "模拟操作",
55
+ "印章",
56
+ "放行",
57
+ "联盟",
58
+ "梦境内容",
59
+ "梦境无",
60
+ "海关",
61
+ "宣言",
62
+ "魔法",
63
+ "香薰皮",
64
+ "果酱",
65
+ "Dreamer",
66
+ ]
67
+ return any(term in clean for term in dream_literals)
68
+
69
+
70
+ def _safe_practical_suggestion(intake: DreamIntake) -> str:
71
+ if _contains_cjk(intake.merged_text()):
72
+ mood = intake.mood.strip()
73
+ if mood in {"焦虑", "迷雾", "累"}:
74
+ return "今天先做一件能稳住身体的小事:喝水、吃点东西,把最重要的一件事写成 10 分钟能开始的版本。"
75
+ return "今天给自己留一个低风险开头:先整理桌面或日程 5 分钟,再只开始一件最小的任务。"
76
+ return "Do one low-risk stabilizing thing today: drink water, eat something, and write the most important task as a 10-minute first step."
77
+
78
+
79
+ def _safe_weird_task(intake: DreamIntake) -> str:
80
+ if _contains_cjk(intake.merged_text()):
81
+ return "把今天最小的任务写在纸上,旁边画一个很小的通行章,然后只做 5 分钟。"
82
+ return "Write your smallest task on paper, draw a tiny clearance stamp beside it, and work on it for just five minutes."
83
+
84
+
85
+ def _polish_card_for_daily_use(card: PactCard, intake: DreamIntake, answers: str) -> PactCard:
86
+ polished = card.model_copy(deep=True)
87
+ merged = "\n".join([intake.merged_text(), answers or ""])
88
+ chinese = _contains_cjk(merged)
89
+
90
+ if chinese and (not polished.visitor_name.strip() or re.fullmatch(r"[A-Za-z\s_-]+", polished.visitor_name.strip())):
91
+ polished.visitor_name = "昨夜来访者"
92
+
93
+ if _looks_unclear_or_dream_literal(polished.practical_suggestion):
94
+ polished.practical_suggestion = _safe_practical_suggestion(intake)
95
+
96
+ if _looks_unclear_or_dream_literal(polished.weird_task) and polished.weird_task.strip() == polished.practical_suggestion.strip():
97
+ polished.weird_task = _safe_weird_task(intake)
98
+ elif len((polished.weird_task or "").strip()) < 8:
99
+ polished.weird_task = _safe_weird_task(intake)
100
+
101
+ if chinese:
102
+ if len((polished.alliance_reading or "").strip()) < 12 or "联盟成员" in polished.alliance_reading:
103
+ polished.alliance_reading = "这个梦可以先当作昨晚情绪留下的一点信号,不需要急着解释,今天先照顾好现实里的节奏。"
104
+ if polished.risk_level.strip() in {"低", "中", "高"}:
105
+ polished.risk_level = f"{polished.risk_level.strip()}:适合温和处理,不需要把它当成预兆。"
106
+
107
+ if not needs_escalation(merged):
108
+ polished.safety_note = ""
109
+
110
+ return polished
111
+
112
+
113
  def intake_from_modalities(
114
  dream_text: str,
115
  image_path: Optional[str],
 
137
  prompt = pact_prompt(intake, answers)
138
  card = text_client.generate_pact(prompt)
139
  merged = intake.merged_text() + "\n" + answers
140
+ card = _polish_card_for_daily_use(card, intake, answers)
141
  if needs_escalation(merged):
142
  card.safety_note = safety_note()
143
  card = _stamp_card_for_today(card)
dream_customs/prompts.py CHANGED
@@ -14,14 +14,17 @@ def negotiation_prompt(intake: DreamIntake) -> str:
14
  return f"""
15
  You are the Dream Customs diplomat. The user is not asking for diagnosis.
16
  Treat the dream as a strange visitor that can form a small pact with the user.
17
- The tone should be gentle, playful, and specific. Do not make medical claims.
 
 
 
18
 
19
  Dream intake:
20
  {intake.merged_text()}
21
 
22
  Return JSON with:
23
  - visitor_name: short vivid name
24
- - questions: 2 or 3 gentle, specific, slightly weird questions
25
  - tone_note: one sentence explaining the visitor without certainty
26
  """.strip()
27
 
@@ -29,7 +32,10 @@ Return JSON with:
29
  def followup_question_prompt(intake: DreamIntake, question_history: list[str], answer_history: list[str]) -> str:
30
  return f"""
31
  You are the Dream Customs diplomat. Ask one more gentle customs question.
32
- Do not diagnose. Do not repeat previous questions. The question should help the user decide today's pact.
 
 
 
33
 
34
  Dream intake:
35
  {intake.merged_text()}
@@ -51,8 +57,13 @@ def pact_prompt(intake: DreamIntake, answers: str) -> str:
51
  return f"""
52
  You are the Dream Customs diplomat. Generate a final Today's Pact card.
53
  Do not diagnose. Do not claim the dream has one certain meaning.
54
- Give one practical next-day suggestion and one weird task doable in 5 minutes.
 
 
 
 
55
  Use warm, non-clinical language. If the user wrote in Chinese, answer in Chinese.
 
56
 
57
  Dream intake:
58
  {intake.merged_text()}
@@ -71,6 +82,9 @@ def pact_revision_prompt(intake: DreamIntake, answers: str, current_pact: str, r
71
  You are the Dream Customs diplomat. Revise the draft Today's Pact card.
72
  Keep the same dream visitor unless the user's new material clearly changes it.
73
  Do not diagnose. Do not make the dream sound certain or frightening.
 
 
 
74
 
75
  Dream intake:
76
  {intake.merged_text()}
 
14
  return f"""
15
  You are the Dream Customs diplomat. The user is not asking for diagnosis.
16
  Treat the dream as a strange visitor that can form a small pact with the user.
17
+ The tone should be gentle, plain, and specific. Do not make medical claims.
18
+ Ask questions that an ordinary person can understand without knowing the app lore.
19
+ Prefer questions about today's mood, one small real-life concern, or one safe action.
20
+ Do not ask vague symbolic questions such as what a stamp wants to release.
21
 
22
  Dream intake:
23
  {intake.merged_text()}
24
 
25
  Return JSON with:
26
  - visitor_name: short vivid name
27
+ - questions: 2 or 3 gentle, specific, easy-to-understand questions
28
  - tone_note: one sentence explaining the visitor without certainty
29
  """.strip()
30
 
 
32
  def followup_question_prompt(intake: DreamIntake, question_history: list[str], answer_history: list[str]) -> str:
33
  return f"""
34
  You are the Dream Customs diplomat. Ask one more gentle customs question.
35
+ Do not diagnose. Do not repeat previous questions.
36
+ The question must be plain and useful: ask what the user wants to make easier today,
37
+ or whether there is one realistic thing they want help starting.
38
+ Do not use unclear metaphors about stamps, release, fate, symbols, or hidden meanings.
39
 
40
  Dream intake:
41
  {intake.merged_text()}
 
57
  return f"""
58
  You are the Dream Customs diplomat. Generate a final Today's Pact card.
59
  Do not diagnose. Do not claim the dream has one certain meaning.
60
+ The card must be useful for the user's real day, not only poetic.
61
+ Give:
62
+ - practical_suggestion: one safe, concrete life tip for today, such as hydration, eating, writing one task down, taking a short walk, reducing one task, checking the calendar, or asking for help. It must not be mystical or dream-literal.
63
+ - weird_task: one harmless, playful, slightly odd thing doable in 5 minutes. It can be imaginative, but it must be understandable.
64
+ - safety_note: empty string unless the user mentions self-harm, harming others, severe distress, severe insomnia, panic, or inability to function.
65
  Use warm, non-clinical language. If the user wrote in Chinese, answer in Chinese.
66
+ Avoid generic English names like "Dreamer" when answering Chinese.
67
 
68
  Dream intake:
69
  {intake.merged_text()}
 
82
  You are the Dream Customs diplomat. Revise the draft Today's Pact card.
83
  Keep the same dream visitor unless the user's new material clearly changes it.
84
  Do not diagnose. Do not make the dream sound certain or frightening.
85
+ Keep practical_suggestion safe, concrete, and useful for daily life.
86
+ Keep weird_task playful but understandable and separate from the practical suggestion.
87
+ Use safety_note only for severe distress or safety risk; otherwise return an empty string.
88
 
89
  Dream intake:
90
  {intake.merged_text()}
dream_customs/render.py CHANGED
@@ -34,7 +34,7 @@ def render_status_bar(session: CustomsSession, text_backend: str = "demo", visio
34
  <span class="dc-brand-mark">DC</span>
35
  <div>
36
  <h1>Dream Customs / 梦境海关</h1>
37
- <p>Turn last night's strange visitor into one gentle pact for the day: a practical suggestion, a small weird task, and a bedtime release.</p>
38
  </div>
39
  </div>
40
  <div class="dc-system-status">
@@ -145,7 +145,7 @@ def render_pact_inspector(session: CustomsSession) -> str:
145
  <p>{escape(card.alliance_reading)}</p>
146
  </section>
147
  <section>
148
- <h3>Today</h3>
149
  <p>{escape(card.practical_suggestion)}</p>
150
  </section>
151
  <section>
@@ -251,8 +251,8 @@ def render_pact_card(card: PactCard) -> str:
251
  <h3>{escape(card.visitor_name)}</h3>
252
  <p><span class="dc-label">Risk:</span> {escape(card.risk_level)}</p>
253
  <p><span class="dc-label">Alliance:</span> {escape(card.alliance_reading)}</p>
254
- <p><span class="dc-label">Suggestion:</span> {escape(card.practical_suggestion)}</p>
255
- <p><span class="dc-label">Weird task:</span> {escape(card.weird_task)}</p>
256
  <p><span class="dc-label">Bedtime release:</span> {escape(card.bedtime_release)}</p>
257
  <div><span class="dc-label">Contraband:</span><ul>{contraband}</ul></div>
258
  {safety}
 
34
  <span class="dc-brand-mark">DC</span>
35
  <div>
36
  <h1>Dream Customs / 梦境海关</h1>
37
+ <p>Turn last night's strange visitor into one gentle pact for the day: a safe life tip, a small weird task, and a bedtime release.</p>
38
  </div>
39
  </div>
40
  <div class="dc-system-status">
 
145
  <p>{escape(card.alliance_reading)}</p>
146
  </section>
147
  <section>
148
+ <h3>Life tip for today</h3>
149
  <p>{escape(card.practical_suggestion)}</p>
150
  </section>
151
  <section>
 
251
  <h3>{escape(card.visitor_name)}</h3>
252
  <p><span class="dc-label">Risk:</span> {escape(card.risk_level)}</p>
253
  <p><span class="dc-label">Alliance:</span> {escape(card.alliance_reading)}</p>
254
+ <p><span class="dc-label">Life tip:</span> {escape(card.practical_suggestion)}</p>
255
+ <p><span class="dc-label">Weird 5-minute task:</span> {escape(card.weird_task)}</p>
256
  <p><span class="dc-label">Bedtime release:</span> {escape(card.bedtime_release)}</p>
257
  <div><span class="dc-label">Contraband:</span><ul>{contraband}</ul></div>
258
  {safety}
dream_customs/ui/actions.py CHANGED
@@ -50,7 +50,7 @@ def _card_plain_text(card: PactCard) -> str:
50
  f"携带情绪违禁品:{contraband}",
51
  f"风险等级:{card.risk_level}",
52
  f"结盟解读:{card.alliance_reading}",
53
- f"今日认真建议:{card.practical_suggestion}",
54
  f"5 分钟怪趣任务:{card.weird_task}",
55
  f"睡前放行仪式:{card.bedtime_release}",
56
  ]
@@ -78,15 +78,15 @@ def _render_today_pass(card: PactCard) -> str:
78
  <h2>{escape(card.visitor_name)}</h2>
79
  <p class="dc-pass-risk">{escape(card.risk_level)}</p>
80
  <section>
81
- <h3>可能在保护</h3>
82
  <p>{escape(card.alliance_reading)}</p>
83
  </section>
84
  <section>
85
- <h3>今天的小建议</h3>
86
  <p>{escape(card.practical_suggestion)}</p>
87
  </section>
88
  <section>
89
- <h3>5 分钟怪任务</h3>
90
  <p>{escape(card.weird_task)}</p>
91
  </section>
92
  <section>
@@ -137,7 +137,7 @@ def _notice_for_status(status: str, error: str = "") -> str:
137
  if status == "error":
138
  return error or "海关还没收到梦的碎片。"
139
  if status == "question":
140
- return "海关只想确认件小事;你也以跳过,直接拿通行证。"
141
  if status == "card":
142
  return "今日通行证已盖章。它是一个温柔的行动提示,不是诊断。"
143
  return "写一句、几句,或贴一段梦;文字路径永远可用。"
 
50
  f"携带情绪违禁品:{contraband}",
51
  f"风险等级:{card.risk_level}",
52
  f"结盟解读:{card.alliance_reading}",
53
+ f"今日生活建议:{card.practical_suggestion}",
54
  f"5 分钟怪趣任务:{card.weird_task}",
55
  f"睡前放行仪式:{card.bedtime_release}",
56
  ]
 
78
  <h2>{escape(card.visitor_name)}</h2>
79
  <p class="dc-pass-risk">{escape(card.risk_level)}</p>
80
  <section>
81
+ <h3>可能代表的情绪</h3>
82
  <p>{escape(card.alliance_reading)}</p>
83
  </section>
84
  <section>
85
+ <h3>今天的生活 tip</h3>
86
  <p>{escape(card.practical_suggestion)}</p>
87
  </section>
88
  <section>
89
+ <h3>5 分钟怪任务</h3>
90
  <p>{escape(card.weird_task)}</p>
91
  </section>
92
  <section>
 
137
  if status == "error":
138
  return error or "海关还没收到梦的碎片。"
139
  if status == "question":
140
+ return "步是选补充:说一点醒来后的真实感受或者直接跳过。"
141
  if status == "card":
142
  return "今日通行证已盖章。它是一个温柔的行动提示,不是诊断。"
143
  return "写一句、几句,或贴一段梦;文字路径永远可用。"
dream_customs/ui/app.py CHANGED
@@ -164,8 +164,21 @@ def _notice_html(view: dict) -> str:
164
 
165
 
166
  def _question_markdown(view: dict) -> str:
167
- question = view.get("question") or ""
168
- return f"## 海关想确认一件小事\n\n{question}" if question else "## 海关还在等梦的碎片"
 
 
 
 
 
 
 
 
 
 
 
 
 
169
 
170
 
171
  def _updates(state: str, view_json: str):
@@ -320,26 +333,27 @@ def build_demo() -> gr.Blocks:
320
  )
321
  notice = gr.HTML(_notice_html(initial))
322
 
323
- with gr.Group(visible=True, elem_classes=["dc-stage"]) as declaration_group:
324
- with gr.Row(elem_classes=["dc-intake-grid"]):
325
- with gr.Group(elem_classes=["dc-composer"]):
326
- gr.HTML(
327
- """
 
328
  <div class="dc-section-title">
329
  <span class="dc-title-icon">1</span>
330
  <strong>Describe your dream</strong>
331
  </div>
332
  """.strip()
333
- )
334
- dream_text = gr.Textbox(
335
- label="写下梦的碎片",
336
- placeholder=DREAM_PLACEHOLDER,
337
- lines=12,
338
- value="",
339
- elem_classes=["dc-dream-text"],
340
- )
341
- gr.HTML(
342
- """
343
  <div class="dc-mic-control">
344
  <button type="button" class="dc-mic-button" aria-label="点击话筒开始语音输入">
345
  <span class="dc-mic-glyph" aria-hidden="true"></span>
@@ -347,158 +361,175 @@ def build_demo() -> gr.Blocks:
347
  <div class="dc-mic-status" aria-live="polite">点击话筒开始语音输入</div>
348
  </div>
349
  """.strip()
350
- )
351
- audio_input = gr.State(None)
352
- gr.HTML(
353
- """
354
  <p class="dc-field-tip">Tips: include people, places, emotions, colors, and anything that stood out.</p>
355
  """.strip()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
356
  )
357
- with gr.Column(elem_classes=["dc-side-panel"]):
358
- gr.HTML(
359
- """
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
360
  <div class="dc-section-title">
361
  <span class="dc-title-icon">2</span>
362
  <strong>After waking feeling</strong>
363
  </div>
364
  """.strip()
365
- )
366
- mood = gr.Dropdown(label="醒来后的感觉", choices=MOOD_OPTIONS, value=DEFAULT_MOOD)
367
- gr.HTML(
368
- """
369
  <div class="dc-side-stamp">
370
  <span>Dream Customs</span>
371
  <strong>Calm clearance</strong>
372
  <small>Gentle declarations</small>
373
  </div>
374
  """.strip()
375
- )
376
- with gr.Row(elem_classes=["dc-submit-row"]):
377
- example_button = gr.Button("清空并试一个例子 →", variant="secondary")
378
- submit_button = gr.Button("盖章生成今日通行证 →", variant="primary")
379
- with gr.Accordion("附加材料", open=False, elem_classes=["dc-attachment-drawer"]):
380
- image_input = gr.Image(label="图片线索", type="filepath", height=160)
381
-
382
- with gr.Group(visible=False, elem_classes=["dc-stage", "dc-question"]) as question_group:
383
- question_markdown = gr.Markdown("## 海关还在等梦的碎片")
384
- answer_text = gr.Textbox(
385
- label="你的回答",
386
- placeholder=ANSWER_PLACEHOLDER,
387
- lines=3,
388
- value="",
389
- )
390
- with gr.Row():
391
- answer_button = gr.Button("回答并生成卡片", variant="primary")
392
- skip_button = gr.Button("跳过,直接生成", variant="secondary")
393
-
394
- with gr.Group(visible=False, elem_classes=["dc-stage", "dc-card"]) as card_group:
395
- card_html = gr.HTML("")
396
- with gr.Row(elem_classes=["dc-actions"]):
397
- gentle_button = gr.Button("再温柔一点", variant="secondary")
398
- weird_button = gr.Button("更怪一点", variant="secondary")
399
- copy_button = gr.Button("复制文本", variant="secondary")
400
- reset_button = gr.Button("重新申报", variant="secondary")
401
- card_text = gr.Textbox(
402
- label="可复制文本",
403
- value="",
404
- lines=8,
405
- show_copy_button=True,
406
- elem_classes=["dc-hidden-text"],
407
- )
408
-
409
- with gr.Accordion("开发者设置", open=False, elem_classes=["dc-dev"]):
410
- with gr.Row(elem_classes=["dc-dev-grid"]):
411
- text_backend = gr.Radio(
412
- label="文本后端",
413
- choices=["demo", "model", "modal", "huggingface", "ollama"],
414
- value=DEFAULT_TEXT_BACKEND,
415
- )
416
- vision_backend = gr.Radio(
417
- label="视觉后端",
418
- choices=["demo", "model", "modal", "huggingface", "ollama"],
419
- value=DEFAULT_VISION_BACKEND,
420
- )
421
- asr_backend = gr.Radio(
422
- label="ASR 后端",
423
- choices=["demo", "modal", "huggingface"],
424
- value="demo",
425
- )
426
- with gr.Row(elem_classes=["dc-dev-grid"]):
427
- text_endpoint = gr.Textbox(label="文本 Endpoint", value="")
428
- vision_endpoint = gr.Textbox(label="视觉 Endpoint", value="")
429
- asr_endpoint = gr.Textbox(label="ASR Endpoint", value="")
430
- hosted_token = gr.Textbox(label="Hosted Token", value="", type="password")
431
- with gr.Row(elem_classes=["dc-dev-grid"]):
432
- text_model = gr.Textbox(label="文本模型", value=DEFAULT_TEXT_MODEL)
433
- vision_model = gr.Textbox(label="视觉模型", value=DEFAULT_VISION_MODEL)
434
- ollama_url = gr.Textbox(label="Ollama URL", value="http://localhost:11434")
435
- with gr.Row(elem_classes=["dc-dev-grid"]):
436
- text_timeout_seconds = gr.Number(
437
- label="文本超时秒",
438
- value=DEFAULT_HOSTED_TIMEOUT_SECONDS,
439
- precision=1,
440
- )
441
- vision_timeout_seconds = gr.Number(
442
- label="视觉超时秒",
443
- value=DEFAULT_HOSTED_TIMEOUT_SECONDS,
444
- precision=1,
445
- )
446
- asr_timeout_seconds = gr.Number(
447
- label="ASR 超时秒",
448
- value=DEFAULT_ASR_TIMEOUT_SECONDS,
449
- precision=1,
450
- )
451
- with gr.Row(elem_classes=["dc-dev-grid"]):
452
- text_latency_budget_ms = gr.Number(
453
- label="Modal 文本延迟预算 ms",
454
- value=DEFAULT_TEXT_LATENCY_BUDGET_MS,
455
- precision=0,
456
- )
457
- vision_latency_budget_ms = gr.Number(
458
- label="Modal 视觉延迟预算 ms",
459
- value=DEFAULT_VISION_LATENCY_BUDGET_MS,
460
- precision=0,
461
- )
462
- asr_latency_budget_ms = gr.Number(
463
- label="ASR 延迟预算 ms",
464
- value=DEFAULT_ASR_LATENCY_BUDGET_MS,
465
- precision=0,
466
- )
467
- with gr.Row(elem_classes=["dc-dev-grid"]):
468
- text_temperature = gr.Slider(
469
- label="文本温度",
470
- minimum=0,
471
- maximum=0.7,
472
- step=0.05,
473
- value=DEFAULT_TEXT_TEMPERATURE,
474
- )
475
- vision_temperature = gr.Slider(
476
- label="视觉温度",
477
- minimum=0,
478
- maximum=0.7,
479
- step=0.05,
480
- value=DEFAULT_VISION_TEMPERATURE,
481
  )
482
- with gr.Row(elem_classes=["dc-dev-grid"]):
483
- text_max_tokens = gr.Slider(
484
- label="文本 max tokens",
485
- minimum=64,
486
- maximum=1200,
487
- step=1,
488
- value=DEFAULT_TEXT_MAX_TOKENS,
489
- )
490
- vision_max_tokens = gr.Slider(
491
- label="视觉 max tokens",
492
- minimum=64,
493
- maximum=800,
494
- step=1,
495
- value=DEFAULT_VISION_MAX_TOKENS,
496
- )
497
- debug_json = gr.Code(
498
- label="调试状态",
499
- value=json.dumps(initial.get("debug", {}), ensure_ascii=False, indent=2),
500
- language="json",
501
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
502
 
503
  outputs = [
504
  session_state,
 
164
 
165
 
166
  def _question_markdown(view: dict) -> str:
167
+ question = escape(view.get("question") or "")
168
+ optional_question = (
169
+ f"<p class='dc-question-original'><span>可选问题</span>{question}</p>"
170
+ if question
171
+ else ""
172
+ )
173
+ return f"""
174
+ <div class="dc-question-card">
175
+ <span class="dc-question-kicker">Optional check-in</span>
176
+ <h2>还想补充一点吗?</h2>
177
+ <p>如果你愿意,可以写下醒来后最想处理的一件现实小事,或者告诉我这个梦让你身体有什么感觉。</p>
178
+ {optional_question}
179
+ <p class="dc-question-note">不想补充也没关系,直接跳过就能生成今日通行证。</p>
180
+ </div>
181
+ """.strip()
182
 
183
 
184
  def _updates(state: str, view_json: str):
 
333
  )
334
  notice = gr.HTML(_notice_html(initial))
335
 
336
+ with gr.Row(elem_classes=["dc-workspace-grid"]):
337
+ with gr.Column(elem_classes=["dc-flow-column"]):
338
+ with gr.Group(visible=True, elem_classes=["dc-stage"]) as declaration_group:
339
+ with gr.Group(elem_classes=["dc-composer"]):
340
+ gr.HTML(
341
+ """
342
  <div class="dc-section-title">
343
  <span class="dc-title-icon">1</span>
344
  <strong>Describe your dream</strong>
345
  </div>
346
  """.strip()
347
+ )
348
+ dream_text = gr.Textbox(
349
+ label="写下梦的碎片",
350
+ placeholder=DREAM_PLACEHOLDER,
351
+ lines=12,
352
+ value="",
353
+ elem_classes=["dc-dream-text"],
354
+ )
355
+ gr.HTML(
356
+ """
357
  <div class="dc-mic-control">
358
  <button type="button" class="dc-mic-button" aria-label="点击话筒开始语音输入">
359
  <span class="dc-mic-glyph" aria-hidden="true"></span>
 
361
  <div class="dc-mic-status" aria-live="polite">点击话筒开始语音输入</div>
362
  </div>
363
  """.strip()
364
+ )
365
+ audio_input = gr.State(None)
366
+ gr.HTML(
367
+ """
368
  <p class="dc-field-tip">Tips: include people, places, emotions, colors, and anything that stood out.</p>
369
  """.strip()
370
+ )
371
+ with gr.Row(elem_classes=["dc-submit-row"]):
372
+ example_button = gr.Button("清空并试一个例子 →", variant="secondary")
373
+ submit_button = gr.Button("盖章生成今日通行证 →", variant="primary")
374
+ with gr.Accordion("附加材料", open=False, elem_classes=["dc-attachment-drawer"]):
375
+ image_input = gr.Image(label="图片线索", type="filepath", height=160)
376
+
377
+ with gr.Group(visible=False, elem_classes=["dc-stage", "dc-question"]) as question_group:
378
+ question_markdown = gr.HTML(_question_markdown(initial))
379
+ answer_text = gr.Textbox(
380
+ label="你的补充",
381
+ placeholder=ANSWER_PLACEHOLDER,
382
+ lines=4,
383
+ value="",
384
  )
385
+ with gr.Row(elem_classes=["dc-question-actions"]):
386
+ answer_button = gr.Button("用这条补充生成卡片", variant="primary")
387
+ skip_button = gr.Button("跳过,直接生成", variant="secondary")
388
+
389
+ with gr.Group(visible=False, elem_classes=["dc-stage", "dc-card"]) as card_group:
390
+ card_html = gr.HTML("")
391
+ with gr.Row(elem_classes=["dc-actions"]):
392
+ gentle_button = gr.Button("再温柔一点", variant="secondary")
393
+ weird_button = gr.Button("更怪一点", variant="secondary")
394
+ copy_button = gr.Button("复制文本", variant="secondary")
395
+ reset_button = gr.Button("重新申报", variant="secondary")
396
+ card_text = gr.Textbox(
397
+ label="可复制文本",
398
+ value="",
399
+ lines=8,
400
+ show_copy_button=True,
401
+ elem_classes=["dc-hidden-text"],
402
+ )
403
+
404
+ with gr.Column(elem_classes=["dc-side-panel"]):
405
+ gr.HTML(
406
+ """
407
  <div class="dc-section-title">
408
  <span class="dc-title-icon">2</span>
409
  <strong>After waking feeling</strong>
410
  </div>
411
  """.strip()
412
+ )
413
+ mood = gr.Dropdown(label="醒来后的感觉", choices=MOOD_OPTIONS, value=DEFAULT_MOOD)
414
+ gr.HTML(
415
+ """
416
  <div class="dc-side-stamp">
417
  <span>Dream Customs</span>
418
  <strong>Calm clearance</strong>
419
  <small>Gentle declarations</small>
420
  </div>
421
  """.strip()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
422
  )
423
+ with gr.Accordion("运行设置", open=True, elem_classes=["dc-dev"]):
424
+ gr.HTML(
425
+ """
426
+ <div class="dc-dev-help">
427
+ <strong>给调试用,普通体验不用改。</strong>
428
+ <span>自动模式会优先使用 Space 已配置的后端;没有端点时会安全回落到演示数据。</span>
429
+ </div>
430
+ """.strip()
431
+ )
432
+ text_backend = gr.Dropdown(
433
+ label="文字生成",
434
+ choices=[
435
+ ("自动:Space 已配置模型", "model"),
436
+ ("演示:稳定假数据", "demo"),
437
+ ("Modal/API:私有端点", "modal"),
438
+ ("本地 Ollama", "ollama"),
439
+ ],
440
+ value=DEFAULT_TEXT_BACKEND,
441
+ )
442
+ vision_backend = gr.Dropdown(
443
+ label="图片理解",
444
+ choices=[
445
+ ("自动:Space 已配置视觉模型", "model"),
446
+ ("演示:不调用图片模型", "demo"),
447
+ ("Modal/API:私有端点", "modal"),
448
+ ("本地 Ollama", "ollama"),
449
+ ],
450
+ value=DEFAULT_VISION_BACKEND,
451
+ )
452
+ asr_backend = gr.Dropdown(
453
+ label="语音输入",
454
+ choices=[
455
+ ("浏览器直接转写(当前)", "demo"),
456
+ ("Modal ASR 端点(待接入)", "modal"),
457
+ ("Hugging Face ASR 端点(待接入)", "huggingface"),
458
+ ],
459
+ value="demo",
460
+ )
461
+ with gr.Accordion("高级端点", open=False, elem_classes=["dc-dev-advanced"]):
462
+ text_endpoint = gr.Textbox(label="文字 Endpoint", value="")
463
+ vision_endpoint = gr.Textbox(label="图片 Endpoint", value="")
464
+ asr_endpoint = gr.Textbox(label="ASR Endpoint", value="")
465
+ hosted_token = gr.Textbox(label="Hosted Token", value="", type="password")
466
+ text_model = gr.Textbox(label="文字模型", value=DEFAULT_TEXT_MODEL)
467
+ vision_model = gr.Textbox(label="图片模型", value=DEFAULT_VISION_MODEL)
468
+ ollama_url = gr.Textbox(label="Ollama URL", value="http://localhost:11434")
469
+ text_timeout_seconds = gr.Number(
470
+ label="文字超时秒",
471
+ value=DEFAULT_HOSTED_TIMEOUT_SECONDS,
472
+ precision=1,
473
+ )
474
+ vision_timeout_seconds = gr.Number(
475
+ label="图片超时秒",
476
+ value=DEFAULT_HOSTED_TIMEOUT_SECONDS,
477
+ precision=1,
478
+ )
479
+ asr_timeout_seconds = gr.Number(
480
+ label="ASR 超时秒",
481
+ value=DEFAULT_ASR_TIMEOUT_SECONDS,
482
+ precision=1,
483
+ )
484
+ text_latency_budget_ms = gr.Number(
485
+ label="Modal 文字延迟预算 ms",
486
+ value=DEFAULT_TEXT_LATENCY_BUDGET_MS,
487
+ precision=0,
488
+ )
489
+ vision_latency_budget_ms = gr.Number(
490
+ label="Modal 图片延迟预算 ms",
491
+ value=DEFAULT_VISION_LATENCY_BUDGET_MS,
492
+ precision=0,
493
+ )
494
+ asr_latency_budget_ms = gr.Number(
495
+ label="ASR 延迟预算 ms",
496
+ value=DEFAULT_ASR_LATENCY_BUDGET_MS,
497
+ precision=0,
498
+ )
499
+ text_temperature = gr.Slider(
500
+ label="文字温度",
501
+ minimum=0,
502
+ maximum=0.7,
503
+ step=0.05,
504
+ value=DEFAULT_TEXT_TEMPERATURE,
505
+ )
506
+ vision_temperature = gr.Slider(
507
+ label="图片温度",
508
+ minimum=0,
509
+ maximum=0.7,
510
+ step=0.05,
511
+ value=DEFAULT_VISION_TEMPERATURE,
512
+ )
513
+ text_max_tokens = gr.Slider(
514
+ label="文字 max tokens",
515
+ minimum=64,
516
+ maximum=1200,
517
+ step=1,
518
+ value=DEFAULT_TEXT_MAX_TOKENS,
519
+ )
520
+ vision_max_tokens = gr.Slider(
521
+ label="图片 max tokens",
522
+ minimum=64,
523
+ maximum=800,
524
+ step=1,
525
+ value=DEFAULT_VISION_MAX_TOKENS,
526
+ )
527
+ debug_json = gr.Code(
528
+ label="当前状态",
529
+ value=json.dumps(initial.get("debug", {}), ensure_ascii=False, indent=2),
530
+ language="json",
531
+ visible=False,
532
+ )
533
 
534
  outputs = [
535
  session_state,
dream_customs/ui/styles.py CHANGED
@@ -261,15 +261,18 @@ body,
261
  transform: rotate(-11deg);
262
  }
263
 
264
- .dc-intake-grid {
265
  align-items: stretch !important;
266
  display: grid !important;
267
  gap: 18px !important;
268
- grid-template-columns: minmax(0, 1.35fr) minmax(300px, 0.65fr);
269
  }
270
 
271
- .dc-composer,
272
- .dc-side-panel {
 
 
 
273
  background: rgba(255, 252, 246, 0.82) !important;
274
  border: 1px solid var(--dc-line-strong) !important;
275
  border-radius: var(--dc-radius-md) !important;
@@ -279,6 +282,20 @@ body,
279
  position: relative;
280
  }
281
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
282
  .dc-composer,
283
  .dc-composer > div,
284
  .dc-composer .form {
@@ -489,13 +506,6 @@ body,
489
  margin: 12px 0 0;
490
  }
491
 
492
- .dc-side-panel {
493
- display: flex;
494
- flex-direction: column;
495
- gap: 16px;
496
- justify-content: space-between;
497
- }
498
-
499
  .dc-side-panel .wrap,
500
  .dc-side-panel .container,
501
  .dc-side-panel select {
@@ -511,7 +521,7 @@ body,
511
  flex-direction: column;
512
  height: 156px;
513
  justify-content: center;
514
- margin: auto;
515
  text-align: center;
516
  transform: rotate(-7deg);
517
  width: 156px;
@@ -542,7 +552,8 @@ body,
542
  }
543
 
544
  .dc-stage button,
545
- .dc-dev button {
 
546
  border-radius: var(--dc-radius-sm) !important;
547
  font-family: Georgia, "Times New Roman", serif !important;
548
  font-weight: 750 !important;
@@ -595,6 +606,69 @@ button.secondary {
595
  grid-template-columns: repeat(3, minmax(0, 1fr));
596
  }
597
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
598
  .dc-question h2,
599
  .dc-card h2 {
600
  color: var(--dc-ink) !important;
@@ -631,6 +705,14 @@ button.secondary {
631
  position: relative;
632
  }
633
 
 
 
 
 
 
 
 
 
634
  .dc-pass-card::after {
635
  border: 2px solid rgba(199, 83, 66, 0.38);
636
  border-radius: 999px;
@@ -723,22 +805,25 @@ button.secondary {
723
  }
724
 
725
  .dc-dev {
 
 
726
  border-radius: var(--dc-radius-md) !important;
727
- box-shadow: var(--dc-soft-shadow);
728
- margin-top: 18px;
729
- padding: 20px !important;
730
  }
731
 
732
- .dc-dev-grid {
733
- align-items: stretch !important;
734
- display: grid !important;
735
- gap: 0 !important;
736
- grid-template-columns: repeat(3, minmax(0, 1fr));
 
 
737
  }
738
 
739
- .dc-dev-grid > div,
740
- .dc-dev > div:not(:last-child) {
741
- border-bottom: 1px solid rgba(189, 168, 143, 0.45);
742
  }
743
 
744
  .dc-dev .form,
@@ -755,6 +840,26 @@ button.secondary {
755
  font-size: 0.84rem !important;
756
  }
757
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
758
  @keyframes dc-mic-pulse {
759
  0% {
760
  box-shadow: 0 0 0 0 rgba(11, 91, 100, 0.32);
@@ -784,17 +889,19 @@ button.secondary {
784
  margin-left: 54px;
785
  }
786
 
787
- .dc-intake-grid,
788
- .dc-dev-grid,
789
  .dc-row,
790
  .dc-submit-row,
791
- .dc-actions {
 
792
  grid-template-columns: 1fr !important;
793
  }
794
 
795
  .dc-composer,
796
  .dc-side-panel {
797
  min-height: 0;
 
 
798
  }
799
 
800
  .dc-side-stamp {
 
261
  transform: rotate(-11deg);
262
  }
263
 
264
+ .dc-workspace-grid {
265
  align-items: stretch !important;
266
  display: grid !important;
267
  gap: 18px !important;
268
+ grid-template-columns: minmax(0, 1fr) minmax(300px, 340px);
269
  }
270
 
271
+ .dc-flow-column {
272
+ min-width: 0;
273
+ }
274
+
275
+ .dc-composer {
276
  background: rgba(255, 252, 246, 0.82) !important;
277
  border: 1px solid var(--dc-line-strong) !important;
278
  border-radius: var(--dc-radius-md) !important;
 
282
  position: relative;
283
  }
284
 
285
+ .dc-side-panel {
286
+ background: rgba(255, 252, 246, 0.86) !important;
287
+ border: 1px solid var(--dc-line-strong) !important;
288
+ border-radius: var(--dc-radius-md) !important;
289
+ box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.52), var(--dc-soft-shadow);
290
+ display: flex;
291
+ flex-direction: column;
292
+ gap: 16px;
293
+ min-height: 470px;
294
+ padding: 18px;
295
+ position: sticky;
296
+ top: 12px;
297
+ }
298
+
299
  .dc-composer,
300
  .dc-composer > div,
301
  .dc-composer .form {
 
506
  margin: 12px 0 0;
507
  }
508
 
 
 
 
 
 
 
 
509
  .dc-side-panel .wrap,
510
  .dc-side-panel .container,
511
  .dc-side-panel select {
 
521
  flex-direction: column;
522
  height: 156px;
523
  justify-content: center;
524
+ margin: 8px auto 2px;
525
  text-align: center;
526
  transform: rotate(-7deg);
527
  width: 156px;
 
552
  }
553
 
554
  .dc-stage button,
555
+ .dc-question-actions button,
556
+ .dc-actions button {
557
  border-radius: var(--dc-radius-sm) !important;
558
  font-family: Georgia, "Times New Roman", serif !important;
559
  font-weight: 750 !important;
 
606
  grid-template-columns: repeat(3, minmax(0, 1fr));
607
  }
608
 
609
+ .dc-question {
610
+ padding: clamp(22px, 3.4vw, 36px);
611
+ }
612
+
613
+ .dc-question-card {
614
+ background:
615
+ linear-gradient(180deg, rgba(255, 252, 246, 0.96), rgba(248, 240, 228, 0.96));
616
+ border: 1px solid var(--dc-line-strong);
617
+ border-radius: var(--dc-radius-md);
618
+ box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.5);
619
+ color: var(--dc-ink);
620
+ margin-bottom: 16px;
621
+ padding: clamp(18px, 3vw, 26px);
622
+ }
623
+
624
+ .dc-question-kicker {
625
+ color: var(--dc-coral-dark);
626
+ display: block;
627
+ font-size: 0.78rem;
628
+ font-weight: 850;
629
+ margin-bottom: 8px;
630
+ text-transform: uppercase;
631
+ }
632
+
633
+ .dc-question-card h2 {
634
+ color: var(--dc-ink) !important;
635
+ font-family: Georgia, "Times New Roman", serif;
636
+ font-size: clamp(1.8rem, 3vw, 2.5rem);
637
+ line-height: 1.1;
638
+ margin: 0 0 10px;
639
+ }
640
+
641
+ .dc-question-card p {
642
+ color: var(--dc-ink);
643
+ line-height: 1.65;
644
+ margin: 0;
645
+ }
646
+
647
+ .dc-question-original {
648
+ border-left: 3px solid rgba(11, 91, 100, 0.35);
649
+ margin-top: 14px !important;
650
+ padding-left: 12px;
651
+ }
652
+
653
+ .dc-question-original span {
654
+ color: var(--dc-teal);
655
+ display: block;
656
+ font-size: 0.78rem;
657
+ font-weight: 850;
658
+ margin-bottom: 4px;
659
+ }
660
+
661
+ .dc-question-note {
662
+ color: var(--dc-muted) !important;
663
+ margin-top: 14px !important;
664
+ }
665
+
666
+ .dc-question-actions {
667
+ display: grid !important;
668
+ gap: 12px !important;
669
+ grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
670
+ }
671
+
672
  .dc-question h2,
673
  .dc-card h2 {
674
  color: var(--dc-ink) !important;
 
705
  position: relative;
706
  }
707
 
708
+ .dc-pass-card section:nth-of-type(2) {
709
+ background: rgba(220, 235, 230, 0.4);
710
+ border: 1px solid rgba(11, 91, 100, 0.16);
711
+ border-radius: var(--dc-radius-sm);
712
+ margin: 12px 0;
713
+ padding: 14px;
714
+ }
715
+
716
  .dc-pass-card::after {
717
  border: 2px solid rgba(199, 83, 66, 0.38);
718
  border-radius: 999px;
 
805
  }
806
 
807
  .dc-dev {
808
+ background: rgba(255, 249, 238, 0.7) !important;
809
+ border: 1px solid rgba(189, 168, 143, 0.58) !important;
810
  border-radius: var(--dc-radius-md) !important;
811
+ box-shadow: none;
812
+ margin-top: 0;
813
+ padding: 12px !important;
814
  }
815
 
816
+ .dc-dev-help {
817
+ color: var(--dc-muted);
818
+ display: grid;
819
+ gap: 4px;
820
+ font-size: 0.78rem;
821
+ line-height: 1.45;
822
+ margin-bottom: 10px;
823
  }
824
 
825
+ .dc-dev-help strong {
826
+ color: var(--dc-ink);
 
827
  }
828
 
829
  .dc-dev .form,
 
840
  font-size: 0.84rem !important;
841
  }
842
 
843
+ .dc-dev .wrap,
844
+ .dc-dev .container,
845
+ .dc-dev input,
846
+ .dc-dev textarea,
847
+ .dc-dev select {
848
+ min-height: 42px !important;
849
+ }
850
+
851
+ .dc-dev label,
852
+ .dc-dev [data-testid="block-label"] {
853
+ font-size: 0.78rem !important;
854
+ }
855
+
856
+ .dc-dev-advanced {
857
+ background: transparent !important;
858
+ border: 1px solid rgba(189, 168, 143, 0.42) !important;
859
+ border-radius: var(--dc-radius-sm) !important;
860
+ margin-top: 10px;
861
+ }
862
+
863
  @keyframes dc-mic-pulse {
864
  0% {
865
  box-shadow: 0 0 0 0 rgba(11, 91, 100, 0.32);
 
889
  margin-left: 54px;
890
  }
891
 
892
+ .dc-workspace-grid,
 
893
  .dc-row,
894
  .dc-submit-row,
895
+ .dc-actions,
896
+ .dc-question-actions {
897
  grid-template-columns: 1fr !important;
898
  }
899
 
900
  .dc-composer,
901
  .dc-side-panel {
902
  min-height: 0;
903
+ position: relative;
904
+ top: auto;
905
  }
906
 
907
  .dc-side-stamp {
tests/test_pipeline.py CHANGED
@@ -16,6 +16,7 @@ from dream_customs.pipeline import (
16
  seal_pact,
17
  skip_question,
18
  )
 
19
 
20
 
21
  def test_dated_permit_id_uses_runtime_date_and_preserves_serial():
@@ -69,6 +70,31 @@ def test_generate_pact_adds_safety_note_for_distress():
69
  assert card.safety_note
70
 
71
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
72
  def test_add_evidence_updates_session_with_text_image_audio_and_mood():
73
  session = add_evidence(
74
  create_session(),
 
16
  seal_pact,
17
  skip_question,
18
  )
19
+ from dream_customs.schema import PactCard
20
 
21
 
22
  def test_dated_permit_id_uses_runtime_date_and_preserves_serial():
 
70
  assert card.safety_note
71
 
72
 
73
+ def test_generate_pact_polishes_unclear_model_output_into_daily_tip():
74
+ class UnclearTextClient:
75
+ def generate_pact(self, prompt):
76
+ return PactCard(
77
+ visitor_name="Dreamer",
78
+ permit_id="DC-001",
79
+ contraband=["融化蜡", "数字14"],
80
+ risk_level="中",
81
+ alliance_reading="联盟成员",
82
+ practical_suggestion="明天早起观察电梯运行情况,若未超速可尝试模拟操作以熟悉流程。",
83
+ weird_task="把今天最小的任务写在纸上,给它盖一个看不见的放行章。",
84
+ bedtime_release="06:30",
85
+ safety_note="梦境内容无医疗价值,建议保持冷静观察。",
86
+ )
87
+
88
+ intake = build_intake(dream_text="我梦见电梯按钮融化,数字停在 14。", mood="焦虑")
89
+ card, html = generate_pact(intake, "", UnclearTextClient())
90
+
91
+ assert card.visitor_name == "昨夜来访者"
92
+ assert "喝水" in card.practical_suggestion
93
+ assert "电梯运行" not in card.practical_suggestion
94
+ assert card.safety_note == ""
95
+ assert "Life tip" in html
96
+
97
+
98
  def test_add_evidence_updates_session_with_text_image_audio_and_mood():
99
  session = add_evidence(
100
  create_session(),