Sync Dream QA V2 submission package

#52
by ADJCJH - opened
.gitattributes CHANGED
@@ -39,6 +39,15 @@ docs/design/assets/dream-customs-probe-night-desk.png filter=lfs diff=lfs merge=
39
  docs/prototypes/2026-06-08-dream-qa-desktop-workbench.png filter=lfs diff=lfs merge=lfs -text
40
  docs/prototypes/2026-06-08-dream-qa-mobile-flow.png filter=lfs diff=lfs merge=lfs -text
41
  docs/prototypes/2026-06-08-dream-qa-tips-card.png filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
 
 
 
42
  videos/dreamqa_01_text_only.mp4 filter=lfs diff=lfs merge=lfs -text
43
  videos/dreamqa_02_image_only.mp4 filter=lfs diff=lfs merge=lfs -text
44
  videos/dreamqa_03_text_plus_image.mp4 filter=lfs diff=lfs merge=lfs -text
 
39
  docs/prototypes/2026-06-08-dream-qa-desktop-workbench.png filter=lfs diff=lfs merge=lfs -text
40
  docs/prototypes/2026-06-08-dream-qa-mobile-flow.png filter=lfs diff=lfs merge=lfs -text
41
  docs/prototypes/2026-06-08-dream-qa-tips-card.png filter=lfs diff=lfs merge=lfs -text
42
+ videos/2026-06-15-dreamqa-v2/ux01_final_ticket.png filter=lfs diff=lfs merge=lfs -text
43
+ videos/2026-06-15-dreamqa-v2/ux01_full_ux.mp4 filter=lfs diff=lfs merge=lfs -text
44
+ videos/2026-06-15-dreamqa-v2/ux01_timeline.jpg filter=lfs diff=lfs merge=lfs -text
45
+ videos/2026-06-15-dreamqa-v2/ux02_final_ticket.png filter=lfs diff=lfs merge=lfs -text
46
+ videos/2026-06-15-dreamqa-v2/ux02_full_ux.mp4 filter=lfs diff=lfs merge=lfs -text
47
+ videos/2026-06-15-dreamqa-v2/ux02_timeline.jpg filter=lfs diff=lfs merge=lfs -text
48
+ videos/2026-06-15-dreamqa-v2/ux03_final_ticket.png filter=lfs diff=lfs merge=lfs -text
49
+ videos/2026-06-15-dreamqa-v2/ux03_full_ux.mp4 filter=lfs diff=lfs merge=lfs -text
50
+ videos/2026-06-15-dreamqa-v2/ux03_timeline.jpg filter=lfs diff=lfs merge=lfs -text
51
  videos/dreamqa_01_text_only.mp4 filter=lfs diff=lfs merge=lfs -text
52
  videos/dreamqa_02_image_only.mp4 filter=lfs diff=lfs merge=lfs -text
53
  videos/dreamqa_03_text_plus_image.mp4 filter=lfs diff=lfs merge=lfs -text
README.md CHANGED
@@ -50,7 +50,10 @@ This is not a therapy, diagnosis, or prophecy product.
50
  - GitHub: <https://github.com/adjcjh777/dream-customs-build-small>
51
  - Primary track: Thousand Token Wood.
52
  - Secondary track: Backyard AI.
53
- - Demo assets in this repo: `videos/dreamqa_01_text_only.mp4`, `videos/dreamqa_02_image_only.mp4`, `videos/dreamqa_03_text_plus_image.mp4`.
 
 
 
54
  - Final public demo video URL: to be added after the final upload.
55
  - Final social post URL: to be added after publishing the X/Instagram post.
56
  - Final submission prep notes: `docs/submission/2026-06-14-build-small-final-pack.md`.
@@ -189,7 +192,11 @@ python scripts/evaluate_today_tip_quality.py
189
 
190
  ## Deployment Smoke Status
191
 
192
- Latest local Dream QA refactor smoke:
 
 
 
 
193
 
194
  - `docs/smoke/2026-06-08-dream-qa-refactor-smoke.md`
195
  - `docs/smoke/2026-06-08-post-refactor-polish-smoke.md`
 
50
  - GitHub: <https://github.com/adjcjh777/dream-customs-build-small>
51
  - Primary track: Thousand Token Wood.
52
  - Secondary track: Backyard AI.
53
+ - Final V2 demo assets in this repo:
54
+ - `videos/2026-06-15-dreamqa-v2/ux01_full_ux.mp4` - train platform + direction sign.
55
+ - `videos/2026-06-15-dreamqa-v2/ux02_full_ux.mp4` - elevator buttons + floor 14 + work message.
56
+ - `videos/2026-06-15-dreamqa-v2/ux03_full_ux.mp4` - floating table + loose keys + sunrise line.
57
  - Final public demo video URL: to be added after the final upload.
58
  - Final social post URL: to be added after publishing the X/Instagram post.
59
  - Final submission prep notes: `docs/submission/2026-06-14-build-small-final-pack.md`.
 
192
 
193
  ## Deployment Smoke Status
194
 
195
+ Latest V2 full UX smoke:
196
+
197
+ - `docs/smoke/2026-06-15-dreamqa-v2-full-ux-smoke.md`
198
+
199
+ Earlier local Dream QA refactor smoke:
200
 
201
  - `docs/smoke/2026-06-08-dream-qa-refactor-smoke.md`
202
  - `docs/smoke/2026-06-08-post-refactor-polish-smoke.md`
docs/smoke/2026-06-15-dreamqa-v2-full-ux-smoke.md ADDED
@@ -0,0 +1,97 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Dream QA V2 Full UX Smoke - 2026-06-15
2
+
3
+ Controlling plan: `/tmp/dreamqa_v1_v2_agent_command_plan.md`.
4
+
5
+ ## Scope
6
+
7
+ This smoke verifies the final Dream QA V2 local demo package for Build Small submission prep:
8
+
9
+ - Text input, image upload, voice input/fallback state, one grounded question, user answer, and final Morning Ticket are visible in each recorded flow.
10
+ - Final Today Tips use image-derived anchors plus the user's answer clue.
11
+ - Voice is represented honestly as a recoverable fallback when transcription is unavailable. No fake transcript is claimed.
12
+ - The final videos are local evidence assets; public/live Space acceptance still requires syncing the final commit to Hugging Face Space and re-smoking live runtime.
13
+
14
+ ## Code And Runtime
15
+
16
+ - Branch: `feature/mimo-asr-modal`
17
+ - Base before this smoke: `c9a54a4 Fix Dream QA multimodal UX grounding`
18
+ - Smoke package commit: `efc02a838bdf6bc476e2c40cdc5ab2d3c33c46e2` (`Finalize Dream QA V2 demo evidence`), followed by an audit-fix commit that tightens ux02 tip grounding and ux03 question grounding.
19
+ - Local URL: `http://127.0.0.1:7862`
20
+ - Local recording backend: demo text/vision/ASR routes, with voice fallback shown in UI.
21
+ - ASR status: fallback only in these recordings. The UI displays `Voice was not transcribed. Type that fragment or try again.`
22
+
23
+ ## Automated Checks
24
+
25
+ Commands run before recording:
26
+
27
+ ```bash
28
+ .venv/bin/python -m pytest -q
29
+ .venv/bin/python scripts/evaluate_today_tip_quality.py
30
+ git diff --check
31
+ ```
32
+
33
+ Observed result:
34
+
35
+ - Full pytest: `182 passed, 6 warnings`
36
+ - Today Tip quality eval: `11/11`, `passes: true`
37
+ - `git diff --check`: clean
38
+
39
+ ## V2 Video Evidence
40
+
41
+ | Case | Video | Duration | Evidence | Timeline | Final ticket | Verdict |
42
+ | --- | --- | ---: | --- | --- | --- | --- |
43
+ | ux01 train platform | `videos/2026-06-15-dreamqa-v2/ux01_full_ux.mp4` | 24.32s | `videos/2026-06-15-dreamqa-v2/ux01_evidence.json` | `videos/2026-06-15-dreamqa-v2/ux01_timeline.jpg` | `videos/2026-06-15-dreamqa-v2/ux01_final_ticket.png` | PASS |
44
+ | ux02 elevator buttons | `videos/2026-06-15-dreamqa-v2/ux02_full_ux.mp4` | 23.36s | `videos/2026-06-15-dreamqa-v2/ux02_evidence.json` | `videos/2026-06-15-dreamqa-v2/ux02_timeline.jpg` | `videos/2026-06-15-dreamqa-v2/ux02_final_ticket.png` | PASS |
45
+ | ux03 floating table | `videos/2026-06-15-dreamqa-v2/ux03_full_ux.mp4` | 23.36s | `videos/2026-06-15-dreamqa-v2/ux03_evidence.json` | `videos/2026-06-15-dreamqa-v2/ux03_timeline.jpg` | `videos/2026-06-15-dreamqa-v2/ux03_final_ticket.png` | PASS |
46
+
47
+ Each evidence JSON records:
48
+
49
+ - `videoPath`
50
+ - `duration`
51
+ - `questionVisibleForSeconds: 3.6`
52
+ - `finalTicketVisibleForSeconds: 4.3`
53
+ - `voiceEvidenceStatus`
54
+ - `finalAnchors`
55
+ - `todayTipText`
56
+ - assertions for answer clue grounding, demo-specific tip cue grounding, demo-specific question cue grounding, honest voice fallback, no template tip phrase, and no safety-boundary leak.
57
+
58
+ ## Semantic Evidence
59
+
60
+ ux01 Today Tip:
61
+
62
+ > Connect train platform to the waking-life clue you named: "It feels like my real question is whether I am waiting for permission to choose a direction at work today". Use it as a tiny direction check today: write the work direction you named on one note, then choose one sign you can test before the day pulls away.
63
+
64
+ ux02 Today Tip:
65
+
66
+ > Connect melted elevator buttons to the work message pressure you named, without changing it into a different task. Write down the one thing the message needs to convey before deciding when to open or send it.
67
+
68
+ After the audit fix, the ux02 Today Tip text is:
69
+
70
+ > Connect melted elevator buttons to the work message pressure you named, without changing it into a different task. Use floor 14 in a puddle and the clock without hands as the boundary: write only what the message needs to convey before deciding when to open or send it.
71
+
72
+ ux03 Today Tip:
73
+
74
+ > Connect floating table to the waking-life clue you named: "I think the question is which small key to try first before the day gets busy". Use it as a morning key check: choose the one small key you named, try that before the day gets busy, and leave the other keys on the table for later.
75
+
76
+ Blocked terms checked across evidence:
77
+
78
+ - `reversible first step`
79
+ - `drawing showing`
80
+ - `various and a`
81
+ - old customs framing
82
+ - diagnosis / prophecy / fixed-meaning claims
83
+
84
+ Result: no blocked terms in the V2 evidence assertions.
85
+
86
+ Additional audit assertions:
87
+
88
+ - ux02 Today Tip text includes `floor 14 in a puddle`, `clock without hands`, and the message answer clue.
89
+ - ux03 question text includes `floating table`, `loose keys`, and `sunrise`.
90
+
91
+ ## Remaining Release Steps
92
+
93
+ 1. Commit and push this smoke package.
94
+ 2. Send final SHA to tester for clean-worktree QA.
95
+ 3. Sync final commit to Hugging Face Space after tester PASS.
96
+ 4. Re-smoke live `/config`, text/image browser path, and one semantic safety case.
97
+ 5. Add public demo video URL and social post URL to README/submission notes after publishing.
docs/submission/2026-06-14-build-small-final-pack.md CHANGED
@@ -1,6 +1,6 @@
1
  # Build Small Final Pack - Dream QA
2
 
3
- Date: 2026-06-14
4
 
5
  ## Current Links
6
 
@@ -8,7 +8,8 @@ Date: 2026-06-14
8
  - Direct app: https://build-small-hackathon-dream-customs.hf.space
9
  - GitHub: https://github.com/adjcjh777/dream-customs-build-small
