Improve Dream QA local flow robustness

#36
.gitattributes CHANGED
@@ -39,3 +39,6 @@ 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
 
 
 
 
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
app.py CHANGED
@@ -5,9 +5,11 @@ from fastapi import Request
5
  from fastapi.responses import JSONResponse, RedirectResponse
6
 
7
  from dream_customs import zerogpu # noqa: F401
 
8
  from dream_customs.ui.app import build_demo
9
 
10
 
 
11
  demo = build_demo()
12
 
13
 
 
5
  from fastapi.responses import JSONResponse, RedirectResponse
6
 
7
  from dream_customs import zerogpu # noqa: F401
8
+ from dream_customs.runtime_env import auto_load_runtime_env_json
9
  from dream_customs.ui.app import build_demo
10
 
11
 
12
+ auto_load_runtime_env_json()
13
  demo = build_demo()
14
 
15
 
docs/smoke/2026-06-12-modal-emotion-led-5round-evidence.json ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "generated_at": "2026-06-12T01:40:02.322834+00:00",
3
+ "cwd": "/Users/junhaocheng/working-dir/ai-competitions/build-small-hackthon",
4
+ "git_commit": "f51f11407c6fa7d41ba57c511b2cf683114e1442",
5
+ "git_branch": "bugfix/emotion-led-dream-qa",
6
+ "model": {
7
+ "modal_app": "dream-customs-minicpm-backend",
8
+ "endpoint_host": "cjh-12569--dream-customs-minicpm-backend-text.modal.run",
9
+ "text_model": "openbmb/MiniCPM5-1B",
10
+ "max_tokens_for_evidence": 220
11
+ },
12
+ "scope_note": "Direct Modal text endpoint evidence using the emotion-led prompt constraints from the local change. Full Gradio TodayTipCard JSON prompt was attempted but timed out/hung on the current Modal deployment, so this file preserves direct model replies instead of app-card schema replies.",
13
+ "secret_handling": "Modal secret was used inside the temporary Modal runner only; token value was not printed or written.",
14
+ "results": [
15
+ {
16
+ "round": 1,
17
+ "case_id": "sea-pressure-comfort",
18
+ "language": "zh",
19
+ "dream_text": "我梦到自己掉进海里,醒来很害怕。我想知道这是不是说明我快撑不住了?",
20
+ "user_answer": "我最近工作压力很大,真的很想被安慰一下。",
21
+ "modal_http_status": 200,
22
+ "modal_transport_ok": true,
23
+ "modal_elapsed_seconds": 26.44,
24
+ "modal_error": {
25
+ "type": "",
26
+ "message": ""
27
+ },
28
+ "modal_route_status": "ok",
29
+ "modal_route_error": "",
30
+ "modal_model_reply": "想理解的问题:用户最近工作压力很大,真的很想被安慰一下。\n\n一步步解读:用户提到“工作压力很大”和“很想被安慰”,这说明用户目前处于情绪低谷,需要关注自己的心理健康。梦到自己掉进海里,醒来后感到害怕,可能是因为现实中的压力和情绪影响睡眠或情绪状态。用户没有表现出害怕、难过、压力、自责或想被安慰的情绪,而是想被安慰,这可能是因为用户希望通过梦境来寻求情感支持。\n\n今日小 Tips:用户可以尝试在睡前写下当天的压力点,并思考如何调整心态,或者与信任的朋友倾诉。梦中的细节可能反映用户潜意识中的担忧或渴望,但不应过度解读。\n\n关心一句:用户需要关注自己的情绪和压力,及时寻求专业帮助或心理咨询。"
31
+ },
32
+ {
33
+ "round": 2,
34
+ "case_id": "ex-message-sadness",
35
+ "language": "zh",
36
+ "dream_text": "我梦到前任发消息又消失了,我醒来很难过,想知道是不是我还没走出来。",
37
+ "user_answer": "我不想要鸡汤,只想知道为什么这么难受。",
38
+ "modal_http_status": 200,
39
+ "modal_transport_ok": true,
40
+ "modal_elapsed_seconds": 5.82,
41
+ "modal_error": {
42
+ "type": "",
43
+ "message": ""
44
+ },
45
+ "modal_route_status": "ok",
46
+ "modal_route_error": "",
47
+ "modal_model_reply": "想理解的问题:用户梦到前任发消息又消失,醒来感到难过,想知道是否已走出过程。\n\n一步步解读:\n1. 梦到前任消失可能反映用户内心对这段关系的复杂感受。梦中的“消失”可能象征着情感上的断裂或未解决的矛盾。\n2. 用户感到“很难过”说明当前情绪状态可能与梦境中的经历有关,梦境往往承载着潜意识的信息。\n3. 用户想了解是否已走出过程,暗示用户可能正经历一段心理调整期,需要关注自己的情绪变化。\n\n今日小 Tips:梦中的具体细节是“前任发消息又消失”,这可能意味着用户在现实生活中遇到了沟通障碍或情感疏离,建议用户记录这些细节,以便更好地理解自己的情绪状态。\n\n关心一句:希望用户能耐心倾听自己的感受,并给予支持。"
48
+ },
49
+ {
50
+ "round": 3,
51
+ "case_id": "black-wave-prophecy",
52
+ "language": "en",
53
+ "dream_text": "I dreamed of a black wave and woke thinking: is this a sign something bad will happen?",
54
+ "user_answer": "It left a tight feeling in my chest, and I want a grounded answer.",
55
+ "modal_http_status": 200,
56
+ "modal_transport_ok": true,
57
+ "modal_elapsed_seconds": 7.24,
58
+ "modal_error": {
59
+ "type": "",
60
+ "message": ""
61
+ },
62
+ "modal_route_status": "ok",
63
+ "modal_route_error": "",
64
+ "modal_model_reply": "It sounds like you're feeling a bit overwhelmed by the news of your black wave dream, and I understand why you want a grounded answer. Here is a step-by-step interpretation based on the details you provided.\n\n**Step 1: Analyze the dream elements**\nYou described a \"black wave,\" which is a very specific and intense dream symbol. In dream analysis, a black wave often represents a sudden, overwhelming surge of emotion, stress, or anxiety that feels like a tidal wave crashing against your mind. It is rarely a literal wave in the ocean; instead, it usually signifies a sudden, intense emotional event that has a high intensity and a strong impact on your psyche.\n\n**Step 2: Assess the emotional reaction**\nYou mentioned feeling a \"tight feeling in my chest\" after the dream. This is a classic sign of anxiety or panic. When a black wave occurs, the brain's amygdala (the fear center) is activated, triggering a fight-or-flight response. This physical sensation of tightness is a direct result of the fear of the sudden, violent nature of the event."
65
+ },
66
+ {
67
+ "round": 4,
68
+ "case_id": "lost-child-guilt",
69
+ "language": "zh",
70
+ "dream_text": "我梦到一个小孩在地铁站找不到家,我一直追不上他,醒来很自责。",
71
+ "user_answer": "我最近总觉得没有照顾好家人,想知道这种内疚该怎么放一放。",
72
+ "modal_http_status": 200,
73
+ "modal_transport_ok": true,
74
+ "modal_elapsed_seconds": 4.75,
75
+ "modal_error": {
76
+ "type": "",
77
+ "message": ""
78
+ },
79
+ "modal_route_status": "ok",
80
+ "modal_route_error": "",
81
+ "modal_model_reply": "想理解的问题:用户醒来后感到内疚和自责,这可能源于对未尽责任的担忧。\n\n一步步解读:用户梦到小孩在地铁站找不到家,这可能象征着生活中未解决的问题或未完成的任务。用户一直追不上小孩,可能意味着在现实生活中感到被忽视或无法跟上他人的步伐。醒来后感到内疚和自责,可能是因为对未能提供足够支持或帮助的担忧。\n\n今日小 Tips:用户可以尝试在日常生活中多关注家人,表达关心和支持。也可以尝试与家人沟通,分享自己的感受和需求。\n\n关心一句:希望用户能够找到适合自己的方式来处理这些情绪,并从中找到力量和成长。"
82
+ },
83
+ {
84
+ "round": 5,
85
+ "case_id": "drowning-not-coping",
86
+ "language": "en",
87
+ "dream_text": "I dreamed I was drowning in dark water and woke scared. Does this mean I am not coping?",
88
+ "user_answer": "I need comfort, not productivity advice.",
89
+ "modal_http_status": 200,
90
+ "modal_transport_ok": true,
91
+ "modal_elapsed_seconds": 7.54,
92
+ "modal_error": {
93
+ "type": "",
94
+ "message": ""
95
+ },
96
+ "modal_route_status": "ok",
97
+ "modal_route_error": "",
98
+ "modal_model_reply": "I need comfort, not productivity advice.\n\nHere is a step-by-step interpretation based on the dream details you provided:\n\n1. **Analyze the Dream Elements**: You were in \"dark water,\" which is a common symbol of confusion, danger, or emotional turmoil. Being \"drowning\" indicates a current crisis or fear of drowning, often linked to feelings of helplessness or anxiety. The fact that you were \"scared\" suggests the dreamer is currently feeling apprehension or panic about the situation.\n\n2. **Assess the Emotional State**: The combination of being in a dangerous environment (dark water) and feeling scared is a classic sign of anxiety or panic. This state is often a reaction to real-life stressors, such as a recent traumatic event, financial stress, or a difficult conversation.\n\n3. **Determine the Need for Support**: Since the dream occurred while you were \"scared,\" the dreamer is likely experiencing a heightened state of anxiety. This is a clear indication that they need immediate emotional comfort and reassurance rather than focusing on tasks or productivity.\n\n4."
99
+ }
100
+ ]
101
+ }
docs/smoke/2026-06-12-modal-emotion-led-5round-evidence.md ADDED
@@ -0,0 +1,183 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Modal Emotion-Led Reply Evidence - 2026-06-12
2
+
3
+ ## Scope
4
+
5
+ - Local branch: `bugfix/emotion-led-dream-qa`
6
+ - Local commit: `f51f11407c6fa7d41ba57c511b2cf683114e1442`
7
+ - Modal app: `dream-customs-minicpm-backend`
8
+ - Text model: `openbmb/MiniCPM5-1B`
9
+ - Endpoint host: `cjh-12569--dream-customs-minicpm-backend-text.modal.run`
10
+ - Evidence mode: direct Modal text endpoint prompt using the emotion-led constraints from the local change.
11
+ - Secret handling: Modal secret was used only inside a temporary Modal runner; no token value was printed or written.
12
+
13
+ ## Important Note
14
+
15
+ The full Gradio `TodayTipCard` JSON prompt path was attempted first, but the current Modal deployment did not return within the local evidence window. This evidence therefore records direct raw Modal model replies under the same product constraints, not the final app card schema. The raw replies show why local backend post-processing remains necessary.
16
+
17
+ ## Summary
18
+
19
+ | Round | Case | HTTP | Elapsed | Reply Evidence |
20
+ | --- | --- | ---: | ---: | --- |
21
+ | 1 | `sea-pressure-comfort` | 200 | 26.44s | medicalized wording risk |
22
+ | 2 | `ex-message-sadness` | 200 | 5.82s | raw reply captured |
23
+ | 3 | `black-wave-prophecy` | 200 | 7.24s | medicalized wording risk |
24
+ | 4 | `lost-child-guilt` | 200 | 4.75s | raw reply captured |
25
+ | 5 | `drowning-not-coping` | 200 | 7.54s | medicalized wording risk |
26
+
27
+ ## Round 1 - sea-pressure-comfort
28
+
29
+ **Dream**
30
+
31
+ 我梦到自己掉进海里,醒来很害怕。我想知道这是不是说明我快撑不住了?
32
+
33
+ **User Answer**
34
+
35
+ 我最近工作压力很大,真的很想被安慰一下。
36
+
37
+ **Modal Route**
38
+
39
+ - HTTP: `200`
40
+ - Transport OK: `True`
41
+ - Elapsed: `26.44s`
42
+ - Route status: `ok`
43
+
44
+ **Modal Model Reply**
45
+
46
+ ```text
47
+ 想理解的问题:用户最近工作压力很大,真的很想被安慰一下。
48
+
49
+ 一步步解读:用户提到“工作压力很大”和“很想被安慰”,这说明用户目前处于情绪低谷,需要关注自己的心理健康。梦到自己掉进海里,醒来后感到害怕,可能是因为现实中的压力和情绪影响睡眠或情绪状态。用户没有表现出害怕、难过、压力、自责或想被安慰的情绪,而是想被安慰,这可能是因为用户希望通过梦境来寻求情感支持。
50
+
51
+ 今日小 Tips:用户可以尝试在睡前写下当天的压力点,并思考如何调整心态,或者与信任的朋友倾诉。梦中的细节可能反映用户潜意识中的担忧或渴望,但不应过度解读。
52
+
53
+ 关心一句:用户需要关注自己的情绪和压力,及时寻求专业帮助或心理咨询。
54
+ ```
55
+
56
+ ## Round 2 - ex-message-sadness
57
+
58
+ **Dream**
59
+
60
+ 我梦到前任发消息又消失了,我醒来很难过,想知道是不是我还没走出来。
61
+
62
+ **User Answer**
63
+
64
+ 我不想要鸡汤,只想知道为什么这么难受。
65
+
66
+ **Modal Route**
67
+
68
+ - HTTP: `200`
69
+ - Transport OK: `True`
70
+ - Elapsed: `5.82s`
71
+ - Route status: `ok`
72
+
73
+ **Modal Model Reply**
74
+
75
+ ```text
76
+ 想理解的问题:用户梦到前任发消息又消失,醒来感到难过,想知道是否已走出过程。
77
+
78
+ 一步步解读:
79
+ 1. 梦到前任消失可能反映用户内心对这段关系的复杂感受。梦中的“消失”可能象征着情感上的断裂或未解决的矛盾。
80
+ 2. 用户感到“很难过”说明当前情绪状态可能与梦境中的经历有关,梦境往往承载着潜意识的信息。
81
+ 3. 用户想了解是否已走出过程,暗示用户可能正经历一段心理调整期,需要关注自己的情绪变化。
82
+
83
+ 今日小 Tips:梦中的具体细节是“前任发消息又消失”,这可能意味着用户在现实生活中遇到了沟通障碍或情感疏离,建议用户记录这些细节,以便更好地理解自己的情绪状态。
84
+
85
+ 关心一句:希望用户能耐心倾听自己的感受,并给予支持。
86
+ ```
87
+
88
+ ## Round 3 - black-wave-prophecy
89
+
90
+ **Dream**
91
+
92
+ I dreamed of a black wave and woke thinking: is this a sign something bad will happen?
93
+
94
+ **User Answer**
95
+
96
+ It left a tight feeling in my chest, and I want a grounded answer.
97
+
98
+ **Modal Route**
99
+
100
+ - HTTP: `200`
101
+ - Transport OK: `True`
102
+ - Elapsed: `7.24s`
103
+ - Route status: `ok`
104
+
105
+ **Modal Model Reply**
106
+
107
+ ```text
108
+ It sounds like you're feeling a bit overwhelmed by the news of your black wave dream, and I understand why you want a grounded answer. Here is a step-by-step interpretation based on the details you provided.
109
+
110
+ **Step 1: Analyze the dream elements**
111
+ You described a "black wave," which is a very specific and intense dream symbol. In dream analysis, a black wave often represents a sudden, overwhelming surge of emotion, stress, or anxiety that feels like a tidal wave crashing against your mind. It is rarely a literal wave in the ocean; instead, it usually signifies a sudden, intense emotional event that has a high intensity and a strong impact on your psyche.
112
+
113
+ **Step 2: Assess the emotional reaction**
114
+ You mentioned feeling a "tight feeling in my chest" after the dream. This is a classic sign of anxiety or panic. When a black wave occurs, the brain's amygdala (the fear center) is activated, triggering a fight-or-flight response. This physical sensation of tightness is a direct result of the fear of the sudden, violent nature of the event.
115
+ ```
116
+
117
+ ## Round 4 - lost-child-guilt
118
+
119
+ **Dream**
120
+
121
+ 我梦到一个小孩在地铁站找不到家,我一直追不上他,醒来很自责。
122
+
123
+ **User Answer**
124
+
125
+ 我最近总觉得没有照顾好家人,想知道这种内疚该怎么放一放。
126
+
127
+ **Modal Route**
128
+
129
+ - HTTP: `200`
130
+ - Transport OK: `True`
131
+ - Elapsed: `4.75s`
132
+ - Route status: `ok`
133
+
134
+ **Modal Model Reply**
135
+
136
+ ```text
137
+ 想理解的问题:用户醒来后感到内疚和自责,这可能源于对未尽责任的担忧。
138
+
139
+ 一步步解读:用户梦到小孩在地铁站找不到家,这可能象征着生活中未解决的问题或未完成的任务。用户一直追不上小孩,可能意味着在现实生活中感到被忽视或无法跟上他人的步伐。醒来后感到内疚和自责,可能是因为对未能提供足够支持或帮助的担忧。
140
+
141
+ 今日小 Tips:用户可以尝试在日常生活中多关注家人,表达关心和支持。也可以尝试与家人沟通,分享自己的感受和需求。
142
+
143
+ 关心一句:希望用户能够找到适合自己的方式来处理这些情绪,并从中找到力量和成长。
144
+ ```
145
+
146
+ ## Round 5 - drowning-not-coping
147
+
148
+ **Dream**
149
+
150
+ I dreamed I was drowning in dark water and woke scared. Does this mean I am not coping?
151
+
152
+ **User Answer**
153
+
154
+ I need comfort, not productivity advice.
155
+
156
+ **Modal Route**
157
+
158
+ - HTTP: `200`
159
+ - Transport OK: `True`
160
+ - Elapsed: `7.54s`
161
+ - Route status: `ok`
162
+
163
+ **Modal Model Reply**
164
+
165
+ ```text
166
+ I need comfort, not productivity advice.
167
+
168
+ Here is a step-by-step interpretation based on the dream details you provided:
169
+
170
+ 1. **Analyze the Dream Elements**: You were in "dark water," which is a common symbol of confusion, danger, or emotional turmoil. Being "drowning" indicates a current crisis or fear of drowning, often linked to feelings of helplessness or anxiety. The fact that you were "scared" suggests the dreamer is currently feeling apprehension or panic about the situation.
171
+
172
+ 2. **Assess the Emotional State**: The combination of being in a dangerous environment (dark water) and feeling scared is a classic sign of anxiety or panic. This state is often a reaction to real-life stressors, such as a recent traumatic event, financial stress, or a difficult conversation.
173
+
174
+ 3. **Determine the Need for Support**: Since the dream occurred while you were "scared," the dreamer is likely experiencing a heightened state of anxiety. This is a clear indication that they need immediate emotional comfort and reassurance rather than focusing on tasks or productivity.
175
+
176
+ 4.
177
+ ```
178
+
179
+ ## Takeaways
180
+
181
+ - Modal returned 5/5 HTTP 200 responses through the real MiniCPM text route.
182
+ - Raw Modal replies often follow the dream/emotion direction, but still drift into medicalized or generic counseling language.
183
+ - The local backend hardening should keep overriding/repairing final app output, especially for safety tone, direct question answering, and non-productivity comfort.
docs/smoke/2026-06-12-modal-vision-sketch-evidence.json ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "generated_at": "2026-06-12T01:53:21.674225+00:00",
3
+ "cwd": "/Users/junhaocheng/working-dir/ai-competitions/build-small-hackthon",
4
+ "git_commit": "ec0441abdecacf44918ca9eace7c2a57e6795d1b",
5
+ "git_branch": "bugfix/emotion-led-dream-qa",
6
+ "model": {
7
+ "modal_app": "dream-customs-minicpm-backend",
8
+ "vision_model": "openbmb/MiniCPM-V-4.6",
9
+ "endpoint_host": "cjh-12569--dream-customs-minicpm-backend-vision.modal.run"
10
+ },
11
+ "secret_handling": "Modal secret used only inside temporary Modal runner; no token printed or written.",
12
+ "results": [
13
+ {
14
+ "case_id": "sea",
15
+ "image_path": "/Users/junhaocheng/working-dir/ai-competitions/build-small-hackthon/tmp/dream-sketches/dream_sketch_01_sea.png",
16
+ "expected_markers": [
17
+ "sea",
18
+ "wave",
19
+ "moon",
20
+ "person"
21
+ ],
22
+ "http_status": 200,
23
+ "transport_ok": true,
24
+ "elapsed_seconds": 25.48,
25
+ "route_error": "",
26
+ "raw_response": "{'input_text': [{'role': 'user', 'content': [{'type': 'image', 'path': '/tmp/tmpjyirmi6i.png'}, {'type': 'text', 'text': 'You are MiniCPM-V-4.6 acting as the visual witness for Dream QA. Describe only what is visible in this dream sketch. Return concise JSON with keys: scene_summary, objects, visible_text, spatial_relations, mood_cues, uncertain_details, surprising_detail. Do not diagnose the user.'}]}], 'generated_text': [{'role': 'user', 'content': [{'type': 'image', 'path': '/tmp/tmpjyirmi6i.png'}, {'type': 'text', 'text': 'You are MiniCPM-V-4.6 acting as the visual witness for Dream QA. Describe only what is visible in this dream sketch. Return concise JSON with keys: scene_summary, objects, visible_text, spatial_relations, mood_cues, uncertain_details, surprising_detail. Do not diagnose the user.'}]}, {'role': 'assistant', 'content': '\\n\\n{\\n \"scene_summary\": \"A dreamlike representation of a sea under night\",\\n \"objects\": [\"a stick figure\", \"wavy lines representing water\", \"a crescent moon\", \"a circular shape above the stick figure\"],\\n \"visible_text\": [\"dark sea dream\"],\\n \"spatial_relations\": \"the stick figure is on the water waves, with the moon in the upper right\",\\n \"mood_cues\": \"calm and dreamy\",\\n \"uncertain_details\": \"the exact emotions or states of the figure\",\\n \"surprising_detail\": \"the stick figure has a curved line above its head, suggesting a sense of wonder or awareness\"\\n}'}]}",
27
+ "marker_hits": [
28
+ "sea",
29
+ "wave",
30
+ "moon"
31
+ ]
32
+ },
33
+ {
34
+ "case_id": "elevator",
35
+ "image_path": "/Users/junhaocheng/working-dir/ai-competitions/build-small-hackthon/tmp/dream-sketches/dream_sketch_02_elevator.png",
36
+ "expected_markers": [
37
+ "elevator",
38
+ "button",
39
+ "14",
40
+ "melted"
41
+ ],
42
+ "http_status": 200,
43
+ "transport_ok": true,
44
+ "elapsed_seconds": 5.55,
45
+ "route_error": "",
46
+ "raw_response": "{'input_text': [{'role': 'user', 'content': [{'type': 'image', 'path': '/tmp/tmpc8qf8m8w.png'}, {'type': 'text', 'text': 'You are MiniCPM-V-4.6 acting as the visual witness for Dream QA. Describe only what is visible in this dream sketch. Return concise JSON with keys: scene_summary, objects, visible_text, spatial_relations, mood_cues, uncertain_details, surprising_detail. Do not diagnose the user.'}]}], 'generated_text': [{'role': 'user', 'content': [{'type': 'image', 'path': '/tmp/tmpc8qf8m8w.png'}, {'type': 'text', 'text': 'You are MiniCPM-V-4.6 acting as the visual witness for Dream QA. Describe only what is visible in this dream sketch. Return concise JSON with keys: scene_summary, objects, visible_text, spatial_relations, mood_cues, uncertain_details, surprising_detail. Do not diagnose the user.'}]}, {'role': 'assistant', 'content': '\\n\\n{\\n \"scene_summary\": \"A conceptual visual representation of melted elevator buttons in a setting\",\\n \"objects\": [\"melted elevator buttons\", \"two tall gray doors\"],\\n \"visible_text\": [\"14\", \"melted elevator buttons\"],\\n \"spatial_relations\": \"The melted buttons are to the right of the elevator doors\",\\n \"mood_cues\": \"Suggests a malfunction or melting process\",\\n \"uncertain_details\": \"The exact state or effects of the melted buttons are implied\",\\n \"surprising_detail\": \"The buttons are depicted as colorful segments, indicating a melting or transition state\"\\n}'}]}",
47
+ "marker_hits": [
48
+ "elevator",
49
+ "button",
50
+ "14",
51
+ "melted"
52
+ ]
53
+ },
54
+ {
55
+ "case_id": "subway_child",
56
+ "image_path": "/Users/junhaocheng/working-dir/ai-competitions/build-small-hackthon/tmp/dream-sketches/dream_sketch_03_subway_child.png",
57
+ "expected_markers": [
58
+ "subway",
59
+ "child",
60
+ "home",
61
+ "arrow"
62
+ ],
63
+ "http_status": 200,
64
+ "transport_ok": true,
65
+ "elapsed_seconds": 5.62,
66
+ "route_error": "",
67
+ "raw_response": "{'input_text': [{'role': 'user', 'content': [{'type': 'image', 'path': '/tmp/tmp38m34fjv.png'}, {'type': 'text', 'text': 'You are MiniCPM-V-4.6 acting as the visual witness for Dream QA. Describe only what is visible in this dream sketch. Return concise JSON with keys: scene_summary, objects, visible_text, spatial_relations, mood_cues, uncertain_details, surprising_detail. Do not diagnose the user.'}]}], 'generated_text': [{'role': 'user', 'content': [{'type': 'image', 'path': '/tmp/tmp38m34fjv.png'}, {'type': 'text', 'text': 'You are MiniCPM-V-4.6 acting as the visual witness for Dream QA. Describe only what is visible in this dream sketch. Return concise JSON with keys: scene_summary, objects, visible_text, spatial_relations, mood_cues, uncertain_details, surprising_detail. Do not diagnose the user.'}]}, {'role': 'assistant', 'content': '\\n\\n{\\n \"scene_summary\": \"A simple sketch depicting a lost child at a subway station\",\\n \"objects\": [\"a child figure\", \"a subway sign\", \"an arrow\"],\\n \"visible_text\": [\"lost child at subway\", \"SUBWAY\", \"HOME ?\"],\\n \"spatial_relations\": \"the child is in front of the subway sign, which has an arrow pointing to the right\",\\n \"mood_cues\": \"suggests a situation of loss or search\",\\n \"uncertain_details\": \"what the child is looking for or where home is\",\\n \"surprising_detail\": \"the sign asks \\'HOME ?\\'\"\\n}'}]}",
68
+ "marker_hits": [
69
+ "subway",
70
+ "child",
71
+ "home",
72
+ "arrow"
73
+ ]
74
+ }
75
+ ]
76
+ }
docs/smoke/2026-06-12-modal-vision-sketch-evidence.md ADDED
@@ -0,0 +1,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-image-paste-fallback.png ADDED
docs/smoke/assets/2026-06-12-chrome-local-modal-sea-tip.png ADDED
docs/smoke/assets/dream_sketch_01_sea.png ADDED
docs/smoke/assets/dream_sketch_02_elevator.png ADDED
docs/smoke/assets/dream_sketch_03_subway_child.png ADDED
dream_customs/models.py CHANGED
@@ -1,4 +1,5 @@
1
  import base64
 
