Make Dream QA responses story-led

#35
by ADJCJH - opened
dream_customs/pipeline.py CHANGED
@@ -19,6 +19,8 @@ from dream_customs.render import render_pact_card, render_today_tip_card
19
  from dream_customs.safety import needs_escalation, safety_note
20
  from dream_customs.schema import CustomsSession, DreamIntake, DreamQAState, EvidenceItem, PactCard, TimelineEvent, TodayTipCard
21
 
 
 
22
 
23
  def _normalize_language(language: str = "en") -> str:
24
  return "zh" if language == "zh" else "en"
@@ -108,6 +110,12 @@ _ZH_ANCHOR_MARKERS = [
108
  "掉进海",
109
  "海里",
110
  "海",
 
 
 
 
 
 
111
  "小孩找不到家",
112
  "小孩",
113
  "找不到家",
@@ -210,6 +218,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",
@@ -304,6 +318,44 @@ _ZH_TO_EN_PHRASES = {
304
  "梦境": "dream",
305
  }
306
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
307
 
308
  def _dedupe_preserve_order(items: List[str]) -> List[str]:
309
  seen = set()
@@ -462,10 +514,23 @@ def _english_anchor_text(text: str) -> str:
462
  return clean
463
 
464
 
 
 
 
 
 
 
 
 
 
 
 
 
 
465
  def _anchors_for_language(intake: DreamIntake, language: str = "en") -> List[str]:
466
  anchors = _extract_dream_anchors(intake)
467
  if _is_zh(language):
468
- return anchors
469
  localized = [_english_anchor_text(anchor) for anchor in anchors]
470
  return _dedupe_preserve_order([anchor for anchor in localized if anchor])
471
 
@@ -514,6 +579,103 @@ def _secondary_anchor(intake: DreamIntake, language: str = "en") -> str:
514
  return anchors[1] if len(anchors) > 1 else _primary_anchor(intake, language)
515
 
516
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
517
  def _anchor_with_article(anchor: str) -> str:
518
  clean = (anchor or "").strip()
519
  if clean.lower().startswith(("the ", "a ", "an ")):
@@ -543,9 +705,314 @@ 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):
@@ -576,12 +1043,31 @@ def _fallback_interpretation(intake: DreamIntake, language: str = "en") -> str:
576
 
577
 
578
  def _grounded_today_tip(intake: DreamIntake, language: str = "en") -> str:
579
- primary = _primary_anchor(intake, language)
 
 
 
580
  if not _is_zh(language):
 
 
 
 
 
 
 
 
581
  return (
582
  f"For today, borrow one action from {_anchor_with_article(primary)}: open the task, write only the first line, "
583
  "and let that be enough for now."
584
  )
 
 
 
 
 
 
 
 
585
  return f"今天先从「{primary}」借一个动作:只做最小的第一步,不急着把整件事完成。"
586
 
587
 
@@ -963,6 +1449,143 @@ def _grounded_followup_question(intake: DreamIntake, language: str = "en") -> st
963
  return f"如果「{primary}」能递给你一个更小的第一步,今天那一步会是什么?"
964
 
965
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
966
  def _polish_card_for_daily_use(card: PactCard, intake: DreamIntake, answers: str) -> PactCard:
967
  polished = card.model_copy(deep=True)
968
  merged = "\n".join([intake.merged_text(), answers or ""])
@@ -1089,14 +1712,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 +1744,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 +1772,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 = (
@@ -1161,11 +1796,34 @@ def _polish_today_tip(card: TodayTipCard, intake: DreamIntake, answers: str = ""
1161
  or not _anchor_in_text(polished.tiny_action, anchors)
1162
  or any(marker in polished.tiny_action.lower() for marker in hard_action_markers)
1163
  ):
 
 
1164
  if _is_zh(language):
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)
@@ -1509,6 +2167,29 @@ def ask_questions(session: CustomsSession, text_client, force_another: bool = Fa
1509
  )
1510
  return next_session