10
  - Performance QA baseline: HF Space main `60a11f36082f4117a5dbcc9f2b9200f97318e666` with app code from GitHub `0272c67b1862ac8ac4294f6945e937c5f60d38cf`.
11
- - Submission materials branch: `origin/feature/mimo-asr-modal`. Re-check the current HF Space main SHA tomorrow before final submission because README/material-only commits change the Space SHA.
 
12
 
13
  ## Official Sources Checked
14
 
@@ -55,6 +56,17 @@ Verdict from tester: `PASS_WITH_ASR_RISK`.
55
 
56
  Recommended demo path: text first, optional image upload second. Voice can be shown as available or mentioned as graceful fallback, but should not be the hero path until live transcription success is stable.
57
 
 
 
 
 
 
 
 
 
 
 
 
58
  ## Demo Video Shotlist
59
 
60
  Target length: 45-75 seconds.
@@ -103,11 +115,11 @@ Space: https://huggingface.co/spaces/build-small-hackathon/dream-customs
103
 
104
  ## Tomorrow's Final Steps
105
 
106
- 1. Record or select the final demo video.
107
  2. Upload the video publicly or keep the repo `videos/` file as the demo asset if accepted.
108
  3. Publish the X or Instagram post.
109
  4. Replace the README demo/social placeholders with final public URLs.
110
- 5. Re-run final smoke: full pytest, Today Tip eval, live `/config`, text/image browser path, and a quick semantic safety case.
111
  6. Submit the Space URL, demo video URL, and social post URL.
112
 
113
  README frontmatter is already aligned to `Dream QA`, the target tracks, and the sponsor/bonus tags. The remaining README blockers are only the final public video URL and the final social post URL.
 
1
  # Build Small Final Pack - Dream QA
2
 
3
+ Date: 2026-06-14, updated 2026-06-15
4
 
5
  ## Current Links
6
 
 
8
  - Direct app: https://build-small-hackathon-dream-customs.hf.space
9
  - GitHub: https://github.com/adjcjh777/dream-customs-build-small
10
  - Performance QA baseline: HF Space main `60a11f36082f4117a5dbcc9f2b9200f97318e666` with app code from GitHub `0272c67b1862ac8ac4294f6945e937c5f60d38cf`.
11
+ - V2 local full-UX package: `docs/smoke/2026-06-15-dreamqa-v2-full-ux-smoke.md` with three recorded flows under `videos/2026-06-15-dreamqa-v2/`.
12
+ - Submission materials branch: `origin/feature/mimo-asr-modal`. Re-check the current HF Space main SHA before final submission because README/material/video commits change the Space SHA.
13
 
14
  ## Official Sources Checked
15
 
 
56
 
57
  Recommended demo path: text first, optional image upload second. Voice can be shown as available or mentioned as graceful fallback, but should not be the hero path until live transcription success is stable.
58
 
59
+ ## V2 Local Demo Package
60
+
61
+ Local V2 recording verdict: PASS for the package evidence produced from the current branch before final HF sync.
62
+
63
+ - `videos/2026-06-15-dreamqa-v2/ux01_full_ux.mp4`: train platform, suitcase, direction sign, and the user's work-direction answer.
64
+ - `videos/2026-06-15-dreamqa-v2/ux02_full_ux.mp4`: melted elevator buttons, floor 14 in a puddle, clock without hands, and work-message pressure.
65
+ - `videos/2026-06-15-dreamqa-v2/ux03_full_ux.mp4`: floating table, loose keys, sunrise/star image, and the user's small-key answer.
66
+ - Each case includes an evidence JSON, final ticket screenshot, and timeline contact sheet in the same directory.
67
+ - Voice state in these recordings is honest fallback: `Voice was not transcribed. Type that fragment or try again.`
68
+ - Do not describe the V2 videos as successful voice transcription unless a later live ASR smoke proves it.
69
+
70
  ## Demo Video Shotlist
71
 
72
  Target length: 45-75 seconds.
 
115
 
116
  ## Tomorrow's Final Steps
117
 
118
+ 1. Choose one or edit together the final demo video from `videos/2026-06-15-dreamqa-v2/`.
119
  2. Upload the video publicly or keep the repo `videos/` file as the demo asset if accepted.
120
  3. Publish the X or Instagram post.
121
  4. Replace the README demo/social placeholders with final public URLs.
122
+ 5. Sync final commit to HF Space, then re-run final smoke: live `/config`, text/image browser path, and a quick semantic safety case.
123
  6. Submit the Space URL, demo video URL, and social post URL.
124
 
125
  README frontmatter is already aligned to `Dream QA`, the target tracks, and the sponsor/bonus tags. The remaining README blockers are only the final public video URL and the final social post URL.
dream_customs/app_logic.py CHANGED
@@ -183,6 +183,7 @@ def _clients(text_backend: str, vision_backend: str, **settings):
183
  token=resolved["hosted_token"],
184
  timeout=resolved["asr_timeout_seconds"],
185
  latency_budget_ms=resolved["asr_latency_budget_ms"],
 
186
  )
187
  else:
188
  asr_client = FakeASRClient()
 
183
  token=resolved["hosted_token"],
184
  timeout=resolved["asr_timeout_seconds"],
185
  latency_budget_ms=resolved["asr_latency_budget_ms"],
186
+ fallback_enabled=False,
187
  )
188
  else:
189
  asr_client = FakeASRClient()
dream_customs/models.py CHANGED
@@ -12,14 +12,31 @@ from dream_customs.schema import DreamBrief, DreamQAState, PactCard, PactCritiqu
12
 
13
 
14
  class FakeVisionClient:
15
- def extract_clues(self, image_path: Optional[str]) -> List[str]:
16
- if not image_path:
17
- return []
18
- return ["blue hallway", "melted elevator buttons", "number 14"]
19
-
20
- def extract_witness(self, image_path: Optional[str]) -> VisionWitness:
21
- if not image_path:
22
- return VisionWitness()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
23
  return VisionWitness(
24
  scene_summary="A blue hallway with a melted elevator button.",
25
  objects=["elevator button", "blue hallway"],
@@ -30,11 +47,24 @@ class FakeVisionClient:
30
  surprising_detail="The button looks soft, almost waxy.",
31
  )
32
 
 
 
 
 
 
 
 
 
 
 
33
 
34
  class FakeASRClient:
35
  def transcribe(self, audio_path: Optional[str]) -> str:
36
  if not audio_path:
37
  return ""
 
 
 
38
  return "The buttons melted and I could not catch the elevator."
39
 
40
 
 
12
 
13
 
14
  class FakeVisionClient:
15
+ def _witness_for_path(self, image_path: str) -> VisionWitness:
16
+ name = os.path.basename(image_path or "").lower()
17
+ if any(term in name for term in ["train", "platform"]):
18
+ return VisionWitness(
19
+ scene_summary="A train platform with a suitcase, a direction sign, and a tall clock.",
20
+ objects=["train platform", "suitcase", "direction sign", "platform clock"],
21
+ visible_text=["TRACK 3"],
22
+ mood_cues=["waiting", "uncertain direction"],
23
+ )
24
+ if any(term in name for term in ["ux02", "elevator_buttons", "floor14", "floor-14"]):
25
+ return VisionWitness(
26
+ scene_summary="An apartment elevator panel with melted buttons, a floor 14 reflection in a puddle, and a clock without hands.",
27
+ objects=["melted elevator buttons", "floor 14 in a puddle", "clock without hands"],
28
+ visible_text=["14"],
29
+ spatial_relations=["floor number reflected under the elevator doors"],
30
+ mood_cues=["stuck", "late", "choice pressure"],
31
+ surprising_detail="The clock has no hands, so time feels present but unreadable.",
32
+ )
33
+ if any(term in name for term in ["floating", "table", "keys"]):
34
+ return VisionWitness(
35
+ scene_summary="A floating table over dark water with loose keys, star shapes, and a sunrise line.",
36
+ objects=["floating table", "loose keys", "star shapes by a sunrise line", "dark water"],
37
+ visible_text=["SUNRISE"],
38
+ mood_cues=["strange", "quiet"],
39
+ )
40
  return VisionWitness(
41
  scene_summary="A blue hallway with a melted elevator button.",
42
  objects=["elevator button", "blue hallway"],
 
47
  surprising_detail="The button looks soft, almost waxy.",
48
  )
49
 
50
+ def extract_clues(self, image_path: Optional[str]) -> List[str]:
51
+ if not image_path:
52
+ return []
53
+ return self._witness_for_path(image_path).to_visual_clues()
54
+
55
+ def extract_witness(self, image_path: Optional[str]) -> VisionWitness:
56
+ if not image_path:
57
+ return VisionWitness()
58
+ return self._witness_for_path(image_path)
59
+
60
 
61
  class FakeASRClient:
62
  def transcribe(self, audio_path: Optional[str]) -> str:
63
  if not audio_path:
64
  return ""
65
+ name = os.path.basename(audio_path or "").lower()
66
+ if not any(term in name for term in ["demo", "elevator", "button", "floor14", "floor-14"]):
67
+ return ""
68
  return "The buttons melted and I could not catch the elevator."
69
 
70
 
dream_customs/pipeline.py CHANGED
@@ -492,20 +492,61 @@ def _clean_visual_clue_text(clue: str) -> str:
492
  return clean
493
 
494
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
495
  def _visual_anchor_candidates(intake: DreamIntake) -> List[str]:
496
- candidates: List[str] = []
 
497
  for clue in intake.visual_clues:
 
 
498
  clean = _clean_visual_clue_text(clue)
499
  if not clean:
500
  continue
501
  for phrase in re.split(r"\s*(?:,|;|,|;|/|\n)\s*", clean):
502
- phrase = phrase.strip(" .,:;!?()[]{}")
503
  if not phrase:
504
  continue
505
  if len(phrase) > 48:
506
  phrase = phrase[:48].rsplit(" ", 1)[0].strip() or phrase[:48].strip()
507
- candidates.append(phrase)
508
- return _dedupe_preserve_order(candidates)
 
 
509
 
510
 
511
  def _extract_dream_anchors(intake: DreamIntake) -> List[str]:
@@ -562,12 +603,13 @@ def _extract_dream_anchors(intake: DreamIntake) -> List[str]:
562
  r"paper|papers|promise|promises|window|windows|suitcase|suitcases|"
563
  r"clerk|clerks|sunrise|elevator|elevators|button|buttons|hallway|"
564
  r"gate|gates|floor|floors|stamp|stamps|number|numbers|exam|exams|"
565
- r"pencil|pencils|train|trains|door|doors|subway|station|stations|snow|snowfield|"
566
- r"hat|hats|phone|phones|water|shoe|shoes|"
567
  r"stairwell|stairwells|room|rooms|key|keys|note|notes|rain|moon|moons|curtain|curtains|"
568
  r"email|emails|meeting|meetings|deadline|deadlines|classroom|classrooms|teacher|teachers|"
569
  r"assignment|assignments|homework|message|messages|airport|airports|glass|bird|birds|child|children|"
570
- r"cat|cats|forest|forests|sign|signs|alley|alleys|mailbox|mailboxes|sketch|sketches|ceiling|ceilings"
 
571
  r")\b"
572
  )
573
  for match in pair_pattern.finditer(text):
@@ -579,9 +621,10 @@ def _extract_dream_anchors(intake: DreamIntake) -> List[str]:
579
 
580
  noun_pattern = re.compile(
581
  r"\b(customs|suitcase|clerk|sunrise|elevator|button|hallway|gate|stamp|number|floor|"
582
- r"exam|pencil|train|door|subway|station|snow|snowfield|hat|phone|water|shoe|stairwell|"
583
- r"room|key|note|rain|moon|curtain|sleep|dream|email|meeting|deadline|classroom|teacher|"
584
- r"assignment|homework|message|airport|glass|bird|child|cat|forest|sign|alley|mailbox|sketch|ceiling)\b"
 
585
  )
586
  candidates.extend(match.group(1) for match in noun_pattern.finditer(text))
587
  candidates.extend(visual_candidates)
