Localize Dream QA visual anchors

#33
by ADJCJH - opened
docs/smoke/2026-06-12-modal-vision-sketch-evidence.md ADDED
@@ -0,0 +1,77 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Modal Vision Sketch Evidence - 2026-06-12
2
+
3
+ Goal: verify whether Dream QA can use MiniCPM-V-4.6 to understand uploaded dream sketches, and clarify how text plus image should be merged.
4
+
5
+ ## Test Assets
6
+
7
+ - Sea sketch: `docs/smoke/assets/dream_sketch_01_sea.png`
8
+ - Elevator sketch: `docs/smoke/assets/dream_sketch_02_elevator.png`
9
+ - Subway child sketch: `docs/smoke/assets/dream_sketch_03_subway_child.png`
10
+ - Raw Modal evidence: `docs/smoke/2026-06-12-modal-vision-sketch-evidence.json`
11
+ - Chrome local paste screenshot: `docs/smoke/assets/2026-06-12-chrome-local-image-paste-fallback.png`
12
+ - Chrome configured local Modal screenshot: `docs/smoke/assets/2026-06-12-chrome-local-modal-sea-tip.png`
13
+
14
+ ## Chrome Local Upload Check
15
+
16
+ Local app URL: `http://127.0.0.1:7870`
17
+
18
+ Result:
19
+
20
+ - Native file chooser injection was blocked by the Chrome extension file-access permission.
21
+ - Clipboard paste into the Gradio image input worked. The page showed `Remove Image`, confirming the image slot received the sketch.
22
+ - Submitting the local page without Modal endpoint/token configured did not prove real MiniCPM-V behavior. The follow-up used fallback/demo visual clues (`melted elevator button`) instead of the pasted sea sketch.
23
+
24
+ Interpretation: the local Gradio image UI can receive images, but a local browser smoke without configured hosted endpoint/token only verifies upload plumbing. It must not be treated as evidence that MiniCPM-V understood the image.
25
+
26
+ Correction after local runtime config check:
27
+
28
+ - The machine already had `/tmp/dream-customs-runtime.json` configured with text, vision, ASR, and hosted token values.
29
+ - Starting the app with `python app.py` does not auto-load that file.
30
+ - Starting with `.venv/bin/python scripts/local_space_mirror.py --port 7871` does auto-load it and prints only configuration booleans.
31
+ - Chrome Debug confirmed `text_endpoint_configured: true`, `vision_endpoint_configured: true`, `asr_endpoint_configured: true`, and `hosted_token_configured: true`.
32
+ - Re-running the sea sketch through Chrome paste plus Chinese text produced real MiniCPM-V image clues: stick figure, wavy water, crescent moon, visible text `dark sea dream`.
33
+ - Final user-visible result preserved the user's question `为什么它让我这么慌?` and used Chinese anchors `小人`, `月牙`, `漆黑的海`, `草图`. The bad English anchor `a simple sketch of a person standing on wavy` no longer appeared after the follow-up fix.
34
+
35
+ ## Real MiniCPM-V-4.6 Route
36
+
37
+ The three sketches were sent to the Modal vision endpoint through a temporary local runner. Secret handling: token was used only inside the Modal runner path; no token was printed, stored, or committed.
38
+
39
+ | Case | Expected visual markers | HTTP | Elapsed | Marker hits | Modal-V result |
40
+ | --- | --- | --- | ---: | --- | --- |
41
+ | `sea` | sea, wave, moon, person | 200 | 25.48s | sea, wave, moon | saw a sea under night, stick figure, waves, crescent moon, visible text `dark sea dream` |
42
+ | `elevator` | elevator, button, 14, melted | 200 | 5.55s | elevator, button, 14, melted | saw melted elevator buttons, two gray doors, visible text `14` |
43
+ | `subway_child` | subway, child, home, arrow | 200 | 5.62s | subway, child, home, arrow | saw a lost child at a subway station, subway sign, arrow, visible text `HOME ?` |
44
+
45
+ Conclusion: MiniCPM-V-4.6 did understand the low-resolution dream sketches and returned concrete visual evidence.
46
+
47
+ ## Backend Fix Verified
48
+
49
+ Observed Modal response shape:
50
+
51
+ - The hosted endpoint returned `response` as a stringified Python dictionary.
52
+ - The useful assistant JSON was nested under `generated_text[-1].content`.
53
+ - Before this fix, Dream QA could treat that whole string as unparsed text and lose the structured witness report.
54
+
55
+ Fix:
56
+
57
+ - `HostedMiniCPMVisionClient` now extracts assistant content from stringified Modal pipeline payloads and message lists.
58
+ - Chinese Dream QA now localizes common English visual anchors from MiniCPM-V, so a Chinese user does not see awkward anchors like `dreamlike representation of a sea`.
59
+
60
+ Verification:
61
+
62
+ ```bash
63
+ .venv/bin/python -m pytest -q tests/test_ollama_models.py tests/test_vision_witness.py tests/test_pipeline.py::test_visual_witness_clues_drive_questions_and_today_tip tests/test_pipeline.py::test_zh_text_and_image_keep_user_question_while_using_visual_anchors
64
+ .venv/bin/python -m py_compile dream_customs/models.py dream_customs/pipeline.py
65
+ ```
66
+
67
+ Result: `21 passed in 0.12s`; `py_compile` passed.
68
+
69
+ ## Text + Image Product Rule
70
+
71
+ When the user uploads both text and image:
72
+
73
+ 1. User text owns the emotional question and intent. Example: `我想知道为什么它让我这么慌` becomes the main question.
74
+ 2. Image contributes concrete anchors. Example: sea, waves, crescent moon, elevator button, subway sign, `HOME ?`.
75
+ 3. The follow-up and Today Tip must combine them: answer the user's feeling first, then use the visual anchors as evidence.
76
+ 4. If text and image conflict, do not force a single meaning. Ask one gentle clarification or phrase it as uncertainty.
77
+ 5. Text-only remains the fallback; image failure must not block the user from getting a grounded response.
docs/smoke/assets/2026-06-12-chrome-local-modal-sea-tip.png ADDED
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",
@@ -304,6 +316,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 +512,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
 
