Harden Dream QA tip grounding

#43
by ADJCJH - opened
dream_customs/pipeline.py CHANGED
@@ -431,6 +431,7 @@ _PLACEHOLDER_ANCHORS = {
431
  "the dream detail",
432
  "dream",
433
  "dream fragment",
 
434
  }
435
 
436
 
@@ -524,6 +525,10 @@ def _extract_dream_anchors(intake: DreamIntake) -> List[str]:
524
  candidates.extend(["公司", "午觉", "被人泼水"])
525
  if "elevator" in text:
526
  candidates.append("elevator")
 
 
 
 
527
  if re.search(r"\bfloor\s*14\b|\b14\b", text):
528
  candidates.append("floor 14")
529
  if "button" in text and re.search(r"\b(melt|melted|melting|wax)\b", text):
@@ -577,6 +582,16 @@ def _english_anchor_text(text: str) -> str:
577
  return clean
578
 
579
 
 
 
 
 
 
 
 
 
 
 
580
  def _zh_anchor_text(text: str) -> str:
581
  clean = re.sub(r"\s+", " ", (text or "").strip(" .,:;!?\"'()[]{}"))
582
  if not clean:
@@ -609,7 +624,7 @@ def _clean_english_today_tip_language(card: TodayTipCard) -> TodayTipCard:
609
  "caring_note",
610
  "safety_note",
611
  ):
612
- setattr(cleaned, field, _english_anchor_text(getattr(cleaned, field)))
613
  cleaned.dream_anchors = _dedupe_preserve_order(
614
  [_english_anchor_text(anchor) for anchor in cleaned.dream_anchors if anchor]
615
  )
@@ -706,6 +721,99 @@ def _story_text(intake: DreamIntake, answers: str = "") -> str:
706
  ).lower()
707
 
708
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
709
  def _source_mentions_melted_detail(intake: DreamIntake, answers: str = "") -> bool:
710
  text = _story_text(intake, answers)
711
  return any(term in text for term in ["融化", "像蜡", "蜡", "melt", "melted", "melting", "wax", "waxy"])
@@ -839,6 +947,42 @@ def _answer_snippet(answers: str, language: str = "en") -> str:
839
  return " ".join(words[:22])
840
 
841
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
842
  def _anchor_with_article(anchor: str) -> str:
843
  clean = (anchor or "").strip()
844
  if clean.lower().startswith(("the ", "a ", "an ")):
@@ -850,15 +994,62 @@ def _title_anchor(text: str) -> str:
850
  return " ".join(part.capitalize() for part in text.split())
851
 
852
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
853
  def _summary_from_intake(intake: DreamIntake, language: str = "en") -> str:
854
- merged = intake.dream_text.strip() or intake.voice_transcript.strip()
 
 
 
855
  if not merged and intake.visual_clues:
856
  merged = "、".join(intake.visual_clues[:3]) if _is_zh(language) else ", ".join(intake.visual_clues[:3])
857
  if not merged:
858
  return "你记录了一个还在整理中的梦。" if _is_zh(language) else "You recorded a dream that is still taking shape."
859
  clean = re.sub(r"\s+", " ", merged).strip()
860
- if len(clean) > 72:
861
- clean = clean[:69].rstrip() + "..."
862
  if not _is_zh(language):
863
  clean = _clean_placeholder_phrase(_english_anchor_text(clean))
864
  if not _is_zh(language):
@@ -892,10 +1083,17 @@ def _clean_user_question(text: str, language: str = "en") -> str:
892
  if not clean:
893
  return ""
894
  if _is_zh(language):
 
895
  clean = re.sub(r"^(我)?(醒来后)?(最)?(想知道|想问|在想|担心|害怕)[::,,\s]*", "", clean)
896
  clean = re.sub(r"^(只)?想知道[::,,\s]*", "", clean)
897
  clean = clean.strip(" ::「」\"'()[]{}")
898
  return clean if clean.endswith(("?", "?")) else f"{clean}?"
 
 
 
 
 
 
899
  clean = re.sub(
900
  r"^(i\s+)?(woke\s+up\s+)?(want\s+to\s+know|wonder|am\s+wondering|need\s+to\s+know|worry|fear)\s*(if|whether|why|what|how)?\s*",
901
  lambda match: (match.group(4) or "").strip() + " ",
@@ -1221,9 +1419,36 @@ def _fallback_interpretation(intake: DreamIntake, language: str = "en") -> str:
1221
  )
1222
 
1223
 
 
 
 
 
1224
  def _numbered_suggestions(items: List[str], language: str = "en") -> str:
1225
- cleaned = [item.strip() for item in items if item and item.strip()]
1226
- return " ".join(f"{index}. {item}" for index, item in enumerate(cleaned[:3], start=1))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1227
 
1228
 
1229
  def _seeded_option(options: List[str], intake: DreamIntake, answers: str = "") -> str:
@@ -1248,9 +1473,9 @@ def _weird_little_action(intake: DreamIntake, answers: str, anchors: List[str],
1248
  f"用手指在桌面画一条看不见的海岸线,把一个小物件从「{anchor}」那侧推到自己这侧,像给醒来的身体靠岸。",
1249
  ],
1250
  "stuck_elevator": [
1251
- "找一张便利贴,画一个只到“草稿层”的电梯按钮,贴在电脑旁;用手指按一下,再邮件第一句话。",
1252
- "在纸上画三枚电梯按钮:14草稿、暂停;闭眼按一下“草稿”,然后打开邮件写第句话。",
1253
- "把一张纸立在键盘旁当电梯门,画个邮件草稿层按钮下纸门,再句就合上。",
1254
  ],