@@ -878,15 +921,25 @@ def _dream_theme(intake: DreamIntake, answers: str = "") -> str:
878
  ],
879
  ):
880
  return "misunderstood_friend"
881
- if _contains_any(text, ["小孩", "child", "找不到家", "lost", "home", "回家", "地铁", "subway"]):
 
 
 
 
 
 
 
 
882
  return "lost_home"
 
 
883
  if _contains_any(text, ["海", "海浪", "月牙", "moon", "sea", "wave", "dark sea", "dark water", "漆黑的海"]):
884
  return "dark_water"
885
  if _contains_any(text, ["电梯", "按钮", "14", "elevator", "button", "floor"]):
886
  return "stuck_elevator"
887
  if _contains_any(text, ["图书馆", "楼梯", "便签", "call home", "library", "staircase", "sticky note"]):
888
  return "library_signal"
889
- if _contains_any(text, ["前任", "消息", "消失", "former partner", "ex", "message", "disappear"]):
890
  return "message_loss"
891
  if _contains_any(text, ["追", "被追", "chase", "chased", "running away"]):
892
  return "chased"
@@ -1021,7 +1074,7 @@ def _answer_reality_cue(answers: str, language: str = "en") -> str:
1021
  for lead in lead_ins:
1022
  if lead in lowered:
1023
  return snippet[lowered.find(lead) :].strip(" ,.;:")
1024
- return snippet
1025
 
1026
 
1027
  def _anchor_with_article(anchor: str) -> str:
@@ -1135,14 +1188,19 @@ def _clean_user_question(text: str, language: str = "en") -> str:
1135
  clean,
1136
  flags=re.IGNORECASE,
1137
  ).strip()
 
1138
  clean = re.sub(
1139
- 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*",
 
 
1140
  lambda match: (match.group(4) or "").strip() + " ",
1141
  clean,
1142
  flags=re.IGNORECASE,
1143
  ).strip()
1144
  clean = clean[0].upper() + clean[1:] if clean else ""
1145
- 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):
 
 
1146
  clean += "?"
1147
  return clean
1148
 
@@ -1182,7 +1240,9 @@ def _extract_explicit_user_question(intake: DreamIntake, answers: str = "", lang
1182
  "can this",
1183
  "should i",
1184
  "i wonder",
 
1185
  "want to know",
 
1186
  "not coping",
1187
  "cope",
1188
  "a sign",
@@ -1272,7 +1332,8 @@ def _direct_question_reassurance(question: str, labels: List[str], language: str
1272
  if "why" in lowered:
1273
  return "The ache makes sense; the dream may be enlarging a feeling that has not had enough room yet."
1274
  emotion = _emotion_phrase(labels, language)
1275
- return f"The {emotion} you named deserves to be met first, not explained away too quickly."
 
1276
 
1277
 
1278
  def _emotion_led_interpretation(intake: DreamIntake, answers: str, anchors: List[str], language: str = "en") -> str:
@@ -1291,11 +1352,12 @@ def _emotion_led_interpretation(intake: DreamIntake, answers: str, anchors: List
1291
  "第三层,今天不急着找唯一答案,先给自己一个能站稳的小支点。"
1292
  )
1293
  opener = f"You asked, \"{question}\" " if question else ""
 
1294
  return (
1295
  f"{opener}{direct} First, let the {emotion} be real without blaming yourself. "
1296
- f"Second, the {_anchor_with_article(anchor)} may be the dream's concrete shape for that feeling. "
1297
  "Third, for today the goal is not to solve the whole dream, but to give yourself one steadier place to stand."
1298
- )
1299
 
1300
 
1301
  def _emotion_led_today_tip(intake: DreamIntake, answers: str, anchors: List[str], language: str = "en") -> str:
@@ -1435,6 +1497,8 @@ def _emotion_led_caring_note(intake: DreamIntake, answers: str, language: str =
1435
  if _is_zh(language):
1436
  emotion = "感受" if emotion == "这个感受" else emotion
1437
  return f"你不是太脆弱,也不是需要被催着立刻想通;这份{emotion}可以先被轻轻接住。"
 
 
1438
  return f"You are not weak for feeling {emotion}; you do not have to turn it into a lesson before you are comforted."
1439
 
1440
 
@@ -1832,10 +1896,19 @@ def _answer_based_today_tip(answers: str, anchor: str, language: str = "en") ->
1832
  language,
1833
  )
1834
  if reality_cue:
 
 
 
 
 
 
 
 
 
1835
  return _numbered_suggestions(
1836
  [
1837
  f"把「{anchor}」和你刚才说的「{reality_cue}」接起来,而不是只解释梦的象征。",
1838
- "今天只选一可回头小动作:写一句说明、问一个入口,或把下一步放到草稿里。",
1839
  ],
1840
  language,
1841
  )
@@ -1859,6 +1932,14 @@ def _answer_based_today_tip(answers: str, anchor: str, language: str = "en") ->
1859
  ],
1860
  language,
1861
  )
 
 
 
 
 
 
 
 
1862
  return _numbered_suggestions(
1863
  [
1864
  f"Connect {anchor} to the work message pressure you named, without changing it into a different task.",
@@ -1908,10 +1989,30 @@ def _answer_based_today_tip(answers: str, anchor: str, language: str = "en") ->
1908
  language,
1909
  )
1910
  if reality_cue:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1911
  return _numbered_suggestions(
1912
  [
1913
  f"Connect {anchor} to the waking-life clue you named: \"{reality_cue}\".",
1914
- "Choose one reversible first step today: draft one sentence, ask for one doorway, or park the next step where you can return to it.",
1915
  ],
1916
  language,
1917
  )
@@ -2357,6 +2458,11 @@ def _decomposition_question(
2357
  "Which image stays with you most: the waves, the moon, or the small figure?",
2358
  "Last check before the tip: do you need help calming your body, or understanding why the image felt so intense?",
2359
  ],
 
 
 
 
 
2360
  "stuck_elevator": [
2361
  f"Which detail in {story_anchor} feels closest to your current stuck point?",
2362
  "Does the stuckness feel more like being late to start, or like trying something and getting no response?",
@@ -2781,7 +2887,10 @@ def _polish_today_tip(card: TodayTipCard, intake: DreamIntake, answers: str = ""
2781
  )
2782
  card_anchors = _without_unsupported_melted_anchors(card_anchors, intake, answers)
2783
  card_anchors = _filter_anchors_by_source(card_anchors, intake, answers, language)
2784
- anchors = _dedupe_preserve_order(card_anchors + [anchor for anchor in intake_anchors if anchor not in card_anchors])
 
 
 
2785
  anchors = _without_unsupported_melted_anchors(anchors, intake, answers)
2786
  if not anchors:
2787
  anchors = _remove_placeholder_anchors([_primary_anchor(intake, language)])
@@ -2824,11 +2933,13 @@ def _polish_today_tip(card: TodayTipCard, intake: DreamIntake, answers: str = ""
2824
  answer_interpretation = _answer_based_interpretation(answers, _answer_bridge_anchor(anchors), language)
2825
  answer_tip = _answer_based_today_tip(answers, anchors[0], language)
2826
  answer_action = _answer_based_tiny_action(answers, intake, anchors, language)
 
2827
  answer_should_shape_visible_tip = bool(
2828
  (answer_tip or answer_action)
2829
  and not _needs_comfort(answers, language)
2830
  and (
2831
  _answer_has_concrete_task_keyword(answers)
 
2832
  or not _should_use_emotion_led_response(intake, answers, language)
2833
  )
2834
  )
@@ -3243,6 +3354,8 @@ def add_evidence(
3243
  transcript = asr_client.transcribe(audio_path) if asr_client else ""
3244
  except Exception:
3245
  error = "Voice transcription failed. Text-only path remains available."
 
 
3246
  if transcript.strip():
3247
  clean_transcript = transcript.strip()
3248
  next_session.intake.voice_transcript = _append_text(next_session.intake.voice_transcript, clean_transcript)
 
492
  return clean
493
 
494
 
495
+ def _clean_visual_candidate_phrase(phrase: str) -> str:
496
+ clean = re.sub(r"\s+", " ", (phrase or "").strip(" .,:;!?()[]{}\"'"))
497
+ clean = re.sub(
498
+ r"^(?:a|an|the)\s+(?:drawing|sketch|simple sketch)\s+(?:showing|of)\s+",
499
+ "",
500
+ clean,
501
+ flags=re.IGNORECASE,
502
+ )
503
+ clean = re.sub(r"^(?:a|an|the|and)\s+", "", clean, flags=re.IGNORECASE)
504
+ clean = re.sub(r"\s+", " ", clean).strip(" .,:;!?()[]{}\"'")
505
+ return clean
506
+
507
+
508
+ def _is_bad_visual_candidate_phrase(phrase: str) -> bool:
509
+ clean = re.sub(r"\s+", " ", (phrase or "").strip().lower())
510
+ if not clean:
511
+ return True
512
+ broken_markers = [
513
+ "various and a",
514
+ "various and an",
515
+ "with various",
516
+ "showing a table with various",
517
+ "visual model unavailable",
518
+ "model unavailable",
519
+ "details need user text confirmation",
520
+ "uploaded dream sketch",
521
+ ]
522
+ if any(marker in clean for marker in broken_markers):
523
+ return True
524
+ if re.search(r"\b(?:and|or|with|of|to|for|a|an|the)\s*$", clean):
525
+ return True
526
+ if clean in {"drawing", "sketch", "image", "picture", "dream sketch"}:
527
+ return True
528
+ return False
529
+
530
+
531
  def _visual_anchor_candidates(intake: DreamIntake) -> List[str]:
532
+ primary_candidates: List[str] = []
533
+ secondary_candidates: List[str] = []
534
  for clue in intake.visual_clues:
535
+ clue_text = clue or ""
536
+ bucket = secondary_candidates if clue_text.lower().startswith("scene:") else primary_candidates
537
  clean = _clean_visual_clue_text(clue)
538
  if not clean:
539
  continue
540
  for phrase in re.split(r"\s*(?:,|;|,|;|/|\n)\s*", clean):
541
+ phrase = _clean_visual_candidate_phrase(phrase)
542
  if not phrase:
543
  continue
544
  if len(phrase) > 48:
545
  phrase = phrase[:48].rsplit(" ", 1)[0].strip() or phrase[:48].strip()
546
+ if _is_bad_visual_candidate_phrase(phrase):
547
+ continue
548
+ bucket.append(phrase)
549
+ return _dedupe_preserve_order(primary_candidates + secondary_candidates)
550
 
551
 
552
  def _extract_dream_anchors(intake: DreamIntake) -> List[str]:
 
603
  r"paper|papers|promise|promises|window|windows|suitcase|suitcases|"
604
  r"clerk|clerks|sunrise|elevator|elevators|button|buttons|hallway|"
605
  r"gate|gates|floor|floors|stamp|stamps|number|numbers|exam|exams|"
606
+ r"pencil|pencils|train|trains|platform|platforms|door|doors|subway|station|stations|snow|snowfield|"
607
+ r"hat|hats|phone|phones|water|puddle|puddles|shoe|shoes|"
608
  r"stairwell|stairwells|room|rooms|key|keys|note|notes|rain|moon|moons|curtain|curtains|"
609
  r"email|emails|meeting|meetings|deadline|deadlines|classroom|classrooms|teacher|teachers|"
610
  r"assignment|assignments|homework|message|messages|airport|airports|glass|bird|birds|child|children|"
611
+ r"cat|cats|forest|forests|sign|signs|alley|alleys|mailbox|mailboxes|sketch|sketches|ceiling|ceilings|"
612
+ r"table|tables|clock|clocks|star|stars|sunrise"
613
  r")\b"
614
  )
615
  for match in pair_pattern.finditer(text):
 
621
 
622
  noun_pattern = re.compile(
623
  r"\b(customs|suitcase|clerk|sunrise|elevator|button|hallway|gate|stamp|number|floor|"
624
+ r"exam|pencil|train|platform|door|subway|station|snow|snowfield|hat|phone|water|puddle|shoe|stairwell|"
625
+ r"room|key|keys|note|rain|moon|curtain|sleep|dream|email|meeting|deadline|classroom|teacher|"
626
+ r"assignment|homework|message|airport|glass|bird|child|cat|forest|sign|alley|mailbox|sketch|ceiling|"
627
+ r"table|tables|clock|clocks|star|stars|sunrise)\b"
628
  )
629
  candidates.extend(match.group(1) for match in noun_pattern.finditer(text))
630
  candidates.extend(visual_candidates)
 
921
  ],
922
  ):
923
  return "misunderstood_friend"
924
+ lost_child_terms = ["小孩", "child", "找不到家"]
925
+ transit_terms = ["地铁", "subway"]
926
+ home_terms = ["home", "回家", "找不到家"]
927
+ lost_terms = ["lost", "迷路", "找不到"]
928
+ if (
929
+ _contains_any(text, lost_child_terms)
930
+ or (_contains_any(text, transit_terms) and _contains_any(text, home_terms + lost_terms))
931
+ or (_contains_any(text, lost_terms) and _contains_any(text, home_terms))
932
+ ):
933
  return "lost_home"
934
+ if _contains_any(text, ["floating table", "loose key", "loose keys", "sunrise", "star shape", "star shapes", "漂浮的桌子", "钥匙"]):
935
+ return "floating_keys"
936
  if _contains_any(text, ["海", "海浪", "月牙", "moon", "sea", "wave", "dark sea", "dark water", "漆黑的海"]):
937
  return "dark_water"
938
  if _contains_any(text, ["电梯", "按钮", "14", "elevator", "button", "floor"]):
939
  return "stuck_elevator"
940
  if _contains_any(text, ["图书馆", "楼梯", "便签", "call home", "library", "staircase", "sticky note"]):
941
  return "library_signal"
942
+ if _contains_any(text, ["前任", "消息", "消失", "former partner", "message", "disappear"]) or re.search(r"\bex\b", text):
943
  return "message_loss"
944
  if _contains_any(text, ["追", "被追", "chase", "chased", "running away"]):
945
  return "chased"
 
1074
  for lead in lead_ins:
1075
  if lead in lowered:
1076
  return snippet[lowered.find(lead) :].strip(" ,.;:")
1077
+ return snippet.strip(" ,.;:")
1078
 
1079
 
1080
  def _anchor_with_article(anchor: str) -> str:
 
1188
  clean,
1189
  flags=re.IGNORECASE,
1190
  ).strip()