@@ -543,9 +606,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 +1384,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 +1416,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 +1444,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 +1472,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",
 
316
  "梦境": "dream",
317
  }
318
 
319
+ _EN_TO_ZH_PHRASES = {
320
+ "a simple sketch of a person standing on wavy": "海浪上的小人",
321
+ "simple sketch of a person standing on wavy": "海浪上的小人",
322
+ "person standing on wavy": "海浪上的小人",
323
+ "wavy lines representing water": "海浪",
324
+ "wavy lines": "海浪",
325
+ "wavy": "海浪",
326
+ "a dreamlike representation of a sea under night": "夜晚的海",
327
+ "dreamlike representation of a sea": "夜晚的海",
328
+ "sea under night": "夜晚的海",
329
+ "dark sea dream": "漆黑的海",
330
+ "dark sea": "漆黑的海",
331
+ "wavy water": "海浪",
332
+ "waves": "海浪",
333
+ "wave": "海浪",
334
+ "sea": "海",
335
+ "crescent moon": "月牙",
336
+ "moon": "月亮",
337
+ "stick figure": "小人",
338
+ "melted elevator buttons": "融化的电梯按钮",
339
+ "melted elevator button": "融化的电梯按钮",
340
+ "elevator buttons": "电梯按钮",
341
+ "elevator button": "电梯按钮",
342
+ "elevator doors": "电梯门",
343
+ "elevator": "电梯",
344
+ "button": "按钮",
345
+ "floor 14": "数字 14",
346
+ "number 14": "数字 14",
347
+ "lost child at subway station": "地铁站里迷路的小孩",
348
+ "lost child at subway": "地铁里迷路的小孩",
349
+ "subway station": "地铁站",
350
+ "subway": "地铁",
351
+ "child figure": "小孩",
352
+ "child": "小孩",
353
+ "home": "回家的方向",
354
+ "arrow": "箭头",
355
+ }
356
+
357
 