1511
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1512
  prompt = (
1513
  followup_question_prompt(next_session.intake, next_session.question_history, next_session.answer_history, language)
1514
  if force_another
@@ -1540,7 +2221,23 @@ def ask_questions(session: CustomsSession, text_client, force_another: bool = Fa
1540
  deduped_questions = [_grounded_followup_question(next_session.intake, language)]
1541
  fresh_questions = deduped_questions
1542
 
1543
- next_session.question_history.extend(fresh_questions[:3])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1544
  next_session.phase = "ask"
1545
  next_session.qa_state = build_qa_state(
1546
  next_session.intake,
@@ -1552,10 +2249,12 @@ def ask_questions(session: CustomsSession, text_client, force_another: bool = Fa
1552
  next_session.events.append(
1553
  _event(
1554
  "assistant",
1555
- ("梦境助手追问" if len(fresh_questions) == 1 else "梦境助手追问清单")
1556
- if _is_zh(language)
1557
- else ("Dream QA question" if len(fresh_questions) == 1 else "Dream QA questions"),
1558
- "\n".join(fresh_questions[:3]),
 
 
1559
  meta=str(negotiation.get("visitor_name", "")),
1560
  status="question",
1561
  )
 
19
  from dream_customs.safety import needs_escalation, safety_note
20
  from dream_customs.schema import CustomsSession, DreamIntake, DreamQAState, EvidenceItem, PactCard, TimelineEvent, TodayTipCard
21
 
22
+ MAX_DREAM_DECOMPOSITION_QUESTIONS = 3
23
+
24
 
25
  def _normalize_language(language: str = "en") -> str:
26
  return "zh" if language == "zh" else "en"
 
110
  "掉进海",
111
  "海里",
112
  "海",
113
+ "前任发消息",
114
+ "发消息又消失",
115
+ "发消息",
116
+ "消息",
117
+ "前任",
118
+ "消失",
119
  "小孩找不到家",
120
  "小孩",
121
  "找不到家",
 
218
  "掉进海": "falling into the sea",
219
  "海里": "sea",
220
  "海": "sea",
221
+ "前任发消息": "former partner sending a message",
222
+ "发消息又消失": "message that disappeared",
223
+ "发消息": "sending a message",
224
+ "消息": "message",
225
+ "前任": "former partner",
226
+ "消失": "disappearing",
227
  "小孩找不到家": "child unable to find home",
228
  "小孩": "child",
229
  "找不到家": "unable to find home",
 
318
  "梦境": "dream",
319
  }
320
 
321
+ _EN_TO_ZH_PHRASES = {
322
+ "a simple sketch of a person standing on wavy": "海浪上的小人",
323
+ "simple sketch of a person standing on wavy": "海浪上的小人",
324
+ "person standing on wavy": "海浪上的小人",
325
+ "wavy lines representing water": "海浪",
326
+ "wavy lines": "海浪",
327
+ "wavy": "海浪",
328
+ "a dreamlike representation of a sea under night": "夜晚的海",
329
+ "dreamlike representation of a sea": "夜晚的海",
330
+ "sea under night": "夜晚的海",
331
+ "dark sea dream": "漆黑的海",
332
+ "dark sea": "漆黑的海",
333
+ "wavy water": "海浪",
334
+ "waves": "海浪",
335
+ "wave": "海浪",
336
+ "sea": "海",
337
+ "crescent moon": "月牙",
338
+ "moon": "月亮",
339
+ "stick figure": "小人",
340
+ "melted elevator buttons": "融化的电梯按钮",
341
+ "melted elevator button": "融化的电梯按钮",
342
+ "elevator buttons": "电梯按钮",
343
+ "elevator button": "电梯按钮",
344
+ "elevator doors": "电梯门",
345
+ "elevator": "电梯",
346
+ "button": "按钮",
347
+ "floor 14": "数字 14",
348
+ "number 14": "数字 14",
349
+ "lost child at subway station": "地铁站里迷路的小孩",
350
+ "lost child at subway": "地铁里迷路的小孩",
351
+ "subway station": "地铁站",
352
+ "subway": "地铁",
353
+ "child figure": "小孩",
354
+ "child": "小孩",
355
+ "home": "回家的方向",
356
+ "arrow": "箭头",
357
+ }
358
+
359
 
360
  def _dedupe_preserve_order(items: List[str]) -> List[str]:
361
  seen = set()
 
514
  return clean
515
 
516
 
517
+ def _zh_anchor_text(text: str) -> str:
518
+ clean = re.sub(r"\s+", " ", (text or "").strip(" .,:;!?\"'()[]{}"))
519
+ if not clean:
520
+ return ""
521
+ lowered = clean.lower()
522
+ for source, target in sorted(_EN_TO_ZH_PHRASES.items(), key=lambda item: len(item[0]), reverse=True):
523
+ if source in lowered:
524
+ return target
525
+ if re.search(r"[A-Za-z]", clean) and not re.search(r"[\u4e00-\u9fff]", clean):
526
+ return ""
527
+ return clean
528
+
529
+
530
  def _anchors_for_language(intake: DreamIntake, language: str = "en") -> List[str]:
531
  anchors = _extract_dream_anchors(intake)
532
  if _is_zh(language):
533
+ return _dedupe_preserve_order([anchor for anchor in (_zh_anchor_text(anchor) for anchor in anchors) if anchor])
534
  localized = [_english_anchor_text(anchor) for anchor in anchors]
535
  return _dedupe_preserve_order([anchor for anchor in localized if anchor])
536
 
 
579
  return anchors[1] if len(anchors) > 1 else _primary_anchor(intake, language)
580
 
581
 
582
+ def _story_text(intake: DreamIntake, answers: str = "") -> str:
583
+ return "\n".join(
584
+ part
585
+ for part in [
586
+ intake.dream_text,
587
+ intake.voice_transcript,
588
+ " ".join(intake.visual_clues),
589
+ intake.mood,
590
+ intake.main_question,
591
+ intake.uncertainty,
592
+ intake.user_context,
593
+ answers or "",
594
+ ]
595
+ if part and part.strip()
596
+ ).lower()
597
+
598
+
599
+ def _contains_any(text: str, terms: List[str]) -> bool:
600
+ return any(term in text for term in terms)
601
+
602
+
603
+ def _dream_theme(intake: DreamIntake, answers: str = "") -> str:
604
+ text = _story_text(intake, answers)
605
+ if _contains_any(text, ["小孩", "child", "找不到家", "lost", "home", "回家", "地铁", "subway"]):
606
+ return "lost_home"
607
+ if _contains_any(text, ["海", "海浪", "水", "月牙", "moon", "sea", "wave", "water", "dark sea"]):
608
+ return "dark_water"
609
+ if _contains_any(text, ["电梯", "按钮", "14", "elevator", "button", "floor"]):
610
+ return "stuck_elevator"
611
+ if _contains_any(text, ["图书馆", "楼梯", "便签", "call home", "library", "staircase", "sticky note"]):
612
+ return "library_signal"
613
+ if _contains_any(text, ["前任", "消息", "消失", "former partner", "ex", "message", "disappear"]):
614
+ return "message_loss"
615
+ if _contains_any(text, ["追", "被追", "chase", "chased", "running away"]):
616
+ return "chased"
617
+ if _contains_any(text, ["考试", "作业", "教室", "老师", "exam", "assignment", "classroom", "teacher", "homework"]):
618
+ return "school_pressure"
619
+ if _contains_any(text, ["路牌", "两条路", "大雾", "找不到路", "sign", "two roads", "fog", "lost road"]):
620
+ return "road_choice"
621
+ return "open"
622
+
623
+
624
+ def _join_anchors(anchors: List[str], language: str = "en", limit: int = 3) -> str:
625
+ visible = [anchor for anchor in anchors[:limit] if anchor]
626
+ if not visible:
627
+ return "梦境片段" if _is_zh(language) else "dream fragments"
628
+ if _is_zh(language):
629
+ return "、".join(visible)
630
+ if len(visible) == 1:
631
+ return visible[0]
632
+ return ", ".join(visible[:-1]) + f", and {visible[-1]}"
633
+
634
+
635
+ def _story_anchor_phrase(intake: DreamIntake, anchors: List[str], language: str = "en", answers: str = "") -> str:
636
+ theme = _dream_theme(intake, answers)
637
+ if _is_zh(language):
638
+ themed = {
639
+ "lost_home": "地铁站里迷路的小孩和回家的方向",
640
+ "dark_water": "夜晚的海、海浪和月牙下的小人",
641
+ "stuck_elevator": "电梯、融化的按钮和数字 14",
642
+ "library_signal": "旧图书馆、红色楼梯和那张便签",
643
+ "message_loss": "那条消息、前任和突然消失",
644
+ "chased": "森林、白色猫和空白路牌",
645
+ "school_pressure": "教室、作业和来不及交上的感觉",
646
+ "road_choice": "雾里的路牌和两条路",
647
+ }
648
+ else:
649
+ themed = {
650
+ "lost_home": "the lost child, the subway, and the way home",
651
+ "dark_water": "the dark water, the waves, and the small figure under the moon",
652
+ "stuck_elevator": "the elevator, the melted button, and floor 14",
653
+ "library_signal": "the old library, the red staircase, and the note",
654
+ "message_loss": "the message, the former partner, and the disappearance",
655
+ "chased": "the chase, the call for help, and the missing route",
656
+ "school_pressure": "the classroom, the assignment, and the late feeling",
657
+ "road_choice": "the foggy sign and the two roads",
658
+ }
659
+ if theme == "chased" and anchors:
660
+ return _join_anchors(anchors, language)
661
+ return themed.get(theme) or _join_anchors(anchors, language)
662
+
663
+
664
+ def _answer_snippet(answers: str, language: str = "en") -> str:
665
+ lines = [
666
+ line.strip()
667
+ for line in (answers or "").splitlines()
668
+ if line.strip() and not _is_skip_answer(line, language)
669
+ ]
670
+ if not lines:
671
+ return ""
672
+ clean = re.sub(r"\s+", " ", lines[-1]).strip(" ::「」\"'()[]{}")
673
+ if _is_zh(language):
674
+ return clean[:34].rstrip(",。;、 ")
675
+ words = clean.split()
676
+ return " ".join(words[:14])
677
+
678
+
679
  def _anchor_with_article(anchor: str) -> str:
680
  clean = (anchor or "").strip()
681
  if clean.lower().startswith(("the ", "a ", "an ")):
 
705
  return f"你梦见{clean}"
706
 
707
 
708
+ def _user_supplied_text(intake: DreamIntake, answers: str = "", include_mood: bool = False) -> str:
709
+ parts = [
710
+ intake.dream_text,
711
+ intake.voice_transcript,
712
+ intake.main_question,
713
+ intake.uncertainty,
714
+ intake.user_context,
715
+ answers or "",
716
+ ]
717
+ if include_mood:
718
+ parts.append(intake.mood)
719
+ return "\n".join(part.strip() for part in parts if part and part.strip())
720
+
721
+
722
+ def _question_sentence_candidates(text: str) -> List[str]:
723
+ pieces = re.split(r"[。!?!?\n\r]+|(?<=\.)\s+|[,,;;]", text or "")
724
+ return [piece.strip(" ::「」\"'()[]{}") for piece in pieces if piece.strip()]
725
+
726
+
727
+ def _clean_user_question(text: str, language: str = "en") -> str:
728
+ clean = re.sub(r"\s+", " ", (text or "").strip(" ::「」\"'()[]{}"))
729
+ if not clean:
730
+ return ""
731
+ if _is_zh(language):
732
+ clean = re.sub(r"^(我)?(醒来后)?(最)?(想知道|想问|在想|担心|害怕)[::,,\s]*", "", clean)
733
+ clean = re.sub(r"^(只)?想知道[::,,\s]*", "", clean)
734
+ clean = clean.strip(" ::「」\"'()[]{}")
735
+ return clean if clean.endswith(("?", "?")) else f"{clean}?"
736
+ clean = re.sub(
737
+ 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*",
738
+ lambda match: (match.group(4) or "").strip() + " ",
739
+ clean,
740
+ flags=re.IGNORECASE,
741
+ ).strip()
742
+ clean = clean[0].upper() + clean[1:] if clean else ""
743
+ 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):
744
+ clean += "?"
745
+ return clean
746
+
747
+
748
+ def _extract_explicit_user_question(intake: DreamIntake, answers: str = "", language: str = "en") -> str:
749
+ if intake.main_question.strip():
750
+ return _clean_user_question(intake.main_question, language)
751
+ text = _user_supplied_text(intake, answers)
752
+ if _is_zh(language):
753
+ markers = (
754
+ "为什么",
755
+ "是不是",
756
+ "会不会",
757
+ "怎么办",
758
+ "我该",
759
+ "能不能",
760
+ "代表",
761
+ "说明",
762
+ "预兆",
763
+ "征兆",
764
+ "想知道",
765
+ "想问",
766
+ "撑不住",
767
+ "走出来",
768
+ "这么难受",
769
+ )
770
+ else:
771
+ markers = (
772
+ "why",
773
+ "what should",
774
+ "how should",
775
+ "does this mean",
776
+ "do i",
777
+ "am i",
778
+ "is this",
779
+ "could this",
780
+ "can this",
781
+ "should i",
782
+ "i wonder",
783
+ "want to know",
784
+ "not coping",
785
+ "cope",
786
+ "a sign",
787
+ "omen",
788
+ )
789
+ for sentence in _question_sentence_candidates(text):
790
+ lowered = sentence.lower()
791
+ if _is_skip_answer(sentence, language):
792
+ continue
793
+ if any(marker in lowered for marker in markers):
794
+ return _clean_user_question(sentence, language)
795
+ return ""
796
+
797
+
798
+ def _emotion_labels_from_text(text: str, language: str = "en") -> List[str]:
799
+ lowered = (text or "").lower()
800
+ if _is_zh(language):
801
+ groups = [
802
+ ("害怕", ["害怕", "吓", "恐惧", "心慌", "心跳很快", "惊醒"]),
803
+ ("压力", ["压力", "撑不住", "扛不住", "崩溃", "压垮", "焦虑", "太累", "疲惫"]),
804
+ ("难过", ["难过", "难受", "伤心", "失落", "想哭", "委屈", "孤独"]),
805
+ ("自责", ["自责", "内疚", "愧疚", "后悔", "责怪自己"]),
806
+ ("需要安慰", ["安慰", "被安慰", "抱抱", "关心", "陪陪", "鸡汤"]),
807
+ ]
808
+ else:
809
+ groups = [
810
+ ("fear", ["scared", "afraid", "terrified", "panic", "panicked", "frightened"]),
811
+ ("pressure", ["overwhelmed", "not coping", "can't cope", "cannot cope", "burned out", "too much", "anxious"]),
812
+ ("sadness", ["sad", "grief", "hurt", "lonely", "heartbroken", "upset"]),
813
+ ("guilt", ["guilty", "ashamed", "blame myself", "regret"]),
814
+ ("comfort", ["comfort", "reassurance", "not productivity", "not advice", "not a pep talk"]),
815
+ ]
816
+ labels: List[str] = []
817
+ for label, terms in groups:
818
+ if any(term in lowered for term in terms):
819
+ labels.append(label)
820
+ return _dedupe_preserve_order(labels)
821
+
822
+
823
+ def _emotion_phrase(labels: List[str], language: str = "en") -> str:
824
+ if not labels:
825
+ return "这个感受" if _is_zh(language) else "this feeling"
826
+ if _is_zh(language):
827
+ return "、".join(labels)
828
+ if len(labels) == 1:
829
+ return labels[0]
830
+ return ", ".join(labels[:-1]) + f", and {labels[-1]}"
831
+
832
+
833
+ def _needs_comfort(text: str, language: str = "en") -> bool:
834
+ lowered = (text or "").lower()
835
+ if _is_zh(language):
836
+ return any(term in lowered for term in ["安慰", "被安慰", "抱抱", "关心", "别催", "不是鸡汤"])
837
+ return any(term in lowered for term in ["comfort", "reassurance", "not productivity", "not a pep talk", "not advice"])
838
+
839
+
840
+ def _should_use_emotion_led_response(intake: DreamIntake, answers: str, language: str = "en") -> bool:
841
+ user_text = _user_supplied_text(intake, answers)
842
+ explicit_question = _extract_explicit_user_question(intake, answers, language)
843
+ labels = _emotion_labels_from_text(user_text, language)
844
+ if _is_low_context_intake(intake) and _is_skip_answer(answers, language) and not explicit_question and not _needs_comfort(user_text, language):
845
+ return False
846
+ return bool(explicit_question or labels or _needs_comfort(user_text, language))
847
+
848
+
849
+ def _direct_question_reassurance(question: str, labels: List[str], language: str = "en") -> str:
850
+ lowered = (question or "").lower()
851
+ if _is_zh(language):
852
+ if any(term in lowered for term in ["撑不住", "扛不住", "崩溃"]):
853
+ return "它不等于你一定撑不住了;更像是在提醒你,最近的压力已经值得被认真照顾。"
854
+ if "走出来" in lowered:
855
+ return "它不急着证明你有没有走出来;更像是在说明这段难受还需要一点被看见的时间。"
856
+ if any(term in lowered for term in ["预兆", "征兆", "坏事", "会不会发生"]):
857
+ return "它不适合被当作预兆;我们先把它当作醒来后仍在身体里的担心来照顾。"
858
+ if any(term in lowered for term in ["我是不是", "是不是我"]):
859
+ return "它不是给你贴标签的证据;它更像是在把一个需要被接住的感受放到你面前。"
860
+ if "为什么" in lowered or "这么难受" in lowered:
861
+ return "这么难受不是你反应过度;梦可能只是把还没放下的感受放大给你看。"
862
+ emotion = _emotion_phrase(labels, language)
863
+ return f"你提到的{emotion}不是小事,它值得先被接住,而不是立刻被解释掉。"
864
+ if any(term in lowered for term in ["not coping", "cope", "coping", "falling apart"]):
865
+ return "This does not prove you are failing to cope; it may be showing that your pressure deserves care before answers."
866
+ if "sign" in lowered or "omen" in lowered or "bad will happen" in lowered:
867
+ return "This is safer to treat as a fear to care for, not as evidence that something bad will happen."
868
+ if "am i" in lowered or "does this mean i" in lowered:
869
+ return "This dream is not evidence for a label about you; it is one feeling asking to be met gently."
870
+ if "why" in lowered:
871
+ return "The ache makes sense; the dream may be enlarging a feeling that has not had enough room yet."
872
+ emotion = _emotion_phrase(labels, language)
873
+ return f"The {emotion} you named deserves to be met first, not explained away too quickly."
874
+
875
+
876
+ def _emotion_led_interpretation(intake: DreamIntake, answers: str, anchors: List[str], language: str = "en") -> str:
877
+ if not _should_use_emotion_led_response(intake, answers, language):
878
+ return ""
879
+ question = _extract_explicit_user_question(intake, answers, language)
880
+ labels = _emotion_labels_from_text(_user_supplied_text(intake, answers), language)
881
+ emotion = _emotion_phrase(labels, language)
882
+ anchor = _story_anchor_phrase(intake, anchors, language, answers)
883
+ direct = _direct_question_reassurance(question, labels, language)
884
+ if _is_zh(language):
885
+ opener = f"你问的是「{question}」。" if question else ""
886
+ return (
887
+ f"{opener}{direct} 第一层,先承认醒来后的{emotion}是真实的,不需要被责怪。"
888
+ f"第二层,梦里的「{anchor}」也许把这种感受变成了一个可以看的画面。"
889
+ "第三层,今天不急着找唯一答案,先给自己一个能站稳的小支点。"
890
+ )
891
+ opener = f"You asked, \"{question}\" " if question else ""
892
+ return (
893
+ f"{opener}{direct} First, let the {emotion} be real without blaming yourself. "
894
+ f"Second, the {_anchor_with_article(anchor)} may be the dream's concrete shape for that feeling. "
895
+ "Third, for today the goal is not to solve the whole dream, but to give yourself one steadier place to stand."
896
+ )
897
+
898
+
899
+ def _emotion_led_today_tip(intake: DreamIntake, answers: str, anchors: List[str], language: str = "en") -> str:
900
+ if not _should_use_emotion_led_response(intake, answers, language):
901
+ return ""
902
+ if _has_prophecy_frame(_user_supplied_text(intake, answers)):
903
+ return ""
904
+ theme = _dream_theme(intake, answers)
905
+ anchor = _story_anchor_phrase(intake, anchors, language, answers)
906
+ answer_snippet = _answer_snippet(answers, language)
907
+ if _is_zh(language):
908
+ if theme == "lost_home":
909
+ extra = f"如果刚才的回答里「{answer_snippet}」最重,就从它开始。" if answer_snippet else ""
910
+ return (
911
+ f"今天先顺着「{anchor}」照顾那种需要被带路的感觉,而不是急着解释梦的含义。"
912
+ f"{extra}给今天补一个很小的“路标”:问一个人、查一个入口,或写下下一站在哪里。"
913
+ )
914
+ if theme == "dark_water":
915
+ return (
916
+ f"今天先把「{anchor}」当成醒来后还在身体里的浪,不当成危险证明。"
917
+ "先选一个让自己回到岸边的动作:开灯、洗脸、发一句“我醒来有点慌,先缓一下”。"
918
+ )
919
+ if theme == "stuck_elevator":
920
+ return (
921
+ f"今天不要逼自己抵达所有楼层;先把「{anchor}」变成一个只按一次的按钮。"
922
+ "只确认当前最卡的一层是什么,再允许自己停在那里做一个很小动作。"
923
+ )
924
+ if theme == "library_signal":
925
+ return (
926
+ f"今天把「{anchor}」当成一张提醒你回到安定处的便签。"
927
+ "不用立刻整理完整答案,只挑一个让你有“回家感”的人、物或角落,靠近它五分钟。"
928
+ )
929
+ if theme == "message_loss":
930
+ return (
931
+ f"今天先把「{anchor}」看成一段还没被好好收起的联系。"
932
+ "不用真的发出去,先写一封不发送的回信,让那句没说完的话有地方停靠。"
933
+ )
934
+ return (
935
+ f"今天的小建议是:先别把「{anchor}」解释成结论;把它当成梦递过来的线索,"
936
+ "问问它在替你保护哪一种感受,再给那种感受一个很小的照顾动作。"
937
+ )
938
+ if theme == "lost_home":
939
+ extra = f" If your answer points to \"{answer_snippet}\", start there." if answer_snippet else ""
940
+ return (
941
+ f"For today, treat {anchor} as a need for guidance, not as a verdict about you.{extra} "
942
+ "Add one tiny wayfinding marker: ask one person, check one entrance, or name the next stop."
943
+ )
944
+ if theme == "dark_water":
945
+ return (
946
+ f"For today, treat {anchor} as a wave still in your body, not proof of danger. "
947
+ "Choose one shore-like action: turn on a light, wash your face, or tell someone you woke up unsettled."
948
+ )
949
+ if theme == "stuck_elevator":
950
+ return (
951
+ f"For today, do not make yourself reach every floor. Let {anchor} become one button: "
952
+ "name the stuck floor, then do only one small action there."
953
+ )
954
+ if theme == "library_signal":
955
+ return (
956
+ f"For today, treat {anchor} like a note pointing you back to steadiness. "
957
+ "Pick one person, place, or object that feels like home and spend five minutes near it."
958
+ )
959
+ if theme == "message_loss":
960
+ return (
961
+ f"For today, treat {anchor} as an unfinished contact, not a demand to reopen everything. "
962
+ "Write one unsent reply so the unsaid sentence has somewhere to rest."
963
+ )
964
+ return (
965
+ f"For today, do not turn {anchor} into a fixed conclusion. Treat it as a clue about one feeling "
966
+ "that needs care, then give that feeling one small caring action."
967
+ )
968
+
969
+
970
+ def _emotion_led_tiny_action(intake: DreamIntake, answers: str, anchors: List[str], language: str = "en") -> str:
971
+ if not _should_use_emotion_led_response(intake, answers, language):
972
+ return ""
973
+ theme = _dream_theme(intake, answers)
974
+ anchor = _story_anchor_phrase(intake, anchors, language, answers)
975
+ if _is_zh(language):
976
+ if theme == "lost_home":
977
+ return f"用 5 分钟给「{anchor}」画一张三格小路线:我在找什么、我能问谁或查哪里、今天只走到哪一站。"
978
+ if theme == "dark_water":
979
+ return f"用 5 分钟做一个“上岸”动作:看着「{anchor}」,说出三个房间里的真实物件,再喝一口水或开一盏灯。"
980
+ if theme == "stuck_elevator":
981
+ return f"用 5 分钟写一个“只按这一层”的按钮:今天卡在哪一层、我能做的最小动作、做完就停。"
982
+ if theme == "library_signal":
983
+ return f"用 5 分钟把「{anchor}」改写成一张现实便签:我现在可以回到哪里,或联系谁,让自己稳一点。"
984
+ if theme == "message_loss":
985
+ return f"用 5 分钟写一封不发送的短信草稿,开头只写:梦里的「{anchor}」让我想承认的是……"
986
+ return f"用 5 分钟做一个三格便签:梦里的「{anchor}」最亮的细节、它带出的感受、今天能照顾它的一小步。"
987
+ if theme == "lost_home":
988
+ return f"Spend five minutes making a three-box route for {anchor}: what I am looking for, who or where I can ask, and the next stop only."
989
+ if theme == "dark_water":
990
+ return f"Spend five minutes doing one shore action for {anchor}: name three real objects in the room, then drink water or turn on a light."
991
+ if theme == "stuck_elevator":
992
+ return f"Spend five minutes making a one-floor button for {anchor}: the stuck floor, the smallest action, and permission to stop after it."
993
+ if theme == "library_signal":
994
+ return f"Spend five minutes turning {anchor} into a real note: where can I return, or who can I contact, to feel steadier?"
995
+ if theme == "message_loss":
996
+ return f"Spend five minutes drafting one unsent message that begins: what {anchor} makes me want to admit is..."
997
+ return f"Spend five minutes making three boxes for {anchor}: brightest detail, feeling it carries, and one small care step for today."
998
+
999
+
1000
+ def _emotion_led_caring_note(intake: DreamIntake, answers: str, language: str = "en") -> str:
1001
+ if not _should_use_emotion_led_response(intake, answers, language):
1002
+ return ""
1003
+ labels = _emotion_labels_from_text(_user_supplied_text(intake, answers), language)
1004
+ emotion = _emotion_phrase(labels, language)
1005
+ if _is_zh(language):
1006
+ return f"你不是太脆弱,也不是需要被催着立刻想通;这份{emotion}可以先被轻轻接住。"
1007
+ return f"You are not weak for feeling {emotion}; you do not have to turn it into a lesson before you are comforted."
1008
+
1009
+
1010
  def _main_question_from_intake(intake: DreamIntake, language: str = "en") -> str:
1011
  if intake.main_question.strip():
1012
+ return _clean_user_question(intake.main_question, language)
1013
+ explicit_question = _extract_explicit_user_question(intake, "", language)
1014
+ if explicit_question:
1015
+ return explicit_question
1016
  task = _task_focus(intake.merged_text(), language)
1017
  if task:
1018
  if not _is_zh(language):
 
1043
 
1044
 
1045
  def _grounded_today_tip(intake: DreamIntake, language: str = "en") -> str:
1046
+ anchors = _anchors_for_language(intake, language)
1047
+ primary = anchors[0] if anchors else _primary_anchor(intake, language)
1048
+ anchor = _story_anchor_phrase(intake, anchors, language)
1049
+ theme = _dream_theme(intake)
1050
  if not _is_zh(language):
1051
+ if theme == "lost_home":
1052
+ return f"For today, let {anchor} become a small wayfinding cue: choose one place to ask, one entrance to check, or one next stop to name."
1053
+ if theme == "dark_water":
1054
+ return f"For today, let {anchor} become a shore cue: name what felt too large, then choose one ordinary thing that helps your body feel here again."
1055
+ if theme == "library_signal":
1056
+ return f"For today, let {anchor} become a note back to steadiness: choose one person, place, or object that feels like home and spend five minutes near it."
1057
+ if theme == "message_loss":
1058
+ return f"For today, let {anchor} become one unsent sentence, not a full conversation: write what is still tender and let it rest."
1059
  return (
1060
  f"For today, borrow one action from {_anchor_with_article(primary)}: open the task, write only the first line, "
1061
  "and let that be enough for now."
1062
  )
1063
+ if theme == "lost_home":
1064
+ return f"今天先让「{anchor}」变成一个小路标:只确认一个可以问的人、一个可以查的入口,或一个下一站。"
1065
+ if theme == "dark_water":
1066
+ return f"今天先让「{anchor}」变成一个上岸提示:说出那个太大的感受,再选一件能让身体回到当下的小事。"
1067
+ if theme == "library_signal":
1068
+ return f"今天先让「{anchor}」变成一张回到安定处的便签:靠近一个有回家感的人、地方或物件五分钟。"
1069
+ if theme == "message_loss":
1070
+ return f"今天先让「{anchor}」变成一句不发送的话,而不是一整场对话:写下还柔软的那句,然后让它停在那里。"
1071
  return f"今天先从「{primary}」借一个动作:只做最小的第一步,不急着把整件事完成。"
1072
 
1073
 
 
1449
  return f"如果「{primary}」能递给你一个更小的第一步,今天那一步会是什么?"
1450
 
1451
 
1452
+ def _first_input_focus_sentence(intake: DreamIntake, language: str = "en") -> str:
1453
+ explicit_question = _extract_explicit_user_question(intake, "", language)
1454
+ labels = _emotion_labels_from_text(_user_supplied_text(intake, "", include_mood=True), language)
1455
+ emotion = _emotion_phrase(labels, language)
1456
+ anchors = _anchors_for_language(intake, language)
1457
+ story_anchor = _story_anchor_phrase(intake, anchors, language)
1458
+ if _is_zh(language):
1459
+ if explicit_question and labels:
1460
+ return f"你最在意的是「{explicit_question}」,而且醒来后的{emotion}需要先被接住。"
1461
+ if explicit_question:
1462
+ return f"你最在意的是「{explicit_question}」,不是只想听一个固定象征解释。"
1463
+ if labels:
1464
+ return f"这个梦留下的{emotion}比符号本身更重要,我会先顺着感受看。"
1465
+ return f"核心线索先落在「{story_anchor}」上,我会先确认它和你今天的需要怎么连起来。"
1466
+ if explicit_question and labels:
1467
+ return f"your main question is \"{explicit_question}\", and the {emotion} after waking needs to be met first."
1468
+ if explicit_question:
1469
+ return f"your main question is \"{explicit_question}\", not a generic symbol reading."
1470
+ if labels:
1471
+ return f"the {emotion} after waking matters more than a fixed symbol meaning."
1472
+ return f"the strongest trail begins with {story_anchor}, so I will connect it to what you may need today."
1473
+
1474
+
1475
+ def _decomposition_question(
1476
+ intake: DreamIntake,
1477
+ existing_count: int,
1478
+ answers: str,
1479
+ model_question: str,
1480
+ language: str = "en",
1481
+ ) -> str:
1482
+ anchors = _anchors_for_language(intake, language)
1483
+ story_anchor = _story_anchor_phrase(intake, anchors, language, answers)
1484
+ theme = _dream_theme(intake, answers)
1485
+ declared_question = _question_for_declared_real_task(intake, language) if existing_count == 0 else ""
1486
+ if declared_question:
1487
+ return declared_question
1488
+ if _is_zh(language):
1489
+ stage_questions = {
1490
+ "lost_home": [
1491
+ "梦里那个迷路的小孩,更像是你自己需要被带路,还是某件现实里暂时找不到家的事?",
1492
+ "如果把地铁站或回家方向看成一个场景,最让你心里一紧的是“没人带路”“找不到入口”,还是“怕把谁弄丢”?",
1493
+ "最后只确认今天的需要:你更想被安慰一下,还是想得到一个很小的找路动作?",
1494
+ ],
1495
+ "dark_water": [
1496
+ "醒来后最强的是害怕、孤单、喘不过气,还是别的身体感受?",
1497
+ "画面里的海浪、月牙或小人,��一个最像你醒来后还忘不掉的部分?",
1498
+ "最后只确认今天的需要:你更想要安定身体,还是想理解为什么会这么慌?",
1499
+ ],
1500
+ "stuck_elevator": [
1501
+ "电梯、按钮或数字 14 里,哪一个最像你最近卡住的感觉?",
1502
+ "这个卡住更像“来不及开始”,还是“已经按了按钮却没有回应”?",
1503
+ "最后只确认今天的需要:你想要一个小行动,还是先要一句能让压力降下来的话?",
1504
+ ],
1505
+ "library_signal": [
1506
+ "旧图书馆、楼梯或便签里,哪一个最像梦在提醒你别忽略的线索?",
1507
+ "那张便签或“回家”的感觉,更像想联系某个人,还是想回到一种安定状态?",
1508
+ "最后只确认今天的需要:你想整理一个现实线索,还是先给自己一点回家的感觉?",
1509
+ ],
1510
+ "message_loss": [
1511
+ "那条消息最刺痛你的地方,是它出现了、消失了,还是它来自某个具体的人?",
1512
+ "这个梦更像在问“我还在等什么”,还是在问“我该怎么照顾还没放下的部分”?",
1513
+ "最后只确认今天的需要:你想要一句安慰,还是一个不打扰别人的小行动?",
1514
+ ],
1515
+ }
1516
+ fallback = [
1517
+ f"在「{story_anchor}」里,醒来后最强烈的感受是什么?",
1518
+ f"如果只选一个细节继续看,你会选「{story_anchor}」里的哪一处?为什么?",
1519
+ "最后只确认今天的需要:你更想被安慰、被提醒,还是得到一个很小的行动?",
1520
+ ]
1521
+ else:
1522
+ stage_questions = {
1523
+ "lost_home": [
1524
+ "Does the lost child feel more like a part of you that needs guidance, or like a real situation that has no clear way home yet?",
1525
+ "In the subway and way-home scene, what tightens most: nobody guiding you, no visible entrance, or fear of losing someone?",
1526
+ "Last check before the tip: would comfort help most today, or one tiny way-finding action?",
1527
+ ],
1528
+ "dark_water": [
1529
+ "After waking, was the strongest feeling fear, loneliness, breathlessness, or something else in your body?",
1530
+ "Which image stays with you most: the waves, the moon, or the small figure?",
1531
+ "Last check before the tip: do you need help calming your body, or understanding why the image felt so intense?",
1532
+ ],
1533
+ "stuck_elevator": [
1534
+ "Which detail feels closest to your current stuck point: the elevator, the melted button, or floor 14?",
1535
+ "Does the stuckness feel more like being late to start, or like pressing the button and getting no response?",
1536
+ "Last check before the tip: would one small action help most, or a sentence that lowers the pressure first?",
1537
+ ],
1538
+ }
1539
+ fallback = [
1540
+ f"In {story_anchor}, what feeling stayed strongest after waking?",
1541
+ f"If we keep only one detail from {story_anchor}, which one matters most, and why?",
1542
+ "Last check before the tip: do you want comfort, a reminder, or one very small action?",
1543
+ ]
1544
+ candidates = stage_questions.get(theme, fallback)
1545
+ index = min(max(existing_count, 0), MAX_DREAM_DECOMPOSITION_QUESTIONS - 1)
1546
+ question = candidates[index] if index < len(candidates) else model_question
1547
+ if question:
1548
+ return question
1549
+ return model_question or _grounded_followup_question(intake, language)
1550
+
1551
+
1552
+ def _compose_decomposition_response(
1553
+ intake: DreamIntake,
1554
+ existing_count: int,
1555
+ question: str,
1556
+ answers: str,
1557
+ language: str = "en",
1558
+ ) -> str:
1559
+ anchors = _anchors_for_language(intake, language)
1560
+ story_anchor = _story_anchor_phrase(intake, anchors, language, answers)
1561
+ answer_snippet = _answer_snippet(answers, language)
1562
+ if _is_zh(language):
1563
+ if existing_count == 0:
1564
+ focus = _first_input_focus_sentence(intake, language)
1565
+ return (
1566
+ f"我先听到的是:{focus}\n\n"
1567
+ f"我会把这个梦拆成三层来看:醒来后的感受、梦里最抓人的画面({story_anchor})、"
1568
+ "以及它今天想帮你照顾的需要。\n\n"
1569
+ f"先问一个会影响最后建议的问题:{question}"
1570
+ )
1571
+ if existing_count == 1:
1572
+ prefix = f"我把你的回答也放进来了:{answer_snippet}。" if answer_snippet else "我再补一层具体画面。"
1573
+ return f"{prefix} 这一轮只看「{story_anchor}」里最关键的连接:{question}"
1574
+ return f"最后只确认一个方向,之后我就生成今日小 Tips:{question}"
1575
+ if existing_count == 0:
1576
+ focus = _first_input_focus_sentence(intake, language)
1577
+ return (
1578
+ f"What I hear first is that {focus}\n\n"
1579
+ f"I will unpack this in three layers: the feeling after waking, the concrete image ({story_anchor}), "
1580
+ f"and what it may be asking you to care for today.\n\n"
1581
+ f"One question that will shape the final tip: {question}"
1582
+ )
1583
+ if existing_count == 1:
1584
+ prefix = f"I am adding your answer: {answer_snippet}." if answer_snippet else "I will add one more concrete layer."
1585
+ return f"{prefix} For this round, I am looking at the key link in {story_anchor}: {question}"
1586
+ return f"One last direction check, then I will write the Today Tip: {question}"
1587
+
1588
+
1589
  def _polish_card_for_daily_use(card: PactCard, intake: DreamIntake, answers: str) -> PactCard:
1590
  polished = card.model_copy(deep=True)
1591
  merged = "\n".join([intake.merged_text(), answers or ""])
 
1712
  setattr(polished, field, _clean_placeholder_phrase(getattr(polished, field)))
1713
  if not polished.dream_summary.strip() or _is_placeholder_anchor(polished.dream_summary) or not _text_uses_anchor(polished.dream_summary, anchors):
1714
  polished.dream_summary = _summary_from_intake(intake, language)
1715
+ explicit_question = _extract_explicit_user_question(intake, answers, language)
1716
+ if explicit_question:
1717
+ polished.main_question = explicit_question
1718
+ elif (
1719
  not polished.main_question.strip()
1720
  or _is_placeholder_anchor(polished.main_question)
1721
  or not _text_uses_anchor(polished.main_question, anchors)
1722
  ):
1723
  polished.main_question = _main_question_from_intake(intake, language)
1724
+ emotion_interpretation = _emotion_led_interpretation(intake, answers, anchors, language)
1725
  answer_interpretation = _answer_based_interpretation(answers, _answer_bridge_anchor(anchors), language)
1726
+ if emotion_interpretation:
1727
+ polished.interpretation = emotion_interpretation
1728
+ elif answer_interpretation:
1729
  polished.interpretation = answer_interpretation
1730
  elif _has_prophecy_frame(intake.merged_text()):
1731
  anchor = _answer_bridge_anchor(anchors)
 
1744
  elif not polished.interpretation.strip() or not _anchor_in_text(polished.interpretation, anchors):
1745
  polished.interpretation = _fallback_interpretation(intake, language)
1746
  generic_tip_markers = ["drink water", "hydrate", "多休息", "保持积极", "take a walk"]
1747
+ emotion_tip = _emotion_led_today_tip(intake, answers, anchors, language)
1748
  answer_tip = _answer_based_today_tip(answers, anchors[0], language)
1749
+ if emotion_tip:
1750
+ polished.today_tip = emotion_tip
1751
+ elif answer_tip:
1752
  polished.today_tip = answer_tip
1753
  elif _has_prophecy_frame(intake.merged_text()):
1754
  anchor = anchors[0]
 
1772
  ):
1773
  polished.today_tip = _grounded_today_tip(intake, language)
1774
  hard_action_markers = ["address it immediately", "fix it immediately", "solve it immediately"]
1775
+ emotion_action = _emotion_led_tiny_action(intake, answers, anchors, language)
1776
  answer_action = _answer_based_tiny_action(answers, language)
1777
+ if emotion_action:
1778
+ polished.tiny_action = emotion_action
1779
+ elif answer_action:
1780
  polished.tiny_action = answer_action
1781
  elif _has_prophecy_frame(intake.merged_text()):
1782
  polished.tiny_action = (
 
1796
  or not _anchor_in_text(polished.tiny_action, anchors)
1797
  or any(marker in polished.tiny_action.lower() for marker in hard_action_markers)
1798
  ):
1799
+ anchor_phrase = _story_anchor_phrase(intake, anchors, language, answers)
1800
+ theme = _dream_theme(intake, answers)
1801
  if _is_zh(language):
1802
+ if theme == "lost_home":
1803
+ polished.tiny_action = f"用 5 分钟给「{anchor_phrase}」补一张小路标:我在找什么、能问哪里、下一站是什么。"
1804
+ elif theme == "dark_water":
1805
+ polished.tiny_action = f"用 5 分钟给「{anchor_phrase}」找一个上岸动作:看见三个真实物件,再做一件让身体安定的小事。"
1806
+ elif theme == "library_signal":
1807
+ polished.tiny_action = f"用 5 分钟把「{anchor_phrase}」变成现实便签:今天我可以靠近哪一个安定来源?"
1808
+ elif theme == "message_loss":
1809
+ polished.tiny_action = f"用 5 分钟给「{anchor_phrase}」写一句不发送的话,然后把手机放下。"
1810
+ else:
1811
+ polished.tiny_action = f"用 5 分钟写下:今天和「{anchors[0]}」有关的第一小步是什么?"
1812
  else:
1813
+ if theme == "lost_home":
1814
+ polished.tiny_action = f"Spend five minutes adding a small route marker for {anchor_phrase}: what I seek, where I can ask, and the next stop."
1815
+ elif theme == "dark_water":
1816
+ polished.tiny_action = f"Spend five minutes finding a shore action for {anchor_phrase}: name three real objects, then do one grounding thing."
1817
+ elif theme == "library_signal":
1818
+ polished.tiny_action = f"Spend five minutes turning {anchor_phrase} into a real note: which steady source can I move closer to today?"
1819
+ elif theme == "message_loss":
1820
+ polished.tiny_action = f"Spend five minutes writing one unsent sentence for {anchor_phrase}, then put the phone down."
1821
+ else:
1822
+ polished.tiny_action = f"Spend five minutes writing the first small step connected to the {anchors[0]}."
1823
+ emotion_caring_note = _emotion_led_caring_note(intake, answers, language)
1824
+ if emotion_caring_note:
1825
+ polished.caring_note = emotion_caring_note
1826
+ elif not polished.caring_note.strip():
1827
  polished.caring_note = (
1828
  "你不需要一醒来就解决整个梦,先把一个细节照亮就很好。"
1829
  if _is_zh(language)
 
2167
  )
2168
  return next_session
2169
 
2170
+ existing_count = len(next_session.question_history)
2171
+ if existing_count >= MAX_DREAM_DECOMPOSITION_QUESTIONS:
2172
+ next_session.phase = "ask"
2173
+ next_session.qa_state = build_qa_state(
2174
+ next_session.intake,
2175
+ questions=next_session.question_history,
2176
+ answers=next_session.answer_history,
2177
+ language=language,
2178
+ )
2179
+ next_session.events.append(
2180
+ _event(
2181
+ "assistant",
2182
+ "线索已经足够" if _is_zh(language) else "Enough context gathered",
2183
+ (
2184
+ "我已经问到 3 个核心问题了;现在更适合生成今日小 Tips,而不是继续追问。"
2185
+ if _is_zh(language)
2186
+ else "I have asked three core questions; it is better to generate the Today Tip than keep questioning."
2187
+ ),
2188
+ status="ready",
2189
+ )
2190
+ )
2191
+ return next_session
2192
+
2193
  prompt = (
2194
  followup_question_prompt(next_session.intake, next_session.question_history, next_session.answer_history, language)
2195
  if force_another
 
2221
  deduped_questions = [_grounded_followup_question(next_session.intake, language)]
2222
  fresh_questions = deduped_questions
2223
 
2224
+ model_question = fresh_questions[0] if fresh_questions else _grounded_followup_question(next_session.intake, language)
2225
+ focused_question = _decomposition_question(
2226
+ next_session.intake,
2227
+ existing_count,
2228
+ next_session.answers_text(),
2229
+ model_question,
2230
+ language,
2231
+ )
2232
+ visible_question = _compose_decomposition_response(
2233
+ next_session.intake,
2234
+ existing_count,
2235
+ focused_question,
2236
+ next_session.answers_text(),
2237
+ language,
2238
+ )
2239
+ if visible_question not in next_session.question_history:
2240
+ next_session.question_history.append(visible_question)
2241
  next_session.phase = "ask"
2242
  next_session.qa_state = build_qa_state(
2243
  next_session.intake,
 
2249
  next_session.events.append(
2250
  _event(
2251
  "assistant",
2252
+ "梦境助手理解与追问" if _is_zh(language) and existing_count == 0 else (
2253
+ "梦境助手追问" if _is_zh(language) else (
2254
+ "Dream QA understanding and question" if existing_count == 0 else "Dream QA question"
2255
+ )
2256
+ ),
2257
+ visible_question,
2258
  meta=str(negotiation.get("visitor_name", "")),
2259
  status="question",
2260
  )
dream_customs/prompts.py CHANGED
@@ -51,12 +51,21 @@ 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
 
@@ -169,6 +178,8 @@ def negotiation_prompt(intake: DreamIntake, language: str = "en") -> str:
169
  return f"""
170
  You are the Dream QA question guide. The user is not asking for diagnosis.
171
  Help the user record the dream, clarify the main question, and answer one gentle follow-up before the final 今日小 Tips.
 
 
172
  The tone should be warm, plain, and specific. Do not make medical claims.
173
  Ask questions that an ordinary person can understand without knowing any app lore.
174
  Prefer questions about the strongest feeling, one confusing scene, or one safe next-day reference.
@@ -195,6 +206,8 @@ def followup_question_prompt(
195
  return f"""
196
  You are the Dream QA question guide. Ask one more gentle follow-up question.
197
  Do not diagnose. Do not repeat previous questions.
 
 
198
  The question must be plain and useful: ask what the user wants to understand, what feeling was strongest,
199
  or whether one concrete dream detail connects to today.
200
  Do not use unclear metaphors about fate, symbols, hidden meanings, stamps, release, or permits.
 
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 the 1-3 follow-up answers, 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 and tiny_action must cite at least one concrete dream anchor and must change with the user's story,
62
+ visual evidence, and follow-up answers. Do not reuse stock exercises such as "write two lines" unless the user's
63
+ own dream or answer makes that exact action feel specific.
64
  Avoid prophecy, frightening certainty, medical advice, therapy framing, and generic wellness filler.
65
+ Keep the whole result short, warm, emotionally responsive, and specific to the user's answer.
66
+ The tiny_action must be a 5-minute self-check or small care step, not a command to solve the whole problem.
67
  Avoid demanding phrases such as "immediately", "must", "fix it", or "solve it".
68
+ If the user asks for comfort, caring_note should be warm, specific, and validating.
69
  Use safety_note only for self-harm, harm to others, severe distress, severe insomnia, panic, or inability to function.
70
  {_language_instruction(language)}
71
 
 
178
  return f"""
179
  You are the Dream QA question guide. The user is not asking for diagnosis.
180
  Help the user record the dream, clarify the main question, and answer one gentle follow-up before the final 今日小 Tips.
181
+ Use a gentle grill-me style: first reflect what you understood, then ask the single question that would most improve
182
+ the final advice. Dream QA can ask at most 3 decomposition questions total, so every question must earn its place.
183
  The tone should be warm, plain, and specific. Do not make medical claims.
184
  Ask questions that an ordinary person can understand without knowing any app lore.
185
  Prefer questions about the strongest feeling, one confusing scene, or one safe next-day reference.
 
206
  return f"""
207
  You are the Dream QA question guide. Ask one more gentle follow-up question.
208
  Do not diagnose. Do not repeat previous questions.
209
+ Treat this as one of at most 3 decomposition rounds. Ask only the missing piece that would make the final Today Tip
210
+ more personal: feeling, concrete image, or what kind of support the user wants today.
211
  The question must be plain and useful: ask what the user wants to understand, what feeling was strongest,
212
  or whether one concrete dream detail connects to today.
213
  Do not use unclear metaphors about fate, symbols, hidden meanings, stamps, release, or permits.
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晚都睡不好。",
@@ -346,13 +413,21 @@ def test_ask_answer_skip_draft_revise_and_seal_actions():
346
  )
347
  session = ask_questions(session, FakeTextClient())
348
  assert session.phase == "ask"
349
- assert len(session.question_history) == 3
 
350
 
351
  session = answer_question(session, "I want one small start.")
352
  assert session.answer_history[-1] == "I want one small start."
353
 
354
  session = ask_questions(session, FakeTextClient(), force_another=True)
355
- assert len(session.question_history) == 4
 
 
 
 
 
 
 
356
 
357
  session = skip_question(session)
358
  assert "skip" in session.answer_history[-1].lower()
@@ -536,6 +611,89 @@ def test_visual_witness_clues_drive_questions_and_today_tip():
536
  assert "objects" not in combined
537
 
538
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
539
  def test_witness_failure_keeps_text_path_alive():
540
  class BrokenWitnessVision:
541
  def extract_witness(self, image_path):
 
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晚都睡不好。",
 
413
  )
414
  session = ask_questions(session, FakeTextClient())
415
  assert session.phase == "ask"
416
+ assert len(session.question_history) == 1
417
+ assert "three layers" in session.question_history[0]
418
 
419
  session = answer_question(session, "I want one small start.")
420
  assert session.answer_history[-1] == "I want one small start."
421
 
422
  session = ask_questions(session, FakeTextClient(), force_another=True)
423
+ assert len(session.question_history) == 2
424
+ assert "For this round" in session.question_history[-1]
425
+
426
+ session = ask_questions(session, FakeTextClient(), force_another=True)
427
+ assert len(session.question_history) == 3
428
+
429
+ session = ask_questions(session, FakeTextClient(), force_another=True)
430
+ assert len(session.question_history) == 3
431
 
432
  session = skip_question(session)
433
  assert "skip" in session.answer_history[-1].lower()
 
611
  assert "objects" not in combined
612
 
613
 
614
+ def test_zh_text_and_image_keep_user_question_while_using_visual_anchors():
615
+ class SeaDreamVision:
616
+ def extract_witness(self, image_path):
617
+ return VisionWitness(
618
+ scene_summary=(
619
+ "A simple sketch of a person standing on wavy lines under a crescent moon, "
620
+ "with the text 'dark sea dream' above."
621
+ ),
622
+ objects=["stick figure", "wavy lines representing water", "crescent moon"],
623
+ visible_text=["dark sea dream"],
624
+ mood_cues=["small figure in a large dark place"],
625
+ )
626
+
627
+ def extract_clues(self, image_path):
628
+ return ["flat fallback should not win"]
629
+
630
+ session = add_evidence(
631
+ create_session(language="zh"),
632
+ dream_text="我醒来很害怕,这张草图是梦里最清楚的画面。我想知道为什么它让我这么慌。",
633
+ image_path="sea.png",
634
+ mood="害怕",
635
+ vision_client=SeaDreamVision(),
636
+ asr_client=FakeASRClient(),
637
+ language="zh",
638
+ )
639
+ card = generate_today_tip(session.intake, "", FakeTextClient(), language="zh")
640
+ combined = "\n".join([card.main_question, ",".join(card.dream_anchors), card.interpretation, card.today_tip])
641
+
642
+ assert "为什么它让我这么慌?" in card.main_question
643
+ assert any(anchor in card.dream_anchors for anchor in ["夜晚的海", "海浪", "月牙", "漆黑的海"])
644
+ assert "blue hallway" not in combined
645
+ assert "a simple sketch" not in combined.lower()
646
+ assert "dreamlike representation" not in combined.lower()
647
+ assert "这么难受不是你反应过度" in card.interpretation
648
+
649
+
650
+ def test_zh_first_response_and_tip_follow_story_and_image_not_template():
651
+ class LostChildVision:
652
+ def extract_witness(self, image_path):
653
+ return VisionWitness(
654
+ scene_summary="A child figure stands inside a subway station with arrows pointing toward HOME.",
655
+ objects=["child figure", "subway station", "arrows"],
656
+ visible_text=["HOME"],
657
+ mood_cues=["lost", "small figure in a large station"],
658
+ )
659
+
660
+ def extract_clues(self, image_path):
661
+ return ["flat fallback should not win"]
662
+
663
+ session = add_evidence(
664
+ create_session(language="zh"),
665
+ dream_text="我梦见一个小孩在地铁站找不到家,我醒来很害怕,想知道为什么这个画面让我这么难受。",
666
+ image_path="lost-child.png",
667
+ mood="害怕",
668
+ vision_client=LostChildVision(),
669
+ asr_client=FakeASRClient(),
670
+ language="zh",
671
+ )
672
+ session = ask_questions(session, FakeTextClient(), language="zh")
673
+ first_response = session.question_history[0]
674
+
675
+ assert "我先听到的是" in first_response
676
+ assert "三层" in first_response
677
+ assert "小孩" in first_response
678
+ assert "回家" in first_response or "地铁" in first_response
679
+
680
+ session = answer_question(session, "更像我自己最近不知道该往哪里走,也很想有人带一下。", language="zh")
681
+ card = generate_today_tip(
682
+ session.intake,
683
+ session.answers_text(),
684
+ FakeTextClient(),
685
+ language="zh",
686
+ followup_questions=session.question_history,
687
+ )
688
+ combined = "\n".join([card.interpretation, card.today_tip, card.tiny_action, card.caring_note])
689
+
690
+ assert "小孩" in combined
691
+ assert "回家" in combined or "地铁" in combined or "路标" in combined
692
+ assert "需要被带路" in card.today_tip or "路标" in card.today_tip
693
+ assert "写两行" not in combined
694
+ assert "当作害怕的形状" not in combined
695
+
696
+
697
  def test_witness_failure_keeps_text_path_alive():
698
  class BrokenWitnessVision:
699
  def extract_witness(self, image_path):