1191
+ clean = re.sub(r"^(?:and|but|so)\s+", "", clean, flags=re.IGNORECASE).strip()
1192
  clean = re.sub(
1193
+ r"^(i\s+)?(woke\s+up\s+)?"
1194
+ r"(want(?:ed)?\s+to\s+know|wonder(?:ed)?|am\s+wondering|need(?:ed)?\s+to\s+know|worr(?:y|ied)|fear(?:ed)?)"
1195
+ r"\s*(if|whether|why|what|how|which)?\s*",
1196
  lambda match: (match.group(4) or "").strip() + " ",
1197
  clean,
1198
  flags=re.IGNORECASE,
1199
  ).strip()
1200
  clean = clean[0].upper() + clean[1:] if clean else ""
1201
+ if clean and re.match(r"^(why|what|how|which|does|do|did|is|am|are|can|could|should|would)\b", clean, re.IGNORECASE):
1202
+ clean = clean.rstrip(".")
1203
+ if clean and not clean.endswith("?") and re.match(r"^(why|what|how|which|does|do|did|is|am|are|can|could|should|would)\b", clean, re.IGNORECASE):
1204
  clean += "?"
1205
  return clean
1206
 
 
1240
  "can this",
1241
  "should i",
1242
  "i wonder",
1243
+ "i wondered",
1244
  "want to know",
1245
+ "wanted to know",
1246
  "not coping",
1247
  "cope",
1248
  "a sign",
 
1332
  if "why" in lowered:
1333
  return "The ache makes sense; the dream may be enlarging a feeling that has not had enough room yet."
1334
  emotion = _emotion_phrase(labels, language)
1335
+ emotion_subject = emotion.capitalize() if emotion.startswith("this ") else f"The {emotion}"
1336
+ return f"{emotion_subject} you named deserves to be met first, not explained away too quickly."
1337
 
1338
 
1339
  def _emotion_led_interpretation(intake: DreamIntake, answers: str, anchors: List[str], language: str = "en") -> str:
 
1352
  "第三层,今天不急着找唯一答案,先给自己一个能站稳的小支点。"
1353
  )
1354
  opener = f"You asked, \"{question}\" " if question else ""
1355
+ emotion_subject = emotion if emotion.startswith("this ") else f"the {emotion}"
1356
  return (
1357
  f"{opener}{direct} First, let the {emotion} be real without blaming yourself. "
1358
+ f"Second, {_anchor_with_article(anchor)} may be the dream's concrete shape for that feeling. "
1359
  "Third, for today the goal is not to solve the whole dream, but to give yourself one steadier place to stand."
1360
+ ).replace(f"let the {emotion}", f"let {emotion_subject}")
1361
 
1362
 
1363
  def _emotion_led_today_tip(intake: DreamIntake, answers: str, anchors: List[str], language: str = "en") -> str:
 
1497
  if _is_zh(language):
1498
  emotion = "感受" if emotion == "这个感受" else emotion
1499
  return f"你不是太脆弱,也不是需要被催着立刻想通;这份{emotion}可以先被轻轻接住。"
1500
+ if emotion == "this feeling":
1501
+ return "You are not weak for needing a clearer next step; the dream can be met one grounded detail at a time."
1502
  return f"You are not weak for feeling {emotion}; you do not have to turn it into a lesson before you are comforted."
1503
 
1504
 
 
1896
  language,
1897
  )
1898
  if reality_cue:
1899
+ zh_anchor_context = f"{anchor} {reality_cue}"
1900
+ if any(term in zh_anchor_context for term in ["小孩", "地铁", "箭头", "路标", "哪里走", "带一下", "带路"]):
1901
+ return _numbered_suggestions(
1902
+ [
1903
+ f"把「{anchor}」和你刚才说的「{reality_cue}」接起来:这更像是在承认有一部分你需要被带路。",
1904
+ "今天先写一个很小的路标:我现在最需要谁或哪件事带我过一站;写完就停,不急着把整条路想完。",
1905
+ ],
1906
+ language,
1907
+ )
1908
  return _numbered_suggestions(
1909
  [
1910
  f"把「{anchor}」和你刚才说的「{reality_cue}」接起来,而不是只解释梦的象征。",
1911
+ "今天让这梦里细节决定下一步的形状一个你真的想试探的入口,然后停在那里。",
1912
  ],
1913
  language,
1914
  )
 
1932
  ],
1933
  language,
1934
  )
1935
+ if any(term in anchor.lower() for term in ["elevator", "button", "puddle", "clock", "floor 14"]):
1936
+ return _numbered_suggestions(
1937
+ [
1938
+ f"Connect {anchor} to the work message pressure you named, without changing it into a different task.",
1939
+ "Use floor 14 in a puddle and the clock without hands as the boundary: write only what the message needs to convey before deciding when to open or send it.",
1940
+ ],
1941
+ language,
1942
+ )
1943
  return _numbered_suggestions(
1944
  [
1945
  f"Connect {anchor} to the work message pressure you named, without changing it into a different task.",
 
1989
  language,
1990
  )
1991
  if reality_cue:
1992
+ if any(term in anchor.lower() for term in ["train", "platform", "suitcase", "direction sign"]):
1993
+ next_step = (
1994
+ "Use it as a tiny direction check today: write the work direction you named on one note, "
1995
+ "then choose one sign you can test before the day pulls away."
1996
+ )
1997
+ elif any(term in anchor.lower() for term in ["floating table", "loose key", "sunrise", "star shape"]):
1998
+ next_step = (
1999
+ "Use it as a morning key check: choose the one small key you named, try that before the day gets busy, "
2000
+ "and leave the other keys on the table for later."
2001
+ )
2002
+ elif any(term in anchor.lower() for term in ["puddle", "clock", "elevator", "floor 14", "button"]):
2003
+ next_step = (
2004
+ "Use floor 14 in a puddle and the clock without hands as a pressure check: write what the message "
2005
+ "needs to carry, then choose only the next button-sized move."
2006
+ )
2007
+ else:
2008
+ next_step = (
2009
+ "Let that dream detail shape the next step you named: write one line that starts with the real question, "
2010
+ "then stop before turning it into the whole task."
2011
+ )
2012
  return _numbered_suggestions(
2013
  [
2014
  f"Connect {anchor} to the waking-life clue you named: \"{reality_cue}\".",
2015
+ next_step,
2016
  ],
2017
  language,
2018
  )
 
2458
  "Which image stays with you most: the waves, the moon, or the small figure?",
2459
  "Last check before the tip: do you need help calming your body, or understanding why the image felt so intense?",
2460
  ],
2461
+ "floating_keys": [
2462
+ "In the floating table, loose keys, and sunrise line, which detail feels most like the small key you want to try today?",
2463
+ "Do the loose keys feel like too many choices, or like one possible key waiting to be picked up?",
2464
+ "Last check before the tip: should the Morning Ticket point to one key, the sunrise line, or the floating table?",
2465
+ ],
2466
  "stuck_elevator": [
2467
  f"Which detail in {story_anchor} feels closest to your current stuck point?",
2468
  "Does the stuckness feel more like being late to start, or like trying something and getting no response?",
 
2887
  )
2888
  card_anchors = _without_unsupported_melted_anchors(card_anchors, intake, answers)
2889
  card_anchors = _filter_anchors_by_source(card_anchors, intake, answers, language)
2890
+ if intake.visual_clues:
2891
+ anchors = _dedupe_preserve_order(intake_anchors + [anchor for anchor in card_anchors if anchor not in intake_anchors])
2892
+ else:
2893
+ anchors = _dedupe_preserve_order(card_anchors + [anchor for anchor in intake_anchors if anchor not in card_anchors])
2894
  anchors = _without_unsupported_melted_anchors(anchors, intake, answers)
2895
  if not anchors:
2896
  anchors = _remove_placeholder_anchors([_primary_anchor(intake, language)])
 
2933
  answer_interpretation = _answer_based_interpretation(answers, _answer_bridge_anchor(anchors), language)
2934
  answer_tip = _answer_based_today_tip(answers, anchors[0], language)
2935
  answer_action = _answer_based_tiny_action(answers, intake, anchors, language)
2936
+ reality_cue_for_tip = _answer_reality_cue(answers, language)
2937
  answer_should_shape_visible_tip = bool(
2938
  (answer_tip or answer_action)
2939
  and not _needs_comfort(answers, language)
2940
  and (
2941
  _answer_has_concrete_task_keyword(answers)
2942
+ or bool(reality_cue_for_tip and intake.visual_clues)
2943
  or not _should_use_emotion_led_response(intake, answers, language)
2944
  )
2945
  )
 
3354
  transcript = asr_client.transcribe(audio_path) if asr_client else ""
3355
  except Exception:
3356
  error = "Voice transcription failed. Text-only path remains available."
3357
+ if not transcript.strip() and not error and getattr(asr_client, "last_error", ""):
3358
+ error = "Voice transcription did not return a transcript. Text-only path remains available."
3359
  if transcript.strip():
3360
  clean_transcript = transcript.strip()
3361
  next_session.intake.voice_transcript = _append_text(next_session.intake.voice_transcript, clean_transcript)
dream_customs/ui/actions.py CHANGED
@@ -42,6 +42,19 @@ def _latest_error(session: CustomsSession) -> str:
42
  return event.body if event else ""
43
 
44
 
 
 
 
 
 
 
 
 
 
 
 
 
 
45
  def _trim_to_one_visible_question(session: CustomsSession, previous_count: int) -> CustomsSession:
46
  if len(session.question_history) <= previous_count + 1:
47
  return session
@@ -170,6 +183,7 @@ def _view_payload(
170
  "dream_anchors": list(session.qa_state.dream_anchors),
171
  "followup_questions": list(session.question_history),
172
  "user_answers": list(session.answer_history),
 
173
  "interpretation": "",
174
  "today_tip": "",
175
  "tiny_action": "",
 
42
  return event.body if event else ""
43
 
44
 
45
+ def _evidence_payload(session: CustomsSession) -> List[Dict[str, str]]:
46
+ return [
47
+ {
48
+ "type": item.type,
49
+ "label": item.label,
50
+ "status": item.status,
51
+ "content": item.content,
52
+ "error": item.error,
53
+ }
54
+ for item in session.evidence_items
55
+ ]
56
+
57
+
58
  def _trim_to_one_visible_question(session: CustomsSession, previous_count: int) -> CustomsSession:
59
  if len(session.question_history) <= previous_count + 1:
60
  return session
 
183
  "dream_anchors": list(session.qa_state.dream_anchors),
184
  "followup_questions": list(session.question_history),
185
  "user_answers": list(session.answer_history),
186
+ "evidence_items": _evidence_payload(session),
187
  "interpretation": "",
188
  "today_tip": "",
189
  "tiny_action": "",
dream_customs/ui/app.py CHANGED
@@ -609,11 +609,13 @@ def _question_markdown(view: dict, language: str = DEFAULT_LANGUAGE) -> str:
609
  if question
610
  else ""
611
  )
 
612
  return f"""
613
  <div class="dc-question-card">
614
  <span class="dc-question-kicker">{copy['question_kicker']}</span>
615
  <h2>{copy['question_title']}</h2>
616
  <p>{copy['question_body']}</p>
 
617
  {anchor_strip}
618
  {optional_question}
619
  {context_html}
@@ -965,8 +967,63 @@ def _field_tip_html(language: str = DEFAULT_LANGUAGE, view=None) -> str:
965
  """.strip()
966
 
967
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
968
  def _processing_html(language: str = DEFAULT_LANGUAGE) -> str:
969
- return f"<p class='dc-processing-note'>{escape(copy_for(language)['processing_note'])}</p>"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
970
 
971
 
972
  def _side_stamp_html(language: str = DEFAULT_LANGUAGE) -> str:
 
609
  if question
610
  else ""
611
  )
612
+ evidence_status = _evidence_status_html(view, language)
613
  return f"""
614
  <div class="dc-question-card">
615
  <span class="dc-question-kicker">{copy['question_kicker']}</span>
616
  <h2>{copy['question_title']}</h2>
617
  <p>{copy['question_body']}</p>
618
+ {evidence_status}
619
  {anchor_strip}
620
  {optional_question}
621
  {context_html}
 
967
  """.strip()
968
 
969
 
970
+ def _evidence_status_html(view: dict, language: str = DEFAULT_LANGUAGE) -> str:
971
+ items = view.get("evidence_items") or []
972
+ if not items:
973
+ return ""
974
+ is_zh = normalize_language(language) == "zh"
975
+ type_labels = {
976
+ "text": "文字" if is_zh else "Text",
977
+ "image": "图片" if is_zh else "Image",
978
+ "audio": "语音" if is_zh else "Voice",
979
+ "mood": "心情" if is_zh else "Mood",
980
+ }
981
+ status_labels = {
982
+ "selected": "已记录" if is_zh else "noted",
983
+ "extracted": "已提取" if is_zh else "extracted",
984
+ "failed": "未提取" if is_zh else "not extracted",
985
+ "queued": "等待中" if is_zh else "queued",
986
+ }
987
+ pills = []
988
+ for item in items[-6:]:
989
+ item_type = str(item.get("type") or "")
990
+ status = str(item.get("status") or "queued")
991
+ label = type_labels.get(item_type, str(item.get("label") or item_type or "Evidence"))
992
+ status_text = status_labels.get(status, status)
993
+ detail = str(item.get("error") or item.get("content") or "").strip()
994
+ if len(detail) > 96:
995
+ detail = detail[:93].rstrip() + "..."
996
+ title = f" title=\"{escape(detail)}\"" if detail else ""
997
+ pills.append(
998
+ f"<span class='dc-evidence-pill is-{escape(status)}'{title}>"
999
+ f"<i aria-hidden='true'></i>{escape(label)} · {escape(status_text)}</span>"
1000
+ )
1001
+ if not pills:
1002
+ return ""
1003
+ caption = "输入证据" if is_zh else "Input evidence"
1004
+ return f"""
1005
+ <div class="dc-evidence-status" aria-label="{escape(caption)}">
1006
+ {''.join(pills)}
1007
+ </div>
1008
+ """.strip()
1009
+
1010
+
1011
  def _processing_html(language: str = DEFAULT_LANGUAGE) -> str:
1012
+ if language == "zh":
1013
+ steps = ["文字线索", "图片线索", "语音转写"]
1014
+ aria = "处理通道"
1015
+ else:
1016
+ steps = ["Text clues", "Image clues", "Voice transcript"]
1017
+ aria = "Processing routes"
1018
+ step_html = "".join(f"<span>{escape(step)}</span>" for step in steps)
1019
+ return f"""
1020
+ <div class="dc-processing-panel" aria-live="polite">
1021
+ <p class='dc-processing-note'>{escape(copy_for(language)['processing_note'])}</p>
1022
+ <div class="dc-processing-steps" aria-label="{escape(aria)}">
1023
+ {step_html}
1024
+ </div>
1025
+ </div>
1026
+ """.strip()
1027
 
1028
 
1029
  def _side_stamp_html(language: str = DEFAULT_LANGUAGE) -> str:
dream_customs/ui/copy.py CHANGED
@@ -28,10 +28,10 @@ APP_COPY = {
28
  "mic_listening": "Recording. Tap again to stop, or pause and MiMo ASR will transcribe.",
29
  "mic_transcribing": "Transcribing with MiMo ASR...",
30
  "mic_waking": "MiMo ASR is waking on Modal. This first pass can take a moment.",
31
- "mic_done": "Added the ASR transcript to the dream note.",
32
  "mic_empty": "No speech detected. Tap again if you want to retry.",
33
- "mic_error": "Voice transcription failed. You can type this fragment instead.",
34
- "mic_timeout": "MiMo ASR timed out. Tap the mic to try once more.",
35
  "voice_label": "Voice note",
36
  "voice_help": "Record or upload a short voice note. It is sent to MiMo ASR when you continue.",
37
  "field_tip": "The desk looks for three anchors: a place, an object, and the question the dream left behind.",
@@ -103,10 +103,10 @@ APP_COPY = {
103
  "mic_listening": "正在录音。再次点击可停止,停顿后会交给 MiMo ASR 转写。",
104
  "mic_transcribing": "正在用 MiMo ASR 转写...",
105
  "mic_waking": "Modal 上的 MiMo ASR 正在唤醒,首次转写可能需要等一下。",
106
- "mic_done": "已把 ASR 转写加入梦境记录。",
107
  "mic_empty": "没有检测到语音。想重试的话,再点一次麦克风。",
108
- "mic_error": "语音转写失败你也可以手动输入这一段。",
109
- "mic_timeout": "MiMo ASR 转写超时。可以再点一次麦克风重试。",
110
  "voice_label": "语音片段",
111
  "voice_help": "可以录音或上传一小段语音。点击继续后,会直接发送给 MiMo ASR 转写。",
112
  "field_tip": "问讯室会优先寻找三个锚点:地点、物件、以及梦醒后留下的问题。",
 
28
  "mic_listening": "Recording. Tap again to stop, or pause and MiMo ASR will transcribe.",
29
  "mic_transcribing": "Transcribing with MiMo ASR...",
30
  "mic_waking": "MiMo ASR is waking on Modal. This first pass can take a moment.",
31
+ "mic_done": "ASR transcript added to the dream note.",
32
  "mic_empty": "No speech detected. Tap again if you want to retry.",
33
+ "mic_error": "Voice was not transcribed. Type that fragment or try again.",
34
+ "mic_timeout": "MiMo ASR timed out; no transcript was saved. Type it or retry.",
35
  "voice_label": "Voice note",
36
  "voice_help": "Record or upload a short voice note. It is sent to MiMo ASR when you continue.",
37
  "field_tip": "The desk looks for three anchors: a place, an object, and the question the dream left behind.",
 
103
  "mic_listening": "正在录音。再次点击可停止,停顿后会交给 MiMo ASR 转写。",
104
  "mic_transcribing": "正在用 MiMo ASR 转写...",
105
  "mic_waking": "Modal 上的 MiMo ASR 正在唤醒,首次转写可能需要等一下。",
106
+ "mic_done": "ASR 转写加入梦境记录。",
107
  "mic_empty": "没有检测到语音。想重试的话,再点一次麦克风。",
108
+ "mic_error": "语音没有完成转写。可以手动输入这一段,或再试一次。",
109
+ "mic_timeout": "MiMo ASR 转写超时,没有保存转写。可以手动输入或重试。",
110
  "voice_label": "语音片段",
111
  "voice_help": "可以录音或上传一小段语音。点击继续后,会直接发送给 MiMo ASR 转写。",
112
  "field_tip": "问讯室会优先寻找三个锚点:地点、物件、以及梦醒后留下的问题。",
dream_customs/ui/styles.py CHANGED
@@ -670,6 +670,32 @@ button.secondary {
670
  font-weight: 850;
671
  }
672
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
673
  .dc-attachment-drawer {
674
  background: transparent !important;
675
  border: 0 !important;
@@ -1627,6 +1653,45 @@ body,
1627
  width: 104px;
1628
  }
1629
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1630
  .dc-question-anchor-wrap {
1631
  background: rgba(255, 253, 248, 0.62);
1632
  border: 1px dashed rgba(95, 143, 104, 0.28);
@@ -3414,4 +3479,33 @@ CSS += """
3414
  border-radius: 14px;
3415
  }
3416
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3417
  """
 
670
  font-weight: 850;
671
  }
672
 
673
+ .dc-processing-panel {
674
+ margin-top: 12px;
675
+ }
676
+
677
+ .dc-processing-panel .dc-processing-note {
678
+ margin-top: 0;
679
+ }
680
+
681
+ .dc-processing-steps {
682
+ display: flex;
683
+ flex-wrap: wrap;
684
+ gap: 6px;
685
+ margin: 8px 2px 0;
686
+ }
687
+
688
+ .dc-processing-steps span {
689
+ background: rgba(255, 255, 255, 0.7);
690
+ border: 1px solid rgba(96, 85, 68, 0.16);
691
+ border-radius: 999px;
692
+ color: var(--dc-muted);
693
+ font-size: 0.76rem;
694
+ font-weight: 760;
695
+ line-height: 1;
696
+ padding: 6px 9px;
697
+ }
698
+
699
  .dc-attachment-drawer {
700
  background: transparent !important;
701
  border: 0 !important;
 
1653
  width: 104px;
1654
  }
1655
 
1656
+ .dc-evidence-status {
1657
+ display: flex;
1658
+ flex-wrap: wrap;
1659
+ gap: 8px;
1660
+ margin: 14px 0 4px;
1661
+ }
1662
+
1663
+ .dc-evidence-pill {
1664
+ align-items: center;
1665
+ background: rgba(255, 253, 248, 0.8);
1666
+ border: 1px solid rgba(95, 143, 104, 0.24);
1667
+ border-radius: 999px;
1668
+ color: #35553d;
1669
+ display: inline-flex;
1670
+ font-size: 0.76rem;
1671
+ font-weight: 780;
1672
+ gap: 6px;
1673
+ line-height: 1.2;
1674
+ min-height: 30px;
1675
+ padding: 6px 10px;
1676
+ }
1677
+
1678
+ .dc-evidence-pill i {
1679
+ background: #5f8f68;
1680
+ border-radius: 999px;
1681
+ height: 8px;
1682
+ width: 8px;
1683
+ }
1684
+
1685
+ .dc-evidence-pill.is-failed {
1686
+ background: rgba(255, 247, 237, 0.92);
1687
+ border-color: rgba(181, 103, 47, 0.34);
1688
+ color: #7a4325;
1689
+ }
1690
+
1691
+ .dc-evidence-pill.is-failed i {
1692
+ background: #b5672f;
1693
+ }
1694
+
1695
  .dc-question-anchor-wrap {
1696
  background: rgba(255, 253, 248, 0.62);
1697
  border: 1px dashed rgba(95, 143, 104, 0.28);
 
3479
  border-radius: 14px;
3480
  }
3481
  }
3482
+
3483
+ .dc-processing-note.is-active {
3484
+ align-items: center;
3485
+ background: #fff5d8 !important;
3486
+ border: 2px solid rgba(181, 103, 47, 0.42) !important;
3487
+ border-radius: 8px !important;
3488
+ box-shadow: 0 10px 24px rgba(85, 65, 35, 0.12);
3489
+ color: #3f311f !important;
3490
+ display: flex;
3491
+ font-size: 0.9rem !important;
3492
+ font-weight: 720;
3493
+ gap: 10px;
3494
+ line-height: 1.45 !important;
3495
+ margin-top: 14px !important;
3496
+ min-height: 42px;
3497
+ padding: 10px 12px !important;
3498
+ }
3499
+
3500
+ .dc-processing-note.is-active::before {
3501
+ animation: dc-processing-pulse 1.1s ease-in-out infinite;
3502
+ background: #b5672f !important;
3503
+ border-radius: 999px;
3504
+ content: "" !important;
3505
+ display: inline-block;
3506
+ flex: 0 0 auto;
3507
+ height: 10px;
3508
+ position: static !important;
3509
+ width: 10px;
3510
+ }
3511
  """
tests/test_pipeline.py CHANGED
@@ -110,6 +110,26 @@ def test_elevator_floor14_demo_anchors_are_clean_for_question_stage():
110
  assert "for floor" not in state.dream_anchors
111
 
112
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
113
  def test_zh_company_nap_splashed_water_keeps_real_anchors():
114
  session = add_evidence(
115
  create_session(language="zh"),
@@ -1347,3 +1367,197 @@ def test_witness_failure_keeps_text_path_alive():
1347
 
1348
  assert "Text still works." in intake.merged_text()
1349
  assert "fallback clue" in intake.merged_text()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
110
  assert "for floor" not in state.dream_anchors
111
 
112
 
113
+ def test_english_question_cleanup_handles_wanted_and_wondered_forms():
114
+ wanted = build_qa_state(
115
+ build_intake("I wanted to know which way to go next after the platform sign kept spinning."),
116
+ language="en",
117
+ )
118
+ leading_and = build_qa_state(
119
+ build_intake("The direction sign kept spinning, and I wanted to know which way to go next."),
120
+ language="en",
121
+ )
122
+ wondered = build_qa_state(
123
+ build_intake("I wondered why choosing a floor felt impossible."),
124
+ language="en",
125
+ )
126
+
127
+ assert wanted.main_question == "Which way to go next after the platform sign kept spinning?"
128
+ assert leading_and.main_question == "Which way to go next?"
129
+ assert wondered.main_question == "Why choosing a floor felt impossible?"
130
+ assert "Ed why" not in wondered.main_question
131
+
132
+
133
  def test_zh_company_nap_splashed_water_keeps_real_anchors():
134
  session = add_evidence(
135
  create_session(language="zh"),
 
1367
 
1368
  assert "Text still works." in intake.merged_text()
1369
  assert "fallback clue" in intake.merged_text()
1370
+
1371
+
1372
+ def test_train_platform_sketch_does_not_inherit_elevator_or_lost_child_defaults():
1373
+ class TrainPlatformVision:
1374
+ def extract_witness(self, image_path):
1375
+ return VisionWitness(
1376
+ scene_summary="A train platform with a suitcase, a direction sign, and a tall clock.",
1377
+ objects=["train platform", "suitcase", "direction sign", "platform clock"],
1378
+ visible_text=["TRACK 3", "NORTH EXIT"],
1379
+ mood_cues=["waiting", "uncertain direction"],
1380
+ )
1381
+
1382
+ def extract_clues(self, image_path):
1383
+ return ["flat fallback should not win"]
1384
+
1385
+ session = add_evidence(
1386
+ create_session(),
1387
+ dream_text=(
1388
+ "I was waiting on a train platform with a suitcase. "
1389
+ "The direction sign kept spinning, and I wanted to know which way to go next."
1390
+ ),
1391
+ image_path="ux01_train_platform_sketch.png",
1392
+ audio_path="ux01_voice.wav",
1393
+ mood="uneasy",
1394
+ vision_client=TrainPlatformVision(),
1395
+ asr_client=FakeASRClient(),
1396
+ )
1397
+ session = ask_questions(session, FakeTextClient())
1398
+ card = generate_today_tip(
1399
+ session.intake,
1400
+ "It feels like my real question is whether I am waiting for permission to choose a direction at work today.",
1401
+ FakeTextClient(),
1402
+ followup_questions=session.question_history,
1403
+ )
1404
+ combined = "\n".join(
1405
+ [
1406
+ session.question_history[0],
1407
+ ",".join(card.dream_anchors),
1408
+ card.dream_summary,
1409
+ card.interpretation,
1410
+ card.today_tip,
1411
+ card.tiny_action,
1412
+ card.caring_note,
1413
+ ]
1414
+ ).lower()
1415
+
1416
+ assert any(anchor in card.dream_anchors for anchor in ["train platform", "suitcase", "direction sign"])
1417
+ assert "elevator" not in combined
1418
+ assert "floor 14" not in combined
1419
+ assert "melted" not in combined
1420
+ assert "lost child" not in combined
1421
+ assert "message parachute" not in combined
1422
+ assert "the this feeling" not in combined
1423
+ assert "the the" not in combined
1424
+ assert "reversible first step" not in card.today_tip.lower()
1425
+ assert "direction check" in card.today_tip.lower() or "one sign" in card.today_tip.lower()
1426
+
1427
+
1428
+ def test_elevator_buttons_sketch_preserves_puddle_clock_and_choice_pressure():
1429
+ session = add_evidence(
1430
+ create_session(),
1431
+ dream_text=(
1432
+ "I dreamed I was in an apartment elevator lobby. The doors would not open, "
1433
+ "floor fourteen appeared in a puddle, and the buttons melted before I could press anything."
1434
+ ),
1435
+ image_path="ux02_elevator_buttons_sketch.png",
1436
+ audio_path="ux02_voice.wav",
1437
+ mood="anxious",
1438
+ vision_client=FakeVisionClient(),
1439
+ asr_client=FakeASRClient(),
1440
+ )
1441
+ session = ask_questions(session, FakeTextClient())
1442
+ card = generate_today_tip(
1443
+ session.intake,
1444
+ "The strongest feeling was that every option would make me late, especially a message I have been avoiding.",
1445
+ FakeTextClient(),
1446
+ followup_questions=session.question_history,
1447
+ )
1448
+ combined = "\n".join(
1449
+ [
1450
+ session.question_history[0],
1451
+ ",".join(card.dream_anchors),
1452
+ card.dream_summary,
1453
+ card.interpretation,
1454
+ card.today_tip,
1455
+ card.tiny_action,
1456
+ card.caring_note,
1457
+ ]
1458
+ ).lower()
1459
+
1460
+ assert "floor 14" in combined
1461
+ assert "puddle" in combined
1462
+ assert "clock" in combined
1463
+ assert any("puddle" in anchor for anchor in card.dream_anchors)
1464
+ assert any("clock" in anchor for anchor in card.dream_anchors)
1465
+ assert "choice pressure" in session.intake.merged_text().lower()
1466
+ assert "train platform" not in combined
1467
+ assert "lost child" not in combined
1468
+ assert "feeling this feeling" not in combined
1469
+ assert "reversible first step" not in card.today_tip.lower()
1470
+ assert "floor 14" in card.today_tip.lower() or "clock without hands" in card.today_tip.lower()
1471
+
1472
+
1473
+ def test_broken_visual_sentence_is_not_used_as_anchor_or_tip_copy():
1474
+ class BrokenTableVision:
1475
+ def extract_witness(self, image_path):
1476
+ return VisionWitness(
1477
+ scene_summary="A drawing showing a table with various and a",
1478
+ objects=["floating table", "loose keys", "star shapes"],
1479
+ visible_text=["SUNRISE"],
1480
+ spatial_relations=["keys hover over dark water"],
1481
+ )
1482
+
1483
+ def extract_clues(self, image_path):
1484
+ return ["a drawing showing a table with various and a", "floating table", "loose keys"]
1485
+
1486
+ session = add_evidence(
1487
+ create_session(),
1488
+ dream_text="I saw a floating table above dark water, with keys, stars, and a sunrise line.",
1489
+ image_path="ux03_floating_table_keys_sketch.png",
1490
+ mood="curious",
1491
+ vision_client=BrokenTableVision(),
1492
+ asr_client=FakeASRClient(),
1493
+ )
1494
+ session = ask_questions(session, FakeTextClient())
1495
+ card = generate_today_tip(
1496
+ session.intake,
1497
+ "I think the question is which small key to try first before the day gets busy.",
1498
+ FakeTextClient(),
1499
+ followup_questions=session.question_history,
1500
+ )
1501
+ combined = "\n".join(
1502
+ [
1503
+ session.question_history[0],
1504
+ ",".join(card.dream_anchors),
1505
+ card.dream_summary,
1506
+ card.interpretation,
1507
+ card.today_tip,
1508
+ card.tiny_action,
1509
+ card.caring_note,
1510
+ ]
1511
+ ).lower()
1512
+
1513
+ assert any(anchor in card.dream_anchors for anchor in ["floating table", "loose key", "dark water", "sunrise"])
1514
+ assert "various and a" not in combined
1515
+ assert "drawing showing" not in combined
1516
+ assert "the this feeling" not in combined
1517
+ assert "the the" not in combined
1518
+ assert "reversible first step" not in card.today_tip.lower()
1519
+ assert "morning key check" in card.today_tip.lower() or "before the day gets busy" in card.today_tip.lower()
1520
+
1521
+
1522
+ def test_floating_table_demo_keeps_star_and_sunrise_image_details():
1523
+ session = add_evidence(
1524
+ create_session(),
1525
+ dream_text=(
1526
+ "I dreamed my kitchen table was floating on shallow water. "
1527
+ "Three keys were arranged in a triangle, a cup spilled tiny stars, "
1528
+ "and morning light was coming through a square window."
1529
+ ),
1530
+ image_path="ux03_floating_table_keys_sketch.png",
1531
+ audio_path="ux03_voice.wav",
1532
+ mood="curious",
1533
+ vision_client=FakeVisionClient(),
1534
+ asr_client=FakeASRClient(),
1535
+ )
1536
+ session = ask_questions(session, FakeTextClient())
1537
+ card = generate_today_tip(
1538
+ session.intake,
1539
+ "I think the question is which small key to try first before the day gets busy.",
1540
+ FakeTextClient(),
1541
+ followup_questions=session.question_history,
1542
+ )
1543
+ combined = "\n".join(
1544
+ [
1545
+ session.question_history[0],
1546
+ ",".join(card.dream_anchors),
1547
+ card.dream_summary,
1548
+ card.interpretation,
1549
+ card.today_tip,
1550
+ card.tiny_action,
1551
+ card.caring_note,
1552
+ ]
1553
+ ).lower()
1554
+
1555
+ assert "floating table" in combined
1556
+ assert "key" in combined
1557
+ assert "star" in combined
1558
+ assert "sunrise" in combined
1559
+ assert "floating table" in session.question_history[0].lower()
1560
+ assert "loose keys" in session.question_history[0].lower()
1561
+ assert "sunrise" in session.question_history[0].lower()
1562
+ assert "floor 14" not in combined
1563
+ assert "elevator" not in combined
tests/test_ui_actions.py CHANGED
@@ -275,6 +275,33 @@ def test_question_stage_shows_anchor_chips_before_the_question():
275
  assert html.index("When floor 14") < html.index("Why this question")
276
 
277
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
278
  def test_long_running_buttons_show_processing_feedback():
279
  source = inspect.getsource(ui_app.build_demo)
280
  app_source = inspect.getsource(ui_app)
@@ -292,6 +319,11 @@ def test_long_running_buttons_show_processing_feedback():
292
  assert "dc-is-loading" in ui_app.VOICE_JS
293
  assert "Asking from another angle" in ui_app.VOICE_JS
294
  assert "one more grounded question" in ui_app.VOICE_JS
 
 
 
 
 
295
  assert "js=SUBMIT_PROCESSING_JS" not in source
296
  assert "js=TIP_PROCESSING_JS" not in source
297
  assert source.count("scroll_to_output=True") >= 5
@@ -299,6 +331,7 @@ def test_long_running_buttons_show_processing_feedback():
299
  assert "Folding the follow-up answer" in app_source
300
  assert ".dc-notice.is-processing" in CSS
301
  assert ".dc-processing-note.is-active" in CSS
 
302
  assert ".dc-stage button.dc-is-loading" in CSS
303
  assert "endpoint" not in ui_app.VOICE_JS.lower()
304
  assert "debug" not in ui_app.VOICE_JS.lower()
 
275
  assert html.index("When floor 14") < html.index("Why this question")
276
 
277
 
278
+ def test_question_stage_surfaces_evidence_status_and_failed_asr():
279
+ _state, view_json = submit_dream_action(
280
+ dream_text="I was waiting on a train platform with a suitcase and a spinning direction sign.",
281
+ image_value="ux01_train_platform_sketch.png",
282
+ audio_value="ux01_voice.wav",
283
+ mood="Uneasy",
284
+ text_backend="demo",
285
+ vision_backend="demo",
286
+ language="en",
287
+ asr_backend="modal",
288
+ asr_endpoint="",
289
+ )
290
+ view = json.loads(view_json)
291
+ statuses = {(item["type"], item["status"]) for item in view["evidence_items"]}
292
+ html = ui_app._question_markdown(view, "en")
293
+
294
+ assert ("text", "selected") in statuses
295
+ assert ("image", "extracted") in statuses
296
+ assert ("audio", "failed") in statuses
297
+ assert "dc-evidence-status" in html
298
+ assert "Text · noted" in html
299
+ assert "Image · extracted" in html
300
+ assert "Voice · not extracted" in html
301
+ assert "Voice transcription did not return a transcript" in html
302
+ assert ".dc-evidence-pill.is-failed" in CSS
303
+
304
+
305
  def test_long_running_buttons_show_processing_feedback():
306
  source = inspect.getsource(ui_app.build_demo)
307
  app_source = inspect.getsource(ui_app)
 
319
  assert "dc-is-loading" in ui_app.VOICE_JS
320
  assert "Asking from another angle" in ui_app.VOICE_JS
321
  assert "one more grounded question" in ui_app.VOICE_JS
322
+ processing = ui_app._processing_html("en")
323
+ assert "dc-processing-steps" in processing
324
+ assert "Text clues" in processing
325
+ assert "Image clues" in processing
326
+ assert "Voice transcript" in processing
327
  assert "js=SUBMIT_PROCESSING_JS" not in source
328
  assert "js=TIP_PROCESSING_JS" not in source
329
  assert source.count("scroll_to_output=True") >= 5
 
331
  assert "Folding the follow-up answer" in app_source
332
  assert ".dc-notice.is-processing" in CSS
333
  assert ".dc-processing-note.is-active" in CSS
334
+ assert "#fff5d8" in CSS
335
  assert ".dc-stage button.dc-is-loading" in CSS
336
  assert "endpoint" not in ui_app.VOICE_JS.lower()
337
  assert "debug" not in ui_app.VOICE_JS.lower()
videos/2026-06-15-dreamqa-v2/ux01_evidence.json ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "id": "ux01",
3
+ "title": "Train platform",
4
+ "url": "http://127.0.0.1:7862",
5
+ "imagePath": "/Users/junhaocheng/working-dir/ai-competitions/build-small-hackthon/videos/2026-06-15-dreamqa-full-ux/assets/ux01_train_platform_sketch.png",
6
+ "audioPath": "/Users/junhaocheng/working-dir/ai-competitions/build-small-hackthon/videos/2026-06-15-dreamqa-full-ux/assets/ux01_voice.wav",
7
+ "startedAt": "2026-06-15T04:39:39.530Z",
8
+ "steps": [
9
+ "opened_local_app",
10
+ "filled_text_dream",
11
+ "uploaded_image_sketch",
12
+ "uploaded_voice_wav",
13
+ "submitted_intake",
14
+ "received_followup_question",
15
+ "answered_followup",
16
+ "received_morning_ticket"
17
+ ],
18
+ "status": "passed",
19
+ "voiceStatusAfterInput": "Voice was not transcribed. Type that fragment or try again.",
20
+ "questionText": "\n Step 3 of 4 · Answer or skip\n One useful question\n Answer in one or two lines, or skip and let the existing anchors carry the ticket.\n \n Text · notedMood · notedImage · extractedVoice · not extracted\n\n Sticky details already on the desktrain platformsuitcasedirection sign\n Morning Question DeskIn train platform, suitcase, and direction sign, what feeling stayed strongest after waking?\n Why this questionWhat I hear first is that the strongest trail begins with train platform, suitcase, and direction sign, so I will connect it to what you may need today. I will unpack this in three layers: the feeling after waking, the concrete image (train platform, suitcase, and direction sign), and what it may be asking you to care for today. One question that will shape the final tip\n This step makes the tip more specific. It is not diagnosis.\n",
21
+ "ticketText": "\n \n \n Morning Ticket\n Not a diagnosis, not a prophecy. Just one small note for today, grounded in the dream.\n \n \n Take one note today\n Grounded in the dream\n \n \n train platformsuitcasedirection sign\n \n Because your dream kept returning to:train platform suitcase direction sign\n \n Today Tip\n Connect train platform to the waking-life clue you named: \"It feels like my real question is whether I am waiting for permission to choose a direction at work today\". Use it as a tiny direction check today: write the work direction you named on one note, then choose one sign you can test before the day pulls away.\n \n Tiny 5-minute actionPlace two pens like two roads, put a note labeled train platform, suitcase, and direction sign between them, spin the note once, and check only the entrance it points to.\n \n Supporting reflection\n Maybe the train platform is not asking for a fixed dream meaning. It is pointing back to the waking-life clue you named: \"It feels like my real question is whether I am waiting for permission to choose a direction at work today\".\n \n \n \n How this was made small\n MiniCPM-V reads sketches, notes, and image clues; MiniCPM5-1B writes the follow-up question, reflection, and ticket.\n \n Question recordWhat I hear first is that the strongest trail begins with train platform, suitcase, and direction sign, so I will connect it to what you may need today. I will unpack this in three layers: the feeling after waking, the concrete image (train platform, suitcase, and direction sign), and what it may be asking you to care for today. One question that will shape the final tip: In train platform, suitcase, and direction sign, what feeling stayed strongest after wakingIt feels like my real question is whether I am waiting for permission to choose a direction at work today\n A gentle reflection for today, not a diagnosis or prophecy.\n Care noteYou do not have to solve the whole dream this morning; start by noticing one detail and one small next step.\n",
22
+ "evidenceStatus": "\n Text · notedMood · notedImage · extractedVoice · not extracted\n",
23
+ "processingRoutes": "\n Text cluesImage cluesVoice transcript\n ",
24
+ "assertions": {
25
+ "requiredTermsPresent": true,
26
+ "bannedTermsAbsent": true,
27
+ "noBrokenVisualPhrase": true,
28
+ "evidenceStatusVisible": true,
29
+ "processingRoutesVisible": true,
30
+ "todayTipUsesAnswerClue": true,
31
+ "todayTipUsesDemoSpecificCue": true,
32
+ "questionUsesDemoSpecificCue": true,
33
+ "voiceFallbackHonest": true,
34
+ "noTemplateTipPhrase": true,
35
+ "noSafetyBoundaryLeak": true
36
+ },
37
+ "endedAt": "2026-06-15T04:40:03.195Z",
38
+ "videoWebm": "/Users/junhaocheng/working-dir/ai-competitions/build-small-hackthon/videos/2026-06-15-dreamqa-full-ux/ux01_full_ux.webm",
39
+ "videoPath": "videos/2026-06-15-dreamqa-v2/ux01_full_ux.mp4",
40
+ "duration": 24.32,
41
+ "sizeBytes": 1642562,
42
+ "questionVisibleForSeconds": 3.6,
43
+ "finalTicketVisibleForSeconds": 4.3,
44
+ "voiceEvidenceStatus": "fallback: Voice was not transcribed. Type that fragment or try again.",
45
+ "finalAnchors": [
46
+ "train platform",
47
+ "suitcase",
48
+ "direction sign"
49
+ ],
50
+ "todayTipText": "Connect train platform to the waking-life clue you named: \"It feels like my real question is whether I am waiting for permission to choose a direction at work today\". Use it as a tiny direction check today: write the work direction you named on one note, then choose one sign you can test before the day pulls away.",
51
+ "requiredAnswerClues": [
52
+ "permission",
53
+ "direction at work"
54
+ ],
55
+ "requiredTipClues": [
56
+ "direction check",
57
+ "one sign"
58
+ ],
59
+ "requiredQuestionClues": [
60
+ "train platform",
61
+ "direction"
62
+ ]
63
+ }
videos/2026-06-15-dreamqa-v2/ux01_final_ticket.png ADDED

Git LFS Details

  • SHA256: 6f7f64d5436e615872790b9242f4e1e9644eb4d405f05c3b81e45a759bfd6e83
  • Pointer size: 131 Bytes
  • Size of remote file: 840 kB
videos/2026-06-15-dreamqa-v2/ux01_full_ux.mp4 ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:c9207c017c7d788c1f6a612097b256cdedbc7899d5f27929d1ed7020a2ca8caf
3
+ size 1642562
videos/2026-06-15-dreamqa-v2/ux01_timeline.jpg ADDED

Git LFS Details

  • SHA256: 5e195f4ee1e20ac9313a221fa206b16694cbe5345f2e505b6a7094059b58906a
  • Pointer size: 132 Bytes
  • Size of remote file: 1.06 MB
videos/2026-06-15-dreamqa-v2/ux02_evidence.json ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "id": "ux02",
3
+ "title": "Elevator buttons",
4
+ "url": "http://127.0.0.1:7862",
5
+ "imagePath": "/Users/junhaocheng/working-dir/ai-competitions/build-small-hackthon/videos/2026-06-15-dreamqa-full-ux/assets/ux02_elevator_buttons_sketch.png",
6
+ "audioPath": "/Users/junhaocheng/working-dir/ai-competitions/build-small-hackthon/videos/2026-06-15-dreamqa-full-ux/assets/ux02_voice.wav",
7
+ "startedAt": "2026-06-15T04:40:26.093Z",
8
+ "steps": [
9
+ "opened_local_app",
10
+ "filled_text_dream",
11
+ "uploaded_image_sketch",
12
+ "uploaded_voice_wav",
13
+ "submitted_intake",
14
+ "received_followup_question",
15
+ "answered_followup",
16
+ "received_morning_ticket"
17
+ ],
18
+ "status": "passed",
19
+ "voiceStatusAfterInput": "Voice was not transcribed. Type that fragment or try again.",
20
+ "questionText": "\n Step 3 of 4 · Answer or skip\n One useful question\n Answer in one or two lines, or skip and let the existing anchors carry the ticket.\n \n Text · notedMood · notedImage · extractedVoice · not extracted\n\n Sticky details already on the deskmelted elevator buttonsfloor 14 in a puddleclock without hands\n Morning Question DeskWhich detail in melted elevator buttons, elevator, and floor 14 in a puddle feels closest to your current stuck point?\n Why this questionWhat I hear first is that the strongest trail begins with melted elevator buttons, elevator, and floor 14 in a puddle, so I will connect it to what you may need today. I will unpack this in three layers: the feeling after waking, the concrete image (melted elevator buttons, elevator, and floor 14 in a puddle), and what it may be asking you to care for today. One question that will shape the final tip\n This step makes the tip more specific. It is not diagnosis.\n",
21
+ "ticketText": "\n \n \n Morning Ticket\n Not a diagnosis, not a prophecy. Just one small note for today, grounded in the dream.\n \n \n Take one note today\n Grounded in the dream\n \n \n melted elevator buttonsfloor 14 in a puddleclock without hands\n \n Because your dream kept returning to:melted elevator buttons floor 14 in a puddle clock without hands\n \n Today Tip\n Connect melted elevator buttons to the work message pressure you named, without changing it into a different task. Use floor 14 in a puddle and the clock without hands as the boundary: write only what the message needs to convey before deciding when to open or send it.\n \n Tiny 5-minute actionDraw a tiny elevator button labeled melted elevator buttons, elevator, and floor 14 in a puddle, press it once with your finger, then name the smallest doorway action you can try today.\n \n Supporting reflection\n Maybe the floor 14 in a puddle is not asking you to finish every message at once. It is pointing to the gentler threshold: naming what the message needs to carry.\n \n \n \n How this was made small\n MiniCPM-V reads sketches, notes, and image clues; MiniCPM5-1B writes the follow-up question, reflection, and ticket.\n \n Question recordWhat I hear first is that the strongest trail begins with melted elevator buttons, elevator, and floor 14 in a puddle, so I will connect it to what you may need today. I will unpack this in three layers: the feeling after waking, the concrete image (melted elevator buttons, elevator, and floor 14 in a puddle), and what it may be asking you to care for today. One question that will shape the final tip: Which detail in melted elevator buttons, elevator, and floor 14 in a puddle feels closest to your current stuck pointThe strongest feeling was that every option would make me late, especially a message I have been avoiding\n A gentle reflection for today, not a diagnosis or prophecy.\n Care noteYou do not have to solve the whole dream this morning; start by noticing one detail and one small next step.\n",
22
+ "evidenceStatus": "\n Text · notedMood · notedImage · extractedVoice · not extracted\n",
23
+ "processingRoutes": "\n Text cluesImage cluesVoice transcript\n ",
24
+ "assertions": {
25
+ "requiredTermsPresent": true,
26
+ "bannedTermsAbsent": true,
27
+ "noBrokenVisualPhrase": true,
28
+ "evidenceStatusVisible": true,
29
+ "processingRoutesVisible": true,
30
+ "todayTipUsesAnswerClue": true,
31
+ "todayTipUsesDemoSpecificCue": true,
32
+ "questionUsesDemoSpecificCue": true,
33
+ "voiceFallbackHonest": true,
34
+ "noTemplateTipPhrase": true,
35
+ "noSafetyBoundaryLeak": true
36
+ },
37
+ "endedAt": "2026-06-15T04:40:48.693Z",
38
+ "videoWebm": "/Users/junhaocheng/working-dir/ai-competitions/build-small-hackthon/videos/2026-06-15-dreamqa-full-ux/ux02_full_ux.webm",
39
+ "videoPath": "videos/2026-06-15-dreamqa-v2/ux02_full_ux.mp4",
40
+ "duration": 23.36,
41
+ "sizeBytes": 1803192,
42
+ "questionVisibleForSeconds": 3.6,
43
+ "finalTicketVisibleForSeconds": 4.3,
44
+ "voiceEvidenceStatus": "fallback: Voice was not transcribed. Type that fragment or try again.",
45
+ "finalAnchors": [
46
+ "melted elevator buttons",
47
+ "floor 14 in a puddle",
48
+ "clock without hands"
49
+ ],
50
+ "todayTipText": "Connect melted elevator buttons to the work message pressure you named, without changing it into a different task. Use floor 14 in a puddle and the clock without hands as the boundary: write only what the message needs to convey before deciding when to open or send it.",
51
+ "requiredAnswerClues": [
52
+ "message",
53
+ "late"
54
+ ],
55
+ "requiredTipClues": [
56
+ "floor 14 in a puddle",
57
+ "clock without hands",
58
+ "message needs to convey"
59
+ ],
60
+ "requiredQuestionClues": [
61
+ "melted elevator buttons",
62
+ "floor 14"
63
+ ]
64
+ }
videos/2026-06-15-dreamqa-v2/ux02_final_ticket.png ADDED

Git LFS Details

  • SHA256: 25e26c26dd42e58db923803f3e5c0b10df92cfc1da0225209e5e477f9fef058d
  • Pointer size: 131 Bytes
  • Size of remote file: 827 kB
videos/2026-06-15-dreamqa-v2/ux02_full_ux.mp4 ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:43a960dd96fff4c8f16e0bacf908f3d6ae0750dcc355549a2129409d0a7b7425
3
+ size 1803192
videos/2026-06-15-dreamqa-v2/ux02_timeline.jpg ADDED

Git LFS Details

  • SHA256: 1328b21b83a3a31cde5121b6ba585b2437bf5647c562fe4cfd26417e6351e2a3
  • Pointer size: 132 Bytes
  • Size of remote file: 1.07 MB
videos/2026-06-15-dreamqa-v2/ux03_evidence.json ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "id": "ux03",
3
+ "title": "Floating kitchen table",
4
+ "url": "http://127.0.0.1:7862",
5
+ "imagePath": "/Users/junhaocheng/working-dir/ai-competitions/build-small-hackthon/videos/2026-06-15-dreamqa-full-ux/assets/ux03_floating_table_keys_sketch.png",
6
+ "audioPath": "/Users/junhaocheng/working-dir/ai-competitions/build-small-hackthon/videos/2026-06-15-dreamqa-full-ux/assets/ux03_voice.wav",
7
+ "startedAt": "2026-06-15T04:41:12.340Z",
8
+ "steps": [
9
+ "opened_local_app",
10
+ "filled_text_dream",
11
+ "uploaded_image_sketch",
12
+ "uploaded_voice_wav",
13
+ "submitted_intake",
14
+ "received_followup_question",
15
+ "answered_followup",
16
+ "received_morning_ticket"
17
+ ],
18
+ "status": "passed",
19
+ "voiceStatusAfterInput": "Voice was not transcribed. Type that fragment or try again.",
20
+ "questionText": "\n Step 3 of 4 · Answer or skip\n One useful question\n Answer in one or two lines, or skip and let the existing anchors carry the ticket.\n \n Text · notedMood · notedImage · extractedVoice · not extracted\n\n Sticky details already on the deskfloating tableloose keysstar shapes by a sunrise line\n Morning Question DeskIn the floating table, loose keys, and sunrise line, which detail feels most like the small key you want to try today?\n Why this questionWhat I hear first is that the strongest trail begins with floating table, loose keys, and star shapes by a sunrise line, so I will connect it to what you may need today. I will unpack this in three layers: the feeling after waking, the concrete image (floating table, loose keys, and star shapes by a sunrise line), and what it may be asking you to care for today. One question that will shape the final tip\n This step makes the tip more specific. It is not diagnosis.\n",
21
+ "ticketText": "\n \n \n Morning Ticket\n Not a diagnosis, not a prophecy. Just one small note for today, grounded in the dream.\n \n \n Take one note today\n Grounded in the dream\n \n \n floating tableloose keysstar shapes by a sunrise line\n \n Because your dream kept returning to:floating table loose keys star shapes by a sunrise line\n \n Today Tip\n Connect floating table to the waking-life clue you named: \"I think the question is which small key to try first before the day gets busy\". Use it as a morning key check: choose the one small key you named, try that before the day gets busy, and leave the other keys on the table for later.\n \n Tiny 5-minute actionChoose one desk object as a stand-in for floating table, loose keys, and star shapes by a sunrise line, move it one centimeter, then name the smallest waking-life action it points to.\n \n Supporting reflection\n Maybe the floating table is not asking for a fixed dream meaning. It is pointing back to the waking-life clue you named: \"I think the question is which small key to try first before the day gets busy\".\n \n \n \n How this was made small\n MiniCPM-V reads sketches, notes, and image clues; MiniCPM5-1B writes the follow-up question, reflection, and ticket.\n \n Question recordWhat I hear first is that the strongest trail begins with floating table, loose keys, and star shapes by a sunrise line, so I will connect it to what you may need today. I will unpack this in three layers: the feeling after waking, the concrete image (floating table, loose keys, and star shapes by a sunrise line), and what it may be asking you to care for today. One question that will shape the final tip: In the floating table, loose keys, and sunrise line, which detail feels most like the small key you want to try todayI think the question is which small key to try first before the day gets busy\n A gentle reflection for today, not a diagnosis or prophecy.\n Care noteYou do not have to solve the whole dream this morning; start by noticing one detail and one small next step.\n",
22
+ "evidenceStatus": "\n Text · notedMood · notedImage · extractedVoice · not extracted\n",
23
+ "processingRoutes": "\n Text cluesImage cluesVoice transcript\n ",
24
+ "assertions": {
25
+ "requiredTermsPresent": true,
26
+ "bannedTermsAbsent": true,
27
+ "noBrokenVisualPhrase": true,
28
+ "evidenceStatusVisible": true,
29
+ "processingRoutesVisible": true,
30
+ "todayTipUsesAnswerClue": true,
31
+ "todayTipUsesDemoSpecificCue": true,
32
+ "questionUsesDemoSpecificCue": true,
33
+ "voiceFallbackHonest": true,
34
+ "noTemplateTipPhrase": true,
35
+ "noSafetyBoundaryLeak": true
36
+ },
37
+ "endedAt": "2026-06-15T04:41:34.960Z",
38
+ "videoWebm": "/Users/junhaocheng/working-dir/ai-competitions/build-small-hackthon/videos/2026-06-15-dreamqa-full-ux/ux03_full_ux.webm",
39
+ "videoPath": "videos/2026-06-15-dreamqa-v2/ux03_full_ux.mp4",
40
+ "duration": 23.36,
41
+ "sizeBytes": 1782024,
42
+ "questionVisibleForSeconds": 3.6,
43
+ "finalTicketVisibleForSeconds": 4.3,
44
+ "voiceEvidenceStatus": "fallback: Voice was not transcribed. Type that fragment or try again.",
45
+ "finalAnchors": [
46
+ "floating table",
47
+ "loose keys",
48
+ "star shapes by a sunrise line"
49
+ ],
50
+ "todayTipText": "Connect floating table to the waking-life clue you named: \"I think the question is which small key to try first before the day gets busy\". Use it as a morning key check: choose the one small key you named, try that before the day gets busy, and leave the other keys on the table for later.",
51
+ "requiredAnswerClues": [
52
+ "small key",
53
+ "before the day gets busy"
54
+ ],
55
+ "requiredTipClues": [
56
+ "morning key check",
57
+ "leave the other keys on the table"
58
+ ],
59
+ "requiredQuestionClues": [
60
+ "floating table",
61
+ "loose keys",
62
+ "sunrise"
63
+ ]
64
+ }
videos/2026-06-15-dreamqa-v2/ux03_final_ticket.png ADDED

Git LFS Details

  • SHA256: 6e83479c1a73ae54096080112194a58f238fe75e3388e4b703197783a051a5d7
  • Pointer size: 131 Bytes
  • Size of remote file: 828 kB
videos/2026-06-15-dreamqa-v2/ux03_full_ux.mp4 ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:b520601a44983a82652cf99e37294be8bb3e6b9fd74a8da1c4694989a72ac590
3
+ size 1782024
videos/2026-06-15-dreamqa-v2/ux03_timeline.jpg ADDED

Git LFS Details

  • SHA256: 181027b5ff2b36d334e3242b112e6b8b375f3caeb40db9e7961d82f298e373a9
  • Pointer size: 132 Bytes
  • Size of remote file: 1.07 MB