358
  def _dedupe_preserve_order(items: List[str]) -> List[str]:
359
  seen = set()
 
512
  return clean
513
 
514
 
515
+ def _zh_anchor_text(text: str) -> str:
516
+ clean = re.sub(r"\s+", " ", (text or "").strip(" .,:;!?\"'()[]{}"))
517
+ if not clean:
518
+ return ""
519
+ lowered = clean.lower()
520
+ for source, target in sorted(_EN_TO_ZH_PHRASES.items(), key=lambda item: len(item[0]), reverse=True):
521
+ if source in lowered:
522
+ return target
523
+ if re.search(r"[A-Za-z]", clean) and not re.search(r"[\u4e00-\u9fff]", clean):
524
+ return ""
525
+ return clean
526
+
527
+
528
  def _anchors_for_language(intake: DreamIntake, language: str = "en") -> List[str]:
529
  anchors = _extract_dream_anchors(intake)
530
  if _is_zh(language):
531
+ return _dedupe_preserve_order([anchor for anchor in (_zh_anchor_text(anchor) for anchor in anchors) if anchor])
532
  localized = [_english_anchor_text(anchor) for anchor in anchors]
533
  return _dedupe_preserve_order([anchor for anchor in localized if anchor])
534
 
 
606
  return f"你梦见{clean}"
607
 
608
 
