Harden Dream QA vision intake

#32
by ADJCJH - opened
docs/smoke/2026-06-12-modal-vision-sketch-evidence.json ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "generated_at": "2026-06-12T01:53:21.674225+00:00",
3
+ "cwd": "/Users/junhaocheng/working-dir/ai-competitions/build-small-hackthon",
4
+ "git_commit": "ec0441abdecacf44918ca9eace7c2a57e6795d1b",
5
+ "git_branch": "bugfix/emotion-led-dream-qa",
6
+ "model": {
7
+ "modal_app": "dream-customs-minicpm-backend",
8
+ "vision_model": "openbmb/MiniCPM-V-4.6",
9
+ "endpoint_host": "cjh-12569--dream-customs-minicpm-backend-vision.modal.run"
10
+ },
11
+ "secret_handling": "Modal secret used only inside temporary Modal runner; no token printed or written.",
12
+ "results": [
13
+ {
14
+ "case_id": "sea",
15
+ "image_path": "/Users/junhaocheng/working-dir/ai-competitions/build-small-hackthon/tmp/dream-sketches/dream_sketch_01_sea.png",
16
+ "expected_markers": [
17
+ "sea",
18
+ "wave",
19
+ "moon",
20
+ "person"
21
+ ],
22
+ "http_status": 200,
23
+ "transport_ok": true,
24
+ "elapsed_seconds": 25.48,
25
+ "route_error": "",
26
+ "raw_response": "{'input_text': [{'role': 'user', 'content': [{'type': 'image', 'path': '/tmp/tmpjyirmi6i.png'}, {'type': 'text', 'text': 'You are MiniCPM-V-4.6 acting as the visual witness for Dream QA. Describe only what is visible in this dream sketch. Return concise JSON with keys: scene_summary, objects, visible_text, spatial_relations, mood_cues, uncertain_details, surprising_detail. Do not diagnose the user.'}]}], 'generated_text': [{'role': 'user', 'content': [{'type': 'image', 'path': '/tmp/tmpjyirmi6i.png'}, {'type': 'text', 'text': 'You are MiniCPM-V-4.6 acting as the visual witness for Dream QA. Describe only what is visible in this dream sketch. Return concise JSON with keys: scene_summary, objects, visible_text, spatial_relations, mood_cues, uncertain_details, surprising_detail. Do not diagnose the user.'}]}, {'role': 'assistant', 'content': '\\n\\n{\\n \"scene_summary\": \"A dreamlike representation of a sea under night\",\\n \"objects\": [\"a stick figure\", \"wavy lines representing water\", \"a crescent moon\", \"a circular shape above the stick figure\"],\\n \"visible_text\": [\"dark sea dream\"],\\n \"spatial_relations\": \"the stick figure is on the water waves, with the moon in the upper right\",\\n \"mood_cues\": \"calm and dreamy\",\\n \"uncertain_details\": \"the exact emotions or states of the figure\",\\n \"surprising_detail\": \"the stick figure has a curved line above its head, suggesting a sense of wonder or awareness\"\\n}'}]}",
27
+ "marker_hits": [
28
+ "sea",
29
+ "wave",
30
+ "moon"
31
+ ]
32
+ },
33
+ {
34
+ "case_id": "elevator",
35
+ "image_path": "/Users/junhaocheng/working-dir/ai-competitions/build-small-hackthon/tmp/dream-sketches/dream_sketch_02_elevator.png",
36
+ "expected_markers": [
37
+ "elevator",
38
+ "button",
39
+ "14",
40
+ "melted"
41
+ ],
42
+ "http_status": 200,
43
+ "transport_ok": true,
44
+ "elapsed_seconds": 5.55,
45
+ "route_error": "",
46
+ "raw_response": "{'input_text': [{'role': 'user', 'content': [{'type': 'image', 'path': '/tmp/tmpc8qf8m8w.png'}, {'type': 'text', 'text': 'You are MiniCPM-V-4.6 acting as the visual witness for Dream QA. Describe only what is visible in this dream sketch. Return concise JSON with keys: scene_summary, objects, visible_text, spatial_relations, mood_cues, uncertain_details, surprising_detail. Do not diagnose the user.'}]}], 'generated_text': [{'role': 'user', 'content': [{'type': 'image', 'path': '/tmp/tmpc8qf8m8w.png'}, {'type': 'text', 'text': 'You are MiniCPM-V-4.6 acting as the visual witness for Dream QA. Describe only what is visible in this dream sketch. Return concise JSON with keys: scene_summary, objects, visible_text, spatial_relations, mood_cues, uncertain_details, surprising_detail. Do not diagnose the user.'}]}, {'role': 'assistant', 'content': '\\n\\n{\\n \"scene_summary\": \"A conceptual visual representation of melted elevator buttons in a setting\",\\n \"objects\": [\"melted elevator buttons\", \"two tall gray doors\"],\\n \"visible_text\": [\"14\", \"melted elevator buttons\"],\\n \"spatial_relations\": \"The melted buttons are to the right of the elevator doors\",\\n \"mood_cues\": \"Suggests a malfunction or melting process\",\\n \"uncertain_details\": \"The exact state or effects of the melted buttons are implied\",\\n \"surprising_detail\": \"The buttons are depicted as colorful segments, indicating a melting or transition state\"\\n}'}]}",
47
+ "marker_hits": [
48
+ "elevator",
49
+ "button",
50
+ "14",
51
+ "melted"
52
+ ]
53
+ },
54
+ {
55
+ "case_id": "subway_child",
56
+ "image_path": "/Users/junhaocheng/working-dir/ai-competitions/build-small-hackthon/tmp/dream-sketches/dream_sketch_03_subway_child.png",
57
+ "expected_markers": [
58
+ "subway",
59
+ "child",
60
+ "home",
61
+ "arrow"
62
+ ],
63
+ "http_status": 200,
64
+ "transport_ok": true,
65
+ "elapsed_seconds": 5.62,
66
+ "route_error": "",
67
+ "raw_response": "{'input_text': [{'role': 'user', 'content': [{'type': 'image', 'path': '/tmp/tmp38m34fjv.png'}, {'type': 'text', 'text': 'You are MiniCPM-V-4.6 acting as the visual witness for Dream QA. Describe only what is visible in this dream sketch. Return concise JSON with keys: scene_summary, objects, visible_text, spatial_relations, mood_cues, uncertain_details, surprising_detail. Do not diagnose the user.'}]}], 'generated_text': [{'role': 'user', 'content': [{'type': 'image', 'path': '/tmp/tmp38m34fjv.png'}, {'type': 'text', 'text': 'You are MiniCPM-V-4.6 acting as the visual witness for Dream QA. Describe only what is visible in this dream sketch. Return concise JSON with keys: scene_summary, objects, visible_text, spatial_relations, mood_cues, uncertain_details, surprising_detail. Do not diagnose the user.'}]}, {'role': 'assistant', 'content': '\\n\\n{\\n \"scene_summary\": \"A simple sketch depicting a lost child at a subway station\",\\n \"objects\": [\"a child figure\", \"a subway sign\", \"an arrow\"],\\n \"visible_text\": [\"lost child at subway\", \"SUBWAY\", \"HOME ?\"],\\n \"spatial_relations\": \"the child is in front of the subway sign, which has an arrow pointing to the right\",\\n \"mood_cues\": \"suggests a situation of loss or search\",\\n \"uncertain_details\": \"what the child is looking for or where home is\",\\n \"surprising_detail\": \"the sign asks \\'HOME ?\\'\"\\n}'}]}",
68
+ "marker_hits": [
69
+ "subway",
70
+ "child",
71
+ "home",
72
+ "arrow"
73
+ ]
74
+ }
75
+ ]
76
+ }
docs/smoke/2026-06-12-modal-vision-sketch-evidence.md ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+
13
+ ## Chrome Local Upload Check
14
+
15
+ Local app URL: `http://127.0.0.1:7870`
16
+
17
+ Result:
18
+
19
+ - Native file chooser injection was blocked by the Chrome extension file-access permission.
20
+ - Clipboard paste into the Gradio image input worked. The page showed `Remove Image`, confirming the image slot received the sketch.
21
+ - 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.
22
+
23
+ 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.
24
+
25
+ ## Real MiniCPM-V-4.6 Route
26
+
27
+ 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.
28
+
29
+ | Case | Expected visual markers | HTTP | Elapsed | Marker hits | Modal-V result |
30
+ | --- | --- | --- | ---: | --- | --- |
31
+ | `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` |
32
+ | `elevator` | elevator, button, 14, melted | 200 | 5.55s | elevator, button, 14, melted | saw melted elevator buttons, two gray doors, visible text `14` |
33
+ | `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 ?` |
34
+
35
+ Conclusion: MiniCPM-V-4.6 did understand the low-resolution dream sketches and returned concrete visual evidence.
36
+
37
+ ## Backend Fix Verified
38
+
39
+ Observed Modal response shape:
40
+
41
+ - The hosted endpoint returned `response` as a stringified Python dictionary.
42
+ - The useful assistant JSON was nested under `generated_text[-1].content`.
43
+ - Before this fix, Dream QA could treat that whole string as unparsed text and lose the structured witness report.
44
+
45
+ Fix:
46
+
47
+ - `HostedMiniCPMVisionClient` now extracts assistant content from stringified Modal pipeline payloads and message lists.
48
+ - 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`.
49
+
50
+ Verification:
51
+
52
+ ```bash
53
+ .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
54
+ .venv/bin/python -m py_compile dream_customs/models.py dream_customs/pipeline.py
55
+ ```
56
+
57
+ Result: `21 passed in 0.12s`; `py_compile` passed.
58
+
59
+ ## Text + Image Product Rule
60
+
61
+ When the user uploads both text and image:
62
+
63
+ 1. User text owns the emotional question and intent. Example: `我想知道为什么它让我这么慌` becomes the main question.
64
+ 2. Image contributes concrete anchors. Example: sea, waves, crescent moon, elevator button, subway sign, `HOME ?`.
65
+ 3. The follow-up and Today Tip must combine them: answer the user's feeling first, then use the visual anchors as evidence.
66
+ 4. If text and image conflict, do not force a single meaning. Ask one gentle clarification or phrase it as uncertainty.
67
+ 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-image-paste-fallback.png ADDED
docs/smoke/assets/dream_sketch_01_sea.png ADDED
docs/smoke/assets/dream_sketch_02_elevator.png ADDED
docs/smoke/assets/dream_sketch_03_subway_child.png ADDED
dream_customs/models.py CHANGED
@@ -1,4 +1,5 @@
1
  import base64
 
2
  import json
3
  import os
4
  import re
@@ -549,7 +550,16 @@ def _hosted_text_from_response(payload: Dict[str, Any]) -> str:
549
  for key in ("response", "text", "generated_text", "output"):
550
  value = payload.get(key)
551
  if isinstance(value, str):
 
 
 
 
 
552
  return value
 
 
 
 
553
  choices = payload.get("choices")
554
  if isinstance(choices, list) and choices:
555
  first = choices[0]
@@ -565,6 +575,32 @@ def _hosted_text_from_response(payload: Dict[str, Any]) -> str:
565
  return ""
566
 
567
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
568
  class HostedMiniCPMTextClient:
569
  def __init__(
570
  self,
 
1
  import base64
2
+ import ast
3
  import json
4
  import os
5
  import re
 
550
  for key in ("response", "text", "generated_text", "output"):
551
  value = payload.get(key)
552
  if isinstance(value, str):
553
+ parsed = _python_literal_dict(value)
554
+ if parsed:
555
+ nested = _hosted_text_from_response(parsed)
556
+ if nested:
557
+ return nested
558
  return value
559
+ if isinstance(value, list):
560
+ nested = _content_from_messages(value)
561
+ if nested:
562
+ return nested
563
  choices = payload.get("choices")
564
  if isinstance(choices, list) and choices:
565
  first = choices[0]
 
575
  return ""
576
 
577
 
578
+ def _python_literal_dict(text: str) -> Optional[Dict[str, Any]]:
579
+ try:
580
+ parsed = ast.literal_eval(text)
581
+ except (SyntaxError, ValueError):
582
+ return None
583
+ return parsed if isinstance(parsed, dict) else None
584
+
585
+
586
+ def _content_from_messages(messages: List[Any]) -> str:
587
+ for item in reversed(messages):
588
+ if not isinstance(item, dict):
589
+ continue
590
+ content = item.get("content")
591
+ if isinstance(content, str) and content.strip():
592
+ return content.strip()
593
+ if isinstance(content, list):
594
+ text_parts = [
595
+ str(part.get("text", "")).strip()
596
+ for part in content
597
+ if isinstance(part, dict) and str(part.get("text", "")).strip()
598
+ ]
599
+ if text_parts:
600
+ return "\n".join(text_parts)
601
+ return ""
602
+
603
+
604
  class HostedMiniCPMTextClient:
605
  def __init__(
606
  self,
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,38 @@ _ZH_TO_EN_PHRASES = {
304
  "梦境": "dream",
305
  }
306
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
307
 
308
  def _dedupe_preserve_order(items: List[str]) -> List[str]:
309
  seen = set()
@@ -462,10 +506,21 @@ 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 +598,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 +1376,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 +1408,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 +1436,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 +1464,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 dreamlike representation of a sea under night": "夜晚的海",
321
+ "dreamlike representation of a sea": "夜晚的海",
322
+ "sea under night": "夜晚的海",
323
+ "dark sea dream": "漆黑的海",
324
+ "dark sea": "漆黑的海",
325
+ "wavy water": "海浪",
326
+ "waves": "海浪",
327
+ "wave": "海浪",
328
+ "sea": "海",
329
+ "crescent moon": "月牙",
330
+ "moon": "月亮",
331
+ "stick figure": "小人",
332
+ "melted elevator buttons": "融化的电梯按钮",
333
+ "melted elevator button": "融化的电梯按钮",
334
+ "elevator buttons": "电梯按钮",
335
+ "elevator button": "电梯按钮",
336
+ "elevator doors": "电梯门",
337
+ "elevator": "电梯",
338
+ "button": "按钮",
339
+ "floor 14": "数字 14",
340
+ "number 14": "数字 14",
341
+ "lost child at subway station": "地铁站里迷路的小孩",
342
+ "lost child at subway": "地铁里迷路的小孩",
343
+ "subway station": "地铁站",
344
+ "subway": "地铁",
345
+ "child figure": "小孩",
346
+ "child": "小孩",
347
+ "home": "回家的方向",
348
+ "arrow": "箭头",
349
+ }
350
+
351
 
352
  def _dedupe_preserve_order(items: List[str]) -> List[str]:
353
  seen = set()
 
506
  return clean
507
 
508
 
509
+ def _zh_anchor_text(text: str) -> str:
510
+ clean = re.sub(r"\s+", " ", (text or "").strip(" .,:;!?\"'()[]{}"))
511
+ if not clean:
512
+ return ""
513
+ lowered = clean.lower()
514
+ for source, target in sorted(_EN_TO_ZH_PHRASES.items(), key=lambda item: len(item[0]), reverse=True):
515
+ if source in lowered:
516
+ return target
517
+ return clean
518
+
519
+
520
  def _anchors_for_language(intake: DreamIntake, language: str = "en") -> List[str]:
521
  anchors = _extract_dream_anchors(intake)
522
  if _is_zh(language):
523
+ return _dedupe_preserve_order([anchor for anchor in (_zh_anchor_text(anchor) for anchor in anchors) if anchor])
524
  localized = [_english_anchor_text(anchor) for anchor in anchors]
525
  return _dedupe_preserve_order([anchor for anchor in localized if anchor])
526
 
 
598
  return f"你梦见{clean}"
599
 
600
 
601
+ def _user_supplied_text(intake: DreamIntake, answers: str = "", include_mood: bool = False) -> str:
602
+ parts = [
603
+ intake.dream_text,
604
+ intake.voice_transcript,
605
+ intake.main_question,
606
+ intake.uncertainty,
607
+ intake.user_context,
608
+ answers or "",
609
+ ]
610
+ if include_mood:
611
+ parts.append(intake.mood)
612
+ return "\n".join(part.strip() for part in parts if part and part.strip())
613
+
614
+
615
+ def _question_sentence_candidates(text: str) -> List[str]:
616
+ pieces = re.split(r"[。!?!?\n\r]+|(?<=\.)\s+|[,,;;]", text or "")
617
+ return [piece.strip(" ::「」\"'()[]{}") for piece in pieces if piece.strip()]
618
+
619
+
620
+ def _clean_user_question(text: str, language: str = "en") -> str:
621
+ clean = re.sub(r"\s+", " ", (text or "").strip(" ::「」\"'()[]{}"))
622
+ if not clean:
623
+ return ""
624
+ if _is_zh(language):
625
+ clean = re.sub(r"^(我)?(醒来后)?(最)?(想知道|想问|在想|担心|害怕)[::,,\s]*", "", clean)
626
+ clean = re.sub(r"^(只)?想知道[::,,\s]*", "", clean)
627
+ clean = clean.strip(" ::「」\"'()[]{}")
628
+ return clean if clean.endswith(("?", "?")) else f"{clean}?"
629
+ clean = re.sub(
630
+ 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*",
631
+ lambda match: (match.group(4) or "").strip() + " ",
632
+ clean,
633
+ flags=re.IGNORECASE,
634
+ ).strip()
635
+ clean = clean[0].upper() + clean[1:] if clean else ""
636
+ 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):
637
+ clean += "?"
638
+ return clean
639
+
640
+
641
+ def _extract_explicit_user_question(intake: DreamIntake, answers: str = "", language: str = "en") -> str:
642
+ if intake.main_question.strip():
643
+ return _clean_user_question(intake.main_question, language)
644
+ text = _user_supplied_text(intake, answers)
645
+ if _is_zh(language):
646
+ markers = (
647
+ "为什么",
648
+ "是不��",
649
+ "会不会",
650
+ "怎么办",
651
+ "我该",
652
+ "能不能",
653
+ "代表",
654
+ "说明",
655
+ "预兆",
656
+ "征兆",
657
+ "想知道",
658
+ "想问",
659
+ "撑不住",
660
+ "走出来",
661
+ "这么难受",
662
+ )
663
+ else:
664
+ markers = (
665
+ "why",
666
+ "what should",
667
+ "how should",
668
+ "does this mean",
669
+ "do i",
670
+ "am i",
671
+ "is this",
672
+ "could this",
673
+ "can this",
674
+ "should i",
675
+ "i wonder",
676
+ "want to know",
677
+ "not coping",
678
+ "cope",
679
+ "a sign",
680
+ "omen",
681
+ )
682
+ for sentence in _question_sentence_candidates(text):
683
+ lowered = sentence.lower()
684
+ if _is_skip_answer(sentence, language):
685
+ continue
686
+ if any(marker in lowered for marker in markers):
687
+ return _clean_user_question(sentence, language)
688
+ return ""
689
+
690
+
691
+ def _emotion_labels_from_text(text: str, language: str = "en") -> List[str]:
692
+ lowered = (text or "").lower()
693
+ if _is_zh(language):
694
+ groups = [
695
+ ("害怕", ["害怕", "吓", "恐惧", "心慌", "心跳很快", "惊醒"]),
696
+ ("压力", ["压力", "撑不住", "扛不住", "崩溃", "压垮", "焦虑", "太累", "疲惫"]),
697
+ ("难过", ["难过", "难受", "伤心", "失落", "想哭", "委屈", "孤独"]),
698
+ ("自责", ["自责", "内疚", "愧疚", "后悔", "责怪自己"]),
699
+ ("需要安慰", ["安慰", "被安慰", "抱抱", "关心", "陪陪", "鸡汤"]),
700
+ ]
701
+ else:
702
+ groups = [
703
+ ("fear", ["scared", "afraid", "terrified", "panic", "panicked", "frightened"]),
704
+ ("pressure", ["overwhelmed", "not coping", "can't cope", "cannot cope", "burned out", "too much", "anxious"]),
705
+ ("sadness", ["sad", "grief", "hurt", "lonely", "heartbroken", "upset"]),
706
+ ("guilt", ["guilty", "ashamed", "blame myself", "regret"]),
707
+ ("comfort", ["comfort", "reassurance", "not productivity", "not advice", "not a pep talk"]),
708
+ ]
709
+ labels: List[str] = []
710
+ for label, terms in groups:
711
+ if any(term in lowered for term in terms):
712
+ labels.append(label)
713
+ return _dedupe_preserve_order(labels)
714
+
715
+
716
+ def _emotion_phrase(labels: List[str], language: str = "en") -> str:
717
+ if not labels:
718
+ return "这个感受" if _is_zh(language) else "this feeling"
719
+ if _is_zh(language):
720
+ return "、".join(labels)
721
+ if len(labels) == 1:
722
+ return labels[0]
723
+ return ", ".join(labels[:-1]) + f", and {labels[-1]}"
724
+
725
+
726
+ def _needs_comfort(text: str, language: str = "en") -> bool:
727
+ lowered = (text or "").lower()
728
+ if _is_zh(language):
729
+ return any(term in lowered for term in ["安慰", "被安慰", "抱抱", "关心", "别催", "不是鸡汤"])
730
+ return any(term in lowered for term in ["comfort", "reassurance", "not productivity", "not a pep talk", "not advice"])
731
+
732
+
733
+ def _should_use_emotion_led_response(intake: DreamIntake, answers: str, language: str = "en") -> bool:
734
+ user_text = _user_supplied_text(intake, answers)
735
+ explicit_question = _extract_explicit_user_question(intake, answers, language)
736
+ labels = _emotion_labels_from_text(user_text, language)
737
+ if _is_low_context_intake(intake) and _is_skip_answer(answers, language) and not explicit_question and not _needs_comfort(user_text, language):
738
+ return False
739
+ return bool(explicit_question or labels or _needs_comfort(user_text, language))
740
+
741
+
742
+ def _direct_question_reassurance(question: str, labels: List[str], language: str = "en") -> str:
743
+ lowered = (question or "").lower()
744
+ if _is_zh(language):
745
+ if any(term in lowered for term in ["撑不住", "扛不住", "崩溃"]):
746
+ return "它不等于你一定撑不住了;更像是在提醒你,最近的压力已经值得被认真照顾。"
747
+ if "走出来" in lowered:
748
+ return "它不急着证明你有没有走出来;更像是在说明这段难受还需要一点被看见的时间。"
749
+ if any(term in lowered for term in ["预兆", "征兆", "坏事", "会不会发生"]):
750
+ return "它不适合被当作预兆;我们先把它当作醒来后仍在身体里的担心来照顾。"
751
+ if any(term in lowered for term in ["我是不是", "是不是我"]):
752
+ return "它不是给你贴标签的证据;它更像是在把一个需要被接住的感受放到你面前。"
753
+ if "为什么" in lowered or "这么难受" in lowered:
754
+ return "这么难受不是你反应过度;梦可能只是把还没放下的感受放大给你看。"
755
+ emotion = _emotion_phrase(labels, language)
756
+ return f"你提到的{emotion}不是小事,它值得先被接住,而不是立刻被解释掉。"
757
+ if any(term in lowered for term in ["not coping", "cope", "coping", "falling apart"]):
758
+ return "This does not prove you are failing to cope; it may be showing that your pressure deserves care before answers."
759
+ if "sign" in lowered or "omen" in lowered or "bad will happen" in lowered:
760
+ return "This is safer to treat as a fear to care for, not as evidence that something bad will happen."
761
+ if "am i" in lowered or "does this mean i" in lowered:
762
+ return "This dream is not evidence for a label about you; it is one feeling asking to be met gently."
763
+ if "why" in lowered:
764
+ return "The ache makes sense; the dream may be enlarging a feeling that has not had enough room yet."
765
+ emotion = _emotion_phrase(labels, language)
766
+ return f"The {emotion} you named deserves to be met first, not explained away too quickly."
767
+
768
+
769
+ def _emotion_led_interpretation(intake: DreamIntake, answers: str, anchors: List[str], language: str = "en") -> str:
770
+ if not _should_use_emotion_led_response(intake, answers, language):
771
+ return ""
772
+ question = _extract_explicit_user_question(intake, answers, language)
773
+ labels = _emotion_labels_from_text(_user_supplied_text(intake, answers), language)
774
+ emotion = _emotion_phrase(labels, language)
775
+ anchor = _answer_bridge_anchor(anchors)
776
+ direct = _direct_question_reassurance(question, labels, language)
777
+ if _is_zh(language):
778
+ opener = f"你问的是「{question}」。" if question else ""
779
+ return (
780
+ f"{opener}{direct} 第一层,先承认醒来后的{emotion}是真实的,不需要被责怪。"
781
+ f"第二层,梦里的「{anchor}」也许把这种感受变成了一个可以看的画面。"
782
+ "第三层,今天不急着找唯一答案,先给自己一个能站稳的小支点。"
783
+ )
784
+ opener = f"You asked, \"{question}\" " if question else ""
785
+ return (
786
+ f"{opener}{direct} First, let the {emotion} be real without blaming yourself. "
787
+ f"Second, the {_anchor_with_article(anchor)} may be the dream's concrete shape for that feeling. "
788
+ "Third, for today the goal is not to solve the whole dream, but to give yourself one steadier place to stand."
789
+ )
790
+
791
+
792
+ def _emotion_led_today_tip(intake: DreamIntake, answers: str, anchors: List[str], language: str = "en") -> str:
793
+ if not _should_use_emotion_led_response(intake, answers, language):
794
+ return ""
795
+ if _has_prophecy_frame(_user_supplied_text(intake, answers)):
796
+ return ""
797
+ labels = _emotion_labels_from_text(_user_supplied_text(intake, answers), language)
798
+ emotion = _emotion_phrase(labels, language)
799
+ anchor = anchors[0]
800
+ if _is_zh(language):
801
+ return (
802
+ f"今天先把「{anchor}」当作{emotion}的形状,而不是结论:写一句“我现在被什么淹到/刺痛”,"
803
+ "再写一句“我此刻可以抓住的一个小安定”。"
804
+ )
805
+ return (
806
+ f"For today, treat the {anchor} as the shape of {emotion}, not a verdict: write one line naming "
807
+ "what feels heavy, then one line naming a small steady thing you can hold now."
808
+ )
809
+
810
+
811
+ def _emotion_led_tiny_action(intake: DreamIntake, answers: str, anchors: List[str], language: str = "en") -> str:
812
+ if not _should_use_emotion_led_response(intake, answers, language):
813
+ return ""
814
+ anchor = anchors[0]
815
+ if _is_zh(language):
816
+ return f"用 5 分钟写两行:1.「{anchor}」让我最难受的是…… 2. 现在我能给自己的一点安定是……"
817
+ return f"Spend five minutes writing two lines: 1. The {anchor} hurts most because... 2. One steady thing I can offer myself now is..."
818
+
819
+
820
+ def _emotion_led_caring_note(intake: DreamIntake, answers: str, language: str = "en") -> str:
821
+ if not _should_use_emotion_led_response(intake, answers, language):
822
+ return ""
823
+ labels = _emotion_labels_from_text(_user_supplied_text(intake, answers), language)
824
+ emotion = _emotion_phrase(labels, language)
825
+ if _is_zh(language):
826
+ return f"你不是太脆弱,也不是需要被催着立刻想通;这份{emotion}可以先被轻轻接住。"
827
+ return f"You are not weak for feeling {emotion}; you do not have to turn it into a lesson before you are comforted."
828
+
829
+
830
  def _main_question_from_intake(intake: DreamIntake, language: str = "en") -> str:
831
  if intake.main_question.strip():
832
+ return _clean_user_question(intake.main_question, language)
833
+ explicit_question = _extract_explicit_user_question(intake, "", language)
834
+ if explicit_question:
835
+ return explicit_question
836
  task = _task_focus(intake.merged_text(), language)
837
  if task:
838
  if not _is_zh(language):
 
1376
  setattr(polished, field, _clean_placeholder_phrase(getattr(polished, field)))
1377
  if not polished.dream_summary.strip() or _is_placeholder_anchor(polished.dream_summary) or not _text_uses_anchor(polished.dream_summary, anchors):
1378
  polished.dream_summary = _summary_from_intake(intake, language)
1379
+ explicit_question = _extract_explicit_user_question(intake, answers, language)
1380
+ if explicit_question:
1381
+ polished.main_question = explicit_question
1382
+ elif (
1383
  not polished.main_question.strip()
1384
  or _is_placeholder_anchor(polished.main_question)
1385
  or not _text_uses_anchor(polished.main_question, anchors)
1386
  ):
1387
  polished.main_question = _main_question_from_intake(intake, language)
1388
+ emotion_interpretation = _emotion_led_interpretation(intake, answers, anchors, language)
1389
  answer_interpretation = _answer_based_interpretation(answers, _answer_bridge_anchor(anchors), language)
1390
+ if emotion_interpretation:
1391
+ polished.interpretation = emotion_interpretation
1392
+ elif answer_interpretation:
1393
  polished.interpretation = answer_interpretation
1394
  elif _has_prophecy_frame(intake.merged_text()):
1395
  anchor = _answer_bridge_anchor(anchors)
 
1408
  elif not polished.interpretation.strip() or not _anchor_in_text(polished.interpretation, anchors):
1409
  polished.interpretation = _fallback_interpretation(intake, language)
1410
  generic_tip_markers = ["drink water", "hydrate", "多休息", "保持积极", "take a walk"]
1411
+ emotion_tip = _emotion_led_today_tip(intake, answers, anchors, language)
1412
  answer_tip = _answer_based_today_tip(answers, anchors[0], language)
1413
+ if emotion_tip:
1414
+ polished.today_tip = emotion_tip
1415
+ elif answer_tip:
1416
  polished.today_tip = answer_tip
1417
  elif _has_prophecy_frame(intake.merged_text()):
1418
  anchor = anchors[0]
 
1436
  ):
1437
  polished.today_tip = _grounded_today_tip(intake, language)
1438
  hard_action_markers = ["address it immediately", "fix it immediately", "solve it immediately"]
1439
+ emotion_action = _emotion_led_tiny_action(intake, answers, anchors, language)
1440
  answer_action = _answer_based_tiny_action(answers, language)
1441
+ if emotion_action:
1442
+ polished.tiny_action = emotion_action
1443
+ elif answer_action:
1444
  polished.tiny_action = answer_action
1445
  elif _has_prophecy_frame(intake.merged_text()):
1446
  polished.tiny_action = (
 
1464
  polished.tiny_action = f"用 5 分钟写下:今天和「{anchors[0]}」有关的第一小步是什么?"
1465
  else:
1466
  polished.tiny_action = f"Spend five minutes writing the first small step connected to the {anchors[0]}."
1467
+ emotion_caring_note = _emotion_led_caring_note(intake, answers, language)
1468
+ if emotion_caring_note:
1469
+ polished.caring_note = emotion_caring_note
1470
+ elif not polished.caring_note.strip():
1471
  polished.caring_note = (
1472
  "你不需要一醒来就解决整个梦,先把一个细节照亮就很好。"
1473
  if _is_zh(language)
tests/test_ollama_models.py CHANGED
@@ -242,3 +242,44 @@ def test_hosted_vision_client_accepts_partial_witness_json():
242
  assert "Object: elevator button" in clues
243
  assert "Visible text: 14" in clues
244
  assert "Mood cue: cold" in clues
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
242
  assert "Object: elevator button" in clues
243
  assert "Visible text: 14" in clues
244
  assert "Mood cue: cold" in clues
245
+
246
+
247
+ def test_hosted_vision_client_extracts_modal_pipeline_assistant_content():
248
+ class StubHostedNestedVisionClient(HostedMiniCPMVisionClient):
249
+ def _post_image(self, image_path):
250
+ return {
251
+ "response": str(
252
+ {
253
+ "input_text": [
254
+ {
255
+ "role": "user",
256
+ "content": [
257
+ {"type": "image", "path": "/tmp/demo.png"},
258
+ {"type": "text", "text": "Describe the sketch."},
259
+ ],
260
+ }
261
+ ],
262
+ "generated_text": [
263
+ {"role": "user", "content": [{"type": "text", "text": "Describe the sketch."}]},
264
+ {
265
+ "role": "assistant",
266
+ "content": (
267
+ '{"scene_summary":"A child is lost at a subway station.",'
268
+ '"objects":["child figure","subway sign","arrow"],'
269
+ '"visible_text":["SUBWAY","HOME ?"],'
270
+ '"spatial_relations":"child in front of sign",'
271
+ '"mood_cues":"lost or searching",'
272
+ '"uncertain_details":"where home is",'
273
+ '"surprising_detail":"the sign asks HOME ?"}'
274
+ ),
275
+ },
276
+ ],
277
+ }
278
+ )
279
+ }
280
+
281
+ clues = StubHostedNestedVisionClient(endpoint="https://example.test").extract_witness("demo.png").to_visual_clues()
282
+
283
+ assert "Scene: A child is lost at a subway station." in clues
284
+ assert "Object: child figure" in clues
285
+ assert "Visible text: HOME ?" in clues
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,38 @@ 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="A dreamlike representation of a sea under night.",
611
+ objects=["stick figure", "wavy water", "crescent moon"],
612
+ visible_text=["dark sea dream"],
613
+ mood_cues=["small figure in a large dark place"],
614
+ )
615
+
616
+ def extract_clues(self, image_path):
617
+ return ["flat fallback should not win"]
618
+
619
+ session = add_evidence(
620
+ create_session(language="zh"),
621
+ dream_text="我醒来很害怕,这张草图是梦里最清楚的画面。我想知道为什么它让我这么慌。",
622
+ image_path="sea.png",
623
+ mood="害怕",
624
+ vision_client=SeaDreamVision(),
625
+ asr_client=FakeASRClient(),
626
+ language="zh",
627
+ )
628
+ card = generate_today_tip(session.intake, "", FakeTextClient(), language="zh")
629
+ combined = "\n".join([card.main_question, ",".join(card.dream_anchors), card.interpretation, card.today_tip])
630
+
631
+ assert "为什么它让我这么慌?" in card.main_question
632
+ assert any(anchor in card.dream_anchors for anchor in ["夜晚的海", "海浪", "月牙", "漆黑的海"])
633
+ assert "blue hallway" not in combined
634
+ assert "dreamlike representation" not in combined.lower()
635
+ assert "这么难受不是你反应过度" in card.interpretation
636
+
637
+
638
  def test_witness_failure_keeps_text_path_alive():
639
  class BrokenWitnessVision:
640
  def extract_witness(self, image_path):