2
  import json
3
  import os
4
  import re
@@ -549,7 +550,16 @@ def _hosted_text_from_response(payload: Dict[str, Any]) -> str:
549
  for key in ("response", "text", "generated_text", "output"):
550
  value = payload.get(key)
551
  if isinstance(value, str):
 
 
 
 
 
552
  return value
 
 
 
 
553
  choices = payload.get("choices")
554
  if isinstance(choices, list) and choices:
555
  first = choices[0]
@@ -565,6 +575,32 @@ def _hosted_text_from_response(payload: Dict[str, Any]) -> str:
565
  return ""
566
 
567
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
568
  class HostedMiniCPMTextClient:
569
  def __init__(
570
  self,
 
1
  import base64
2
+ import ast
3
  import json
4
  import os
5
  import re
 
550
  for key in ("response", "text", "generated_text", "output"):
551
  value = payload.get(key)
552
  if isinstance(value, str):
553
+ parsed = _python_literal_dict(value)
554
+ if parsed:
555
+ nested = _hosted_text_from_response(parsed)
556
+ if nested:
557
+ return nested
558
  return value
559
+ if isinstance(value, list):
560
+ nested = _content_from_messages(value)
561
+ if nested:
562
+ return nested
563
  choices = payload.get("choices")
564
  if isinstance(choices, list) and choices:
565
  first = choices[0]
 
575
  return ""
576
 
577
 
578
+ def _python_literal_dict(text: str) -> Optional[Dict[str, Any]]:
579
+ try:
580
+ parsed = ast.literal_eval(text)
581
+ except (SyntaxError, ValueError):
582
+ return None
583
+ return parsed if isinstance(parsed, dict) else None
584
+
585
+
586
+ def _content_from_messages(messages: List[Any]) -> str:
587
+ for item in reversed(messages):
588
+ if not isinstance(item, dict):
589
+ continue
590
+ content = item.get("content")
591
+ if isinstance(content, str) and content.strip():
592
+ return content.strip()
593
+ if isinstance(content, list):
594
+ text_parts = [
595
+ str(part.get("text", "")).strip()
596
+ for part in content
597
+ if isinstance(part, dict) and str(part.get("text", "")).strip()
598
+ ]
599
+ if text_parts:
600
+ return "\n".join(text_parts)
601
+ return ""
602
+
603
+
604
  class HostedMiniCPMTextClient:
605
  def __init__(
606
  self,
dream_customs/pipeline.py CHANGED
@@ -175,10 +175,14 @@ _ZH_ANCHOR_MARKERS = [
175
  "家门",
176
  "旧家",
177
  "楼顶",
 
 
178
  "漆黑的走廊",
179
  "漆黑走廊",
180
  "黑暗走廊",
181
  "走廊",
 
 
182
  "教室",
183
  "老师",
184
  "交作业",
@@ -194,10 +198,15 @@ _ZH_ANCHOR_MARKERS = [
194
  "安全帽",
195
  "男生",
196
  "电梯按钮",
 
 
 
197
  "融化的按钮",
198
  "按钮融化",
199
  "数字 14",
200
  "数字14",
 
 
201
  "老楼",
202
  "桥",
203
  "水",
@@ -285,10 +294,14 @@ _ZH_TO_EN_PHRASES = {
285
  "家门": "home door",
286
  "旧家": "old home",
287
  "楼顶": "rooftop",
 
 
288
  "漆黑的走廊": "dark hallway",
289
  "漆黑走廊": "dark hallway",
290
  "黑暗走廊": "dark hallway",
291
  "走廊": "hallway",
 
 
292
  "教室": "classroom",
293
  "老师": "teacher",
294
  "交作业": "assignment",
@@ -304,10 +317,15 @@ _ZH_TO_EN_PHRASES = {
304
  "安全帽": "hard hat",
305
  "男生": "young man",
306
  "电梯按钮": "elevator button",
 
 
 
307
  "融化的按钮": "melted button",
308
  "按钮融化": "melted button",
309
  "数字 14": "floor 14",
310
  "数字14": "floor 14",
 
 
311
  "楼层数字": "floor number",
312
  "楼层": "floor",
313
  "老楼": "old apartment building",
@@ -567,6 +585,53 @@ def _clean_placeholder_phrase(text: str) -> str:
567
  return clean.strip()
568
 
569
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
570
  def _primary_anchor(intake: DreamIntake, language: str = "en") -> str:
571
  anchors = _anchors_for_language(intake, language)
572
  if anchors:
@@ -596,6 +661,25 @@ def _story_text(intake: DreamIntake, answers: str = "") -> str:
596
  ).lower()
597
 
598
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
599
  def _contains_any(text: str, terms: List[str]) -> bool:
600
  return any(term in text for term in terms)
601
 
@@ -632,13 +716,39 @@ def _join_anchors(anchors: List[str], language: str = "en", limit: int = 3) -> s
632
  return ", ".join(visible[:-1]) + f", and {visible[-1]}"
633
 
634
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
635
  def _story_anchor_phrase(intake: DreamIntake, anchors: List[str], language: str = "en", answers: str = "") -> str:
636
  theme = _dream_theme(intake, answers)
 
 
637
  if _is_zh(language):
638
  themed = {
639
  "lost_home": "地铁站里迷路的小孩和回家的方向",
640
  "dark_water": "夜晚的海、海浪和月牙下的小人",
641
- "stuck_elevator": "电梯、融化的按钮和数字 14",
642
  "library_signal": "旧图书馆、红色楼梯和那张便签",
643
  "message_loss": "那条消息、前任和突然消失",
644
  "chased": "森林、白色猫和空白路牌",
@@ -649,7 +759,6 @@ def _story_anchor_phrase(intake: DreamIntake, anchors: List[str], language: str
649
  themed = {
650
  "lost_home": "the lost child, the subway, and the way home",
651
  "dark_water": "the dark water, the waves, and the small figure under the moon",
652
- "stuck_elevator": "the elevator, the melted button, and floor 14",
653
  "library_signal": "the old library, the red staircase, and the note",
654
  "message_loss": "the message, the former partner, and the disappearance",
655
  "chased": "the chase, the call for help, and the missing route",
@@ -671,9 +780,16 @@ def _answer_snippet(answers: str, language: str = "en") -> str:
671
  return ""
672
  clean = re.sub(r"\s+", " ", lines[-1]).strip(" ::「」\"'()[]{}")
673
  if _is_zh(language):
674
- return clean[:34].rstrip(",。;、 ")
 
 
 
 
 
 
 
675
  words = clean.split()
676
- return " ".join(words[:14])
677
 
678
 
679
  def _anchor_with_article(anchor: str) -> str:
@@ -1498,7 +1614,7 @@ def _decomposition_question(
1498
  "最后只确认今天的需要:你更想要安定身体,还是想理解为什么会这么慌?",
1499
  ],
1500
  "stuck_elevator": [
1501
- "电梯、按钮或数字 14 里,哪一个最像你最近卡住的感觉?",
1502
  "这个卡住更像“来不及开始”,还是“已经按了按钮却没有回应”?",
1503
  "最后只确认今天的需要:你想要一个小行动,还是先要一句能让压力降下来的话?",
1504
  ],
@@ -1531,8 +1647,8 @@ def _decomposition_question(
1531
  "Last check before the tip: do you need help calming your body, or understanding why the image felt so intense?",
1532
  ],
1533
  "stuck_elevator": [
1534
- "Which detail feels closest to your current stuck point: the elevator, the melted button, or floor 14?",
1535
- "Does the stuckness feel more like being late to start, or like pressing the button and getting no response?",
1536
  "Last check before the tip: would one small action help most, or a sentence that lowers the pressure first?",
1537
  ],
1538
  }
@@ -1685,16 +1801,22 @@ def _polish_today_tip(card: TodayTipCard, intake: DreamIntake, answers: str = ""
1685
  answer_lines = [line.strip() for line in (answers or "").splitlines() if line.strip()]
1686
  if answer_lines:
1687
  polished.user_answers = answer_lines
1688
- intake_anchors = _remove_placeholder_anchors(_anchors_for_language(intake, language))
 
 
 
 
1689
  card_anchors = _remove_placeholder_anchors(
1690
  polished.dream_anchors
1691
  if _is_zh(language)
1692
  else _dedupe_preserve_order([_english_anchor_text(anchor) for anchor in polished.dream_anchors])
1693
  )
 
1694
  if intake_anchors and not any(_text_uses_anchor(anchor, intake_anchors) for anchor in card_anchors):
1695
  anchors = intake_anchors
1696
  else:
1697
  anchors = card_anchors or intake_anchors
 
1698
  if not anchors:
1699
  anchors = _remove_placeholder_anchors([_primary_anchor(intake, language)])
1700
  if not anchors:
@@ -1709,7 +1831,13 @@ def _polish_today_tip(card: TodayTipCard, intake: DreamIntake, answers: str = ""
1709
  "caring_note",
1710
  "safety_note",
1711
  ):
1712
- setattr(polished, field, _clean_placeholder_phrase(getattr(polished, field)))
 
 
 
 
 
 
1713
  if not polished.dream_summary.strip() or _is_placeholder_anchor(polished.dream_summary) or not _text_uses_anchor(polished.dream_summary, anchors):
1714
  polished.dream_summary = _summary_from_intake(intake, language)
1715
  explicit_question = _extract_explicit_user_question(intake, answers, language)
@@ -1723,7 +1851,12 @@ def _polish_today_tip(card: TodayTipCard, intake: DreamIntake, answers: str = ""
1723
  polished.main_question = _main_question_from_intake(intake, language)
1724
  emotion_interpretation = _emotion_led_interpretation(intake, answers, anchors, language)
1725
  answer_interpretation = _answer_based_interpretation(answers, _answer_bridge_anchor(anchors), language)
1726
- if emotion_interpretation:
 
 
 
 
 
1727
  polished.interpretation = emotion_interpretation
1728
  elif answer_interpretation:
1729
  polished.interpretation = answer_interpretation
@@ -1745,8 +1878,9 @@ def _polish_today_tip(card: TodayTipCard, intake: DreamIntake, answers: str = ""
1745
  polished.interpretation = _fallback_interpretation(intake, language)
1746
  generic_tip_markers = ["drink water", "hydrate", "多休息", "保持积极", "take a walk"]
1747
  emotion_tip = _emotion_led_today_tip(intake, answers, anchors, language)
1748
- answer_tip = _answer_based_today_tip(answers, anchors[0], language)
1749
- if emotion_tip:
 
1750
  polished.today_tip = emotion_tip
1751
  elif answer_tip:
1752
  polished.today_tip = answer_tip
@@ -1773,8 +1907,9 @@ def _polish_today_tip(card: TodayTipCard, intake: DreamIntake, answers: str = ""
1773
  polished.today_tip = _grounded_today_tip(intake, language)
1774
  hard_action_markers = ["address it immediately", "fix it immediately", "solve it immediately"]
1775
  emotion_action = _emotion_led_tiny_action(intake, answers, anchors, language)
1776
- answer_action = _answer_based_tiny_action(answers, language)
1777
- if emotion_action:
 
1778
  polished.tiny_action = emotion_action
1779
  elif answer_action:
1780
  polished.tiny_action = answer_action
 
175
  "家门",
176
  "旧家",
177
  "楼顶",
178
+ "办公楼",
179
+ "高的办公楼",
180
  "漆黑的走廊",
181
  "漆黑走廊",
182
  "黑暗走廊",
183
  "走廊",
184
+ "邮件",
185
+ "迟迟没发出去的邮件",
186
  "教室",
187
  "老师",
188
  "交作业",
 
198
  "安全帽",
199
  "男生",
200
  "电梯按钮",
201
+ "电梯口",
202
+ "小台灯",
203
+ "台灯",
204
  "融化的按钮",
205
  "按钮融化",
206
  "数字 14",
207
  "数字14",
208
+ "14 层",
209
+ "14层",
210
  "老楼",
211
  "桥",
212
  "水",
 
294
  "家门": "home door",
295
  "旧家": "old home",
296
  "楼顶": "rooftop",
297
+ "办公楼": "office building",
298
+ "高的办公楼": "tall office building",
299
  "漆黑的走廊": "dark hallway",
300
  "漆黑走廊": "dark hallway",
301
  "黑暗走廊": "dark hallway",
302
  "走廊": "hallway",
303
+ "邮件": "email",
304
+ "迟迟没发出去的邮件": "unsent email",
305
  "教室": "classroom",
306
  "老师": "teacher",
307
  "交作业": "assignment",
 
317
  "安全帽": "hard hat",
318
  "男生": "young man",
319
  "电梯按钮": "elevator button",
320
+ "电梯口": "elevator doorway",
321
+ "小台灯": "small desk lamp",
322
+ "台灯": "desk lamp",
323
  "融化的按钮": "melted button",
324
  "按钮融化": "melted button",
325
  "数字 14": "floor 14",
326
  "数字14": "floor 14",
327
+ "14 层": "floor 14",
328
+ "14层": "floor 14",
329
  "楼层数字": "floor number",
330
  "楼层": "floor",
331
  "老楼": "old apartment building",
 
585
  return clean.strip()
586
 
587
 
588
+ def _clean_unsupported_melted_detail(
589
+ text: str,
590
+ intake: DreamIntake,
591
+ anchors: List[str],
592
+ language: str = "en",
593
+ answers: str = "",
594
+ ) -> str:
595
+ if not text or _source_mentions_melted_detail(intake, answers):
596
+ return text
597
+ replacement = _story_anchor_phrase(intake, _without_unsupported_melted_anchors(anchors, intake, answers), language, answers)
598
+ if _is_zh(language):
599
+ clean = re.sub(r"电梯[、,, ]+融化的(?:电梯)?按钮[和、,, ]+数字\s*14", replacement, text)
600
+ clean = re.sub(r"电梯[、,, ]+融化的(?:电梯)?按钮[和、,, ]+14\s*层", replacement, clean)
601
+ clean = re.sub(r"电梯[、,, ]+融化的(?:电梯)?按钮[和、,, ]+楼层数字", replacement, clean)
602
+ clean = re.sub(r"(?:电梯)?按钮像蜡一样融化[,,、和 ]*", replacement, clean)
603
+ clean = re.sub(r"融化的(?:电梯)?按钮", replacement, clean)
604
+ clean = re.sub(r"(?:按钮)?像蜡一样融化", replacement, clean)
605
+ clean = clean.replace("按钮融化", replacement).replace("融化", replacement)
606
+ clean = re.sub(rf"(?:{re.escape(replacement)}[、,, ]*){{2,}}", replacement, clean)
607
+ return re.sub(r"\s+", " ", clean).strip()
608
+ clean = re.sub(
609
+ r"the elevator,\s*the melted button,\s*and floor 14",
610
+ replacement,
611
+ text,
612
+ flags=re.IGNORECASE,
613
+ )
614
+ clean = re.sub(
615
+ r"the elevator,\s*melted button,\s*and floor 14",
616
+ replacement,
617
+ clean,
618
+ flags=re.IGNORECASE,
619
+ )
620
+ clean = re.sub(
621
+ r"its button melted like wax,\s*and\s*",
622
+ "",
623
+ clean,
624
+ flags=re.IGNORECASE,
625
+ )
626
+ clean = re.sub(r"the melted (?:elevator )?button", replacement, clean, flags=re.IGNORECASE)
627
+ clean = re.sub(r"melted (?:elevator )?button", replacement, clean, flags=re.IGNORECASE)
628
+ clean = re.sub(r"(?:button|buttons) melted like wax", replacement, clean, flags=re.IGNORECASE)
629
+ clean = re.sub(r"\bmelted\b|\bwax\b|\bwaxy\b", replacement, clean, flags=re.IGNORECASE)
630
+ clean = re.sub(rf"(?:{re.escape(replacement)}[,\s]*){{2,}}", replacement, clean, flags=re.IGNORECASE)
631
+ clean = re.sub(r"\s+", " ", clean)
632
+ return clean.strip()
633
+
634
+
635
  def _primary_anchor(intake: DreamIntake, language: str = "en") -> str:
636
  anchors = _anchors_for_language(intake, language)
637
  if anchors:
 
661
  ).lower()
662
 
663
 
664
+ def _source_mentions_melted_detail(intake: DreamIntake, answers: str = "") -> bool:
665
+ text = _story_text(intake, answers)
666
+ return any(term in text for term in ["融化", "像蜡", "蜡", "melt", "melted", "melting", "wax", "waxy"])
667
+
668
+
669
+ def _without_unsupported_melted_anchors(
670
+ anchors: List[str],
671
+ intake: DreamIntake,
672
+ answers: str = "",
673
+ ) -> List[str]:
674
+ if _source_mentions_melted_detail(intake, answers):
675
+ return anchors
676
+ return [
677
+ anchor
678
+ for anchor in anchors
679
+ if not any(term in anchor.lower() for term in ["融化", "melt", "wax"])
680
+ ]
681
+
682
+
683
  def _contains_any(text: str, terms: List[str]) -> bool:
684
  return any(term in text for term in terms)
685
 
 
716
  return ", ".join(visible[:-1]) + f", and {visible[-1]}"
717
 
718
 
719
+ def _stuck_elevator_anchor_phrase(intake: DreamIntake, anchors: List[str], language: str = "en", answers: str = "") -> str:
720
+ clean_anchors = _without_unsupported_melted_anchors(anchors, intake, answers)
721
+ if not clean_anchors:
722
+ clean_anchors = anchors
723
+ priority_groups = (
724
+ ("电梯", "elevator"),
725
+ ("14", "floor 14"),
726
+ ("走廊", "hallway", "corridor"),
727
+ ("邮件", "email"),
728
+ ("台灯", "lamp"),
729
+ ("按钮", "button"),
730
+ )
731
+ ordered: List[str] = []
732
+ for group in priority_groups:
733
+ for anchor in clean_anchors:
734
+ lowered = anchor.lower()
735
+ if anchor not in ordered and any(term in lowered for term in group):
736
+ ordered.append(anchor)
737
+ for anchor in clean_anchors:
738
+ if anchor not in ordered:
739
+ ordered.append(anchor)
740
+ fallback = "电梯" if _is_zh(language) else "the elevator"
741
+ return _join_anchors(ordered or [fallback], language)
742
+
743
+
744
  def _story_anchor_phrase(intake: DreamIntake, anchors: List[str], language: str = "en", answers: str = "") -> str:
745
  theme = _dream_theme(intake, answers)
746
+ if theme == "stuck_elevator":
747
+ return _stuck_elevator_anchor_phrase(intake, anchors, language, answers)
748
  if _is_zh(language):
749
  themed = {
750
  "lost_home": "地铁站里迷路的小孩和回家的方向",
751
  "dark_water": "夜晚的海、海浪和月牙下的小人",
 
752
  "library_signal": "旧图书馆、红色楼梯和那张便签",
753
  "message_loss": "那条消息、前任和突然消失",
754
  "chased": "森林、白色猫和空白路牌",
 
759
  themed = {
760
  "lost_home": "the lost child, the subway, and the way home",
761
  "dark_water": "the dark water, the waves, and the small figure under the moon",
 
762
  "library_signal": "the old library, the red staircase, and the note",
763
  "message_loss": "the message, the former partner, and the disappearance",
764
  "chased": "the chase, the call for help, and the missing route",
 
780
  return ""
781
  clean = re.sub(r"\s+", " ", lines[-1]).strip(" ::「」\"'()[]{}")
782
  if _is_zh(language):
783
+ if len(clean) <= 72:
784
+ return clean.rstrip(",。;、 ")
785
+ prefix = clean[:72].rstrip(",;、 ")
786
+ sentence_breaks = [prefix.rfind(mark) for mark in ("。", "!", "?", ";")]
787
+ break_at = max(sentence_breaks)
788
+ if break_at >= 24:
789
+ return prefix[: break_at + 1].strip(",;、 ")
790
+ return prefix.rstrip(",;、 ") + "..."
791
  words = clean.split()
792
+ return " ".join(words[:22])
793
 
794
 
795
  def _anchor_with_article(anchor: str) -> str:
 
1614
  "最后只确认今天的需要:你更想要安定身体,还是想理解为什么会这么慌?",
1615
  ],
1616
  "stuck_elevator": [
1617
+ f"在「{story_anchor}」里,哪一个细节最像你最近卡住的感觉?",
1618
  "这个卡住更像“来不及开始”,还是“已经按了按钮却没有回应”?",
1619
  "最后只确认今天的需要:你想要一个小行动,还是先要一句能让压力降下来的话?",
1620
  ],
 
1647
  "Last check before the tip: do you need help calming your body, or understanding why the image felt so intense?",
1648
  ],
1649
  "stuck_elevator": [
1650
+ f"Which detail in {story_anchor} feels closest to your current stuck point?",
1651
+ "Does the stuckness feel more like being late to start, or like trying something and getting no response?",
1652
  "Last check before the tip: would one small action help most, or a sentence that lowers the pressure first?",
1653
  ],
1654
  }
 
1801
  answer_lines = [line.strip() for line in (answers or "").splitlines() if line.strip()]
1802
  if answer_lines:
1803
  polished.user_answers = answer_lines
1804
+ intake_anchors = _without_unsupported_melted_anchors(
1805
+ _remove_placeholder_anchors(_anchors_for_language(intake, language)),
1806
+ intake,
1807
+ answers,
1808
+ )
1809
  card_anchors = _remove_placeholder_anchors(
1810
  polished.dream_anchors
1811
  if _is_zh(language)
1812
  else _dedupe_preserve_order([_english_anchor_text(anchor) for anchor in polished.dream_anchors])
1813
  )
1814
+ card_anchors = _without_unsupported_melted_anchors(card_anchors, intake, answers)
1815
  if intake_anchors and not any(_text_uses_anchor(anchor, intake_anchors) for anchor in card_anchors):
1816
  anchors = intake_anchors
1817
  else:
1818
  anchors = card_anchors or intake_anchors
1819
+ anchors = _without_unsupported_melted_anchors(anchors, intake, answers)
1820
  if not anchors:
1821
  anchors = _remove_placeholder_anchors([_primary_anchor(intake, language)])
1822
  if not anchors:
 
1831
  "caring_note",
1832
  "safety_note",
1833
  ):
1834
+ cleaned_field = _clean_placeholder_phrase(getattr(polished, field))
1835
+ cleaned_field = _clean_unsupported_melted_detail(cleaned_field, intake, anchors, language, answers)
1836
+ setattr(polished, field, cleaned_field)
1837
+ polished.followup_questions = [
1838
+ _clean_unsupported_melted_detail(_clean_placeholder_phrase(question), intake, anchors, language, answers)
1839
+ for question in polished.followup_questions
1840
+ ]
1841
  if not polished.dream_summary.strip() or _is_placeholder_anchor(polished.dream_summary) or not _text_uses_anchor(polished.dream_summary, anchors):
1842
  polished.dream_summary = _summary_from_intake(intake, language)
1843
  explicit_question = _extract_explicit_user_question(intake, answers, language)
 
1851
  polished.main_question = _main_question_from_intake(intake, language)
1852
  emotion_interpretation = _emotion_led_interpretation(intake, answers, anchors, language)
1853
  answer_interpretation = _answer_based_interpretation(answers, _answer_bridge_anchor(anchors), language)
1854
+ answer_tip = _answer_based_today_tip(answers, anchors[0], language)
1855
+ answer_action = _answer_based_tiny_action(answers, language)
1856
+ answer_should_shape_visible_tip = bool((answer_tip or answer_action) and not _needs_comfort(answers, language))
1857
+ if answer_interpretation and answer_should_shape_visible_tip:
1858
+ polished.interpretation = answer_interpretation
1859
+ elif emotion_interpretation:
1860
  polished.interpretation = emotion_interpretation
1861
  elif answer_interpretation:
1862
  polished.interpretation = answer_interpretation
 
1878
  polished.interpretation = _fallback_interpretation(intake, language)
1879
  generic_tip_markers = ["drink water", "hydrate", "多休息", "保持积极", "take a walk"]
1880
  emotion_tip = _emotion_led_today_tip(intake, answers, anchors, language)
1881
+ if answer_tip and answer_should_shape_visible_tip:
1882
+ polished.today_tip = answer_tip
1883
+ elif emotion_tip:
1884
  polished.today_tip = emotion_tip
1885
  elif answer_tip:
1886
  polished.today_tip = answer_tip
 
1907
  polished.today_tip = _grounded_today_tip(intake, language)
1908
  hard_action_markers = ["address it immediately", "fix it immediately", "solve it immediately"]
1909
  emotion_action = _emotion_led_tiny_action(intake, answers, anchors, language)
1910
+ if answer_action and answer_should_shape_visible_tip:
1911
+ polished.tiny_action = answer_action
1912
+ elif emotion_action:
1913
  polished.tiny_action = emotion_action
1914
  elif answer_action:
1915
  polished.tiny_action = answer_action
dream_customs/runtime_env.py ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import os
3
+ from pathlib import Path
4
+
5
+
6
+ SPACE_ID = "build-small-hackathon/dream-customs"
7
+ DEFAULT_RUNTIME_ENV_JSON = Path("/tmp/dream-customs-runtime.json")
8
+ RUNTIME_ENV_KEYS = {
9
+ "DREAM_CUSTOMS_TEXT_ENDPOINT",
10
+ "DREAM_CUSTOMS_VISION_ENDPOINT",
11
+ "DREAM_CUSTOMS_ASR_ENDPOINT",
12
+ "DREAM_CUSTOMS_HOSTED_TOKEN",
13
+ }
14
+
15
+
16
+ def runtime_env_json_path() -> Path:
17
+ return Path(os.getenv("DREAM_CUSTOMS_RUNTIME_ENV_JSON", str(DEFAULT_RUNTIME_ENV_JSON)))
18
+
19
+
20
+ def _runtime_env_autoload_enabled() -> bool:
21
+ value = os.getenv("DREAM_CUSTOMS_DISABLE_RUNTIME_ENV_JSON", "").strip().lower()
22
+ return value not in {"1", "true", "yes", "on"}
23
+
24
+
25
+ def load_runtime_env_json(path: Path) -> dict:
26
+ if not path.exists():
27
+ return {"loaded": False, "path": str(path), "reason": "missing"}
28
+ try:
29
+ data = json.loads(path.read_text(encoding="utf-8"))
30
+ except (OSError, json.JSONDecodeError) as exc:
31
+ return {"loaded": False, "path": str(path), "reason": exc.__class__.__name__}
32
+
33
+ loaded_keys = []
34
+ for key in sorted(RUNTIME_ENV_KEYS):
35
+ value = str(data.get(key, "")).strip()
36
+ if value:
37
+ os.environ[key] = value
38
+ loaded_keys.append(key)
39
+ return {
40
+ "loaded": bool(loaded_keys),
41
+ "path": str(path),
42
+ "configured_keys": loaded_keys,
43
+ }
44
+
45
+
46
+ def auto_load_runtime_env_json() -> dict:
47
+ if not _runtime_env_autoload_enabled():
48
+ return {"loaded": False, "reason": "disabled"}
49
+ if os.getenv("PYTEST_CURRENT_TEST") and not os.getenv("DREAM_CUSTOMS_RUNTIME_ENV_JSON"):
50
+ return {"loaded": False, "reason": "pytest"}
51
+ return load_runtime_env_json(runtime_env_json_path())
52
+
53
+
54
+ def configured_env(space_id: str = SPACE_ID) -> dict:
55
+ return {
56
+ "space_id": os.getenv("SPACE_ID", space_id),
57
+ "text_endpoint_configured": bool(os.getenv("DREAM_CUSTOMS_TEXT_ENDPOINT", "").strip()),
58
+ "vision_endpoint_configured": bool(os.getenv("DREAM_CUSTOMS_VISION_ENDPOINT", "").strip()),
59
+ "asr_endpoint_configured": bool(os.getenv("DREAM_CUSTOMS_ASR_ENDPOINT", "").strip()),
60
+ "hosted_token_configured": bool(os.getenv("DREAM_CUSTOMS_HOSTED_TOKEN", "").strip()),
61
+ }
dream_customs/ui/app.py CHANGED
@@ -309,6 +309,73 @@ VOICE_JS = r"""
309
  const bindComposerControls = () => {
310
  bindVoiceButton();
311
  bindAttachmentButton();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
312
  };
313
 
314
  bindComposerControls();
@@ -318,6 +385,32 @@ VOICE_JS = r"""
318
  """
319
 
320
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
321
  def _load_view(view_json: str) -> dict:
322
  try:
323
  return json.loads(view_json or "{}")
@@ -359,6 +452,7 @@ def _updates(state: str, view_json: str):
359
  view_json,
360
  _hero_html(language, status),
361
  _notice_html(view),
 
362
  _question_markdown(view, language),
363
  view.get("card_html", ""),
364
  view.get("card_text", ""),
@@ -649,8 +743,17 @@ def _attachment_html(language: str = DEFAULT_LANGUAGE) -> str:
649
  """.strip()
650
 
651
 
652
- def _field_tip_html(language: str = DEFAULT_LANGUAGE) -> str:
653
- return f"<p class=\"dc-field-tip\">{escape(copy_for(language)['field_tip'])}</p>"
 
 
 
 
 
 
 
 
 
654
 
655
 
656
  def _processing_html(language: str = DEFAULT_LANGUAGE) -> str:
@@ -733,7 +836,11 @@ def build_demo() -> gr.Blocks:
733
  field_tip_html = gr.HTML(_field_tip_html(DEFAULT_LANGUAGE))
734
  with gr.Row(elem_classes=["dc-submit-row"]):
735
  example_button = gr.Button(initial_copy["example_button"], variant="secondary")
736
- submit_button = gr.Button(initial_copy["submit_button"], variant="primary")
 
 
 
 
737
  processing_html = gr.HTML(_processing_html(DEFAULT_LANGUAGE))
738
 
739
  with gr.Group(visible=False, elem_classes=["dc-stage", "dc-question"]) as question_group:
@@ -745,14 +852,30 @@ def build_demo() -> gr.Blocks:
745
  value="",
746
  )
747
  with gr.Row(elem_classes=["dc-question-actions"]):
748
- answer_button = gr.Button(initial_copy["answer_button"], variant="primary")
749
- skip_button = gr.Button(initial_copy["skip_button"], variant="secondary")
 
 
 
 
 
 
 
 
750
 
751
  with gr.Group(visible=False, elem_classes=["dc-stage", "dc-card"]) as card_group:
752
  card_html = gr.HTML("")
753
  with gr.Row(elem_classes=["dc-actions"]):
754
- gentle_button = gr.Button(initial_copy["ask_again_button"], variant="secondary")
755
- weird_button = gr.Button(initial_copy["angle_button"], variant="secondary")
 
 
 
 
 
 
 
 
756
  copy_button = gr.Button(initial_copy["copy_button"], variant="secondary")
757
  reset_button = gr.Button(initial_copy["reset_button"], variant="secondary")
758
  card_text = gr.Textbox(
@@ -883,6 +1006,7 @@ def build_demo() -> gr.Blocks:
883
  view_state,
884
  hero_html,
885
  notice,
 
886
  question_markdown,
887
  card_html,
888
  card_text,
@@ -917,6 +1041,7 @@ def build_demo() -> gr.Blocks:
917
  inputs=[dream_text, image_input, audio_input, mood, language, text_backend, vision_backend] + settings_inputs,
918
  outputs=outputs,
919
  api_name=False,
 
920
  show_api=False,
921
  )
922
  answer_button.click(
@@ -924,6 +1049,7 @@ def build_demo() -> gr.Blocks:
924
  inputs=[session_state, answer_text, language, text_backend, vision_backend] + settings_inputs,
925
  outputs=outputs,
926
  api_name=False,
 
927
  show_api=False,
928
  )
929
  skip_button.click(
@@ -931,6 +1057,7 @@ def build_demo() -> gr.Blocks:
931
  inputs=[session_state, language, text_backend, vision_backend] + settings_inputs,
932
  outputs=outputs,
933
  api_name=False,
 
934
  show_api=False,
935
  )
936
  gentle_button.click(
@@ -938,6 +1065,7 @@ def build_demo() -> gr.Blocks:
938
  inputs=[session_state, gr.State("softer"), language, text_backend, vision_backend] + settings_inputs,
939
  outputs=outputs,
940
  api_name=False,
 
941
  show_api=False,
942
  )
943
  weird_button.click(
@@ -945,9 +1073,17 @@ def build_demo() -> gr.Blocks:
945
  inputs=[session_state, gr.State("stranger"), language, text_backend, vision_backend] + settings_inputs,
946
  outputs=outputs,
947
  api_name=False,
 
 
 
 
 
 
 
 
 
948
  show_api=False,
949
  )
950
- copy_button.click(lambda text: text, inputs=card_text, outputs=card_text, api_name=False, show_api=False)
951
  reset_button.click(
952
  _reset,
953
  inputs=[language, text_backend, vision_backend] + settings_inputs,
 
309
  const bindComposerControls = () => {
310
  bindVoiceButton();
311
  bindAttachmentButton();
312
+ bindProcessingButtons();
313
+ };
314
+
315
+ const currentLanguage = () => {
316
+ const micButton = document.querySelector(".dc-mic-button");
317
+ if (micButton?.dataset.language) {
318
+ return micButton.dataset.language;
319
+ }
320
+ return document.body.innerText.includes("梦境问答台") ? "zh" : "en";
321
+ };
322
+
323
+ const setProcessingCopy = (mode) => {
324
+ const isZh = currentLanguage() === "zh";
325
+ const copy = mode === "submit"
326
+ ? {
327
+ notice: isZh
328
+ ? "正在整理梦境线索。下一步会生成一个贴着细节的温和追问。"
329
+ : "Reading the dream details. Next, Dream QA will ask one grounded question.",
330
+ note: isZh
331
+ ? "正在提取人物、地点、情绪和具体物件;如果生成较慢,通常需要十几秒。"
332
+ : "Extracting people, places, feelings, and concrete objects. A slower run can take several seconds.",
333
+ }
334
+ : mode === "question"
335
+ ? {
336
+ notice: isZh
337
+ ? "正在换一个追问角度。"
338
+ : "Asking from another angle.",
339
+ note: isZh
340
+ ? "正在保留你的回答和梦境锚点,只重新生成一个更贴近当下的追问。"
341
+ : "Keeping your answer and dream anchors while writing one more grounded question.",
342
+ }
343
+ : {
344
+ notice: isZh
345
+ ? "正在把追问回答整理进今日小 Tips。"
346
+ : "Folding the follow-up answer into the Today Tip.",
347
+ note: isZh
348
+ ? "正在检查梦境锚点、你的回答和安全边界,然后生成一个可复制结果。"
349
+ : "Checking dream anchors, your answer, and safety boundaries before writing the copyable result.",
350
+ };
351
+
352
+ document.querySelectorAll(".dc-notice").forEach((el) => {
353
+ el.textContent = copy.notice;
354
+ el.classList.remove("is-error");
355
+ el.classList.add("is-processing");
356
+ });
357
+ document.querySelectorAll(".dc-processing-note").forEach((el) => {
358
+ el.textContent = copy.note;
359
+ el.classList.add("is-active");
360
+ });
361
+ };
362
+
363
+ const bindProcessingButtons = () => {
364
+ const bindButton = (selector, mode) => {
365
+ document.querySelectorAll(selector).forEach((root) => {
366
+ const button = root.matches("button") ? root : root.querySelector("button");
367
+ if (!button || button.dataset.processingBound === "true") {
368
+ return;
369
+ }
370
+ button.dataset.processingBound = "true";
371
+ button.addEventListener("click", () => setProcessingCopy(mode));
372
+ });
373
+ };
374
+
375
+ bindButton(".dc-submit-button", "submit");
376
+ bindButton(".dc-answer-button", "tip");
377
+ bindButton(".dc-tip-button", "tip");
378
+ bindButton(".dc-question-button", "question");
379
  };
380
 
381
  bindComposerControls();
 
385
  """
386
 
387
 
388
+ COPY_RESULT_JS = """
389
+ (text) => {
390
+ const value = text || "";
391
+ const showNotice = (message, isError = false) => {
392
+ document.querySelectorAll(".dc-notice").forEach((el) => {
393
+ el.textContent = message;
394
+ el.classList.toggle("is-error", isError);
395
+ el.classList.remove("is-processing");
396
+ });
397
+ };
398
+ if (!value.trim()) {
399
+ showNotice("Nothing to copy yet. Generate a Today Tip first. / 还没有可复制结果,请先生成今日小 Tips。", true);
400
+ return value;
401
+ }
402
+ if (navigator.clipboard && navigator.clipboard.writeText) {
403
+ navigator.clipboard.writeText(value)
404
+ .then(() => showNotice("Result copied. / 已复制结果。"))
405
+ .catch(() => showNotice("Copy was blocked by the browser. Use the Copy button inside the text box. / 浏览器阻止复制时,请使用文本框内置 Copy。", true));
406
+ } else {
407
+ showNotice("This browser blocked direct copy. Use the Copy button inside the text box. / 浏览器阻止复制时,请使用文本框内置 Copy。", true);
408
+ }
409
+ return value;
410
+ }
411
+ """
412
+
413
+
414
  def _load_view(view_json: str) -> dict:
415
  try:
416
  return json.loads(view_json or "{}")
 
452
  view_json,
453
  _hero_html(language, status),
454
  _notice_html(view),
455
+ _field_tip_html(language, view),
456
  _question_markdown(view, language),
457
  view.get("card_html", ""),
458
  view.get("card_text", ""),
 
743
  """.strip()
744
 
745
 
746
+ def _field_tip_html(language: str = DEFAULT_LANGUAGE, view=None) -> str:
747
+ copy = copy_for(language)
748
+ view = view or {}
749
+ if view.get("status") == "error":
750
+ message = (
751
+ "Dream note needs at least one sentence, image, or voice clue before Continue."
752
+ if normalize_language(language) == "en"
753
+ else "梦境记录需要至少一句文字、图片或语音线索,然后再继续。"
754
+ )
755
+ return f'<p class="dc-field-tip is-error" role="alert">{escape(message)}</p>'
756
+ return f"<p class=\"dc-field-tip\">{escape(copy['field_tip'])}</p>"
757
 
758
 
759
  def _processing_html(language: str = DEFAULT_LANGUAGE) -> str:
 
836
  field_tip_html = gr.HTML(_field_tip_html(DEFAULT_LANGUAGE))
837
  with gr.Row(elem_classes=["dc-submit-row"]):
838
  example_button = gr.Button(initial_copy["example_button"], variant="secondary")
839
+ submit_button = gr.Button(
840
+ initial_copy["submit_button"],
841
+ variant="primary",
842
+ elem_classes=["dc-submit-button"],
843
+ )
844
  processing_html = gr.HTML(_processing_html(DEFAULT_LANGUAGE))
845
 
846
  with gr.Group(visible=False, elem_classes=["dc-stage", "dc-question"]) as question_group:
 
852
  value="",
853
  )
854
  with gr.Row(elem_classes=["dc-question-actions"]):
855
+ answer_button = gr.Button(
856
+ initial_copy["answer_button"],
857
+ variant="primary",
858
+ elem_classes=["dc-answer-button"],
859
+ )
860
+ skip_button = gr.Button(
861
+ initial_copy["skip_button"],
862
+ variant="secondary",
863
+ elem_classes=["dc-tip-button"],
864
+ )
865
 
866
  with gr.Group(visible=False, elem_classes=["dc-stage", "dc-card"]) as card_group:
867
  card_html = gr.HTML("")
868
  with gr.Row(elem_classes=["dc-actions"]):
869
+ gentle_button = gr.Button(
870
+ initial_copy["ask_again_button"],
871
+ variant="secondary",
872
+ elem_classes=["dc-question-button"],
873
+ )
874
+ weird_button = gr.Button(
875
+ initial_copy["angle_button"],
876
+ variant="secondary",
877
+ elem_classes=["dc-question-button"],
878
+ )
879
  copy_button = gr.Button(initial_copy["copy_button"], variant="secondary")
880
  reset_button = gr.Button(initial_copy["reset_button"], variant="secondary")
881
  card_text = gr.Textbox(
 
1006
  view_state,
1007
  hero_html,
1008
  notice,
1009
+ field_tip_html,
1010
  question_markdown,
1011
  card_html,
1012
  card_text,
 
1041
  inputs=[dream_text, image_input, audio_input, mood, language, text_backend, vision_backend] + settings_inputs,
1042
  outputs=outputs,
1043
  api_name=False,
1044
+ scroll_to_output=True,
1045
  show_api=False,
1046
  )
1047
  answer_button.click(
 
1049
  inputs=[session_state, answer_text, language, text_backend, vision_backend] + settings_inputs,
1050
  outputs=outputs,
1051
  api_name=False,
1052
+ scroll_to_output=True,
1053
  show_api=False,
1054
  )
1055
  skip_button.click(
 
1057
  inputs=[session_state, language, text_backend, vision_backend] + settings_inputs,
1058
  outputs=outputs,
1059
  api_name=False,
1060
+ scroll_to_output=True,
1061
  show_api=False,
1062
  )
1063
  gentle_button.click(
 
1065
  inputs=[session_state, gr.State("softer"), language, text_backend, vision_backend] + settings_inputs,
1066
  outputs=outputs,
1067
  api_name=False,
1068
+ scroll_to_output=True,
1069
  show_api=False,
1070
  )
1071
  weird_button.click(
 
1073
  inputs=[session_state, gr.State("stranger"), language, text_backend, vision_backend] + settings_inputs,
1074
  outputs=outputs,
1075
  api_name=False,
1076
+ scroll_to_output=True,
1077
+ show_api=False,
1078
+ )
1079
+ copy_button.click(
1080
+ lambda text: text,
1081
+ inputs=card_text,
1082
+ outputs=card_text,
1083
+ api_name=False,
1084
+ js=COPY_RESULT_JS,
1085
  show_api=False,
1086
  )
 
1087
  reset_button.click(
1088
  _reset,
1089
  inputs=[language, text_backend, vision_backend] + settings_inputs,
dream_customs/ui/copy.py CHANGED
@@ -44,8 +44,8 @@ APP_COPY = {
44
  "answer_placeholder": "Write one answer, or leave it blank and skip.",
45
  "answer_button": "Send answer",
46
  "skip_button": "Skip and generate tip",
47
- "ask_again_button": "Ask another question",
48
- "angle_button": "Try another angle",
49
  "copy_button": "Copy result",
50
  "reset_button": "Start over",
51
  "copy_label": "Copyable result",
@@ -100,8 +100,8 @@ APP_COPY = {
100
  "answer_placeholder": "写一句回答,或留空后选择跳过。",
101
  "answer_button": "发送回答",
102
  "skip_button": "跳过,生成 Tips",
103
- "ask_again_button": "再问一个问题",
104
- "angle_button": "换个角度",
105
  "copy_button": "复制结果",
106
  "reset_button": "重新开始",
107
  "copy_label": "可复制结果",
 
44
  "answer_placeholder": "Write one answer, or leave it blank and skip.",
45
  "answer_button": "Send answer",
46
  "skip_button": "Skip and generate tip",
47
+ "ask_again_button": "Ask one more question",
48
+ "angle_button": "Ask from another angle",
49
  "copy_button": "Copy result",
50
  "reset_button": "Start over",
51
  "copy_label": "Copyable result",
 
100
  "answer_placeholder": "写一句回答,或留空后选择跳过。",
101
  "answer_button": "发送回答",
102
  "skip_button": "跳过,生成 Tips",
103
+ "ask_again_button": "再问一",
104
+ "angle_button": "换个追问角度",
105
  "copy_button": "复制结果",
106
  "reset_button": "重新开始",
107
  "copy_label": "可复制结果",
dream_customs/ui/styles.py CHANGED
@@ -519,6 +519,15 @@ body,
519
  margin: 12px 0 0;
520
  }
521
 
 
 
 
 
 
 
 
 
 
522
  .dc-side-panel .wrap,
523
  .dc-side-panel .container,
524
  .dc-side-panel select {
@@ -612,6 +621,39 @@ button.secondary {
612
  max-width: 44rem;
613
  }
614
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
615
  .dc-processing-note::before {
616
  color: var(--dc-coral-dark);
617
  content: "While the stamp dries: ";
@@ -753,6 +795,12 @@ button.secondary {
753
  color: #9f321c;
754
  }
755
 
 
 
 
 
 
 
756
  .dc-pass-card {
757
  background:
758
  linear-gradient(180deg, rgba(255, 252, 246, 0.98), rgba(246, 237, 222, 0.98));
 
519
  margin: 12px 0 0;
520
  }
521
 
522
+ .dc-field-tip.is-error {
523
+ background: rgba(255, 240, 235, 0.95);
524
+ border: 1px solid #efb19d;
525
+ border-radius: 10px;
526
+ color: #9f321c;
527
+ margin-top: 12px;
528
+ padding: 10px 12px;
529
+ }
530
+
531
  .dc-side-panel .wrap,
532
  .dc-side-panel .container,
533
  .dc-side-panel select {
 
621
  max-width: 44rem;
622
  }
623
 
624
+ .dc-processing-note.is-active {
625
+ background: rgba(244, 201, 94, 0.16);
626
+ border: 1px solid rgba(191, 133, 35, 0.28);
627
+ border-radius: 10px;
628
+ color: var(--dc-ink);
629
+ padding: 10px 12px 10px 36px;
630
+ position: relative;
631
+ }
632
+
633
+ .dc-processing-note.is-active::before {
634
+ animation: dc-processing-pulse 1.1s ease-in-out infinite;
635
+ background: var(--dc-sage);
636
+ border-radius: 999px;
637
+ content: "";
638
+ height: 10px;
639
+ left: 14px;
640
+ position: absolute;
641
+ top: 16px;
642
+ width: 10px;
643
+ }
644
+
645
+ @keyframes dc-processing-pulse {
646
+ 0%,
647
+ 100% {
648
+ opacity: 0.45;
649
+ transform: scale(0.82);
650
+ }
651
+ 50% {
652
+ opacity: 1;
653
+ transform: scale(1);
654
+ }
655
+ }
656
+
657
  .dc-processing-note::before {
658
  color: var(--dc-coral-dark);
659
  content: "While the stamp dries: ";
 
795
  color: #9f321c;
796
  }
797
 
798
+ .dc-notice.is-processing {
799
+ background: rgba(244, 201, 94, 0.18);
800
+ border-color: rgba(191, 133, 35, 0.32);
801
+ color: var(--dc-ink);
802
+ }
803
+
804
  .dc-pass-card {
805
  background:
806
  linear-gradient(180deg, rgba(255, 252, 246, 0.98), rgba(246, 237, 222, 0.98));
scripts/local_space_mirror.py CHANGED
@@ -9,16 +9,17 @@ import os
9
  import sys
10
  from pathlib import Path
11
 
12
-
13
  ROOT = Path(__file__).resolve().parents[1]
14
- SPACE_ID = "build-small-hackathon/dream-customs"
15
- DEFAULT_RUNTIME_ENV_JSON = Path("/tmp/dream-customs-runtime.json")
16
- RUNTIME_ENV_KEYS = {
17
- "DREAM_CUSTOMS_TEXT_ENDPOINT",
18
- "DREAM_CUSTOMS_VISION_ENDPOINT",
19
- "DREAM_CUSTOMS_ASR_ENDPOINT",
20
- "DREAM_CUSTOMS_HOSTED_TOKEN",
21
- }
 
 
22
 
23
 
24
  def _repo_root_on_path() -> None:
@@ -27,37 +28,6 @@ def _repo_root_on_path() -> None:
27
  sys.path.insert(0, root)
28
 
29
 
30
- def _configured_env() -> dict:
31
- return {
32
- "space_id": os.getenv("SPACE_ID", SPACE_ID),
33
- "text_endpoint_configured": bool(os.getenv("DREAM_CUSTOMS_TEXT_ENDPOINT", "").strip()),
34
- "vision_endpoint_configured": bool(os.getenv("DREAM_CUSTOMS_VISION_ENDPOINT", "").strip()),
35
- "asr_endpoint_configured": bool(os.getenv("DREAM_CUSTOMS_ASR_ENDPOINT", "").strip()),
36
- "hosted_token_configured": bool(os.getenv("DREAM_CUSTOMS_HOSTED_TOKEN", "").strip()),
37
- }
38
-
39
-
40
- def load_runtime_env_json(path: Path) -> dict:
41
- if not path.exists():
42
- return {"loaded": False, "path": str(path), "reason": "missing"}
43
- try:
44
- data = json.loads(path.read_text(encoding="utf-8"))
45
- except (OSError, json.JSONDecodeError) as exc:
46
- return {"loaded": False, "path": str(path), "reason": exc.__class__.__name__}
47
-
48
- loaded_keys = []
49
- for key in sorted(RUNTIME_ENV_KEYS):
50
- value = str(data.get(key, "")).strip()
51
- if value:
52
- os.environ[key] = value
53
- loaded_keys.append(key)
54
- return {
55
- "loaded": bool(loaded_keys),
56
- "path": str(path),
57
- "configured_keys": loaded_keys,
58
- }
59
-
60
-
61
  def mirror_manifest(host: str, port: int) -> dict:
62
  _repo_root_on_path()
63
  from dream_customs.defaults import DEFAULT_ASR_BACKEND, DEFAULT_TEXT_BACKEND, DEFAULT_VISION_BACKEND
@@ -72,7 +42,7 @@ def mirror_manifest(host: str, port: int) -> dict:
72
  "vision": DEFAULT_VISION_BACKEND,
73
  "asr": DEFAULT_ASR_BACKEND,
74
  },
75
- "env": _configured_env(),
76
  "notes": [
77
  "Uses the same app.py/build_demo path as the Hugging Face Space.",
78
  "Missing hosted endpoint secrets fall back to deterministic demo behavior.",
 
9
  import sys
10
  from pathlib import Path
11
 
 
12
  ROOT = Path(__file__).resolve().parents[1]
13
+ root = str(ROOT)
14
+ if root not in sys.path:
15
+ sys.path.insert(0, root)
16
+
17
+ from dream_customs.runtime_env import (
18
+ DEFAULT_RUNTIME_ENV_JSON,
19
+ SPACE_ID,
20
+ configured_env,
21
+ load_runtime_env_json,
22
+ )
23
 
24
 
25
  def _repo_root_on_path() -> None:
 
28
  sys.path.insert(0, root)
29
 
30
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
31
  def mirror_manifest(host: str, port: int) -> dict:
32
  _repo_root_on_path()
33
  from dream_customs.defaults import DEFAULT_ASR_BACKEND, DEFAULT_TEXT_BACKEND, DEFAULT_VISION_BACKEND
 
42
  "vision": DEFAULT_VISION_BACKEND,
43
  "asr": DEFAULT_ASR_BACKEND,
44
  },
45
+ "env": configured_env(SPACE_ID),
46
  "notes": [
47
  "Uses the same app.py/build_demo path as the Hugging Face Space.",
48
  "Missing hosted endpoint secrets fall back to deterministic demo behavior.",
tests/test_local_space_mirror.py CHANGED
@@ -1,10 +1,20 @@
1
  import json
 
 
 
2
 
3
  from scripts.local_space_mirror import load_runtime_env_json, mirror_manifest
4
  from scripts.smoke_local_space_mirror import inspect_config
5
 
6
 
7
- def test_local_space_mirror_manifest_matches_space_entrypoint():
 
 
 
 
 
 
 
8
  manifest = mirror_manifest("127.0.0.1", 7862)
9
 
10
  assert manifest["mode"] == "local-space-mirror"
@@ -61,6 +71,58 @@ def test_local_space_mirror_loads_runtime_env_json_without_printing_values(tmp_p
61
  assert "super-secret-token" not in serialized
62
 
63
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
64
  def test_local_space_mirror_config_smoke_requires_composer_debug_markers():
65
  config = {
66
  "title": "Dream QA",
 
1
  import json
2
+ import os
3
+ import subprocess
4
+ import sys
5
 
6
  from scripts.local_space_mirror import load_runtime_env_json, mirror_manifest
7
  from scripts.smoke_local_space_mirror import inspect_config
8
 
9
 
10
+ def test_local_space_mirror_manifest_matches_space_entrypoint(monkeypatch):
11
+ for key in [
12
+ "DREAM_CUSTOMS_TEXT_ENDPOINT",
13
+ "DREAM_CUSTOMS_VISION_ENDPOINT",
14
+ "DREAM_CUSTOMS_ASR_ENDPOINT",
15
+ "DREAM_CUSTOMS_HOSTED_TOKEN",
16
+ ]:
17
+ monkeypatch.delenv(key, raising=False)
18
  manifest = mirror_manifest("127.0.0.1", 7862)
19
 
20
  assert manifest["mode"] == "local-space-mirror"
 
71
  assert "super-secret-token" not in serialized
72
 
73
 
74
+ def test_app_import_auto_loads_local_runtime_env_json(tmp_path):
75
+ runtime_json = tmp_path / "runtime.json"
76
+ runtime_json.write_text(
77
+ json.dumps(
78
+ {
79
+ "DREAM_CUSTOMS_TEXT_ENDPOINT": "https://example.test/text",
80
+ "DREAM_CUSTOMS_VISION_ENDPOINT": "https://example.test/vision",
81
+ "DREAM_CUSTOMS_ASR_ENDPOINT": "https://example.test/asr",
82
+ "DREAM_CUSTOMS_HOSTED_TOKEN": "super-secret-token",
83
+ }
84
+ ),
85
+ encoding="utf-8",
86
+ )
87
+ env = os.environ.copy()
88
+ env.pop("PYTEST_CURRENT_TEST", None)
89
+ for key in [
90
+ "DREAM_CUSTOMS_TEXT_ENDPOINT",
91
+ "DREAM_CUSTOMS_VISION_ENDPOINT",
92
+ "DREAM_CUSTOMS_ASR_ENDPOINT",
93
+ "DREAM_CUSTOMS_HOSTED_TOKEN",
94
+ ]:
95
+ env.pop(key, None)
96
+ env["DREAM_CUSTOMS_RUNTIME_ENV_JSON"] = str(runtime_json)
97
+ result = subprocess.run(
98
+ [
99
+ sys.executable,
100
+ "-c",
101
+ (
102
+ "import json, os; import app; "
103
+ "print(json.dumps({key: bool(os.getenv(key)) for key in "
104
+ "['DREAM_CUSTOMS_TEXT_ENDPOINT','DREAM_CUSTOMS_VISION_ENDPOINT',"
105
+ "'DREAM_CUSTOMS_ASR_ENDPOINT','DREAM_CUSTOMS_HOSTED_TOKEN']}))"
106
+ ),
107
+ ],
108
+ check=True,
109
+ capture_output=True,
110
+ text=True,
111
+ env=env,
112
+ )
113
+
114
+ loaded = json.loads(result.stdout.strip())
115
+
116
+ assert loaded == {
117
+ "DREAM_CUSTOMS_TEXT_ENDPOINT": True,
118
+ "DREAM_CUSTOMS_VISION_ENDPOINT": True,
119
+ "DREAM_CUSTOMS_ASR_ENDPOINT": True,
120
+ "DREAM_CUSTOMS_HOSTED_TOKEN": True,
121
+ }
122
+ assert "https://example.test" not in result.stdout
123
+ assert "super-secret-token" not in result.stdout
124
+
125
+
126
  def test_local_space_mirror_config_smoke_requires_composer_debug_markers():
127
  config = {
128
  "title": "Dream QA",
tests/test_ollama_models.py CHANGED
@@ -242,3 +242,44 @@ def test_hosted_vision_client_accepts_partial_witness_json():
242
  assert "Object: elevator button" in clues
243
  assert "Visible text: 14" in clues
244
  assert "Mood cue: cold" in clues
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
242
  assert "Object: elevator button" in clues
243
  assert "Visible text: 14" in clues
244
  assert "Mood cue: cold" in clues
245
+
246
+
247
+ def test_hosted_vision_client_extracts_modal_pipeline_assistant_content():
248
+ class StubHostedNestedVisionClient(HostedMiniCPMVisionClient):
249
+ def _post_image(self, image_path):
250
+ return {
251
+ "response": str(
252
+ {
253
+ "input_text": [
254
+ {
255
+ "role": "user",
256
+ "content": [
257
+ {"type": "image", "path": "/tmp/demo.png"},
258
+ {"type": "text", "text": "Describe the sketch."},
259
+ ],
260
+ }
261
+ ],
262
+ "generated_text": [
263
+ {"role": "user", "content": [{"type": "text", "text": "Describe the sketch."}]},
264
+ {
265
+ "role": "assistant",
266
+ "content": (
267
+ '{"scene_summary":"A child is lost at a subway station.",'
268
+ '"objects":["child figure","subway sign","arrow"],'
269
+ '"visible_text":["SUBWAY","HOME ?"],'
270
+ '"spatial_relations":"child in front of sign",'
271
+ '"mood_cues":"lost or searching",'
272
+ '"uncertain_details":"where home is",'
273
+ '"surprising_detail":"the sign asks HOME ?"}'
274
+ ),
275
+ },
276
+ ],
277
+ }
278
+ )
279
+ }
280
+
281
+ clues = StubHostedNestedVisionClient(endpoint="https://example.test").extract_witness("demo.png").to_visual_clues()
282
+
283
+ assert "Scene: A child is lost at a subway station." in clues
284
+ assert "Object: child figure" in clues
285
+ assert "Visible text: HOME ?" in clues
tests/test_pipeline.py CHANGED
@@ -178,6 +178,99 @@ 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_follows_user_question_and_comfort_need_in_chinese():
182
  intake = build_intake(
183
  dream_text="我梦到自己掉进海里,醒来很害怕。我想知道这是不是说明我快撑不住了?",
@@ -449,6 +542,29 @@ def test_ask_answer_skip_draft_revise_and_seal_actions():
449
  assert session.sealed_pact == session.draft_pact
450
 
451
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
452
  def test_draft_pact_falls_back_to_legacy_generate_pact_client():
453
  class LegacyPactOnlyClient:
454
  def generate_pact(self, prompt):
 
178
  assert "does not indicate any real-life concerns" not in combined
179
 
180
 
181
+ def test_elevator_floor_14_without_melted_button_does_not_invent_example_detail():
182
+ class ContaminatedElevatorClient:
183
+ def generate_today_tip(self, prompt):
184
+ return TodayTipCard(
185
+ dream_summary="你梦见电梯按钮像蜡一样融化,楼层数字停在 14。",
186
+ main_question="为什么梦里总是卡在电梯口?",
187
+ dream_anchors=["电梯", "融化的按钮", "数字 14"],
188
+ followup_questions=["电梯、融化的按钮和数字 14 里,哪一个最像你最近卡住的感觉?"],
189
+ user_answers=[],
190
+ interpretation="第二层,梦里的「电梯、融化的按钮和数字 14」也许把这种感受变成了画面。",
191
+ today_tip="今天不要逼自己抵达所有楼层;先把「电梯、融化的按钮和数字 14」变成一个只按一次的按钮。",
192
+ tiny_action="用 5 分钟写一个只按这一层的按钮。",
193
+ caring_note="不用解决所有楼层。",
194
+ safety_note="",
195
+ )
196
+
197
+ dream_text = (
198
+ "昨晚梦见我在一栋很高的办公楼14层找一封迟迟没发出去的邮件。"
199
+ "电梯一直停在黑暗的走廊,我手里拿着一盏小台灯,感觉有点焦急。"
200
+ "醒来最想知道为什么梦里总是卡在电梯口。"
201
+ )
202
+ session = add_evidence(
203
+ create_session(language="zh"),
204
+ dream_text=dream_text,
205
+ mood="焦急",
206
+ vision_client=FakeVisionClient(),
207
+ asr_client=FakeASRClient(),
208
+ language="zh",
209
+ )
210
+ session = ask_questions(session, FakeTextClient(), language="zh")
211
+
212
+ card = generate_today_tip(
213
+ session.intake,
214
+ "我觉得那封邮件像是我一直拖着的一次工作沟通。",
215
+ ContaminatedElevatorClient(),
216
+ language="zh",
217
+ followup_questions=session.question_history,
218
+ )
219
+ combined = "\n".join(
220
+ [
221
+ session.question_history[0],
222
+ card.dream_summary,
223
+ card.main_question,
224
+ ",".join(card.dream_anchors),
225
+ "\n".join(card.followup_questions),
226
+ card.interpretation,
227
+ card.today_tip,
228
+ card.tiny_action,
229
+ card.caring_note,
230
+ ]
231
+ )
232
+
233
+ assert "融化" not in combined
234
+ assert "像蜡" not in combined
235
+ assert "电梯" in combined
236
+ assert "14" in combined
237
+ assert "邮件" in combined or "办公楼" in combined or "台灯" in combined
238
+
239
+
240
+ def test_elevator_melted_button_stays_when_user_supplies_it():
241
+ intake = build_intake(dream_text="我梦见电梯按钮融化,数字停在 14。", mood="焦虑")
242
+
243
+ card = generate_today_tip(intake, "用户选择跳过这个追问。", FakeTextClient(), language="zh")
244
+ combined = "\n".join([card.dream_summary, ",".join(card.dream_anchors), card.interpretation, card.today_tip])
245
+
246
+ assert "融化" in combined
247
+ assert "电梯" in combined
248
+
249
+
250
+ def test_generate_today_tip_uses_chinese_concrete_answer_in_visible_tip():
251
+ intake = build_intake(
252
+ dream_text=(
253
+ "昨晚梦见我在一栋很高的办公楼14层找一封迟迟没发出去的邮件。"
254
+ "电梯一直停在黑暗的走廊,我手里拿着一盏小台灯,感觉有点焦急。"
255
+ "醒来最想知道为什么梦里总是卡在电梯口。"
256
+ ),
257
+ mood="焦急",
258
+ )
259
+
260
+ card = generate_today_tip(
261
+ intake,
262
+ "我觉得那封邮件像是我一直拖着的一次工作沟通,也可能有一点道歉。今天可以先写第一句,不一定马上发出去。",
263
+ FakeTextClient(),
264
+ language="zh",
265
+ )
266
+ visible = "\n".join([card.interpretation, card.today_tip, card.tiny_action])
267
+
268
+ assert "邮件" in visible
269
+ assert "第一句" in visible or "第一句话" in visible
270
+ assert "不要求立刻发出" in visible or "不一定马上发" in visible
271
+ assert "融化" not in visible
272
+
273
+
274
  def test_generate_today_tip_follows_user_question_and_comfort_need_in_chinese():
275
  intake = build_intake(
276
  dream_text="我梦到自己掉进海里,醒来很害怕。我想知道这是不是说明我快撑不住了?",
 
542
  assert session.sealed_pact == session.draft_pact
543
 
544
 
545
+ def test_force_another_question_keeps_chinese_answer_snippet_readable():
546
+ session = add_evidence(
547
+ create_session(language="zh"),
548
+ dream_text="我梦见自己卡在电梯口,14 层的灯一直亮着。",
549
+ mood="焦急",
550
+ vision_client=FakeVisionClient(),
551
+ asr_client=FakeASRClient(),
552
+ language="zh",
553
+ )
554
+ session = ask_questions(session, FakeTextClient(), language="zh")
555
+ session = answer_question(
556
+ session,
557
+ "我觉得那封���件像是我一直拖着的一次工作沟通,也可能有一点道歉。今天可以先写第一句,不一定马上发出去。",
558
+ language="zh",
559
+ )
560
+ session = ask_questions(session, FakeTextClient(), force_another=True, language="zh")
561
+
562
+ latest = session.question_history[-1]
563
+ assert "今天可。" not in latest
564
+ assert "第一句" in latest or "工作沟通" in latest
565
+ assert "我把你的回答也放进来了" in latest
566
+
567
+
568
  def test_draft_pact_falls_back_to_legacy_generate_pact_client():
569
  class LegacyPactOnlyClient:
570
  def generate_pact(self, prompt):
tests/test_ui_actions.py CHANGED
@@ -159,6 +159,49 @@ def test_processing_note_is_story_copy_not_backend_jargon():
159
  assert "debug" not in lowered
160
 
161
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
162
  def test_mobile_reset_restores_calm_mood():
163
  settings_values = [
164
  "",
@@ -233,6 +276,35 @@ def test_mobile_mvp_empty_submit_stays_on_record_with_clear_error():
233
  assert json.loads(state)["phase"] == "error"
234
 
235
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
236
  def test_mobile_mvp_auto_switches_chinese_input_to_chinese_result():
237
  state, view_json = submit_dream_action(
238
  dream_text="梦里我在一片雾很大的森林里追着一只白色猫跑,周围有很多空白路牌。",
 
159
  assert "debug" not in lowered
160
 
161
 
162
+ def test_long_running_buttons_show_processing_feedback():
163
+ source = inspect.getsource(ui_app.build_demo)
164
+ app_source = inspect.getsource(ui_app)
165
+
166
+ assert "elem_classes=[\"dc-submit-button\"]" in source
167
+ assert "elem_classes=[\"dc-answer-button\"]" in source
168
+ assert source.count("elem_classes=[\"dc-tip-button\"]") >= 1
169
+ assert source.count("elem_classes=[\"dc-question-button\"]") >= 2
170
+ assert "bindProcessingButtons" in ui_app.VOICE_JS
171
+ assert "setProcessingCopy" in ui_app.VOICE_JS
172
+ assert "processingBound" in ui_app.VOICE_JS
173
+ assert "Asking from another angle" in ui_app.VOICE_JS
174
+ assert "one more grounded question" in ui_app.VOICE_JS
175
+ assert "js=SUBMIT_PROCESSING_JS" not in source
176
+ assert "js=TIP_PROCESSING_JS" not in source
177
+ assert source.count("scroll_to_output=True") >= 5
178
+ assert "Reading the dream details" in app_source
179
+ assert "Folding the follow-up answer" in app_source
180
+ assert ".dc-notice.is-processing" in CSS
181
+ assert ".dc-processing-note.is-active" in CSS
182
+ assert "endpoint" not in ui_app.VOICE_JS.lower()
183
+ assert "debug" not in ui_app.VOICE_JS.lower()
184
+
185
+
186
+ def test_copy_result_button_writes_to_clipboard_with_fallback():
187
+ source = inspect.getsource(ui_app.build_demo)
188
+
189
+ assert "js=COPY_RESULT_JS" in source
190
+ assert "navigator.clipboard.writeText" in ui_app.COPY_RESULT_JS
191
+ assert "Result copied" in ui_app.COPY_RESULT_JS
192
+ assert "text box" in ui_app.COPY_RESULT_JS
193
+ assert "return value" in ui_app.COPY_RESULT_JS
194
+
195
+
196
+ def test_another_angle_copy_is_explicitly_question_focused():
197
+ from dream_customs.ui.copy import copy_for
198
+
199
+ assert copy_for("en")["ask_again_button"] == "Ask one more question"
200
+ assert copy_for("en")["angle_button"] == "Ask from another angle"
201
+ assert copy_for("zh")["ask_again_button"] == "再追问一下"
202
+ assert copy_for("zh")["angle_button"] == "换个追问角度"
203
+
204
+
205
  def test_mobile_reset_restores_calm_mood():
206
  settings_values = [
207
  "",
 
276
  assert json.loads(state)["phase"] == "error"
277
 
278
 
279
+ def test_empty_submit_updates_inline_field_error_near_composer():
280
+ state, view_json = submit_dream_action(
281
+ dream_text="",
282
+ mood="Uneasy",
283
+ text_backend="demo",
284
+ vision_backend="demo",
285
+ language="en",
286
+ )
287
+ view = json.loads(view_json)
288
+ updates = ui_app._updates(state, view_json)
289
+ field_tip_html = updates[4]
290
+
291
+ assert view["status"] == "error"
292
+ assert "dc-field-tip is-error" in field_tip_html
293
+ assert 'role="alert"' in field_tip_html
294
+ assert "Dream note needs" in field_tip_html
295
+
296
+ ready_state, ready_view_json = submit_dream_action(
297
+ dream_text="I dreamed of a late elevator.",
298
+ mood="Uneasy",
299
+ text_backend="demo",
300
+ vision_backend="demo",
301
+ language="en",
302
+ )
303
+ ready_updates = ui_app._updates(ready_state, ready_view_json)
304
+
305
+ assert "dc-field-tip is-error" not in ready_updates[4]
306
+
307
+
308
  def test_mobile_mvp_auto_switches_chinese_input_to_chinese_result():
309
  state, view_json = submit_dream_action(
310
  dream_text="梦里我在一片雾很大的森林里追着一只白色猫跑,周围有很多空白路牌。",
videos/dreamqa_01_text_only.mp4 ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:deaa14482d904b3084d4533dacfbff04e7689c15f1212331aebc9649da8c2cf6
3
+ size 957336
videos/dreamqa_02_image_only.mp4 ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:eca1043f4ca313d5b2fe6823f23ae2ddc23f38a617a5587fea88b7cbddff5e9f
3
+ size 930264
videos/dreamqa_03_text_plus_image.mp4 ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:3722ac1e78fdb69a86154abe9d7d22b1437e2f0bc7d0f66239110b197d160f55
3
+ size 680603