609
+ def _user_supplied_text(intake: DreamIntake, answers: str = "", include_mood: bool = False) -> str:
610
+ parts = [
611
+ intake.dream_text,
612
+ intake.voice_transcript,
613
+ intake.main_question,
614
+ intake.uncertainty,
615
+ intake.user_context,
616
+ answers or "",
617
+ ]
618
+ if include_mood:
619
+ parts.append(intake.mood)
620
+ return "\n".join(part.strip() for part in parts if part and part.strip())
621
+
622
+
623
+ def _question_sentence_candidates(text: str) -> List[str]:
624
+ pieces = re.split(r"[。!?!?\n\r]+|(?<=\.)\s+|[,,;;]", text or "")
625
+ return [piece.strip(" ::「」\"'()[]{}") for piece in pieces if piece.strip()]
626
+
627
+
628
+ def _clean_user_question(text: str, language: str = "en") -> str:
629
+ clean = re.sub(r"\s+", " ", (text or "").strip(" ::「」\"'()[]{}"))
630
+ if not clean:
631
+ return ""
632
+ if _is_zh(language):
633
+ clean = re.sub(r"^(我)?(醒来后)?(最)?(想知道|想问|在想|担心|害怕)[::,,\s]*", "", clean)
634
+ clean = re.sub(r"^(只)?想知道[::,,\s]*", "", clean)
635
+ clean = clean.strip(" ::「」\"'()[]{}")
636
+ return clean if clean.endswith(("?", "?")) else f"{clean}?"
637
+ clean = re.sub(
638
+ 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*",
639
+ lambda match: (match.group(4) or "").strip() + " ",
640
+ clean,
641
+ flags=re.IGNORECASE,
642
+ ).strip()
643
+ clean = clean[0].upper() + clean[1:] if clean else ""
644
+ 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):
645
+ clean += "?"
646
+ return clean
647
+
648
+
649
+ def _extract_explicit_user_question(intake: DreamIntake, answers: str = "", language: str = "en") -> str:
650
+ if intake.main_question.strip():
651
+ return _clean_user_question(intake.main_question, language)
652
+ text = _user_supplied_text(intake, answers)
653
+ if _is_zh(language):
654
+ markers = (
655
+ "为什么",
656
+ "是不是",
657
+ "会不会",
658
+ "怎么办",
659
+ "我该",
660
+ "能不能",
661
+ "代表",
662
+ "说明",
663
+ "预兆",
664
+ "征兆",
665
+ "想知道",
666
+ "想问",
667
+ "撑不住",
668
+ "走出来",
669
+ "这么难受",
670
+ )
671
+ else:
672
+ markers = (
673
+ "why",
674
+ "what should",
675
+ "how should",
676
+ "does this mean",
677
+ "do i",
678
+ "am i",
679
+ "is this",
680
+ "could this",
681
+ "can this",
682
+ "should i",
683
+ "i wonder",
684
+ "want to know",
685
+ "not coping",
686
+ "cope",
687
+ "a sign",
688
+ "omen",
689
+ )
690
+ for sentence in _question_sentence_candidates(text):
691
+ lowered = sentence.lower()
692
+ if _is_skip_answer(sentence, language):
693
+ continue
694
+ if any(marker in lowered for marker in markers):
695
+ return _clean_user_question(sentence, language)
696
+ return ""
697
+
698
+
699
+ def _emotion_labels_from_text(text: str, language: str = "en") -> List[str]:
700
+ lowered = (text or "").lower()
701
+ if _is_zh(language):
702
+ groups = [
703
+ ("害怕", ["害怕", "吓", "恐惧", "心慌", "心跳很快", "惊醒"]),
704
+ ("压力", ["压力", "撑不住", "扛不住", "崩溃", "压垮", "焦虑", "太累", "疲惫"]),
705
+ ("难过", ["难过", "难受", "伤心", "失落", "想哭", "委屈", "孤独"]),
706
+ ("自责", ["自责", "内疚", "愧疚", "后悔", "责怪自己"]),
707
+ ("需要安慰", ["安慰", "被安慰", "抱抱", "关心", "陪陪", "鸡汤"]),
708
+ ]
709
+ else:
710
+ groups = [
711
+ ("fear", ["scared", "afraid", "terrified", "panic", "panicked", "frightened"]),
712
+ ("pressure", ["overwhelmed", "not coping", "can't cope", "cannot cope", "burned out", "too much", "anxious"]),
713
+ ("sadness", ["sad", "grief", "hurt", "lonely", "heartbroken", "upset"]),
714
+ ("guilt", ["guilty", "ashamed", "blame myself", "regret"]),
715
+ ("comfort", ["comfort", "reassurance", "not productivity", "not advice", "not a pep talk"]),
716
+ ]
717
+ labels: List[str] = []
718
+ for label, terms in groups:
719
+ if any(term in lowered for term in terms):
720
+ labels.append(label)
721
+ return _dedupe_preserve_order(labels)
722
+
723
+
724
+ def _emotion_phrase(labels: List[str], language: str = "en") -> str:
725
+ if not labels:
726
+ return "这个感受" if _is_zh(language) else "this feeling"
727
+ if _is_zh(language):
728
+ return "、".join(labels)
729
+ if len(labels) == 1:
730
+ return labels[0]
731
+ return ", ".join(labels[:-1]) + f", and {labels[-1]}"
732
+
733
+
734
+ def _needs_comfort(text: str, language: str = "en") -> bool:
735
+ lowered = (text or "").lower()
736
+ if _is_zh(language):
737
+ return any(term in lowered for term in ["安慰", "被安慰", "抱抱", "关心", "别催", "不是鸡汤"])
738
+ return any(term in lowered for term in ["comfort", "reassurance", "not productivity", "not a pep talk", "not advice"])
739
+
740
+
741
+ def _should_use_emotion_led_response(intake: DreamIntake, answers: str, language: str = "en") -> bool:
742
+ user_text = _user_supplied_text(intake, answers)
743
+ explicit_question = _extract_explicit_user_question(intake, answers, language)
744
+ labels = _emotion_labels_from_text(user_text, language)
745
+ if _is_low_context_intake(intake) and _is_skip_answer(answers, language) and not explicit_question and not _needs_comfort(user_text, language):
746
+ return False
747
+ return bool(explicit_question or labels or _needs_comfort(user_text, language))
748
+
749
+
750
+ def _direct_question_reassurance(question: str, labels: List[str], language: str = "en") -> str:
751
+ lowered = (question or "").lower()
752
+ if _is_zh(language):
753
+ if any(term in lowered for term in ["撑不住", "扛不住", "崩溃"]):
754
+ return "它不等于你一定撑不住了;更像是在提醒你,最近的压力已经值得被认真照顾。"
755
+ if "走出来" in lowered:
756
+ return "它不急着证明你有没有走出来;更像是在说明这段难受还需要一点被看见的时间。"
757
+ if any(term in lowered for term in ["预兆", "征兆", "坏事", "会不会发生"]):
758
+ return "它不适合被当作预兆;我们先把它当作醒来后仍在身体里的担心来照顾。"
759
+ if any(term in lowered for term in ["我是不是", "是不是我"]):
760
+ return "它不是给你贴标签的证据;它更像是在把一个需要被接住的感受放到你面前。"
761
+ if "为什么" in lowered or "这么难受" in lowered:
762
+ return "这么难受不是你反应过度;梦可能只是把还没放下的感受放大给你看。"
763
+ emotion = _emotion_phrase(labels, language)
764
+ return f"你提到的{emotion}不是小事,它值得先被接住,而不是立刻被解释掉。"
765
+ if any(term in lowered for term in ["not coping", "cope", "coping", "falling apart"]):
766
+ return "This does not prove you are failing to cope; it may be showing that your pressure deserves care before answers."
767
+ if "sign" in lowered or "omen" in lowered or "bad will happen" in lowered:
768
+ return "This is safer to treat as a fear to care for, not as evidence that something bad will happen."
769
+ if "am i" in lowered or "does this mean i" in lowered:
770
+ return "This dream is not evidence for a label about you; it is one feeling asking to be met gently."
771
+ if "why" in lowered:
772
+ return "The ache makes sense; the dream may be enlarging a feeling that has not had enough room yet."
773
+ emotion = _emotion_phrase(labels, language)
774
+ return f"The {emotion} you named deserves to be met first, not explained away too quickly."
775
+
776
+
777
+ def _emotion_led_interpretation(intake: DreamIntake, answers: str, anchors: List[str], language: str = "en") -> str:
778
+ if not _should_use_emotion_led_response(intake, answers, language):
779
+ return ""
780
+ question = _extract_explicit_user_question(intake, answers, language)
781
+ labels = _emotion_labels_from_text(_user_supplied_text(intake, answers), language)
782
+ emotion = _emotion_phrase(labels, language)
783
+ anchor = _answer_bridge_anchor(anchors)
784
+ direct = _direct_question_reassurance(question, labels, language)
785
+ if _is_zh(language):
786
+ opener = f"你问的是「{question}」。" if question else ""
787
+ return (
788
+ f"{opener}{direct} 第一层,先承认醒来后的{emotion}是真实的,不需要被责怪。"
789
+ f"第二层,梦里的「{anchor}」也许把这种感受变成了一个可以看的画面。"
790
+ "第三层,今天不急着找唯一答案,先给自己一个能站稳的小支点。"
791
+ )
792
+ opener = f"You asked, \"{question}\" " if question else ""
793
+ return (
794
+ f"{opener}{direct} First, let the {emotion} be real without blaming yourself. "
795
+ f"Second, the {_anchor_with_article(anchor)} may be the dream's concrete shape for that feeling. "
796
+ "Third, for today the goal is not to solve the whole dream, but to give yourself one steadier place to stand."
797
+ )
798
+
799
+
800
+ def _emotion_led_today_tip(intake: DreamIntake, answers: str, anchors: List[str], language: str = "en") -> str:
801
+ if not _should_use_emotion_led_response(intake, answers, language):
802
+ return ""
803
+ if _has_prophecy_frame(_user_supplied_text(intake, answers)):
804
+ return ""
805
+ labels = _emotion_labels_from_text(_user_supplied_text(intake, answers), language)
806
+ emotion = _emotion_phrase(labels, language)
807
+ anchor = anchors[0]
808
+ if _is_zh(language):
809
+ return (
810
+ f"今天先把「{anchor}」当作{emotion}的形状,而不是结论:写一句“我现在被什么淹到/刺痛”,"
811
+ "再写一句“我此刻可以抓住的一个小安定”。"
812
+ )
813
+ return (
814
+ f"For today, treat the {anchor} as the shape of {emotion}, not a verdict: write one line naming "
815
+ "what feels heavy, then one line naming a small steady thing you can hold now."
816
+ )
817
+
818
+
819
+ def _emotion_led_tiny_action(intake: DreamIntake, answers: str, anchors: List[str], language: str = "en") -> str:
820
+ if not _should_use_emotion_led_response(intake, answers, language):
821
+ return ""
822
+ anchor = anchors[0]
823
+ if _is_zh(language):
824
+ return f"用 5 分钟写两行:1.「{anchor}」让我最难受的是…… 2. 现在我能给自己的一点安定是……"
825
+ return f"Spend five minutes writing two lines: 1. The {anchor} hurts most because... 2. One steady thing I can offer myself now is..."
826
+
827
+
828
+ def _emotion_led_caring_note(intake: DreamIntake, answers: str, language: str = "en") -> str:
829
+ if not _should_use_emotion_led_response(intake, answers, language):
830
+ return ""
831
+ labels = _emotion_labels_from_text(_user_supplied_text(intake, answers), language)
832
+ emotion = _emotion_phrase(labels, language)
833
+ if _is_zh(language):
834
+ return f"你不是太脆弱,也不是需要被催着立刻想通;这份{emotion}可以先被轻轻接住。"
835
+ return f"You are not weak for feeling {emotion}; you do not have to turn it into a lesson before you are comforted."
836
+
837
+
838
  def _main_question_from_intake(intake: DreamIntake, language: str = "en") -> str:
839
  if intake.main_question.strip():
840
+ return _clean_user_question(intake.main_question, language)
841
+ explicit_question = _extract_explicit_user_question(intake, "", language)
842
+ if explicit_question:
843
+ return explicit_question
844
  task = _task_focus(intake.merged_text(), language)
845
  if task:
846
  if not _is_zh(language):
 
1384
  setattr(polished, field, _clean_placeholder_phrase(getattr(polished, field)))
1385
  if not polished.dream_summary.strip() or _is_placeholder_anchor(polished.dream_summary) or not _text_uses_anchor(polished.dream_summary, anchors):
1386
  polished.dream_summary = _summary_from_intake(intake, language)
1387
+ explicit_question = _extract_explicit_user_question(intake, answers, language)
1388
+ if explicit_question:
1389
+ polished.main_question = explicit_question
1390
+ elif (
1391
  not polished.main_question.strip()
1392
  or _is_placeholder_anchor(polished.main_question)
1393
  or not _text_uses_anchor(polished.main_question, anchors)
1394
  ):
1395
  polished.main_question = _main_question_from_intake(intake, language)
1396
+ emotion_interpretation = _emotion_led_interpretation(intake, answers, anchors, language)
1397
  answer_interpretation = _answer_based_interpretation(answers, _answer_bridge_anchor(anchors), language)
