Make Dream QA responses emotion led

#31
dream_customs/pipeline.py CHANGED
@@ -108,6 +108,12 @@ _ZH_ANCHOR_MARKERS = [
108
  "掉进海",
109
  "海里",
110
  "海",
 
 
 
 
 
 
111
  "小孩找不到家",
112
  "小孩",
113
  "找不到家",
@@ -210,6 +216,12 @@ _ZH_TO_EN_PHRASES = {
210
  "掉进海": "falling into the sea",
211
  "海里": "sea",
212
  "海": "sea",
 
 
 
 
 
 
213
  "小孩找不到家": "child unable to find home",
214
  "小孩": "child",
215
  "找不到家": "unable to find home",
@@ -543,9 +555,241 @@ def _summary_from_intake(intake: DreamIntake, language: str = "en") -> str:
543
  return f"你梦见{clean}"
544
 
545
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
546
  def _main_question_from_intake(intake: DreamIntake, language: str = "en") -> str:
547
  if intake.main_question.strip():
548
- return intake.main_question.strip()
 
 
 
549
  task = _task_focus(intake.merged_text(), language)
550
  if task:
551
  if not _is_zh(language):
@@ -1089,14 +1333,20 @@ def _polish_today_tip(card: TodayTipCard, intake: DreamIntake, answers: str = ""
1089
  setattr(polished, field, _clean_placeholder_phrase(getattr(polished, field)))
1090
  if not polished.dream_summary.strip() or _is_placeholder_anchor(polished.dream_summary) or not _text_uses_anchor(polished.dream_summary, anchors):
1091
  polished.dream_summary = _summary_from_intake(intake, language)
1092
- if (
 
 
 
1093
  not polished.main_question.strip()
1094
  or _is_placeholder_anchor(polished.main_question)
1095
  or not _text_uses_anchor(polished.main_question, anchors)
1096
  ):
1097
  polished.main_question = _main_question_from_intake(intake, language)
 
1098
  answer_interpretation = _answer_based_interpretation(answers, _answer_bridge_anchor(anchors), language)
1099
- if answer_interpretation:
 
 
1100
  polished.interpretation = answer_interpretation
1101
  elif _has_prophecy_frame(intake.merged_text()):
1102
  anchor = _answer_bridge_anchor(anchors)
@@ -1115,8 +1365,11 @@ def _polish_today_tip(card: TodayTipCard, intake: DreamIntake, answers: str = ""
1115
  elif not polished.interpretation.strip() or not _anchor_in_text(polished.interpretation, anchors):
1116
  polished.interpretation = _fallback_interpretation(intake, language)
1117
  generic_tip_markers = ["drink water", "hydrate", "多休息", "保持积极", "take a walk"]
 
1118
  answer_tip = _answer_based_today_tip(answers, anchors[0], language)
1119
- if answer_tip:
 
 
1120
  polished.today_tip = answer_tip
1121
  elif _has_prophecy_frame(intake.merged_text()):
1122
  anchor = anchors[0]
@@ -1140,8 +1393,11 @@ def _polish_today_tip(card: TodayTipCard, intake: DreamIntake, answers: str = ""
1140
  ):
1141
  polished.today_tip = _grounded_today_tip(intake, language)
1142
  hard_action_markers = ["address it immediately", "fix it immediately", "solve it immediately"]
 
1143
  answer_action = _answer_based_tiny_action(answers, language)
1144
- if answer_action:
 
 
1145
  polished.tiny_action = answer_action
1146
  elif _has_prophecy_frame(intake.merged_text()):
1147
  polished.tiny_action = (
@@ -1165,7 +1421,10 @@ def _polish_today_tip(card: TodayTipCard, intake: DreamIntake, answers: str = ""
1165
  polished.tiny_action = f"用 5 分钟写下:今天和「{anchors[0]}」有关的第一小步是什么?"
1166
  else:
1167
  polished.tiny_action = f"Spend five minutes writing the first small step connected to the {anchors[0]}."
1168
- if not polished.caring_note.strip():
 
 
 
1169
  polished.caring_note = (
1170
  "你不需要一醒来就解决整个梦,先把一个细节照亮就很好。"
1171
  if _is_zh(language)
 
108
  "掉进海",
109
  "海里",
110
  "海",
111
+ "前任发消息",
112
+ "发消息又消失",
113
+ "发消息",
114
+ "消息",
115
+ "前任",
116
+ "消失",
117
  "小孩找不到家",
118
  "小孩",
119
  "找不到家",
 
216
  "掉进海": "falling into the sea",
217
  "海里": "sea",
218
  "海": "sea",
219
+ "前任发消息": "former partner sending a message",
220
+ "发消息又消失": "message that disappeared",
221
+ "发消息": "sending a message",
222
+ "消息": "message",
223
+ "前任": "former partner",
224
+ "消失": "disappearing",
225
  "小孩找不到家": "child unable to find home",
226
  "小孩": "child",
227
  "找不到家": "unable to find home",
 
555
  return f"你梦见{clean}"
556
 
557
 
558
+ def _user_supplied_text(intake: DreamIntake, answers: str = "", include_mood: bool = False) -> str:
559
+ parts = [
560
+ intake.dream_text,
561
+ intake.voice_transcript,
562
+ intake.main_question,
563
+ intake.uncertainty,
564
+ intake.user_context,
565
+ answers or "",
566
+ ]
567
+ if include_mood:
568
+ parts.append(intake.mood)
569
+ return "\n".join(part.strip() for part in parts if part and part.strip())
570
+
571
+
572
+ def _question_sentence_candidates(text: str) -> List[str]:
573
+ pieces = re.split(r"[。!?!?\n\r]+|(?<=\.)\s+|[,,;;]", text or "")
574
+ return [piece.strip(" ::「」\"'()[]{}") for piece in pieces if piece.strip()]
575
+
576
+
577
+ def _clean_user_question(text: str, language: str = "en") -> str:
578
+ clean = re.sub(r"\s+", " ", (text or "").strip(" ::「」\"'()[]{}"))
579
+ if not clean:
580
+ return ""
581
+ if _is_zh(language):
582
+ clean = re.sub(r"^(我)?(醒来后)?(最)?(想知道|想问|在想|担心|害怕)[::,,\s]*", "", clean)
583
+ clean = re.sub(r"^(只)?想知道[::,,\s]*", "", clean)
584
+ clean = clean.strip(" ::「」\"'()[]{}")
585
+ return clean if clean.endswith(("?", "?")) else f"{clean}?"
586
+ clean = re.sub(
587
+ 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*",
588
+ lambda match: (match.group(4) or "").strip() + " ",
589
+ clean,
590
+ flags=re.IGNORECASE,
591
+ ).strip()
592
+ clean = clean[0].upper() + clean[1:] if clean else ""
593
+ if clean and not clean.endswith("?") and re.match(r"^(why|what|how|does|do|did|is|am|are|can|could|should|would)\b", clean, re.IGNORECASE):
594
+ clean += "?"
595
+ return clean
596
+
597
+
598
+ def _extract_explicit_user_question(intake: DreamIntake, answers: str = "", language: str = "en") -> str:
599
+ if intake.main_question.strip():
600
+ return _clean_user_question(intake.main_question, language)
601
+ text = _user_supplied_text(intake, answers)
602
+ if _is_zh(language):
603
+ markers = (
604
+ "为什么",
605
+ "是不是",
606
+ "会不会",
607
+ "怎么办",
608
+ "我该",
609
+ "能不能",
610
+ "代表",
611
+ "说明",
612
+ "预兆",
613
+ "征兆",
614
+ "想知道",
615
+ "想问",
616
+ "撑不住",
617
+ "走出来",
618
+ "这么难受",
619
+ )
620
+ else:
621
+ markers = (
622
+ "why",
623
+ "what should",
624
+ "how should",
625
+ "does this mean",
626
+ "do i",
627
+ "am i",
628
+ "is this",
629
+ "could this",
630
+ "can this",
631
+ "should i",
632
+ "i wonder",
633
+ "want to know",
634
+ "not coping",
635
+ "cope",
636
+ "a sign",
637
+ "omen",
638
+ )
639
+ for sentence in _question_sentence_candidates(text):
640
+ lowered = sentence.lower()
641
+ if _is_skip_answer(sentence, language):
642
+ continue
643
+ if any(marker in lowered for marker in markers):
644
+ return _clean_user_question(sentence, language)
645
+ return ""
646
+
647
+
648
+ def _emotion_labels_from_text(text: str, language: str = "en") -> List[str]:
649
+ lowered = (text or "").lower()
650
+ if _is_zh(language):
651
+ groups = [
652
+ ("害怕", ["害怕", "吓", "恐惧", "心慌", "心跳很快", "惊醒"]),
653
+ ("压力", ["压力", "撑不住", "扛不住", "崩溃", "压垮", "焦虑", "太累", "疲惫"]),
654
+ ("难过", ["难过", "难受", "伤心", "失落", "想哭", "委屈", "孤独"]),
655
+ ("自责", ["自责", "内疚", "愧疚", "后悔", "责怪自己"]),
656
+ ("需要安慰", ["安慰", "被安慰", "抱抱", "关心", "陪陪", "鸡汤"]),
657
+ ]
658
+ else:
659
+ groups = [
660
+ ("fear", ["scared", "afraid", "terrified", "panic", "panicked", "frightened"]),
661
+ ("pressure", ["overwhelmed", "not coping", "can't cope", "cannot cope", "burned out", "too much", "anxious"]),
662
+ ("sadness", ["sad", "grief", "hurt", "lonely", "heartbroken", "upset"]),
663
+ ("guilt", ["guilty", "ashamed", "blame myself", "regret"]),
664
+ ("comfort", ["comfort", "reassurance", "not productivity", "not advice", "not a pep talk"]),
665
+ ]
666
+ labels: List[str] = []
667
+ for label, terms in groups:
668
+ if any(term in lowered for term in terms):
669
+ labels.append(label)
670
+ return _dedupe_preserve_order(labels)
671
+
672
+
673
+ def _emotion_phrase(labels: List[str], language: str = "en") -> str:
674
+ if not labels:
675
+ return "这个感受" if _is_zh(language) else "this feeling"
676
+ if _is_zh(language):
677
+ return "、".join(labels)
678
+ if len(labels) == 1:
679
+ return labels[0]
680
+ return ", ".join(labels[:-1]) + f", and {labels[-1]}"
681
+
682
+
683
+ def _needs_comfort(text: str, language: str = "en") -> bool:
684
+ lowered = (text or "").lower()
685
+ if _is_zh(language):
686
+ return any(term in lowered for term in ["安慰", "被安慰", "抱抱", "关心", "别催", "不是鸡汤"])
687
+ return any(term in lowered for term in ["comfort", "reassurance", "not productivity", "not a pep talk", "not advice"])
688
+
689
+
690
+ def _should_use_emotion_led_response(intake: DreamIntake, answers: str, language: str = "en") -> bool:
691
+ user_text = _user_supplied_text(intake, answers)
692
+ explicit_question = _extract_explicit_user_question(intake, answers, language)
693
+ labels = _emotion_labels_from_text(user_text, language)
694
+ if _is_low_context_intake(intake) and _is_skip_answer(answers, language) and not explicit_question and not _needs_comfort(user_text, language):
695
+ return False
696
+ return bool(explicit_question or labels or _needs_comfort(user_text, language))
697
+
698
+
699
+ def _direct_question_reassurance(question: str, labels: List[str], language: str = "en") -> str:
700
+ lowered = (question or "").lower()
701
+ if _is_zh(language):
702
+ if any(term in lowered for term in ["撑不住", "扛不住", "崩溃"]):
703
+ return "它不等于你一定撑不住了;更像是在提醒你,最近的压力已经值得被认真照顾。"
704
+ if "走出来" in lowered:
705
+ return "它不急着证明你有没有走出来;更像是在说明这段难受还需要一点被看见的时间。"
706
+ if any(term in lowered for term in ["预兆", "征兆", "坏事", "会不会发生"]):
707
+ return "它不适合被当作预兆;我们先把它当作醒来后仍在身体里的担心来照顾。"
708
+ if any(term in lowered for term in ["我是不是", "是不是我"]):
709
+ return "它不是给你贴标签的证据;它更像是在把一个需要被接住的感受放到你面前。"
710
+ if "为什么" in lowered or "这么难受" in lowered:
711
+ return "这么难受不是你反应过度;梦可能只是把还没放下的感受放大给你看。"
712
+ emotion = _emotion_phrase(labels, language)
713
+ return f"你提到的{emotion}不是小事,它值得先被接住,而不是立刻被解释掉。"
714
+ if any(term in lowered for term in ["not coping", "cope", "coping", "falling apart"]):
715
+ return "This does not prove you are failing to cope; it may be showing that your pressure deserves care before answers."
716
+ if "sign" in lowered or "omen" in lowered or "bad will happen" in lowered:
717
+ return "This is safer to treat as a fear to care for, not as evidence that something bad will happen."
718
+ if "am i" in lowered or "does this mean i" in lowered:
719
+ return "This dream is not evidence for a label about you; it is one feeling asking to be met gently."
720
+ if "why" in lowered:
721
+ return "The ache makes sense; the dream may be enlarging a feeling that has not had enough room yet."
722
+ emotion = _emotion_phrase(labels, language)
723
+ return f"The {emotion} you named deserves to be met first, not explained away too quickly."
724
+
725
+
726
+ def _emotion_led_interpretation(intake: DreamIntake, answers: str, anchors: List[str], language: str = "en") -> str:
727
+ if not _should_use_emotion_led_response(intake, answers, language):
728
+ return ""
729
+ question = _extract_explicit_user_question(intake, answers, language)
730
+ labels = _emotion_labels_from_text(_user_supplied_text(intake, answers), language)
731
+ emotion = _emotion_phrase(labels, language)
732
+ anchor = _answer_bridge_anchor(anchors)
733
+ direct = _direct_question_reassurance(question, labels, language)
734
+ if _is_zh(language):
735
+ opener = f"你问的是「{question}」。" if question else ""
736
+ return (
737
+ f"{opener}{direct} 第一层,先承认醒来后的{emotion}是真实的,不需要被责怪。"
738
+ f"第二层,梦里的「{anchor}」也许把这种感受变成了一个可以看的画面。"
739
+ "第三层,今天不急着找唯一答案,先给自己一个能站稳的小支点。"
740
+ )
741
+ opener = f"You asked, \"{question}\" " if question else ""
742
+ return (
743
+ f"{opener}{direct} First, let the {emotion} be real without blaming yourself. "
744
+ f"Second, the {_anchor_with_article(anchor)} may be the dream's concrete shape for that feeling. "
745
+ "Third, for today the goal is not to solve the whole dream, but to give yourself one steadier place to stand."
746
+ )
747
+
748
+
749
+ def _emotion_led_today_tip(intake: DreamIntake, answers: str, anchors: List[str], language: str = "en") -> str:
750
+ if not _should_use_emotion_led_response(intake, answers, language):
751
+ return ""
752
+ if _has_prophecy_frame(_user_supplied_text(intake, answers)):
753
+ return ""
754
+ labels = _emotion_labels_from_text(_user_supplied_text(intake, answers), language)
755
+ emotion = _emotion_phrase(labels, language)
756
+ anchor = anchors[0]
757
+ if _is_zh(language):
758
+ return (
759
+ f"今天先把「{anchor}」当作{emotion}的形状,而不是结论:写一句“我现在被什么淹到/刺痛”,"
760
+ "再写一句“我此刻可以抓住的一个小安定”。"
761
+ )
762
+ return (
763
+ f"For today, treat the {anchor} as the shape of {emotion}, not a verdict: write one line naming "
764
+ "what feels heavy, then one line naming a small steady thing you can hold now."
765
+ )
766
+
767
+
768
+ def _emotion_led_tiny_action(intake: DreamIntake, answers: str, anchors: List[str], language: str = "en") -> str:
769
+ if not _should_use_emotion_led_response(intake, answers, language):
770
+ return ""
771
+ anchor = anchors[0]
772
+ if _is_zh(language):
773
+ return f"用 5 分钟写两行:1.「{anchor}」让我最难受的是…… 2. 现在我能给自己的一点安定是……"
774
+ return f"Spend five minutes writing two lines: 1. The {anchor} hurts most because... 2. One steady thing I can offer myself now is..."
775
+
776
+
777
+ def _emotion_led_caring_note(intake: DreamIntake, answers: str, language: str = "en") -> str:
778
+ if not _should_use_emotion_led_response(intake, answers, language):
779
+ return ""
780
+ labels = _emotion_labels_from_text(_user_supplied_text(intake, answers), language)
781
+ emotion = _emotion_phrase(labels, language)
782
+ if _is_zh(language):
783
+ return f"你不是太脆弱,也不是需要被催着立刻想通;这份{emotion}可以先被轻轻接住。"
784
+ return f"You are not weak for feeling {emotion}; you do not have to turn it into a lesson before you are comforted."
785
+
786
+
787
  def _main_question_from_intake(intake: DreamIntake, language: str = "en") -> str:
788
  if intake.main_question.strip():
789
+ return _clean_user_question(intake.main_question, language)
790
+ explicit_question = _extract_explicit_user_question(intake, "", language)
791
+ if explicit_question:
792
+ return explicit_question
793
  task = _task_focus(intake.merged_text(), language)
794
  if task:
795
  if not _is_zh(language):
 
1333
  setattr(polished, field, _clean_placeholder_phrase(getattr(polished, field)))
1334
  if not polished.dream_summary.strip() or _is_placeholder_anchor(polished.dream_summary) or not _text_uses_anchor(polished.dream_summary, anchors):
1335
  polished.dream_summary = _summary_from_intake(intake, language)
1336
+ explicit_question = _extract_explicit_user_question(intake, answers, language)
1337
+ if explicit_question:
1338
+ polished.main_question = explicit_question
1339
+ elif (
1340
  not polished.main_question.strip()
1341
  or _is_placeholder_anchor(polished.main_question)
1342
  or not _text_uses_anchor(polished.main_question, anchors)
1343
  ):
1344
  polished.main_question = _main_question_from_intake(intake, language)
1345
+ emotion_interpretation = _emotion_led_interpretation(intake, answers, anchors, language)
1346
  answer_interpretation = _answer_based_interpretation(answers, _answer_bridge_anchor(anchors), language)
1347
+ if emotion_interpretation:
1348
+ polished.interpretation = emotion_interpretation
1349
+ elif answer_interpretation:
1350
  polished.interpretation = answer_interpretation
1351
  elif _has_prophecy_frame(intake.merged_text()):
1352
  anchor = _answer_bridge_anchor(anchors)
 
1365
  elif not polished.interpretation.strip() or not _anchor_in_text(polished.interpretation, anchors):
1366
  polished.interpretation = _fallback_interpretation(intake, language)
1367
  generic_tip_markers = ["drink water", "hydrate", "多休息", "保持积极", "take a walk"]
1368
+ emotion_tip = _emotion_led_today_tip(intake, answers, anchors, language)
1369
  answer_tip = _answer_based_today_tip(answers, anchors[0], language)
1370
+ if emotion_tip:
1371
+ polished.today_tip = emotion_tip
1372
+ elif answer_tip:
1373
  polished.today_tip = answer_tip
1374
  elif _has_prophecy_frame(intake.merged_text()):
1375
  anchor = anchors[0]
 
1393
  ):
1394
  polished.today_tip = _grounded_today_tip(intake, language)
1395
  hard_action_markers = ["address it immediately", "fix it immediately", "solve it immediately"]
1396
+ emotion_action = _emotion_led_tiny_action(intake, answers, anchors, language)
1397
  answer_action = _answer_based_tiny_action(answers, language)
1398
+ if emotion_action:
1399
+ polished.tiny_action = emotion_action
1400
+ elif answer_action:
1401
  polished.tiny_action = answer_action
1402
  elif _has_prophecy_frame(intake.merged_text()):
1403
  polished.tiny_action = (
 
1421
  polished.tiny_action = f"用 5 分钟写下:今天和「{anchors[0]}」有关的第一小步是什么?"
1422
  else:
1423
  polished.tiny_action = f"Spend five minutes writing the first small step connected to the {anchors[0]}."
1424
+ emotion_caring_note = _emotion_led_caring_note(intake, answers, language)
1425
+ if emotion_caring_note:
1426
+ polished.caring_note = emotion_caring_note
1427
+ elif not polished.caring_note.strip():
1428
  polished.caring_note = (
1429
  "你不需要一醒来就解决整个梦,先把一个细节照亮就很好。"
1430
  if _is_zh(language)
dream_customs/prompts.py CHANGED
@@ -51,12 +51,19 @@ def today_tip_prompt(state: DreamQAState, language: str = "en") -> str:
51
  return f"""
52
  You are MiniCPM5-1B writing the final Dream QA result.
53
  Write a non-diagnostic interpretation draft and exactly one primary Today Tip / 今日小 Tips.
 
 
 
 
 
 
54
  Use non-certain language such as "也许", "可以把它当作", "maybe", or "for today, try".
55
  The today_tip must cite at least one concrete dream anchor.
56
  Avoid prophecy, frightening certainty, medical advice, therapy framing, and generic wellness filler.
57
- Keep the whole result short, warm, and specific to the user's answer.
58
- The tiny_action must be a 5-minute first step, not a command to solve the whole problem.
59
  Avoid demanding phrases such as "immediately", "must", "fix it", or "solve it".
 
60
  Use safety_note only for self-harm, harm to others, severe distress, severe insomnia, panic, or inability to function.
61
  {_language_instruction(language)}
62
 
 
51
  return f"""
52
  You are MiniCPM5-1B writing the final Dream QA result.
53
  Write a non-diagnostic interpretation draft and exactly one primary Today Tip / 今日小 Tips.
54
+ First answer the user's stated question directly. If the user sounds scared, sad,
55
+ overwhelmed, guilty, lonely, or asks for comfort, follow that emotion before giving any action.
56
+ The interpretation must be step-by-step: use 2 to 4 short layers that move from
57
+ the user's feeling, to concrete dream anchors, to one gentle way to care for today.
58
+ Do not collapse every dream into productivity advice such as opening a task,
59
+ writing a first line, or making the first step smaller.
60
  Use non-certain language such as "也许", "可以把它当作", "maybe", or "for today, try".
61
  The today_tip must cite at least one concrete dream anchor.
62
  Avoid prophecy, frightening certainty, medical advice, therapy framing, and generic wellness filler.
63
+ Keep the whole result short, warm, emotionally responsive, and specific to the user's answer.
64
+ The tiny_action must be a 5-minute self-check or small care step, not a command to solve the whole problem.
65
  Avoid demanding phrases such as "immediately", "must", "fix it", or "solve it".
66
+ If the user asks for comfort, caring_note should be warm, specific, and validating.
67
  Use safety_note only for self-harm, harm to others, severe distress, severe insomnia, panic, or inability to function.
68
  {_language_instruction(language)}
69
 
tests/test_pipeline.py CHANGED
@@ -178,6 +178,73 @@ def test_generate_today_tip_keeps_answer_history_and_removes_placeholder_text():
178
  assert "does not indicate any real-life concerns" not in combined
179
 
180
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
181
  def test_generate_today_tip_adds_safety_note_for_repeated_insomnia_without_self_harm():
182
  intake = build_intake(
183
  dream_text="我昨晚反复梦见自己在一条漆黑的走廊里走,醒来后心跳很快,已经连续3晚都睡不好。",
 
178
  assert "does not indicate any real-life concerns" not in combined
179
 
180
 
181
+ def test_generate_today_tip_follows_user_question_and_comfort_need_in_chinese():
182
+ intake = build_intake(
183
+ dream_text="我梦到自己掉进海里,醒来很害怕。我想知道这是不是说明我快撑不住了?",
184
+ mood="害怕",
185
+ )
186
+
187
+ card = generate_today_tip(intake, "我最近工作压力很大,真的很想被安慰一下。", FakeTextClient(), language="zh")
188
+ combined = "\n".join(
189
+ [
190
+ card.main_question,
191
+ card.interpretation,
192
+ card.today_tip,
193
+ card.tiny_action,
194
+ card.caring_note,
195
+ ]
196
+ )
197
+
198
+ assert "撑不住" in card.main_question
199
+ assert "海" in combined
200
+ assert "压力" in combined or "害怕" in combined
201
+ assert "第一层" in card.interpretation and "第二层" in card.interpretation
202
+ assert "太脆弱" in card.caring_note
203
+ assert "电梯" not in combined
204
+ assert "只打开那件事" not in combined
205
+
206
+
207
+ def test_generate_today_tip_answers_sad_relationship_question_without_productivity_template():
208
+ intake = build_intake(
209
+ dream_text="我梦到前任发消息又消失了,我醒来很难过,想知道是不是我还没走出来。",
210
+ mood="难过",
211
+ )
212
+
213
+ card = generate_today_tip(intake, "我不想要鸡汤,只想知道为什么这么难受。", FakeTextClient(), language="zh")
214
+ combined = "\n".join([card.main_question, card.interpretation, card.today_tip, card.caring_note])
215
+
216
+ assert "为什么这么难受" in card.main_question or "没走出来" in card.main_question
217
+ assert "难受" in combined or "难过" in combined
218
+ assert "消息" in combined
219
+ assert "第一句话" not in combined
220
+ assert "打开任务" not in combined
221
+
222
+
223
+ def test_generate_today_tip_keeps_english_emotional_question_from_becoming_task_advice():
224
+ intake = build_intake(
225
+ dream_text="I dreamed I was drowning in dark water and woke scared. Does this mean I am not coping?",
226
+ mood="scared",
227
+ )
228
+
229
+ card = generate_today_tip(intake, "I need comfort, not productivity advice.", FakeTextClient(), language="en")
230
+ combined = "\n".join(
231
+ [
232
+ card.main_question,
233
+ card.interpretation,
234
+ card.today_tip,
235
+ card.tiny_action,
236
+ card.caring_note,
237
+ ]
238
+ ).lower()
239
+
240
+ assert "not coping" in card.main_question.lower()
241
+ assert "water" in combined
242
+ assert "first" in card.interpretation.lower() and "second" in card.interpretation.lower()
243
+ assert "not weak" in combined or "comforted" in combined
244
+ assert "open the task" not in combined
245
+ assert "first line" not in combined
246
+
247
+
248
  def test_generate_today_tip_adds_safety_note_for_repeated_insomnia_without_self_harm():
249
  intake = build_intake(
250
  dream_text="我昨晚反复梦见自己在一条漆黑的走廊里走,醒来后心跳很快,已经连续3晚都睡不好。",