1255
  "library_signal": [
1256
  f"做一张迷你借书卡,书名写「{anchor}」,到期日写“今晚不追讨”;把它夹进一本书里,像把梦暂存进图书馆。",
@@ -1284,9 +1509,9 @@ def _weird_little_action(intake: DreamIntake, answers: str, anchors: List[str],
1284
  f"Trace an invisible shoreline on the table and push one small object from the {anchor} side back toward you.",
1285
  ],
1286
  "stuck_elevator": [
1287
- "Draw an elevator button labeled Draft Floor on a sticky note, press it once with your finger, then write only the first sentence of the email.",
1288
- "Draw three elevator buttons on paper: 14, Draft, Pause. Close your eyes, press Draft once, then open the email for one sentence.",
1289
- "Stand a piece of paper beside the keyboard like elevator doors, draw an Email Draft button, press it once, write one sentence, and close the paper doors.",
1290
  ],
1291
  "library_signal": [
1292
  f"Make a tiny library card titled {anchor}, set the due date to Not tonight, and tuck it into a book.",
@@ -1354,7 +1579,7 @@ def _grounded_today_tip(intake: DreamIntake, language: str = "en") -> str:
1354
  return _numbered_suggestions(
1355
  [
1356
  f"Use {anchor} to spot the real-life place where you feel stuck at the entrance.",
1357
- "Choose one waking-life doorway action today: open the draft, check one fact, or ask one person.",
1358
  ],
1359
  language,
1360
  )
@@ -1403,7 +1628,7 @@ def _grounded_today_tip(intake: DreamIntake, language: str = "en") -> str:
1403
  return _numbered_suggestions(
1404
  [
1405
  f"从「{anchor}」找出现实里最像“卡在门口”的一件事。",
1406
- "今天只选一个醒着能做的入口动作:打开草稿、查一个信息,或问一个人。",
1407
  ],
1408
  language,
1409
  )
@@ -1422,8 +1647,16 @@ def _answer_based_tiny_action(
1422
  anchors: List[str],
1423
  language: str = "en",
1424
  ) -> str:
 
 
 
 
 
 
1425
  lowered = (answers or "").lower()
1426
  answer_terms = [
 
 
1427
  "邮件",
1428
  "email",
1429
  "消息",
@@ -1439,31 +1672,64 @@ def _answer_based_tiny_action(
1439
  "rehearse",
1440
  "deadline",
1441
  "application",
 
 
 
1442
  "apolog",
1443
  ]
1444
- if any(term in lowered for term in answer_terms):
1445
- return _weird_little_action(intake, answers, anchors, language)
1446
- return ""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1447
 
1448
 
1449
  def _answer_based_today_tip(answers: str, anchor: str, language: str = "en") -> str:
1450
  lowered = (answers or "").lower()
 
1451
  if _is_zh(language):
1452
  if "邮件" in lowered or "email" in lowered:
1453
  return _numbered_suggestions(
1454
  [
1455
  f"把「{anchor}」翻译成一个现实沟通问题:这封邮件只需要让对方知道哪一件事。",
1456
- "先只打开草稿,补上主题和第一句话,把“开始”和“发送”拆开。",
1457
  "给自己定一个稍后回看的时间;今天可以先存草稿,不一定马上发出去。",
1458
  ],
1459
  language,
1460
  )
1461
  if "消息" in lowered or "发消息" in lowered:
 
 
 
 
 
 
 
 
1462
  return _numbered_suggestions(
1463
  [
1464
  f"把「{anchor}」翻译成现实里一条需要落地的沟通。",
1465
- "先写一条很短进度消息说明现在到哪一步,不要求把整件事立刻完成。",
1466
- "如果还不确定,就先存草稿,等一个具体时间再决定是否发送。",
1467
  ],
1468
  language,
1469
  )
@@ -1476,16 +1742,49 @@ def _answer_based_today_tip(answers: str, anchor: str, language: str = "en") ->
1476
  ],
1477
  language,
1478
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1479
  return ""
1480
- if "email" in lowered or "message" in lowered:
 
1481
  return _numbered_suggestions(
1482
  [
1483
- f"Translate {anchor} into a real-world communication question: what does the overdue email need the other person to know?",
1484
  "Open the draft only long enough to add the subject and first sentence; separate starting from sending.",
1485
  "Save it without sending, then choose one later review time.",
1486
  ],
1487
  language,
1488
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1489
  if "assignment" in lowered or "homework" in lowered or "draft" in lowered:
1490
  return _numbered_suggestions(
1491
  [
@@ -1511,6 +1810,14 @@ def _answer_based_today_tip(answers: str, anchor: str, language: str = "en") ->
1511
  ],
1512
  language,
1513
  )
 
 
 
 
 
 
 
 
1514
  if "apolog" in lowered:
1515
  return _numbered_suggestions(
1516
  [
@@ -1519,28 +1826,49 @@ def _answer_based_today_tip(answers: str, anchor: str, language: str = "en") ->
1519
  ],
1520
  language,
1521
  )
 
 
 
 
 
 
 
 
1522
  return ""
1523
 
1524
 
1525
  def _answer_based_interpretation(answers: str, anchor: str, language: str = "en") -> str:
1526
  lowered = (answers or "").lower()
 
1527
  if _is_zh(language):
1528
  if "邮件" in lowered or "email" in lowered:
1529
  return f"也许「{anchor}」不是在催你立刻完成什么,而是在提醒你:那封邮件可以先从一句话开始。"
1530
  if "消息" in lowered or "message" in lowered:
1531
- return f"也许「{anchor}」不是在催你立刻回应什么,而是在提醒你:那条消息可以先从一句草稿开始。"
 
 
1532
  if "作业" in lowered or "草稿" in lowered:
1533
  return f"也许「{anchor}」不是在说你已经来不及,而是在提醒你:草稿可以先从下一小段开始。"
 
 
 
 
1534
  return ""
1535
  if "email" in lowered:
 
1536
  return (
1537
- f"Maybe the {anchor} is not asking you to finish the overdue email at once. "
1538
  "It is pointing to the gentler threshold: opening it and writing one first sentence."
1539
  )
1540
  if "message" in lowered:
 
 
 
 
 
1541
  return (
1542
- f"Maybe the {anchor} is not asking you to answer every message at once. "
1543
- "It is pointing to the gentler threshold: opening one thread and drafting one first sentence."
1544
  )
1545
  if "assignment" in lowered or "homework" in lowered or "draft" in lowered:
1546
  return (
@@ -1557,11 +1885,21 @@ def _answer_based_interpretation(answers: str, anchor: str, language: str = "en"
1557
  f"Maybe the {anchor} is not asking you to finish the whole application in one push. "
1558
  "It is pointing to the gentler threshold: finding the next missing item."
1559
  )
 
 
 
 
 
1560
  if "apolog" in lowered:
1561
  return (
1562
  f"Maybe the {anchor} is not asking you to repair everything at once. "
1563
  "It is pointing to the gentler threshold: drafting one honest sentence."
1564
  )
 
 
 
 
 
1565
  return ""
1566
 
1567
 
@@ -2135,6 +2473,13 @@ def _has_unsupported_emotion_or_generic_wellness(text: str, intake: DreamIntake,
2135
  "保持积极",
2136
  "积极心态",
2137
  "明天会更好",
 
 
 
 
 
 
 
2138
  "喝一杯温水",
2139
  "一杯温水",
2140
  "温水",
@@ -2143,11 +2488,150 @@ def _has_unsupported_emotion_or_generic_wellness(text: str, intake: DreamIntake,
2143
  "stay positive",
2144
  "positive mindset",
2145
  "tomorrow will be better",
 
 
 
 
 
 
 
2146
  "warm water",
 
 
 
 
 
 
 
2147
  ]
2148
  return any(marker in clean for marker in generic_markers)
2149
 
2150
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2151
  def _nonclinical_caring_note(anchors: List[str], language: str = "en") -> str:
2152
  anchor = anchors[0] if anchors else ("梦里的这个细节" if _is_zh(language) else "this dream detail")
2153
  if _is_zh(language):
@@ -2155,6 +2639,35 @@ def _nonclinical_caring_note(anchors: List[str], language: str = "en") -> str:
2155
  return f"Writing this dream down is already enough; today, let {anchor} be one detail you meet gently."
2156
 
2157
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2158
  def _polish_today_tip(card: TodayTipCard, intake: DreamIntake, answers: str = "", language: str = "en") -> TodayTipCard:
2159
  polished = card.model_copy(deep=True)
2160
  answer_lines = [line.strip() for line in (answers or "").splitlines() if line.strip()]
@@ -2165,21 +2678,20 @@ def _polish_today_tip(card: TodayTipCard, intake: DreamIntake, answers: str = ""
2165
  intake,
2166
  answers,
2167
  )
 
2168
  card_anchors = _remove_placeholder_anchors(
2169
  polished.dream_anchors
2170
  if _is_zh(language)
2171
  else _dedupe_preserve_order([_english_anchor_text(anchor) for anchor in polished.dream_anchors])
2172
  )
2173
  card_anchors = _without_unsupported_melted_anchors(card_anchors, intake, answers)
2174
- if intake_anchors and not any(_text_uses_anchor(anchor, intake_anchors) for anchor in card_anchors):
2175
- anchors = intake_anchors
2176
- else:
2177
- anchors = card_anchors or intake_anchors
2178
  anchors = _without_unsupported_melted_anchors(anchors, intake, answers)
2179
  if not anchors:
2180
  anchors = _remove_placeholder_anchors([_primary_anchor(intake, language)])
2181
  if not anchors:
2182
- anchors = ["梦境片段" if _is_zh(language) else "dream fragment"]
2183
  polished.dream_anchors = anchors
2184
  for field in (
2185
  "dream_summary",
@@ -2197,7 +2709,12 @@ def _polish_today_tip(card: TodayTipCard, intake: DreamIntake, answers: str = ""
2197
  _clean_unsupported_melted_detail(_clean_placeholder_phrase(question), intake, anchors, language, answers)
2198
  for question in polished.followup_questions
2199
  ]
2200
- if not polished.dream_summary.strip() or _is_placeholder_anchor(polished.dream_summary) or not _text_uses_anchor(polished.dream_summary, anchors):
 
 
 
 
 
2201
  polished.dream_summary = _summary_from_intake(intake, language)
2202
  explicit_question = _extract_explicit_user_question(intake, answers, language)
2203
  if explicit_question:
@@ -2212,14 +2729,22 @@ def _polish_today_tip(card: TodayTipCard, intake: DreamIntake, answers: str = ""
2212
  answer_interpretation = _answer_based_interpretation(answers, _answer_bridge_anchor(anchors), language)
2213
  answer_tip = _answer_based_today_tip(answers, anchors[0], language)
2214
  answer_action = _answer_based_tiny_action(answers, intake, anchors, language)
2215
- answer_should_shape_visible_tip = bool((answer_tip or answer_action) and not _needs_comfort(answers, language))
 
 
 
 
 
 
 
 
2216
  if answer_interpretation and answer_should_shape_visible_tip:
2217
  polished.interpretation = answer_interpretation
2218
  elif emotion_interpretation:
2219
  polished.interpretation = emotion_interpretation
2220
  elif answer_interpretation:
2221
  polished.interpretation = answer_interpretation
2222
- elif _has_prophecy_frame(intake.merged_text()):
2223
  anchor = _answer_bridge_anchor(anchors)
2224
  polished.interpretation = (
2225
  f"Maybe the {anchor} is best treated as a fear-shaped image, not as evidence that something bad will happen."
@@ -2233,7 +2758,11 @@ def _polish_today_tip(card: TodayTipCard, intake: DreamIntake, answers: str = ""
2233
  if not _is_zh(language)
2234
  else f"目前线索很少,先把「{anchor}」当作一个可以继续补充的线索,而不是确定解读。"
2235
  )
2236
- elif not polished.interpretation.strip() or not _anchor_in_text(polished.interpretation, anchors):
 
 
 
 
2237
  polished.interpretation = _fallback_interpretation(intake, language)
2238
  generic_tip_markers = [
2239
  "drink water",
@@ -2246,15 +2775,24 @@ def _polish_today_tip(card: TodayTipCard, intake: DreamIntake, answers: str = ""
2246
  "borrow one action",
2247
  "借一个动作",
2248
  "open the task",
 
 
 
 
 
 
 
 
 
2249
  ]
2250
  emotion_tip = _emotion_led_today_tip(intake, answers, anchors, language)
2251
  if answer_tip and answer_should_shape_visible_tip:
2252
  polished.today_tip = answer_tip
2253
  elif emotion_tip:
2254
  polished.today_tip = emotion_tip
2255
- elif answer_tip:
2256
  polished.today_tip = answer_tip
2257
- elif _has_prophecy_frame(intake.merged_text()):
2258
  anchor = anchors[0]
2259
  polished.today_tip = (
2260
  f"For today, do not test whether the {anchor} is a sign. Name one ordinary worry it resembles, then choose one small calming step."
@@ -2273,8 +2811,10 @@ def _polish_today_tip(card: TodayTipCard, intake: DreamIntake, answers: str = ""
2273
  or any(marker in polished.today_tip.lower() for marker in generic_tip_markers)
2274
  or _is_placeholder_anchor(polished.today_tip)
2275
  or not _anchor_in_text(polished.today_tip, anchors)
 
2276
  ):
2277
  polished.today_tip = _grounded_today_tip(intake, language)
 
2278
  hard_action_markers = [
2279
  "address it immediately",
2280
  "fix it immediately",
@@ -2301,6 +2841,7 @@ def _polish_today_tip(card: TodayTipCard, intake: DreamIntake, answers: str = ""
2301
  or _is_placeholder_anchor(polished.tiny_action)
2302
  or not _anchor_in_text(polished.tiny_action, anchors)
2303
  or any(marker in polished.tiny_action.lower() for marker in hard_action_markers)
 
2304
  ):
2305
  polished.tiny_action = _weird_little_action(intake, answers, anchors, language)
2306
  emotion_caring_note = _emotion_led_caring_note(intake, answers, language)
@@ -2325,8 +2866,17 @@ def _polish_today_tip(card: TodayTipCard, intake: DreamIntake, answers: str = ""
2325
  polished.caring_note = "You do not have to solve the whole dream this morning; start by noticing one detail and one small next step."
2326
  elif _is_zh(language) and "所有楼层" in polished.caring_note:
2327
  polished.caring_note = "你不需要一醒来就解释完整个梦;先照顾一个细节和一个很小的下一步就好。"
 
 
2328
  merged = "\n".join([intake.merged_text(), answers or ""])
2329
  has_escalation = needs_escalation(merged)
 
 
 
 
 
 
 
2330
  if not has_escalation:
2331
  if _has_unsupported_clinical_frame(polished.interpretation) or _has_unsupported_emotion_or_generic_wellness(
2332
  polished.interpretation, intake, answers
@@ -2345,6 +2895,7 @@ def _polish_today_tip(card: TodayTipCard, intake: DreamIntake, answers: str = ""
2345
  ):
2346
  polished.caring_note = _nonclinical_caring_note(anchors, language)
2347
  polished.safety_note = safety_note(language) if has_escalation else ""
 
2348
  if not _is_zh(language):
2349
  polished = _clean_english_today_tip_language(polished)
2350
  return polished
 
431
  "the dream detail",
432
  "dream",
433
  "dream fragment",
434
+ "dream fragments",
435
  }
436
 
437
 
 
525
  candidates.extend(["公司", "午觉", "被人泼水"])
526
  if "elevator" in text:
527
  candidates.append("elevator")
528
+ if re.search(r"frightening dream|scary dream|recurring dream|same frightening dream", text):
529
+ candidates.append("frightening dream")
530
+ if re.search(r"barely slept|many nights|can't sleep|cannot sleep|could not sleep|insomnia|slept", text):
531
+ candidates.append("lost sleep")
532
  if re.search(r"\bfloor\s*14\b|\b14\b", text):
533
  candidates.append("floor 14")
534
  if "button" in text and re.search(r"\b(melt|melted|melting|wax)\b", text):
 
582
  return clean
583
 
584
 
585
+ def _english_public_text(text: str) -> str:
586
+ clean = text or ""
587
+ for source, target in sorted(_ZH_TO_EN_PHRASES.items(), key=lambda item: len(item[0]), reverse=True):
588
+ clean = clean.replace(source, target)
589
+ clean = re.sub(r"[\u4e00-\u9fff]+", " dream fragment ", clean)
590
+ clean = re.sub(r"\s+([,.!?;:])", r"\1", clean)
591
+ clean = re.sub(r"\s+", " ", clean).strip()
592
+ return clean
593
+
594
+
595
  def _zh_anchor_text(text: str) -> str:
596
  clean = re.sub(r"\s+", " ", (text or "").strip(" .,:;!?\"'()[]{}"))
597
  if not clean:
 
624
  "caring_note",
625
  "safety_note",
626
  ):
627
+ setattr(cleaned, field, _english_public_text(getattr(cleaned, field)))
628
  cleaned.dream_anchors = _dedupe_preserve_order(
629
  [_english_anchor_text(anchor) for anchor in cleaned.dream_anchors if anchor]
630
  )
 
721
  ).lower()
722
 
723
 
724
+ def _provenance_text(intake: DreamIntake, answers: str = "") -> str:
725
+ parts = [
726
+ intake.dream_text,
727
+ intake.voice_transcript,
728
+ " ".join(intake.visual_clues),
729
+ " ".join(intake.recurring_symbols),
730
+ intake.mood,
731
+ intake.main_question,
732
+ intake.uncertainty,
733
+ intake.user_context,
734
+ answers or "",
735
+ ]
736
+ return re.sub(r"\s+", " ", " ".join(part.strip() for part in parts if part and part.strip())).lower()
737
+
738
+
739
+ def _source_has_any(source: str, terms: List[str]) -> bool:
740
+ return any(term in source for term in terms)
741
+
742
+
743
+ def _anchor_has_source_provenance(anchor: str, intake: DreamIntake, answers: str = "", language: str = "en") -> bool:
744
+ if _is_placeholder_anchor(anchor):
745
+ return False
746
+ source = _provenance_text(intake, answers)
747
+ clean = _english_anchor_text(anchor).lower() if not _is_zh(language) else anchor.lower()
748
+ clean = re.sub(r"\s+", " ", clean).strip(" .,:;!?\"'()[]{}")
749
+ if not clean or _is_placeholder_anchor(clean):
750
+ return False
751
+
752
+ high_risk_requirements = [
753
+ (["office building", "办公楼"], ["office building", "office tower", "办公楼"]),
754
+ (["overdue email", "email", "e-mail", "邮件"], ["email", "e-mail", "邮件"]),
755
+ (["email draft", "邮件草稿"], ["email", "e-mail", "邮件"]),
756
+ (["first sentence of the email", "email first sentence"], ["email", "e-mail", "邮件"]),
757
+ ]
758
+ for markers, required_terms in high_risk_requirements:
759
+ if any(marker in clean for marker in markers):
760
+ return _source_has_any(source, required_terms)
761
+ if "draft" in clean or "草稿" in clean:
762
+ return _source_has_any(
763
+ source,
764
+ [
765
+ "draft",
766
+ "草稿",
767
+ "email",
768
+ "e-mail",
769
+ "邮件",
770
+ "leave request",
771
+ "time off",
772
+ "sick leave",
773
+ "application",
774
+ "assignment",
775
+ "homework",
776
+ "presentation",
777
+ "speech",
778
+ "apolog",
779
+ "请假",
780
+ "申请",
781
+ "作业",
782
+ "演讲",
783
+ "汇报",
784
+ "道歉",
785
+ ],
786
+ )
787
+
788
+ if clean in source:
789
+ return True
790
+ if _is_zh(language):
791
+ for source_phrase, target in _EN_TO_ZH_PHRASES.items():
792
+ if target == clean and source_phrase in source:
793
+ return True
794
+ if "elevator" in clean or "电梯" in clean:
795
+ return "elevator" in source or "电梯" in source
796
+ if "phone" in clean or "手机" in clean:
797
+ return _source_has_any(source, ["phone", "手机", "battery", "电量"])
798
+ if "sleep" in clean or "slept" in clean or "失眠" in clean:
799
+ return _source_has_any(source, ["sleep", "slept", "insomnia", "barely slept", "睡", "失眠"])
800
+ if "message" in clean or "消息" in clean:
801
+ return _source_has_any(source, ["message", "messages", "消息", "发消息"])
802
+ tokens = [token for token in re.split(r"[\s,,。::;;、]+", clean) if len(token) >= 3]
803
+ return bool(tokens) and any(token in source for token in tokens)
804
+
805
+
806
+ def _filter_anchors_by_source(
807
+ anchors: List[str],
808
+ intake: DreamIntake,
809
+ answers: str = "",
810
+ language: str = "en",
811
+ ) -> List[str]:
812
+ return _dedupe_preserve_order(
813
+ [anchor for anchor in anchors if _anchor_has_source_provenance(anchor, intake, answers, language)]
814
+ )
815
+
816
+
817
  def _source_mentions_melted_detail(intake: DreamIntake, answers: str = "") -> bool:
818
  text = _story_text(intake, answers)
819
  return any(term in text for term in ["融化", "像蜡", "蜡", "melt", "melted", "melting", "wax", "waxy"])
 
947
  return " ".join(words[:22])
948
 
949
 
950
+ def _answer_reality_cue(answers: str, language: str = "en") -> str:
951
+ snippet = _answer_snippet(answers, language)
952
+ if not snippet:
953
+ return ""
954
+ lowered = snippet.lower()
955
+ if _is_zh(language):
956
+ lead_ins = [
957
+ "现实里",
958
+ "现实中",
959
+ "可能是",
960
+ "大概是",
961
+ "像是",
962
+ "让我想到",
963
+ "提醒我",
964
+ "其实是",
965
+ ]
966
+ for lead in lead_ins:
967
+ if lead in snippet:
968
+ return snippet[snippet.find(lead) :].strip(" ,。;、")
969
+ return snippet
970
+ lead_ins = [
971
+ "in real life",
972
+ "it is probably",
973
+ "it's probably",
974
+ "probably",
975
+ "it reminded me of",
976
+ "it reminds me of",
977
+ "it felt like",
978
+ "it feels like",
979
+ ]
980
+ for lead in lead_ins:
981
+ if lead in lowered:
982
+ return snippet[lowered.find(lead) :].strip(" ,.;:")
983
+ return snippet
984
+
985
+
986
  def _anchor_with_article(anchor: str) -> str:
987
  clean = (anchor or "").strip()
988
  if clean.lower().startswith(("the ", "a ", "an ")):
 
994
  return " ".join(part.capitalize() for part in text.split())
995
 
996
 
997
+ def _story_portion_for_summary(text: str, language: str = "en") -> str:
998
+ clean = re.sub(r"\s+", " ", (text or "").strip())
999
+ if not clean:
1000
+ return ""
1001
+ if _is_zh(language):
1002
+ parts = re.split(r"(?:我)?(?:最)?想知道|(?:我)?想问|醒来(?:后)?最想知道", clean, maxsplit=1)
1003
+ return parts[0].strip(" ,。;、") or clean
1004
+ parts = re.split(r"\bmy\s+question\s*:|\bi\s+want\s+to\s+know\s*:?", clean, maxsplit=1, flags=re.IGNORECASE)
1005
+ return parts[0].strip(" ,.;:") or clean
1006
+
1007
+
1008
+ def _truncate_summary_text(text: str, language: str = "en", limit: int = 96) -> str:
1009
+ clean = re.sub(r"\s+", " ", (text or "").strip())
1010
+ if len(clean) <= limit:
1011
+ return clean
1012
+ prefix = clean[:limit].rstrip()
1013
+ if _is_zh(language):
1014
+ break_at = max(prefix.rfind(mark) for mark in ("。", "!", "?", ";"))
1015
+ if break_at >= 28:
1016
+ return prefix[: break_at + 1]
1017
+ return prefix.rstrip(",;、 ") + "..."
1018
+ break_at = max(prefix.rfind(mark) for mark in (".", "!", "?", ";"))
1019
+ if break_at >= 42:
1020
+ return prefix[: break_at + 1]
1021
+ word_break = prefix.rfind(" ")
1022
+ if word_break >= 48:
1023
+ prefix = prefix[:word_break]
1024
+ prefix = re.sub(r"\b(?:a|an|the|and|or|but|with|for|to|of|in|at|was|were)\s*$", "", prefix, flags=re.IGNORECASE)
1025
+ return prefix.rstrip(" ,;:") + "..."
1026
+
1027
+
1028
+ def _looks_like_bad_summary(text: str, intake: DreamIntake, answers: str = "", language: str = "en") -> bool:
1029
+ clean = re.sub(r"\s+", " ", (text or "").strip())
1030
+ if not clean:
1031
+ return True
1032
+ if _contains_unprovenanced_detail(clean, intake, answers, language):
1033
+ return True
1034
+ if not _is_zh(language):
1035
+ if re.search(r"\b(?:a|an|the|and|or|but|with|for|to|of|in|at|was|were)\s*$", clean, re.IGNORECASE):
1036
+ return True
1037
+ if len(clean) < 80 and not clean.endswith((".", "!", "?", "...")) and re.search(r"\b\w{1,3}$", clean):
1038
+ return True
1039
+ return False
1040
+
1041
+
1042
  def _summary_from_intake(intake: DreamIntake, language: str = "en") -> str:
1043
+ merged = _story_portion_for_summary(intake.dream_text.strip(), language) or _story_portion_for_summary(
1044
+ intake.voice_transcript.strip(),
1045
+ language,
1046
+ )
1047
  if not merged and intake.visual_clues:
1048
  merged = "、".join(intake.visual_clues[:3]) if _is_zh(language) else ", ".join(intake.visual_clues[:3])
1049
  if not merged:
1050
  return "你记录了一个还在整理中的梦。" if _is_zh(language) else "You recorded a dream that is still taking shape."
1051
  clean = re.sub(r"\s+", " ", merged).strip()
1052
+ clean = _truncate_summary_text(clean, language)
 
1053
  if not _is_zh(language):
1054
  clean = _clean_placeholder_phrase(_english_anchor_text(clean))
1055
  if not _is_zh(language):
 
1083
  if not clean:
1084
  return ""
1085
  if _is_zh(language):
1086
+ clean = re.sub(r"^(我)?(的)?(主要)?问题是[::,,\s]*", "", clean)
1087
  clean = re.sub(r"^(我)?(醒来后)?(最)?(想知道|想问|在想|担心|害怕)[::,,\s]*", "", clean)
1088
  clean = re.sub(r"^(只)?想知道[::,,\s]*", "", clean)
1089
  clean = clean.strip(" ::「」\"'()[]{}")
1090
  return clean if clean.endswith(("?", "?")) else f"{clean}?"
1091
+ clean = re.sub(
1092
+ r"^(?:my\s+)?(?:main\s+)?question\s*(?:is|was)?\s*[::,\-]?\s*",
1093
+ "",
1094
+ clean,
1095
+ flags=re.IGNORECASE,
1096
+ ).strip()
1097
  clean = re.sub(
1098
  r"^(i\s+)?(woke\s+up\s+)?(want\s+to\s+know|wonder|am\s+wondering|need\s+to\s+know|worry|fear)\s*(if|whether|why|what|how)?\s*",
1099
  lambda match: (match.group(4) or "").strip() + " ",
 
1419
  )
1420
 
1421
 
1422
+ def _strip_list_marker(text: str) -> str:
1423
+ return re.sub(r"^\s*(?:\d+[\.)]|[-*•])\s*", "", text or "").strip()
1424
+
1425
+
1426
  def _numbered_suggestions(items: List[str], language: str = "en") -> str:
1427
+ cleaned = [_strip_list_marker(item.strip()) for item in items if item and item.strip()]
1428
+ cleaned = [item for item in cleaned if item]
1429
+ if not cleaned:
1430
+ return ""
1431
+ first = cleaned[0].rstrip("。.;; ")
1432
+ if len(cleaned) == 1:
1433
+ return first
1434
+ second = cleaned[1].strip()
1435
+ if _is_zh(language):
1436
+ return f"{first};{second}"
1437
+ return f"{first}. {second}"
1438
+
1439
+
1440
+ def _one_tip_text(text: str, language: str = "en") -> str:
1441
+ clean = re.sub(r"\s+", " ", (text or "").strip())
1442
+ if not clean:
1443
+ return ""
1444
+ if not re.search(r"(?:^|\s)\d+[\.)]\s+", clean):
1445
+ return _strip_list_marker(clean)
1446
+ parts = [
1447
+ _strip_list_marker(part)
1448
+ for part in re.split(r"(?:^|\s)\d+[\.)]\s+", clean)
1449
+ if _strip_list_marker(part)
1450
+ ]
1451
+ return _numbered_suggestions(parts[:2], language) if parts else _strip_list_marker(clean)
1452
 
1453
 
1454
  def _seeded_option(options: List[str], intake: DreamIntake, answers: str = "") -> str:
 
1473
  f"用手指在桌面画一条看不见的海岸线,把一个小物件从「{anchor}」那侧推到自己这侧,像给醒来的身体靠岸。",
1474
  ],
1475
  "stuck_elevator": [
1476
+ f"找一张便利贴,画一个写着「{anchor}」电梯按钮,贴在桌边;用手指按一下,再写下今天最小的入口动作。",
1477
+ f"在纸上画三枚电梯按钮:等待开始、暂停;闭眼按一下最像「{anchor}」的那一枚,然后只做个一分钟动作。",
1478
+ f"把一张纸立在桌边当电梯门,门上写{anchor}」;推开厘米,再说出个今天能先确认的小事实。",
1479
  ],
1480
  "library_signal": [
1481
  f"做一张迷你借书卡,书名写「{anchor}」,到期日写“今晚不追讨”;把它夹进一本书里,像把梦暂存进图书馆。",
 
1509
  f"Trace an invisible shoreline on the table and push one small object from the {anchor} side back toward you.",
1510
  ],
1511
  "stuck_elevator": [
1512
+ f"Draw a tiny elevator button labeled {anchor}, press it once with your finger, then name the smallest doorway action you can try today.",
1513
+ f"Draw three elevator buttons on paper: Wait, Start, Pause. Press the one that feels closest to {anchor}, then do one action under a minute.",
1514
+ f"Stand a piece of paper on the desk like elevator doors, write {anchor} on it, open the paper one centimeter, and name one fact you can check today.",
1515
  ],
1516
  "library_signal": [
1517
  f"Make a tiny library card titled {anchor}, set the due date to Not tonight, and tuck it into a book.",
 
1579
  return _numbered_suggestions(
1580
  [
1581
  f"Use {anchor} to spot the real-life place where you feel stuck at the entrance.",
1582
+ "Choose one waking-life doorway action today: check one fact, ask one person, or name what you are waiting for.",
1583
  ],
1584
  language,
1585
  )
 
1628
  return _numbered_suggestions(
1629
  [
1630
  f"从「{anchor}」找出现实里最像“卡在门口”的一件事。",
1631
+ "今天只选一个醒着能做的入口动作:查一个信息问一个人,或写下自己在等什么。",
1632
  ],
1633
  language,
1634
  )
 
1647
  anchors: List[str],
1648
  language: str = "en",
1649
  ) -> str:
1650
+ if _answer_has_concrete_task_keyword(answers):
1651
+ return _weird_little_action(intake, answers, anchors, language)
1652
+ return ""
1653
+
1654
+
1655
+ def _answer_has_concrete_task_keyword(answers: str) -> bool:
1656
  lowered = (answers or "").lower()
1657
  answer_terms = [
1658
+ "请假",
1659
+ "申请",
1660
  "邮件",
1661
  "email",
1662
  "消息",
 
1672
  "rehearse",
1673
  "deadline",
1674
  "application",
1675
+ "leave request",
1676
+ "time off",
1677
+ "sick leave",
1678
  "apolog",
1679
  ]
1680
+ return any(term in lowered for term in answer_terms)
1681
+
1682
+
1683
+ def _is_relationship_message_context(text: str) -> bool:
1684
+ lowered = (text or "").lower()
1685
+ relationship_terms = [
1686
+ "friend",
1687
+ "close friend",
1688
+ "misunderstood",
1689
+ "wronged",
1690
+ "hurt",
1691
+ "invisible",
1692
+ "nobody listened",
1693
+ "not angry",
1694
+ "worst possible way",
1695
+ "前任",
1696
+ "朋友",
1697
+ "误解",
1698
+ "委屈",
1699
+ "受伤",
1700
+ "没人听",
1701
+ "看不见",
1702
+ ]
1703
+ task_terms = ["work message", "client", "deadline", "email", "overdue", "工作", "客户", "邮件", "截止"]
1704
+ return any(term in lowered for term in relationship_terms) and not any(term in lowered for term in task_terms)
1705
 
1706
 
1707
  def _answer_based_today_tip(answers: str, anchor: str, language: str = "en") -> str:
1708
  lowered = (answers or "").lower()
1709
+ reality_cue = _answer_reality_cue(answers, language)
1710
  if _is_zh(language):
1711
  if "邮件" in lowered or "email" in lowered:
1712
  return _numbered_suggestions(
1713
  [
1714
  f"把「{anchor}」翻译成一个现实沟通问题:这封邮件只需要让对方知道哪一件事。",
1715
+ "先只打开草稿,补上主题和第一句话,把“开始”和“发送”拆开;今天可以先存草稿,不一定马上发出去。",
1716
  "给自己定一个稍后回看的时间;今天可以先存草稿,不一定马上发出去。",
1717
  ],
1718
  language,
1719
  )
1720
  if "消息" in lowered or "发消息" in lowered:
1721
+ if _is_relationship_message_context(answers):
1722
+ return _numbered_suggestions(
1723
+ [
1724
+ f"把「{anchor}」和那种被误解、没被听见的感觉接起来。",
1725
+ "今天先写一句私密的澄清:我真正希望朋友听懂的是哪一点;不急着立刻发出。",
1726
+ ],
1727
+ language,
1728
+ )
1729
  return _numbered_suggestions(
1730
  [
1731
  f"把「{anchor}」翻译成现实里一条需要落地的沟通。",
1732
+ "先写一句它需要传达核心意思,不要求把整件事立刻完成或发出。",
 
1733
  ],
1734
  language,
1735
  )
 
1742
  ],
1743
  language,
1744
  )
1745
+ if "请假" in lowered or "申请" in lowered:
1746
+ return _numbered_suggestions(
1747
+ [
1748
+ f"把「{anchor}」翻译成现实里那件需要开口的申请,而不是要求自己一次解释清楚所有内疚。",
1749
+ "今天先写一句最普通的请求,说明你需要什么;先存草稿,不急着立刻发送。",
1750
+ ],
1751
+ language,
1752
+ )
1753
+ if reality_cue:
1754
+ return _numbered_suggestions(
1755
+ [
1756
+ f"把「{anchor}」和你刚才说的「{reality_cue}」接起来,而不是只解释梦的象征。",
1757
+ "今天只选一个可回头的小动作:写一句说明、问一个入口,或把下一步先放到草稿里。",
1758
+ ],
1759
+ language,
1760
+ )
1761
  return ""
1762
+ if "email" in lowered:
1763
+ email_label = "the overdue email" if "overdue email" in lowered else "that email"
1764
  return _numbered_suggestions(
1765
  [
1766
+ f"Translate {anchor} into a real-world communication question: what does {email_label} need the other person to know?",
1767
  "Open the draft only long enough to add the subject and first sentence; separate starting from sending.",
1768
  "Save it without sending, then choose one later review time.",
1769
  ],
1770
  language,
1771
  )
1772
+ if "message" in lowered:
1773
+ if _is_relationship_message_context(answers):
1774
+ return _numbered_suggestions(
1775
+ [
1776
+ f"Connect {anchor} to the friend-message worry you named, especially the feeling of not being heard.",
1777
+ "Write one private clarifying sentence about what you hoped they understood, then decide later whether anything needs to be sent.",
1778
+ ],
1779
+ language,
1780
+ )
1781
+ return _numbered_suggestions(
1782
+ [
1783
+ f"Connect {anchor} to the work message pressure you named, without changing it into a different task.",
1784
+ "Write down the one thing the message needs to convey before deciding when to open or send it.",
1785
+ ],
1786
+ language,
1787
+ )
1788
  if "assignment" in lowered or "homework" in lowered or "draft" in lowered:
1789
  return _numbered_suggestions(
1790
  [
 
1810
  ],
1811
  language,
1812
  )
1813
+ if "leave request" in lowered or "time off" in lowered or "sick leave" in lowered:
1814
+ return _numbered_suggestions(
1815
+ [
1816
+ f"Translate {anchor} into the leave request you named, not into a verdict about your guilt.",
1817
+ "Draft one plain sentence that states what you need; save it before deciding when to send.",
1818
+ ],
1819
+ language,
1820
+ )
1821
  if "apolog" in lowered:
1822
  return _numbered_suggestions(
1823
  [
 
1826
  ],
1827
  language,
1828
  )
1829
+ if reality_cue:
1830
+ return _numbered_suggestions(
1831
+ [
1832
+ f"Connect {anchor} to the waking-life clue you named: \"{reality_cue}\".",
1833
+ "Choose one reversible first step today: draft one sentence, ask for one doorway, or park the next step where you can return to it.",
1834
+ ],
1835
+ language,
1836
+ )
1837
  return ""
1838
 
1839
 
1840
  def _answer_based_interpretation(answers: str, anchor: str, language: str = "en") -> str:
1841
  lowered = (answers or "").lower()
1842
+ reality_cue = _answer_reality_cue(answers, language)
1843
  if _is_zh(language):
1844
  if "邮件" in lowered or "email" in lowered:
1845
  return f"也许「{anchor}」不是在催你立刻完成什么,而是在提醒你:那封邮件可以先从一句话开始。"
1846
  if "消息" in lowered or "message" in lowered:
1847
+ if _is_relationship_message_context(answers):
1848
+ return f"也许「{anchor}」更像是在放大被误解、没被听见的委屈,而不是催你立刻回消息。"
1849
+ return f"也许「{anchor}」不是在催你立刻回应什么,而是在提醒你:那条消息可以先只看清要传达的一点。"
1850
  if "作业" in lowered or "草稿" in lowered:
1851
  return f"也许「{anchor}」不是在说你已经来不及,而是在提醒你:草稿可以先从下一小段开始。"
1852
+ if "请假" in lowered or "申请" in lowered:
1853
+ return f"也许「{anchor}」不是在责怪你有需求,而是在提醒你:那件申请可以先从一句普通请求开始。"
1854
+ if reality_cue:
1855
+ return f"也许「{anchor}」不是要给梦一个固定解释,而是在把你刚才说的「{reality_cue}」推到更温和的入口。"
1856
  return ""
1857
  if "email" in lowered:
1858
+ email_label = "the overdue email" if "overdue email" in lowered else "that email"
1859
  return (
1860
+ f"Maybe the {anchor} is not asking you to finish {email_label} at once. "
1861
  "It is pointing to the gentler threshold: opening it and writing one first sentence."
1862
  )
1863
  if "message" in lowered:
1864
+ if _is_relationship_message_context(answers):
1865
+ return (
1866
+ f"Maybe the {anchor} is less about answering a thread and more about the hurt of feeling unseen. "
1867
+ "The friend message can wait until you know what felt unheard."
1868
+ )
1869
  return (
1870
+ f"Maybe the {anchor} is not asking you to finish every message at once. "
1871
+ "It is pointing to the gentler threshold: naming what the message needs to carry."
1872
  )
1873
  if "assignment" in lowered or "homework" in lowered or "draft" in lowered:
1874
  return (
 
1885
  f"Maybe the {anchor} is not asking you to finish the whole application in one push. "
1886
  "It is pointing to the gentler threshold: finding the next missing item."
1887
  )
1888
+ if "leave request" in lowered or "time off" in lowered or "sick leave" in lowered:
1889
+ return (
1890
+ f"Maybe the {anchor} is not blaming you for needing care. "
1891
+ "It is pointing to the gentler threshold: drafting one plain leave request sentence."
1892
+ )
1893
  if "apolog" in lowered:
1894
  return (
1895
  f"Maybe the {anchor} is not asking you to repair everything at once. "
1896
  "It is pointing to the gentler threshold: drafting one honest sentence."
1897
  )
1898
+ if reality_cue:
1899
+ return (
1900
+ f"Maybe the {anchor} is not asking for a fixed dream meaning. "
1901
+ f"It is pointing back to the waking-life clue you named: \"{reality_cue}\"."
1902
+ )
1903
  return ""
1904
 
1905
 
 
2473
  "保持积极",
2474
  "积极心态",
2475
  "明天会更好",
2476
+ "优化效率",
2477
+ "提高效率",
2478
+ "待办清单",
2479
+ "待办事项",
2480
+ "保持专注",
2481
+ "按优先级",
2482
+ "优先级排序",
2483
  "喝一杯温水",
2484
  "一杯温水",
2485
  "温水",
 
2488
  "stay positive",
2489
  "positive mindset",
2490
  "tomorrow will be better",
2491
+ "productivity advice",
2492
+ "productivity hack",
2493
+ "be more productive",
2494
+ "to-do list",
2495
+ "todo list",
2496
+ "sort your tasks",
2497
+ "prioritize your tasks",
2498
  "warm water",
2499
+ "cold glass of water",
2500
+ "glass of water",
2501
+ "clear your mind",
2502
+ "feel more grounded",
2503
+ "make you feel more grounded",
2504
+ "calming song",
2505
+ "calming music",
2506
  ]
2507
  return any(marker in clean for marker in generic_markers)
2508
 
2509
 
2510
+ def _source_allows_draft_language(source: str) -> bool:
2511
+ return _source_has_any(
2512
+ source,
2513
+ [
2514
+ "draft",
2515
+ "草稿",
2516
+ "email",
2517
+ "e-mail",
2518
+ "邮件",
2519
+ "leave request",
2520
+ "time off",
2521
+ "sick leave",
2522
+ "application",
2523
+ "assignment",
2524
+ "homework",
2525
+ "presentation",
2526
+ "speech",
2527
+ "apolog",
2528
+ "请假",
2529
+ "申请",
2530
+ "作业",
2531
+ "演讲",
2532
+ "汇报",
2533
+ "道歉",
2534
+ ],
2535
+ )
2536
+
2537
+
2538
+ def _contains_unprovenanced_detail(text: str, intake: DreamIntake, answers: str = "", language: str = "en") -> bool:
2539
+ clean = re.sub(r"\s+", " ", (text or "").lower())
2540
+ if not clean:
2541
+ return False
2542
+ source = _provenance_text(intake, answers)
2543
+ if "dream fragment" in clean and "dream fragment" not in source:
2544
+ return True
2545
+ if "office building" in clean and not _source_has_any(source, ["office building", "office tower", "办公楼"]):
2546
+ return True
2547
+ if ("elevator" in clean or "电梯" in clean) and not _source_has_any(source, ["elevator", "电梯"]):
2548
+ return True
2549
+ if re.search(r"\bfloor\s*14\b|\b14\b|数字\s*14|14\s*层|14层", clean) and not _source_has_any(
2550
+ source,
2551
+ ["floor 14", "number 14", "14", "数字 14", "14 层", "14层"],
2552
+ ):
2553
+ return True
2554
+ email_is_allowed = _source_has_any(source, ["email", "e-mail", "邮件"])
2555
+ if re.search(r"\boverdue email\b", clean) and not _source_has_any(
2556
+ source,
2557
+ ["overdue email", "unanswered email", "late email", "迟迟没发出去的邮件"],
2558
+ ):
2559
+ return True
2560
+ email_patterns = [
2561
+ r"\bemail\b",
2562
+ r"\be-mail\b",
2563
+ r"\bemail draft\b",
2564
+ r"\bfirst sentence of the email\b",
2565
+ "邮件",
2566
+ ]
2567
+ if any(re.search(pattern, clean) if pattern.startswith("\\") else pattern in clean for pattern in email_patterns):
2568
+ if not email_is_allowed:
2569
+ return True
2570
+ if re.search(r"\bfirst sentence\b", clean) and not _source_has_any(
2571
+ source,
2572
+ ["first sentence", "first line", "第一句", "第一句话", "email", "e-mail", "邮件"],
2573
+ ):
2574
+ return True
2575
+ draft_patterns = [
2576
+ r"\bopen the draft\b",
2577
+ r"\bthe draft\b",
2578
+ r"\bdraft\b",
2579
+ r"\bdrafting\b",
2580
+ r"\bdrafted\b",
2581
+ "草稿",
2582
+ ]
2583
+ if any(re.search(pattern, clean) if pattern.startswith("\\") else pattern in clean for pattern in draft_patterns):
2584
+ if not _source_allows_draft_language(source):
2585
+ return True
2586
+ if "unsent reply" in clean and not _source_has_any(source, ["message", "contact", "friend", "前任", "消息", "朋友", "联系"]):
2587
+ return True
2588
+ return False
2589
+
2590
+
2591
+ def _requires_distress_surrounding_override(text: str) -> bool:
2592
+ lowered = (text or "").lower()
2593
+ return any(
2594
+ term in lowered
2595
+ for term in [
2596
+ "hurt myself",
2597
+ "kill myself",
2598
+ "do not want to wake up",
2599
+ "don't want to wake up",
2600
+ "hopeless",
2601
+ "can't go on",
2602
+ "cannot go on",
2603
+ "self-harm",
2604
+ "hurt someone",
2605
+ "many nights",
2606
+ "barely slept",
2607
+ "can't sleep",
2608
+ "cannot sleep",
2609
+ "cannot function",
2610
+ "panic attack",
2611
+ "想伤害自己",
2612
+ "自杀",
2613
+ "自残",
2614
+ "不想醒来",
2615
+ "不想活",
2616
+ "活不下去",
2617
+ "轻生",
2618
+ "绝望",
2619
+ "伤害别人",
2620
+ "很多天睡不着",
2621
+ "连续3晚",
2622
+ "连续 3 晚",
2623
+ "连续三晚",
2624
+ "连续很多天",
2625
+ "无法正常生活",
2626
+ "无法生活",
2627
+ ]
2628
+ )
2629
+
2630
+
2631
+ def _has_numbered_tip(text: str) -> bool:
2632
+ return bool(re.search(r"(?:^|\s)\d+[\.)]\s+", text or ""))
2633
+
2634
+
2635
  def _nonclinical_caring_note(anchors: List[str], language: str = "en") -> str:
2636
  anchor = anchors[0] if anchors else ("梦里的这个细节" if _is_zh(language) else "this dream detail")
2637
  if _is_zh(language):
 
2639
  return f"Writing this dream down is already enough; today, let {anchor} be one detail you meet gently."
2640
 
2641
 
2642
+ def _distress_interpretation(intake: DreamIntake, answers: str, anchors: List[str], language: str = "en") -> str:
2643
+ anchor = _story_anchor_phrase(intake, anchors, language, answers)
2644
+ if _is_zh(language):
2645
+ return (
2646
+ f"也许「{anchor}」不是在说明你出了什么问题,而是在提醒你:反复惊醒、很久睡不好、白天难以运转,"
2647
+ "已经值得被认真接住。先把害怕和疲惫放在第一位,不急着给梦下结论。"
2648
+ )
2649
+ return (
2650
+ f"Maybe {anchor} is not proof that something is wrong with you. "
2651
+ "The repeated frightening dream, lost sleep, and trouble functioning are signals that the fear and exhaustion deserve support before interpretation."
2652
+ )
2653
+
2654
+
2655
+ def _distress_today_tip(intake: DreamIntake, answers: str, anchors: List[str], language: str = "en") -> str:
2656
+ anchor = _story_anchor_phrase(intake, anchors, language, answers)
2657
+ if _is_zh(language):
2658
+ return f"今天先把「{anchor}」当成需要支持的信号:选一个可信任的人或专业支持入口,说清楚你已经连续睡不好、白天难以运转。"
2659
+ return (
2660
+ f"For today, treat {anchor} as a reason to get support, not as a diagnosis: choose one trusted person or professional support option and tell them the frightening dream and lost sleep are affecting your day."
2661
+ )
2662
+
2663
+
2664
+ def _distress_tiny_action(intake: DreamIntake, answers: str, anchors: List[str], language: str = "en") -> str:
2665
+ anchor = _story_anchor_phrase(intake, anchors, language, answers)
2666
+ if _is_zh(language):
2667
+ return f"在纸上写下「{anchor}」旁边的三个事实:睡了多久、白天哪里最难、可以联系谁;只写事实,不评判自己。"
2668
+ return f"Write three plain facts beside {anchor}: how long this has affected sleep, what is hardest during the day, and who you can contact; keep it factual, not self-blaming."
2669
+
2670
+
2671
  def _polish_today_tip(card: TodayTipCard, intake: DreamIntake, answers: str = "", language: str = "en") -> TodayTipCard:
2672
  polished = card.model_copy(deep=True)
2673
  answer_lines = [line.strip() for line in (answers or "").splitlines() if line.strip()]
 
2678
  intake,
2679
  answers,
2680
  )
2681
+ intake_anchors = _filter_anchors_by_source(intake_anchors, intake, answers, language)
2682
  card_anchors = _remove_placeholder_anchors(
2683
  polished.dream_anchors
2684
  if _is_zh(language)
2685
  else _dedupe_preserve_order([_english_anchor_text(anchor) for anchor in polished.dream_anchors])
2686
  )
2687
  card_anchors = _without_unsupported_melted_anchors(card_anchors, intake, answers)
2688
+ card_anchors = _filter_anchors_by_source(card_anchors, intake, answers, language)
2689
+ anchors = _dedupe_preserve_order(card_anchors + [anchor for anchor in intake_anchors if anchor not in card_anchors])
 
 
2690
  anchors = _without_unsupported_melted_anchors(anchors, intake, answers)
2691
  if not anchors:
2692
  anchors = _remove_placeholder_anchors([_primary_anchor(intake, language)])
2693
  if not anchors:
2694
+ anchors = ["梦境线索" if _is_zh(language) else "dream clues"]
2695
  polished.dream_anchors = anchors
2696
  for field in (
2697
  "dream_summary",
 
2709
  _clean_unsupported_melted_detail(_clean_placeholder_phrase(question), intake, anchors, language, answers)
2710
  for question in polished.followup_questions
2711
  ]
2712
+ if (
2713
+ not polished.dream_summary.strip()
2714
+ or _is_placeholder_anchor(polished.dream_summary)
2715
+ or not _text_uses_anchor(polished.dream_summary, anchors)
2716
+ or _looks_like_bad_summary(polished.dream_summary, intake, answers, language)
2717
+ ):
2718
  polished.dream_summary = _summary_from_intake(intake, language)
2719
  explicit_question = _extract_explicit_user_question(intake, answers, language)
2720
  if explicit_question:
 
2729
  answer_interpretation = _answer_based_interpretation(answers, _answer_bridge_anchor(anchors), language)
2730
  answer_tip = _answer_based_today_tip(answers, anchors[0], language)
2731
  answer_action = _answer_based_tiny_action(answers, intake, anchors, language)
2732
+ answer_should_shape_visible_tip = bool(
2733
+ (answer_tip or answer_action)
2734
+ and not _needs_comfort(answers, language)
2735
+ and (
2736
+ _answer_has_concrete_task_keyword(answers)
2737
+ or not _should_use_emotion_led_response(intake, answers, language)
2738
+ )
2739
+ )
2740
+ has_prophecy_frame = _has_prophecy_frame(intake.merged_text())
2741
  if answer_interpretation and answer_should_shape_visible_tip:
2742
  polished.interpretation = answer_interpretation
2743
  elif emotion_interpretation:
2744
  polished.interpretation = emotion_interpretation
2745
  elif answer_interpretation:
2746
  polished.interpretation = answer_interpretation
2747
+ elif has_prophecy_frame:
2748
  anchor = _answer_bridge_anchor(anchors)
2749
  polished.interpretation = (
2750
  f"Maybe the {anchor} is best treated as a fear-shaped image, not as evidence that something bad will happen."
 
2758
  if not _is_zh(language)
2759
  else f"目前线索很少,先把「{anchor}」当作一个可以继续补充的线索,而不是确定解读。"
2760
  )
2761
+ elif (
2762
+ not polished.interpretation.strip()
2763
+ or not _anchor_in_text(polished.interpretation, anchors)
2764
+ or _contains_unprovenanced_detail(polished.interpretation, intake, answers, language)
2765
+ ):
2766
  polished.interpretation = _fallback_interpretation(intake, language)
2767
  generic_tip_markers = [
2768
  "drink water",
 
2775
  "borrow one action",
2776
  "借一个动作",
2777
  "open the task",
2778
+ "for the tiny action",
2779
+ "cold glass of water",
2780
+ "glass of water",
2781
+ "clear your mind",
2782
+ "feel more grounded",
2783
+ "make you feel more grounded",
2784
+ "calming song",
2785
+ "calming music",
2786
+ "listen to a short",
2787
  ]
2788
  emotion_tip = _emotion_led_today_tip(intake, answers, anchors, language)
2789
  if answer_tip and answer_should_shape_visible_tip:
2790
  polished.today_tip = answer_tip
2791
  elif emotion_tip:
2792
  polished.today_tip = emotion_tip
2793
+ elif answer_tip and not has_prophecy_frame and not _should_use_emotion_led_response(intake, answers, language):
2794
  polished.today_tip = answer_tip
2795
+ elif has_prophecy_frame:
2796
  anchor = anchors[0]
2797
  polished.today_tip = (
2798
  f"For today, do not test whether the {anchor} is a sign. Name one ordinary worry it resembles, then choose one small calming step."
 
2811
  or any(marker in polished.today_tip.lower() for marker in generic_tip_markers)
2812
  or _is_placeholder_anchor(polished.today_tip)
2813
  or not _anchor_in_text(polished.today_tip, anchors)
2814
+ or _contains_unprovenanced_detail(polished.today_tip, intake, answers, language)
2815
  ):
2816
  polished.today_tip = _grounded_today_tip(intake, language)
2817
+ polished.today_tip = _one_tip_text(polished.today_tip, language)
2818
  hard_action_markers = [
2819
  "address it immediately",
2820
  "fix it immediately",
 
2841
  or _is_placeholder_anchor(polished.tiny_action)
2842
  or not _anchor_in_text(polished.tiny_action, anchors)
2843
  or any(marker in polished.tiny_action.lower() for marker in hard_action_markers)
2844
+ or _contains_unprovenanced_detail(polished.tiny_action, intake, answers, language)
2845
  ):
2846
  polished.tiny_action = _weird_little_action(intake, answers, anchors, language)
2847
  emotion_caring_note = _emotion_led_caring_note(intake, answers, language)
 
2866
  polished.caring_note = "You do not have to solve the whole dream this morning; start by noticing one detail and one small next step."
2867
  elif _is_zh(language) and "所有楼层" in polished.caring_note:
2868
  polished.caring_note = "你不需要一醒来就解释完整个梦;先照顾一个细节和一个很小的下一步就好。"
2869
+ elif _contains_unprovenanced_detail(polished.caring_note, intake, answers, language):
2870
+ polished.caring_note = _nonclinical_caring_note(anchors, language)
2871
  merged = "\n".join([intake.merged_text(), answers or ""])
2872
  has_escalation = needs_escalation(merged)
2873
+ if has_escalation and _requires_distress_surrounding_override(merged):
2874
+ polished.interpretation = _distress_interpretation(intake, answers, anchors, language)
2875
+ polished.today_tip = _distress_today_tip(intake, answers, anchors, language)
2876
+ if _contains_unprovenanced_detail(polished.tiny_action, intake, answers, language):
2877
+ polished.tiny_action = _distress_tiny_action(intake, answers, anchors, language)
2878
+ if _contains_unprovenanced_detail(polished.caring_note, intake, answers, language):
2879
+ polished.caring_note = _nonclinical_caring_note(anchors, language)
2880
  if not has_escalation:
2881
  if _has_unsupported_clinical_frame(polished.interpretation) or _has_unsupported_emotion_or_generic_wellness(
2882
  polished.interpretation, intake, answers
 
2895
  ):
2896
  polished.caring_note = _nonclinical_caring_note(anchors, language)
2897
  polished.safety_note = safety_note(language) if has_escalation else ""
2898
+ polished.today_tip = _one_tip_text(polished.today_tip, language)
2899
  if not _is_zh(language):
2900
  polished = _clean_english_today_tip_language(polished)
2901
  return polished
dream_customs/prompts.py CHANGED
@@ -54,18 +54,18 @@ Return strict JSON with:
54
  def today_tip_prompt(state: DreamQAState, language: str = "en") -> str:
55
  return f"""
56
  You are MiniCPM5-1B writing the final Dream QA result.
57
- Write a non-diagnostic interpretation draft, a waking-life Today Tip / 今日小 Tips with 1 to 3 small suggestions,
58
  and one weird little thing / 古怪的小事.
59
  First answer the user's stated question directly. If the user sounds scared, sad,
60
  overwhelmed, guilty, lonely, or asks for comfort, follow that emotion before giving any action.
61
  The interpretation must be step-by-step: use 2 to 4 short layers that move from
62
- the user's feeling, to concrete dream anchors, to the 1-3 follow-up answers, to one gentle way to care for today.
63
  Do not collapse every dream into productivity advice such as opening a task,
64
  writing a first line, or making the first step smaller.
65
  Use non-certain language such as "也许", "可以把它当作", "maybe", or "for today, try".
66
  The today_tip must be about the user's awake, real-world life, not about acting inside the dream scene.
67
- It may refer to dream anchors, but each suggestion must translate them into practical waking-life choices,
68
- ordinary constraints, and real-world consequences. Give 1 to 3 small suggestions; avoid rigid templates.
69
  The tiny_action field is the weird little thing / 古怪的小事. It must cite at least one concrete dream anchor,
70
  use real-world physics or an ordinary physical object, and create one strange, playful, eye-opening action
71
  the user can actually do in 1 to 5 minutes while awake. It should feel random and fresh, not like a stock
 
54
  def today_tip_prompt(state: DreamQAState, language: str = "en") -> str:
55
  return f"""
56
  You are MiniCPM5-1B writing the final Dream QA result.
57
+ Write a non-diagnostic interpretation draft, one waking-life Today Tip / 今日小 Tips,
58
  and one weird little thing / 古怪的小事.
59
  First answer the user's stated question directly. If the user sounds scared, sad,
60
  overwhelmed, guilty, lonely, or asks for comfort, follow that emotion before giving any action.
61
  The interpretation must be step-by-step: use 2 to 4 short layers that move from
62
+ the user's feeling, to concrete dream anchors, to the follow-up answers, to one gentle way to care for today.
63
  Do not collapse every dream into productivity advice such as opening a task,
64
  writing a first line, or making the first step smaller.
65
  Use non-certain language such as "也许", "可以把它当作", "maybe", or "for today, try".
66
  The today_tip must be about the user's awake, real-world life, not about acting inside the dream scene.
67
+ It must refer to at least one concrete dream anchor and translate it into one practical waking-life choice,
68
+ ordinary constraint, or real-world consequence. Return a single grounded tip, not a numbered list or multiple instructions.
69
  The tiny_action field is the weird little thing / 古怪的小事. It must cite at least one concrete dream anchor,
70
  use real-world physics or an ordinary physical object, and create one strange, playful, eye-opening action
71
  the user can actually do in 1 to 5 minutes while awake. It should feel random and fresh, not like a stock
dream_customs/ui/actions.py CHANGED
@@ -108,6 +108,19 @@ def _card_plain_text(card: TodayTipCard, language: str) -> str:
108
  return "\n".join(lines)
109
 
110
 
 
 
 
 
 
 
 
 
 
 
 
 
 
111
  def _render_today_pass(card: TodayTipCard, language: str) -> str:
112
  return render_today_tip_card(card, language=language)
113
 
@@ -139,7 +152,8 @@ def _view_payload(
139
  else:
140
  status = "record"
141
  copy = copy_for(language)
142
- card = None if status == "ask" else session.sealed_tip or session.draft_tip
 
143
  payload = {
144
  "status": status,
145
  "phase": session.phase,
 
108
  return "\n".join(lines)
109
 
110
 
111
+ def _sanitize_visible_card(card: TodayTipCard, language: str) -> TodayTipCard:
112
+ visible = card.model_copy(deep=True)
113
+ caring_note = (visible.caring_note or "").lower()
114
+ if "dream fragment" in caring_note or "梦境片段" in caring_note:
115
+ anchor = visible.dream_anchors[0] if visible.dream_anchors else ("梦里的这个细节" if language == "zh" else "this dream detail")
116
+ visible.caring_note = (
117
+ f"今天先把「{anchor}」当作一个被看见的线索,不急着把整个梦解释完。"
118
+ if language == "zh"
119
+ else f"You do not have to solve the whole dream today; let {anchor} be one detail you meet gently."
120
+ )
121
+ return visible
122
+
123
+
124
  def _render_today_pass(card: TodayTipCard, language: str) -> str:
125
  return render_today_tip_card(card, language=language)
126
 
 
152
  else:
153
  status = "record"
154
  copy = copy_for(language)
155
+ raw_card = None if status == "ask" else session.sealed_tip or session.draft_tip
156
+ card = _sanitize_visible_card(raw_card, language) if raw_card else None
157
  payload = {
158
  "status": status,
159
  "phase": session.phase,
dream_customs/ui/app.py CHANGED
@@ -455,14 +455,52 @@ VOICE_JS = r"""
455
  };
456
 
457
  const bindProcessingButtons = () => {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
458
  const bindButton = (selector, mode) => {
459
  document.querySelectorAll(selector).forEach((root) => {
460
  const button = root.matches("button") ? root : root.querySelector("button");
461
- if (!button || button.dataset.processingBound === "true") {
 
 
 
 
462
  return;
463
  }
464
  button.dataset.processingBound = "true";
465
- button.addEventListener("click", () => setProcessingCopy(mode));
 
 
 
466
  });
467
  };
468
 
@@ -518,9 +556,43 @@ def _notice_html(view: dict) -> str:
518
  return f"<div class='{css}'>{message}</div>" if message else ""
519
 
520
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
521
  def _question_markdown(view: dict, language: str = DEFAULT_LANGUAGE) -> str:
522
  copy = copy_for(language)
523
- question = escape(view.get("question") or "")
 
 
 
 
 
 
 
 
 
524
  anchor_items = [str(anchor).strip() for anchor in view.get("dream_anchors", [])[:3] if str(anchor).strip()]
525
  anchor_label = "梦境锚点" if language == "zh" else "Dream anchors"
526
  anchor_chips = "".join(f"<span>{escape(anchor)}</span>" for anchor in anchor_items)
@@ -533,7 +605,7 @@ def _question_markdown(view: dict, language: str = DEFAULT_LANGUAGE) -> str:
533
  else ""
534
  )
535
  optional_question = (
536
- f"<p class='dc-question-original'><span>{copy['question_speaker']}</span>{question}</p>"
537
  if question
538
  else ""
539
  )
@@ -544,6 +616,7 @@ def _question_markdown(view: dict, language: str = DEFAULT_LANGUAGE) -> str:
544
  <p>{copy['question_body']}</p>
545
  {anchor_strip}
546
  {optional_question}
 
547
  <p class="dc-question-note">{copy['question_note']}</p>
548
  </div>
549
  """.strip()
@@ -756,7 +829,7 @@ def _agent_dream_qa(dream_text: str, mood: str = "", answer: str = "", language:
756
 
757
 
758
  def _active_step_for_status(status: str) -> int:
759
- return {"record": 1, "error": 1, "ask": 2, "drafting": 2, "tip": 3}.get(status, 1)
760
 
761
 
762
  def _hero_html(language: str = DEFAULT_LANGUAGE, status: str = "record") -> str:
@@ -1069,13 +1142,6 @@ def build_demo() -> gr.Blocks:
1069
  show_copy_button=True,
1070
  elem_classes=["dc-hidden-text"],
1071
  )
1072
- with gr.Accordion(initial_copy["debug_title"], open=False, elem_classes=["dc-debug-panel"]) as debug_panel:
1073
- debug_help_html = gr.HTML(_debug_help_html(DEFAULT_LANGUAGE))
1074
- debug_json = gr.Code(
1075
- label=initial_copy["debug_state_label"],
1076
- value=json.dumps(initial.get("debug", {}), ensure_ascii=False, indent=2),
1077
- language="json",
1078
- )
1079
 
1080
  with gr.Column(elem_classes=["dc-side-panel"]):
1081
  language = gr.Radio(
@@ -1176,6 +1242,13 @@ def build_demo() -> gr.Blocks:
1176
  value=DEFAULT_ASR_LATENCY_BUDGET_MS,
1177
  precision=0,
1178
  )
 
 
 
 
 
 
 
1179
 
1180
  with gr.Group(visible=False):
1181
  agent_dream_text = gr.Textbox(label="Agent dream text")
 
455
  };
456
 
457
  const bindProcessingButtons = () => {
458
+ const setBusyButton = (button, mode) => {
459
+ const isZh = currentLanguage() === "zh";
460
+ const label = mode === "submit"
461
+ ? (isZh ? "正在整理线索..." : "Reading clues...")
462
+ : mode === "question"
463
+ ? (isZh ? "正在换角度..." : "Finding another angle...")
464
+ : (isZh ? "正在生成 Tips..." : "Writing the tip...");
465
+ if (!button.dataset.originalText) {
466
+ button.dataset.originalText = button.textContent.trim();
467
+ }
468
+ button.textContent = label;
469
+ button.classList.add("dc-is-loading");
470
+ button.setAttribute("aria-busy", "true");
471
+ button.dataset.busyStarted = String(Date.now());
472
+ };
473
+
474
+ const resetSettledButton = (button) => {
475
+ const startedAt = Number(button.dataset.busyStarted || "0");
476
+ if (
477
+ button.dataset.originalText
478
+ && button.getAttribute("aria-busy") === "true"
479
+ && startedAt
480
+ && Date.now() - startedAt > 700
481
+ ) {
482
+ button.textContent = button.dataset.originalText;
483
+ button.classList.remove("dc-is-loading");
484
+ button.removeAttribute("aria-busy");
485
+ delete button.dataset.busyStarted;
486
+ }
487
+ };
488
+
489
  const bindButton = (selector, mode) => {
490
  document.querySelectorAll(selector).forEach((root) => {
491
  const button = root.matches("button") ? root : root.querySelector("button");
492
+ if (!button) {
493
+ return;
494
+ }
495
+ resetSettledButton(button);
496
+ if (button.dataset.processingBound === "true") {
497
  return;
498
  }
499
  button.dataset.processingBound = "true";
500
+ button.addEventListener("click", () => {
501
+ setProcessingCopy(mode);
502
+ setBusyButton(button, mode);
503
+ });
504
  });
505
  };
506
 
 
556
  return f"<div class='{css}'>{message}</div>" if message else ""
557
 
558
 
559
+ def _split_question_for_display(raw_question: str) -> tuple[str, str]:
560
+ question = " ".join(str(raw_question or "").split())
561
+ if not question:
562
+ return "", ""
563
+ if "?" not in question:
564
+ return question, ""
565
+
566
+ question_end = question.rfind("?") + 1
567
+ prefix = question[:question_end]
568
+ suffix = question[question_end:].strip()
569
+ candidate = prefix
570
+ for marker in (": ", ":"):
571
+ if marker in candidate:
572
+ candidate = candidate.rsplit(marker, 1)[-1].strip()
573
+ if len(candidate) < 12:
574
+ candidate = prefix.strip()
575
+
576
+ context = question[: max(0, question.find(candidate))].strip(" ::")
577
+ if suffix:
578
+ context = f"{context} {suffix}".strip()
579
+ if context == candidate:
580
+ context = ""
581
+ return candidate, context
582
+
583
+
584
  def _question_markdown(view: dict, language: str = DEFAULT_LANGUAGE) -> str:
585
  copy = copy_for(language)
586
+ primary_question, context = _split_question_for_display(view.get("question") or "")
587
+ question = escape(primary_question)
588
+ context_html = (
589
+ "<details class='dc-question-context'>"
590
+ f"<summary>{escape(copy['question_context_label'])}</summary>"
591
+ f"<p>{escape(context)}</p>"
592
+ "</details>"
593
+ if context
594
+ else ""
595
+ )
596
  anchor_items = [str(anchor).strip() for anchor in view.get("dream_anchors", [])[:3] if str(anchor).strip()]
597
  anchor_label = "梦境锚点" if language == "zh" else "Dream anchors"
598
  anchor_chips = "".join(f"<span>{escape(anchor)}</span>" for anchor in anchor_items)
 
605
  else ""
606
  )
607
  optional_question = (
608
+ f"<div class='dc-question-original'><span>{copy['question_speaker']}</span><p>{question}</p></div>"
609
  if question
610
  else ""
611
  )
 
616
  <p>{copy['question_body']}</p>
617
  {anchor_strip}
618
  {optional_question}
619
+ {context_html}
620
  <p class="dc-question-note">{copy['question_note']}</p>
621
  </div>
622
  """.strip()
 
829
 
830
 
831
  def _active_step_for_status(status: str) -> int:
832
+ return {"record": 1, "error": 1, "ask": 3, "drafting": 3, "tip": 4}.get(status, 1)
833
 
834
 
835
  def _hero_html(language: str = DEFAULT_LANGUAGE, status: str = "record") -> str:
 
1142
  show_copy_button=True,
1143
  elem_classes=["dc-hidden-text"],
1144
  )
 
 
 
 
 
 
 
1145
 
1146
  with gr.Column(elem_classes=["dc-side-panel"]):
1147
  language = gr.Radio(
 
1242
  value=DEFAULT_ASR_LATENCY_BUDGET_MS,
1243
  precision=0,
1244
  )
1245
+ with gr.Accordion(initial_copy["debug_title"], open=False, elem_classes=["dc-debug-panel"]) as debug_panel:
1246
+ debug_help_html = gr.HTML(_debug_help_html(DEFAULT_LANGUAGE))
1247
+ debug_json = gr.Code(
1248
+ label=initial_copy["debug_state_label"],
1249
+ value=json.dumps(initial.get("debug", {}), ensure_ascii=False, indent=2),
1250
+ language="json",
1251
+ )
1252
 
1253
  with gr.Group(visible=False):
1254
  agent_dream_text = gr.Textbox(label="Agent dream text")
dream_customs/ui/copy.py CHANGED
@@ -15,7 +15,7 @@ APP_COPY = {
15
  "hero_badge": "Text · image · voice",
16
  "hero_mobile_note": "Half-awake friendly",
17
  "brand_subtitle": "Dream Customs",
18
- "steps": ["Record", "One Question", "Today Tip"],
19
  "notice_record": "Write one dream fragment, then use a demo chip if you want the 90-second judge path.",
20
  "notice_ask": "Answer this one question, or skip it and turn the existing clues into a Morning Ticket.",
21
  "notice_tip": "Your Morning Ticket is ready. Treat it as gentle reflection, not diagnosis or prophecy.",
@@ -48,12 +48,13 @@ APP_COPY = {
48
  "image_paste": "Paste from Clipboard",
49
  "demo_intro_label": "Dream slips",
50
  "demo_intro_body": "Pick one if you want the fast judge path.",
51
- "question_kicker": "Question",
52
- "question_title": "One question before the ticket",
53
  "question_body": "Answer in one or two lines, or skip and let the existing anchors carry the ticket.",
54
  "question_speaker": "Morning Question Desk",
55
  "question_note": "This step makes the tip more specific. It is not diagnosis.",
56
  "question_anchor_label": "Sticky details already on the desk",
 
57
  "answer_label": "Your answer",
58
  "answer_placeholder": "Write one answer, or leave it blank and skip.",
59
  "answer_button": "Send answer",
@@ -90,7 +91,7 @@ APP_COPY = {
90
  "hero_badge": "文字 · 图片 · 语音",
91
  "hero_mobile_note": "适合半醒时使用",
92
  "brand_subtitle": "Dream Customs",
93
- "steps": ["记录", "一个追问", "清晨小票"],
94
  "notice_record": "写一个梦境片段;想走 90 秒演示路径,也可以直接点示例 chip。",
95
  "notice_ask": "回答这一个追问,或跳过,用已有梦境线索生成清晨小票。",
96
  "notice_tip": "清晨小票已生成。把它当作温和参考,不是诊断或预言。",
@@ -123,12 +124,13 @@ APP_COPY = {
123
  "image_paste": "从剪贴板粘贴",
124
  "demo_intro_label": "梦境纸片",
125
  "demo_intro_body": "想快速演示,可以先抽一张。",
126
- "question_kicker": "追问",
127
- "question_title": "出票前,只问这一个问",
128
  "question_body": "回答一两句就好;也可以跳过,让已有锚点直接生成小票。",
129
  "question_speaker": "清晨问讯室",
130
  "question_note": "这个步骤是为了让最终建议更贴近你的梦,不是问诊。",
131
  "question_anchor_label": "桌面上已经留下的细节",
 
132
  "answer_label": "你的回答",
133
  "answer_placeholder": "写一句回答,或留空后选择跳过。",
134
  "answer_button": "发送回答",
 
15
  "hero_badge": "Text · image · voice",
16
  "hero_mobile_note": "Half-awake friendly",
17
  "brand_subtitle": "Dream Customs",
18
+ "steps": ["Record", "Clarify", "Answer", "Tip"],
19
  "notice_record": "Write one dream fragment, then use a demo chip if you want the 90-second judge path.",
20
  "notice_ask": "Answer this one question, or skip it and turn the existing clues into a Morning Ticket.",
21
  "notice_tip": "Your Morning Ticket is ready. Treat it as gentle reflection, not diagnosis or prophecy.",
 
48
  "image_paste": "Paste from Clipboard",
49
  "demo_intro_label": "Dream slips",
50
  "demo_intro_body": "Pick one if you want the fast judge path.",
51
+ "question_kicker": "Step 3 of 4 · Answer or skip",
52
+ "question_title": "One useful question",
53
  "question_body": "Answer in one or two lines, or skip and let the existing anchors carry the ticket.",
54
  "question_speaker": "Morning Question Desk",
55
  "question_note": "This step makes the tip more specific. It is not diagnosis.",
56
  "question_anchor_label": "Sticky details already on the desk",
57
+ "question_context_label": "Why this question",
58
  "answer_label": "Your answer",
59
  "answer_placeholder": "Write one answer, or leave it blank and skip.",
60
  "answer_button": "Send answer",
 
91
  "hero_badge": "文字 · 图片 · 语音",
92
  "hero_mobile_note": "适合半醒时使用",
93
  "brand_subtitle": "Dream Customs",
94
+ "steps": ["记录", "明确问题", "回答追问", "Tips"],
95
  "notice_record": "写一个梦境片段;想走 90 秒演示路径,也可以直接点示例 chip。",
96
  "notice_ask": "回答这一个追问,或跳过,用已有梦境线索生成清晨小票。",
97
  "notice_tip": "清晨小票已生成。把它当作温和参考,不是诊断或预言。",
 
124
  "image_paste": "从剪贴板粘贴",
125
  "demo_intro_label": "梦境纸片",
126
  "demo_intro_body": "想快速演示,可以先抽一张。",
127
+ "question_kicker": "第 3 / 4 步 · 回答或跳过",
128
+ "question_title": "一个有用的追问",
129
  "question_body": "回答一两句就好;也可以跳过,让已有锚点直接生成小票。",
130
  "question_speaker": "清晨问讯室",
131
  "question_note": "这个步骤是为了让最终建议更贴近你的梦,不是问诊。",
132
  "question_anchor_label": "桌面上已经留下的细节",
133
+ "question_context_label": "为什么问这个",
134
  "answer_label": "你的回答",
135
  "answer_placeholder": "写一句回答,或留空后选择跳过。",
136
  "answer_button": "发送回答",
dream_customs/ui/styles.py CHANGED
@@ -1209,6 +1209,86 @@ a.built-with[href*="gradio.app"] {
1209
  }
1210
  """
1211
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1212
  CSS += """
1213
  /* Reference-space polish: soft PawMap spacing, case-file step clarity, paper-note tactility. */
1214
  :root {
 
1209
  }
1210
  """
1211
 
1212
+ CSS += """
1213
+ .dc-question-original p {
1214
+ font-size: clamp(1.16rem, 2.4vw, 1.45rem);
1215
+ font-weight: 720;
1216
+ line-height: 1.45;
1217
+ margin: 4px 0 0 !important;
1218
+ }
1219
+
1220
+ .dc-question-context {
1221
+ background: rgba(255, 253, 248, 0.72);
1222
+ border: 1px solid rgba(215, 168, 66, 0.22);
1223
+ border-radius: 10px;
1224
+ margin-top: 12px;
1225
+ padding: 10px 12px;
1226
+ }
1227
+
1228
+ .dc-question-context summary {
1229
+ color: var(--dqa-sage-deep);
1230
+ cursor: pointer;
1231
+ font-size: 0.84rem;
1232
+ font-weight: 760;
1233
+ }
1234
+
1235
+ .dc-question-context p {
1236
+ color: var(--dqa-muted);
1237
+ font-size: 0.92rem;
1238
+ margin-top: 8px;
1239
+ }
1240
+
1241
+ .dc-stage button.dc-is-loading {
1242
+ cursor: progress !important;
1243
+ opacity: 0.82;
1244
+ pointer-events: none;
1245
+ }
1246
+
1247
+ .dc-stage button.dc-is-loading::after {
1248
+ animation: dqa-loading-dot 900ms ease-in-out infinite;
1249
+ content: "";
1250
+ display: inline-block;
1251
+ margin-left: 8px;
1252
+ }
1253
+
1254
+ @keyframes dqa-loading-dot {
1255
+ 0%,
1256
+ 100% {
1257
+ content: "";
1258
+ }
1259
+ 33% {
1260
+ content: ".";
1261
+ }
1262
+ 66% {
1263
+ content: "..";
1264
+ }
1265
+ }
1266
+
1267
+ @media (max-width: 640px) {
1268
+ .dc-hero {
1269
+ min-height: 248px !important;
1270
+ padding-bottom: 18px !important;
1271
+ }
1272
+
1273
+ .dc-stepper {
1274
+ margin-top: 18px !important;
1275
+ }
1276
+
1277
+ .dc-stepper span {
1278
+ flex-basis: 62px !important;
1279
+ font-size: 0.82rem !important;
1280
+ }
1281
+
1282
+ .dc-stepper strong {
1283
+ height: 38px !important;
1284
+ width: 38px !important;
1285
+ }
1286
+
1287
+ .dc-side-stamp {
1288
+ display: none;
1289
+ }
1290
+ }
1291
+ """
1292
  CSS += """
1293
  /* Reference-space polish: soft PawMap spacing, case-file step clarity, paper-note tactility. */
1294
  :root {
tests/test_pipeline.py CHANGED
@@ -1,3 +1,4 @@
 
1
  from datetime import date
2
 
3
  import pytest
@@ -24,6 +25,21 @@ from dream_customs.prompts import pact_prompt, today_tip_prompt
24
  from dream_customs.schema import PactCard, TodayTipCard, VisionWitness
25
 
26
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
27
  def test_dated_permit_id_uses_runtime_date_and_preserves_serial():
28
  assert dated_permit_id("DREAM2024-001", today=date(2026, 6, 6)) == "DREAM20260606-001"
29
  assert dated_permit_id("DC-DEMO-014", today=date(2026, 6, 6)) == "DREAM20260606-014"
@@ -421,7 +437,7 @@ def test_chinese_email_tip_becomes_real_world_suggestions_and_weird_action():
421
 
422
  assert "办公楼」当成允许慢慢开始的按钮" not in card.today_tip
423
  assert "现实" in card.today_tip
424
- assert "1." in card.today_tip and "2." in card.today_tip
425
  assert "邮件" in card.today_tip
426
  assert "第一句" in card.today_tip or "第一句话" in card.today_tip
427
  assert "便利贴" in card.tiny_action or "纸" in card.tiny_action
@@ -498,6 +514,281 @@ def test_generate_today_tip_keeps_english_emotional_question_from_becoming_task_
498
  assert "first line" not in combined
499
 
500
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
501
  def test_generate_today_tip_adds_safety_note_for_repeated_insomnia_without_self_harm():
502
  intake = build_intake(
503
  dream_text="我昨晚反复梦见自己在一条漆黑的走廊里走,醒来后心跳很快,已经连续3晚都睡不好。",
@@ -652,7 +943,7 @@ def test_today_tip_prompt_requires_waking_life_suggestions_and_weird_action():
652
  prompt = today_tip_prompt(state, language="zh")
653
 
654
  assert "waking-life" in prompt
655
- assert "1 to 3" in prompt
656
  assert "weird little thing" in prompt
657
  assert "real-world physics" in prompt
658
  assert "古怪的小事" in prompt
 
1
+ import re
2
  from datetime import date
3
 
4
  import pytest
 
25
  from dream_customs.schema import PactCard, TodayTipCard, VisionWitness
26
 
27
 
28
+ def _today_tip_public_text(card: TodayTipCard) -> str:
29
+ return "\n".join(
30
+ [
31
+ card.dream_summary,
32
+ card.main_question,
33
+ ",".join(card.dream_anchors),
34
+ card.interpretation,
35
+ card.today_tip,
36
+ card.tiny_action,
37
+ card.caring_note,
38
+ card.safety_note,
39
+ ]
40
+ )
41
+
42
+
43
  def test_dated_permit_id_uses_runtime_date_and_preserves_serial():
44
  assert dated_permit_id("DREAM2024-001", today=date(2026, 6, 6)) == "DREAM20260606-001"
45
  assert dated_permit_id("DC-DEMO-014", today=date(2026, 6, 6)) == "DREAM20260606-014"
 
437
 
438
  assert "办公楼」当成允许慢慢开始的按钮" not in card.today_tip
439
  assert "现实" in card.today_tip
440
+ assert "1." not in card.today_tip and "2." not in card.today_tip
441
  assert "邮件" in card.today_tip
442
  assert "第一句" in card.today_tip or "第一句话" in card.today_tip
443
  assert "便利贴" in card.tiny_action or "纸" in card.tiny_action
 
514
  assert "first line" not in combined
515
 
516
 
517
+ def test_today_tip_rejects_hollow_productivity_advice_even_when_it_mentions_anchor():
518
+ class HollowProductivityClient:
519
+ def generate_today_tip(self, prompt):
520
+ return TodayTipCard(
521
+ dream_summary="你梦见旧图书馆里有一把打不开的钥匙。",
522
+ main_question="这个梦在提醒我什么?",
523
+ dream_anchors=["旧图书馆", "钥匙"],
524
+ followup_questions=["旧图书馆和钥匙里,哪个细节最抓住你?"],
525
+ user_answers=[],
526
+ interpretation="「旧图书馆」说明你需要优化效率,整理待办清单。",
527
+ today_tip="今天把「旧图书馆」当作提高效率的提醒,整理待办清单并保持专注。",
528
+ tiny_action="写三项待办事项,按优先级排序。",
529
+ caring_note="保持专注就会更好。",
530
+ safety_note="",
531
+ )
532
+
533
+ intake = build_intake(
534
+ dream_text="我梦见自己在旧图书馆里拿着一把旧钥匙,却找不到它能打开哪扇门。",
535
+ mood="困惑",
536
+ )
537
+
538
+ card = generate_today_tip(intake, "用户选择跳过这个追问。", HollowProductivityClient(), language="zh")
539
+ combined = "\n".join([card.interpretation, card.today_tip, card.tiny_action, card.caring_note])
540
+
541
+ assert "旧图书馆" in combined or "钥匙" in combined
542
+ assert "效率" not in combined
543
+ assert "待办" not in combined
544
+ assert "专注" not in combined
545
+ assert "优先级" not in combined
546
+ assert "保持积极" not in combined
547
+
548
+
549
+ def test_today_tip_absorbs_non_template_followup_answer_as_real_life_cue():
550
+ class AnchorOnlyClient:
551
+ def generate_today_tip(self, prompt):
552
+ return TodayTipCard(
553
+ dream_summary="You dreamed of a locked green room and a warm key.",
554
+ main_question="What is the locked room asking me to notice?",
555
+ dream_anchors=["locked green room", "warm key"],
556
+ followup_questions=["What does the locked room feel closest to in waking life?"],
557
+ user_answers=[],
558
+ interpretation="Maybe the locked green room is a private threshold.",
559
+ today_tip="For today, treat the locked green room as a small threshold and take one careful step.",
560
+ tiny_action="Draw the warm key and move it one inch.",
561
+ caring_note="One small threshold is enough.",
562
+ safety_note="",
563
+ )
564
+
565
+ intake = build_intake(
566
+ dream_text="I found a locked green room behind my kitchen. The key was warm but would not turn.",
567
+ mood="uneasy",
568
+ main_question="Why does it feel like I cannot ask for what I need?",
569
+ )
570
+ answer = "In real life it is probably the leave request I keep avoiding because I feel guilty."
571
+
572
+ card = generate_today_tip(intake, answer, AnchorOnlyClient(), language="en")
573
+ combined = "\n".join([card.main_question, card.interpretation, card.today_tip, card.tiny_action, card.caring_note]).lower()
574
+
575
+ assert "locked green room" in combined or "warm key" in combined
576
+ assert "leave request" in combined
577
+ assert "guilty" in combined or "guilt" in combined
578
+ assert "one careful step" not in card.today_tip.lower()
579
+
580
+
581
+ def test_today_tip_filters_absent_office_email_and_repairs_question_summary_and_numbered_tip():
582
+ class PollutedOfficeEmailClient:
583
+ def generate_today_tip(self, prompt):
584
+ return TodayTipCard(
585
+ dream_summary="I dreamed I was rushing to catch an elevator, but my phone was dead a",
586
+ main_question="My question: does this mean I have been too anxious lately",
587
+ dream_anchors=["office building", "elevator", "email"],
588
+ followup_questions=["Which detail felt closest?"],
589
+ user_answers=[],
590
+ interpretation=(
591
+ "Maybe the office building is asking you to open one thread and draft one first sentence."
592
+ ),
593
+ today_tip=(
594
+ "1. Translate office building into a real-world communication question: what does the "
595
+ "overdue email need? 2. Open the draft and write the first sentence of the email. "
596
+ "3. Save it."
597
+ ),
598
+ tiny_action="Draw the office building and write only the first sentence of the email.",
599
+ caring_note="The office building can wait.",
600
+ safety_note="",
601
+ )
602
+
603
+ intake = build_intake(
604
+ dream_text=(
605
+ "I dreamed I was rushing to catch an elevator, but my phone was dead and I was late for something "
606
+ "important. I kept pressing the elevator button and worrying I had already missed my chance. "
607
+ "My question: does this mean I have been too anxious lately?"
608
+ ),
609
+ mood="Anxious",
610
+ )
611
+ answer = "The phone with no battery feels closest. I keep avoiding a work message, so I feel behind before I even start."
612
+
613
+ card = generate_today_tip(intake, answer, PollutedOfficeEmailClient(), language="en")
614
+ combined = _today_tip_public_text(card).lower()
615
+
616
+ assert "office building" not in combined
617
+ assert "overdue email" not in combined
618
+ assert "first sentence of the email" not in combined
619
+ assert "elevator" in combined or "phone" in combined
620
+ assert "work message" in combined or "message" in combined
621
+ assert "my question:" not in card.main_question.lower()
622
+ assert card.main_question == "Does this mean I have been too anxious lately?"
623
+ assert not card.dream_summary.rstrip(".").endswith("phone was dead a")
624
+ assert not re.search(r"\b[123]\.", card.today_tip)
625
+
626
+
627
+ def test_today_tip_keeps_friend_misunderstanding_emotional_instead_of_email_draft_template():
628
+ class PollutedFriendClient:
629
+ def generate_today_tip(self, prompt):
630
+ return TodayTipCard(
631
+ dream_summary="I dreamed my close friend misunderstood me in front of everyone. I ke",
632
+ main_question="My question: why did I wake up feeling so wronged and hurt",
633
+ dream_anchors=["room"],
634
+ followup_questions=["What felt closest?"],
635
+ user_answers=[],
636
+ interpretation=(
637
+ "Maybe the room is not asking you to answer every message at once; it points to opening "
638
+ "one thread and drafting one first sentence."
639
+ ),
640
+ today_tip=(
641
+ "1. Translate room into a real-world communication question: what does the overdue email "
642
+ "need? 2. Open the draft and write one sentence."
643
+ ),
644
+ tiny_action="Open the message thread and draft the first sentence of the email.",
645
+ caring_note="One thread at a time.",
646
+ safety_note="",
647
+ )
648
+
649
+ intake = build_intake(
650
+ dream_text=(
651
+ "I dreamed my close friend misunderstood me in front of everyone. I kept explaining, but nobody "
652
+ "listened, and the room got louder every time I tried. My question: why did I wake up feeling so "
653
+ "wronged and hurt?"
654
+ ),
655
+ mood="Hurt and confused",
656
+ )
657
+ answer = "It felt like I was invisible, not angry. Yesterday I worried that a friend read my message in the worst possible way."
658
+
659
+ card = generate_today_tip(intake, answer, PollutedFriendClient(), language="en")
660
+ combined = _today_tip_public_text(card).lower()
661
+
662
+ assert "overdue email" not in combined
663
+ assert "first sentence of the email" not in combined
664
+ assert "draft the first sentence" not in combined
665
+ assert "wronged" in combined or "hurt" in combined or "invisible" in combined or "not listened" in combined
666
+ assert "friend" in combined or "message" in combined
667
+ assert "my question:" not in card.main_question.lower()
668
+ assert card.main_question == "Why did I wake up feeling so wronged and hurt?"
669
+ assert not re.search(r"\b[123]\.", card.today_tip)
670
+
671
+
672
+ def test_today_tip_skip_path_uses_original_anchors_without_default_email_or_broken_caring_note():
673
+ class PollutedSkipClient:
674
+ def generate_today_tip(self, prompt):
675
+ return TodayTipCard(
676
+ dream_summary="I dreamed I was late, my phone battery died, and the elevator never a",
677
+ main_question="What might the elevator mean?",
678
+ dream_anchors=["office building", "elevator", "email"],
679
+ followup_questions=["Which detail felt closest?"],
680
+ user_answers=[],
681
+ interpretation="Maybe the office building, elevator, and email point to a draft you need to open.",
682
+ today_tip=(
683
+ "1. Use elevator, my phone, and phone. 2. Choose one waking-life doorway action today: "
684
+ "open the draft."
685
+ ),
686
+ tiny_action="Draw an elevator button and write only the first sentence of the email.",
687
+ caring_note="dream fragment , dream fragment floor.",
688
+ safety_note="",
689
+ )
690
+
691
+ intake = build_intake(
692
+ dream_text=(
693
+ "I dreamed I was late, my phone battery died, and the elevator never arrived. I want a quick tip, "
694
+ "but I want to skip the follow-up."
695
+ ),
696
+ mood="Anxious",
697
+ )
698
+
699
+ card = generate_today_tip(intake, "", PollutedSkipClient(), language="en")
700
+ combined = _today_tip_public_text(card).lower()
701
+
702
+ assert "office building" not in combined
703
+ assert "email" not in combined
704
+ assert "open the draft" not in combined
705
+ assert "first sentence" not in combined
706
+ assert "dream fragment" not in combined
707
+ assert "elevator" in combined
708
+ assert "phone" in combined or "late" in combined
709
+ assert not re.search(r"\b[123]\.", card.today_tip)
710
+
711
+
712
+ def test_today_tip_skip_path_repairs_generic_calming_and_tiny_action_inside_tip():
713
+ class GenericCalmingClient:
714
+ def generate_today_tip(self, prompt):
715
+ return TodayTipCard(
716
+ dream_summary="I dreamed I was late, my phone battery died, and the elevator never arrived.",
717
+ main_question="What might the elevator be asking me to notice today?",
718
+ dream_anchors=["elevator", "phone"],
719
+ followup_questions=[],
720
+ user_answers=[],
721
+ interpretation="Maybe the elevator and phone point to the pressure of waiting.",
722
+ today_tip=(
723
+ "Connect your phone's battery to your anxious morning. If the elevator voice persists, "
724
+ "listen to a short, calming song. For the tiny action, hold a cold glass of water for one minute "
725
+ "so you feel more grounded."
726
+ ),
727
+ tiny_action="Name one elevator button you can press today.",
728
+ caring_note="One small detail is enough.",
729
+ safety_note="",
730
+ )
731
+
732
+ intake = build_intake(
733
+ dream_text="I dreamed I was late, my phone battery died, and the elevator never arrived.",
734
+ mood="Anxious",
735
+ )
736
+
737
+ card = generate_today_tip(intake, "", GenericCalmingClient(), language="en")
738
+ combined = _today_tip_public_text(card).lower()
739
+
740
+ assert "elevator" in card.today_tip.lower() or "phone" in card.today_tip.lower()
741
+ assert "calming song" not in combined
742
+ assert "cold glass of water" not in combined
743
+ assert "for the tiny action" not in card.today_tip.lower()
744
+ assert not re.search(r"\b[123]\.", card.today_tip)
745
+
746
+
747
+ def test_today_tip_distress_repairs_polluted_surrounding_fields_while_keeping_safety_note():
748
+ class PollutedDistressClient:
749
+ def generate_today_tip(self, prompt):
750
+ return TodayTipCard(
751
+ dream_summary="I keep having the same frightening dream and I have barely slept for",
752
+ main_question="What is wrong with me?",
753
+ dream_anchors=["office building", "elevator", "email"],
754
+ followup_questions=["What help would feel possible?"],
755
+ user_answers=[],
756
+ interpretation=(
757
+ "Second, the office building, elevator, and email may be the dream's concrete shape."
758
+ ),
759
+ today_tip=(
760
+ "1. Treat office building, elevator, and email as an unfinished contact. "
761
+ "2. Write one private unsent reply."
762
+ ),
763
+ tiny_action="Write one word from office building, elevator, and email.",
764
+ caring_note="Start with the email contact.",
765
+ safety_note="Seek support.",
766
+ )
767
+
768
+ intake = build_intake(
769
+ dream_text=(
770
+ "I keep having the same frightening dream and I have barely slept for many nights. I feel like I "
771
+ "cannot function during the day and I am scared something is wrong with me."
772
+ ),
773
+ mood="Scared and exhausted",
774
+ )
775
+ answer = "I do not feel unsafe right now, but I cannot keep going like this without help."
776
+
777
+ card = generate_today_tip(intake, answer, PollutedDistressClient(), language="en")
778
+ combined = _today_tip_public_text(card).lower()
779
+
780
+ assert card.safety_note
781
+ assert "office building" not in combined
782
+ assert "elevator" not in combined
783
+ assert "email" not in combined
784
+ assert "unsent reply" not in combined
785
+ assert "dream clues" not in combined
786
+ assert any(anchor in card.dream_anchors for anchor in ["frightening dream", "lost sleep"])
787
+ assert "frightening" in combined or "sleep" in combined or "scared" in combined or "help" in combined
788
+ assert "trusted" in card.safety_note.lower() or "professional" in card.safety_note.lower()
789
+ assert not re.search(r"\b[123]\.", card.today_tip)
790
+
791
+
792
  def test_generate_today_tip_adds_safety_note_for_repeated_insomnia_without_self_harm():
793
  intake = build_intake(
794
  dream_text="我昨晚反复梦见自己在一条漆黑的走廊里走,醒来后心跳很快,已经连续3晚都睡不好。",
 
943
  prompt = today_tip_prompt(state, language="zh")
944
 
945
  assert "waking-life" in prompt
946
+ assert "single grounded tip" in prompt
947
  assert "weird little thing" in prompt
948
  assert "real-world physics" in prompt
949
  assert "古怪的小事" in prompt
tests/test_ui_actions.py CHANGED
@@ -10,7 +10,9 @@ from dream_customs.ui.actions import (
10
  skip_to_card_action,
11
  submit_dream_action,
12
  )
 
13
  import dream_customs.ui.app as ui_app
 
14
  from dream_customs.ui.app import _reset
15
  from dream_customs.ui.copy import DEFAULT_MOOD, PROCESSING_NOTE
16
  from dream_customs.ui.styles import CSS
@@ -29,10 +31,12 @@ def test_runtime_settings_are_collapsed_for_public_flow():
29
  flow_column_source = source.split('with gr.Column(elem_classes=["dc-flow-column"]):', 1)[1].split(
30
  'with gr.Column(elem_classes=["dc-side-panel"]):', 1
31
  )[0]
 
32
 
33
  assert 'gr.Accordion("Advanced", open=False' in source
34
  assert 'with gr.Accordion(initial_copy["debug_title"], open=False' in source
35
- assert 'with gr.Accordion(initial_copy["debug_title"], open=False' in flow_column_source
 
36
 
37
 
38
  def test_voice_input_records_audio_to_modal_asr_without_browser_speech_recognition():
@@ -109,11 +113,12 @@ def test_hero_stepper_tracks_app_status():
109
 
110
  assert '<span class="dc-step is-complete"><strong>1</strong>' in ask_html
111
  assert '<i class="dc-stepper-line is-complete" aria-hidden="true"></i>' in ask_html
112
- assert '<span class="dc-step is-active" aria-current="step"><strong>2</strong>' in ask_html
113
- assert '<span class="dc-step is-complete"><strong>2</strong>' in tip_html
114
- assert '<span class="dc-step is-active" aria-current="step"><strong>3</strong>' in tip_html
115
- assert tip_html.count("dc-stepper-line is-complete") == 2
116
- assert "One Question" in tip_html
 
117
 
118
 
119
  def test_hero_subtitle_explains_app_instead_of_legacy_name():
@@ -216,7 +221,10 @@ def test_processing_note_is_story_copy_not_backend_jargon():
216
  def test_question_stage_shows_anchor_chips_before_the_question():
217
  html = ui_app._question_markdown(
218
  {
219
- "question": "When floor 14 shows up beside the overdue email, what would make opening it feel smaller?",
 
 
 
220
  "dream_anchors": ["elevator", "floor 14", "melting button"],
221
  },
222
  "en",
@@ -226,8 +234,11 @@ def test_question_stage_shows_anchor_chips_before_the_question():
226
  assert "dc-question-anchor-label" in html
227
  assert "Sticky details already on the desk" in html
228
  assert html.index("elevator") < html.index("Morning Question Desk")
 
229
  assert "floor 14" in html
230
  assert "melting button" in html
 
 
231
 
232
 
233
  def test_long_running_buttons_show_processing_feedback():
@@ -241,6 +252,10 @@ def test_long_running_buttons_show_processing_feedback():
241
  assert "bindProcessingButtons" in ui_app.VOICE_JS
242
  assert "setProcessingCopy" in ui_app.VOICE_JS
243
  assert "processingBound" in ui_app.VOICE_JS
 
 
 
 
244
  assert "Asking from another angle" in ui_app.VOICE_JS
245
  assert "one more grounded question" in ui_app.VOICE_JS
246
  assert "js=SUBMIT_PROCESSING_JS" not in source
@@ -250,6 +265,7 @@ def test_long_running_buttons_show_processing_feedback():
250
  assert "Folding the follow-up answer" in app_source
251
  assert ".dc-notice.is-processing" in CSS
252
  assert ".dc-processing-note.is-active" in CSS
 
253
  assert "endpoint" not in ui_app.VOICE_JS.lower()
254
  assert "debug" not in ui_app.VOICE_JS.lower()
255
 
@@ -331,6 +347,28 @@ def test_mobile_mvp_submit_then_skip_generates_today_tip():
331
  assert "Today Tip" in view["card_html"]
332
 
333
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
334
  def test_mobile_mvp_empty_submit_stays_on_record_with_clear_error():
335
  state, view_json = submit_dream_action(
336
  dream_text="",
 
10
  skip_to_card_action,
11
  submit_dream_action,
12
  )
13
+ from dream_customs.schema import CustomsSession, TodayTipCard
14
  import dream_customs.ui.app as ui_app
15
+ import dream_customs.ui.actions as ui_actions
16
  from dream_customs.ui.app import _reset
17
  from dream_customs.ui.copy import DEFAULT_MOOD, PROCESSING_NOTE
18
  from dream_customs.ui.styles import CSS
 
31
  flow_column_source = source.split('with gr.Column(elem_classes=["dc-flow-column"]):', 1)[1].split(
32
  'with gr.Column(elem_classes=["dc-side-panel"]):', 1
33
  )[0]
34
+ side_panel_source = source.split('with gr.Column(elem_classes=["dc-side-panel"]):', 1)[1]
35
 
36
  assert 'gr.Accordion("Advanced", open=False' in source
37
  assert 'with gr.Accordion(initial_copy["debug_title"], open=False' in source
38
+ assert 'with gr.Accordion(initial_copy["debug_title"], open=False' not in flow_column_source
39
+ assert 'with gr.Accordion(initial_copy["debug_title"], open=False' in side_panel_source
40
 
41
 
42
  def test_voice_input_records_audio_to_modal_asr_without_browser_speech_recognition():
 
113
 
114
  assert '<span class="dc-step is-complete"><strong>1</strong>' in ask_html
115
  assert '<i class="dc-stepper-line is-complete" aria-hidden="true"></i>' in ask_html
116
+ assert '<span class="dc-step is-complete"><strong>2</strong>' in ask_html
117
+ assert '<span class="dc-step is-active" aria-current="step"><strong>3</strong>' in ask_html
118
+ assert '<span class="dc-step is-complete"><strong>3</strong>' in tip_html
119
+ assert '<span class="dc-step is-active" aria-current="step"><strong>4</strong>' in tip_html
120
+ assert tip_html.count("dc-stepper-line is-complete") == 3
121
+ assert "Answer" in tip_html
122
 
123
 
124
  def test_hero_subtitle_explains_app_instead_of_legacy_name():
 
221
  def test_question_stage_shows_anchor_chips_before_the_question():
222
  html = ui_app._question_markdown(
223
  {
224
+ "question": (
225
+ "What I hear first is the concrete image. One question that will shape the final tip: "
226
+ "When floor 14 shows up beside the overdue email, what would make opening it feel smaller?"
227
+ ),
228
  "dream_anchors": ["elevator", "floor 14", "melting button"],
229
  },
230
  "en",
 
234
  assert "dc-question-anchor-label" in html
235
  assert "Sticky details already on the desk" in html
236
  assert html.index("elevator") < html.index("Morning Question Desk")
237
+ assert "Why this question" in html
238
  assert "floor 14" in html
239
  assert "melting button" in html
240
+ assert "What I hear first" in html
241
+ assert html.index("When floor 14") < html.index("Why this question")
242
 
243
 
244
  def test_long_running_buttons_show_processing_feedback():
 
252
  assert "bindProcessingButtons" in ui_app.VOICE_JS
253
  assert "setProcessingCopy" in ui_app.VOICE_JS
254
  assert "processingBound" in ui_app.VOICE_JS
255
+ assert "setBusyButton" in ui_app.VOICE_JS
256
+ assert "resetSettledButton" in ui_app.VOICE_JS
257
+ assert "busyStarted" in ui_app.VOICE_JS
258
+ assert "dc-is-loading" in ui_app.VOICE_JS
259
  assert "Asking from another angle" in ui_app.VOICE_JS
260
  assert "one more grounded question" in ui_app.VOICE_JS
261
  assert "js=SUBMIT_PROCESSING_JS" not in source
 
265
  assert "Folding the follow-up answer" in app_source
266
  assert ".dc-notice.is-processing" in CSS
267
  assert ".dc-processing-note.is-active" in CSS
268
+ assert ".dc-stage button.dc-is-loading" in CSS
269
  assert "endpoint" not in ui_app.VOICE_JS.lower()
270
  assert "debug" not in ui_app.VOICE_JS.lower()
271
 
 
347
  assert "Today Tip" in view["card_html"]
348
 
349
 
350
+ def test_visible_tip_payload_repairs_broken_caring_note_placeholder():
351
+ session = CustomsSession(language="en", phase="tip")
352
+ session.sealed_tip = TodayTipCard(
353
+ dream_summary="I dreamed I was late, my phone battery died, and the elevator never arrived.",
354
+ main_question="What might the elevator be asking me to notice today?",
355
+ dream_anchors=["elevator", "phone"],
356
+ followup_questions=[],
357
+ user_answers=["The user chose to skip this question."],
358
+ interpretation="Maybe the elevator and phone show a small stuck point.",
359
+ today_tip="Use the elevator and phone to name one doorway action for today.",
360
+ tiny_action="Draw one elevator button.",
361
+ caring_note="dream fragment , dream fragment floor。",
362
+ safety_note="",
363
+ )
364
+
365
+ view = ui_actions._view_payload(session, "demo", "demo", language="en")
366
+ combined = "\n".join([view["card_text"], view["card_html"], view["caring_note"]]).lower()
367
+
368
+ assert "dream fragment" not in combined
369
+ assert "elevator" in view["caring_note"].lower()
370
+
371
+
372
  def test_mobile_mvp_empty_submit_stays_on_record_with_clear_error():
373
  state, view_json = submit_dream_action(
374
  dream_text="",