1398
+ if emotion_interpretation:
1399
+ polished.interpretation = emotion_interpretation
1400
+ elif answer_interpretation:
1401
  polished.interpretation = answer_interpretation
1402
  elif _has_prophecy_frame(intake.merged_text()):
1403
  anchor = _answer_bridge_anchor(anchors)
 
1416
  elif not polished.interpretation.strip() or not _anchor_in_text(polished.interpretation, anchors):
1417
  polished.interpretation = _fallback_interpretation(intake, language)
1418
  generic_tip_markers = ["drink water", "hydrate", "多休息", "保持积极", "take a walk"]
1419
+ emotion_tip = _emotion_led_today_tip(intake, answers, anchors, language)
1420
  answer_tip = _answer_based_today_tip(answers, anchors[0], language)
1421
+ if emotion_tip:
1422
+ polished.today_tip = emotion_tip
1423
+ elif answer_tip:
1424
  polished.today_tip = answer_tip
1425
  elif _has_prophecy_frame(intake.merged_text()):
1426
  anchor = anchors[0]
 
1444
  ):
1445
  polished.today_tip = _grounded_today_tip(intake, language)
1446
  hard_action_markers = ["address it immediately", "fix it immediately", "solve it immediately"]
1447
+ emotion_action = _emotion_led_tiny_action(intake, answers, anchors, language)
1448
  answer_action = _answer_based_tiny_action(answers, language)
