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
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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):
|