1449
+ if emotion_action:
1450
+ polished.tiny_action = emotion_action
1451
+ elif answer_action:
1452
  polished.tiny_action = answer_action
1453
  elif _has_prophecy_frame(intake.merged_text()):
1454
  polished.tiny_action = (
 
1472
  polished.tiny_action = f"用 5 分钟写下:今天和「{anchors[0]}」有关的第一小步是什么?"
1473
  else:
1474
  polished.tiny_action = f"Spend five minutes writing the first small step connected to the {anchors[0]}."
1475
+ emotion_caring_note = _emotion_led_caring_note(intake, answers, language)
1476
+ if emotion_caring_note:
1477
+ polished.caring_note = emotion_caring_note
1478
+ elif not polished.caring_note.strip():
1479
  polished.caring_note = (
1480
  "你不需要一醒来就解决整个梦,先把一个细节照亮就很好。"
1481
  if _is_zh(language)
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晚都睡不好。",
@@ -536,6 +603,42 @@ 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晚都睡不好。",
 
603
  assert "objects" not in combined
604
 
605
 
606
+ def test_zh_text_and_image_keep_user_question_while_using_visual_anchors():
607
+ class SeaDreamVision:
608
+ def extract_witness(self, image_path):
609
+ return VisionWitness(
610
+ scene_summary=(
611
+ "A simple sketch of a person standing on wavy lines under a crescent moon, "
612
+ "with the text 'dark sea dream' above."
613
+ ),
614
+ objects=["stick figure", "wavy lines representing water", "crescent moon"],
615
+ visible_text=["dark sea dream"],
616
+ mood_cues=["small figure in a large dark place"],
617
+ )
618
+
619
+ def extract_clues(self, image_path):
620
+ return ["flat fallback should not win"]
621
+
622
+ session = add_evidence(
623
+ create_session(language="zh"),
624
+ dream_text="我醒来很害怕,这张草图是梦里最清楚的画面。我想知道为什么它让我这么慌。",
625
+ image_path="sea.png",
626
+ mood="害怕",
627
+ vision_client=SeaDreamVision(),
628
+ asr_client=FakeASRClient(),
629
+ language="zh",
630
+ )
631
+ card = generate_today_tip(session.intake, "", FakeTextClient(), language="zh")
632
+ combined = "\n".join([card.main_question, ",".join(card.dream_anchors), card.interpretation, card.today_tip])
633
+
634
+ assert "为什么它让我这么慌?" in card.main_question
635
+ assert any(anchor in card.dream_anchors for anchor in ["夜晚的海", "海浪", "月牙", "漆黑的海"])
636
+ assert "blue hallway" not in combined
637
+ assert "a simple sketch" not in combined.lower()
638
+ assert "dreamlike representation" not in combined.lower()
639
+ assert "这么难受不是你反应过度" in card.interpretation
640
+
641
+
642
  def test_witness_failure_keeps_text_path_alive():
643
  class BrokenWitnessVision:
644
  def extract_witness(self, image_path):