Deploy Dream QA post-refactor polish

#17
PRODUCT.md CHANGED
@@ -6,11 +6,13 @@ product
6
 
7
  ## Users
8
 
9
- Dream QA / 梦境问答台 serves people who wake up with vivid, strange, or unsettling dreams and want a small morning conversation before the day takes over. The primary user may still be half asleep, on a phone, and emotionally sticky from the dream. The secondary user is a hackathon judge or playful AI explorer who needs to understand the loop quickly and see why small multimodal models matter.
10
 
11
  ## Product Purpose
12
 
13
- The product helps a user unpack a dream through a guided, step-by-step Q&A. It accepts text, image, and voice fragments, turns them into one structured dream intake, asks gentle follow-up questions, drafts a grounded interpretation, and ends with one `今日小 Tips`.
 
 
14
 
15
  Success means the user receives something more useful than a one-shot interpretation: a short summary of the dream, one clear question they were trying to answer, a non-certain explanation grounded in concrete dream details, and a tiny suggestion for today. The tip can be practical, playful, or simply caring, but it must feel connected to the dream.
16
 
@@ -38,7 +40,9 @@ The voice is a calm dream companion who asks good questions and takes odd detail
38
  4. Every final output should lead to one small next-day reference or action.
39
  5. The final tip must cite a concrete dream anchor.
40
  6. The demo path must survive Space constraints and still have a model-backed route.
 
 
41
 
42
  ## Accessibility & Inclusion
43
 
44
- Target WCAG AA contrast for body text, controls, and placeholder text. Support mobile-first use, keyboard navigation, visible focus states, reduced motion, and Chinese/English text without layout breakage. Avoid flashing effects, fear-based copy, or overconfident claims about dream meaning. Severe distress, self-harm, harm to others, or inability to function must show a clear support note.
 
6
 
7
  ## Users
8
 
9
+ Dream QA / 梦境问答台 serves people who wake up with vivid, strange, or unsettling dreams and want a small morning conversation before the day takes over. The primary user may still be half asleep, on a phone, and emotionally sticky from the dream. The secondary user is an international hackathon judge or playful AI explorer who needs to understand the loop quickly and see why small multimodal models matter.
10
 
11
  ## Product Purpose
12
 
13
+ The product helps a user unpack a dream through a guided, step-by-step Q&A. It accepts text, image, and voice fragments, turns them into one structured dream intake, asks gentle follow-up questions, drafts a grounded interpretation, and ends with one Today Tip.
14
+
15
+ The hackathon-facing experience is English-first. A visible `English / 中文` language control keeps the Chinese experience available without making judges start from a Chinese-only first screen.
16
 
17
  Success means the user receives something more useful than a one-shot interpretation: a short summary of the dream, one clear question they were trying to answer, a non-certain explanation grounded in concrete dream details, and a tiny suggestion for today. The tip can be practical, playful, or simply caring, but it must feel connected to the dream.
18
 
 
40
  4. Every final output should lead to one small next-day reference or action.
41
  5. The final tip must cite a concrete dream anchor.
42
  6. The demo path must survive Space constraints and still have a model-backed route.
43
+ 7. English mode is the default public path; Chinese mode is a first-class toggle, not a separate product.
44
+ 8. Recommendation quality is tested with a deterministic Today Tip eval set before deployment.
45
 
46
  ## Accessibility & Inclusion
47
 
48
+ Target WCAG AA contrast for body text, controls, and placeholder text. Support mobile-first use, keyboard navigation, visible focus states, reduced motion, and English/Chinese text without layout breakage. Avoid flashing effects, fear-based copy, or overconfident claims about dream meaning. Severe distress, self-harm, harm to others, or inability to function must show a clear support note.
README.md CHANGED
@@ -26,9 +26,11 @@ A Build Small Hackathon Gradio app that helps users unpack a dream step by step
26
 
27
  The Hugging Face Space may still be named `Dream Customs` for continuity, but the current product direction is Dream QA: record a dream, answer or skip gentle follow-up questions, read a grounded interpretation draft, and receive a `今日小 Tips`.
28
 
 
 
29
  ## Concept
30
 
31
- Dream QA accepts dream fragments by text, image, or voice. It turns those fragments into a shared dream intake, asks a few gentle questions, and returns a Today Tip card: a short non-certain interpretation, one practical or caring suggestion for today, and an optional tiny action.
32
 
33
  This is not a therapy, diagnosis, or prophecy product.
34
 
@@ -39,6 +41,7 @@ This is not a therapy, diagnosis, or prophecy product.
39
  - A small ASR adapter may be used only for voice transcription.
40
  - The app defaults to a stable demo backend so the local Gradio flow always works.
41
  - Optional Ollama adapters are included for local MiniCPM testing.
 
42
 
43
  ## User Flow
44
 
@@ -48,6 +51,13 @@ This is not a therapy, diagnosis, or prophecy product.
48
  4. Review a grounded interpretation.
49
  5. Receive one Today Tip tied to concrete dream details.
50
 
 
 
 
 
 
 
 
51
  ## Current Direction References
52
 
53
  - Product spec: `docs/spec.md`
@@ -135,6 +145,7 @@ PY
135
 
136
  ```bash
137
  python -m pytest -q
 
138
  ```
139
 
140
  ## Deployment Smoke Status
@@ -142,6 +153,7 @@ python -m pytest -q
142
  Latest local Dream QA refactor smoke:
143
 
144
  - `docs/smoke/2026-06-08-dream-qa-refactor-smoke.md`
 
145
 
146
  Historical smoke notes under `docs/smoke/` describe earlier pact/customs UI passes. They remain useful deployment history, but the current implementation target is the Dream QA flow documented in `docs/handoff.md`, `docs/spec.md`, and `docs/prd.md`.
147
 
 
26
 
27
  The Hugging Face Space may still be named `Dream Customs` for continuity, but the current product direction is Dream QA: record a dream, answer or skip gentle follow-up questions, read a grounded interpretation draft, and receive a `今日小 Tips`.
28
 
29
+ The public hackathon demo is English-first for international judges. A visible in-app language toggle keeps the Chinese experience available as `中文`.
30
+
31
  ## Concept
32
 
33
+ Dream QA accepts dream fragments by text, image, or voice. It turns those fragments into a shared dream intake, asks a grounded follow-up question, and returns a Today Tip card: a short non-certain interpretation, one practical or caring suggestion for today, and an optional tiny action.
34
 
35
  This is not a therapy, diagnosis, or prophecy product.
36
 
 
41
  - A small ASR adapter may be used only for voice transcription.
42
  - The app defaults to a stable demo backend so the local Gradio flow always works.
43
  - Optional Ollama adapters are included for local MiniCPM testing.
44
+ - MiniCPM prompts are language-aware: English by default, Chinese when the user chooses `中文`.
45
 
46
  ## User Flow
47
 
 
51
  4. Review a grounded interpretation.
52
  5. Receive one Today Tip tied to concrete dream details.
53
 
54
+ ## Language
55
+
56
+ - Default public UI: English.
57
+ - Toggle: `English / 中文`.
58
+ - English mode may preserve user-provided dream anchors as written, but all UI labels, guidance, and generated helper text should be English.
59
+ - Chinese mode keeps the warm `梦境问答台` wording.
60
+
61
  ## Current Direction References
62
 
63
  - Product spec: `docs/spec.md`
 
145
 
146
  ```bash
147
  python -m pytest -q
148
+ python scripts/evaluate_today_tip_quality.py
149
  ```
150
 
151
  ## Deployment Smoke Status
 
153
  Latest local Dream QA refactor smoke:
154
 
155
  - `docs/smoke/2026-06-08-dream-qa-refactor-smoke.md`
156
+ - `docs/smoke/2026-06-08-post-refactor-polish-smoke.md`
157
 
158
  Historical smoke notes under `docs/smoke/` describe earlier pact/customs UI passes. They remain useful deployment history, but the current implementation target is the Dream QA flow documented in `docs/handoff.md`, `docs/spec.md`, and `docs/prd.md`.
159
 
docs/handoff.md CHANGED
@@ -10,6 +10,8 @@ The repo and Space still use the `Dream Customs` name for continuity, but future
10
 
11
  > 输入梦境,回答或跳过几个温和追问,最后得到一个和梦境细节相关的今日小 Tips。
12
 
 
 
13
  ## Deprecated Context
14
 
15
  Earlier implementation and smoke docs mention:
@@ -47,7 +49,7 @@ Build a guided dream interpretation app, not a diagnosis app, not a fortune-tell
47
 
48
  Final positioning:
49
 
50
- > 梦境问答台帮你一步步整理梦境疑惑:先记录梦,回答几个温和追问,再得到一个今天可以参考的小 Tips。
51
 
52
  ## Model Decision
53
 
@@ -82,6 +84,13 @@ Voice input is allowed, but current MiniCPM pair does not directly transcribe ra
82
  9. MiniCPM5-1B generates a final `TodayTipCard`.
83
  10. Gradio renders a mobile-readable result card and plain text.
84
 
 
 
 
 
 
 
 
85
  ## Required Output Fields
86
 
87
  - `dream_summary`
@@ -142,6 +151,7 @@ Likely code files to change:
142
  5. Render one clear final tip, not a multi-field customs certificate.
143
  6. Keep mobile readability.
144
  7. Run tests and local Gradio smoke before any Space sync.
 
145
 
146
  ## Repository Status
147
 
@@ -168,10 +178,15 @@ The Space defaults to `model` for both text and vision backends, so a configured
168
 
169
  The public Space can run on ZeroGPU after `dream_customs.zerogpu` registers the lightweight `@spaces.GPU` startup probe. Modal credits still pay for the hidden GPU backend; the ZeroGPU probe is only there to make the HF hardware setting valid for this Gradio frontend.
170
 
 
 
 
 
 
 
171
  ## Open Questions For User
172
 
173
  These do not block document alignment:
174
 
175
- - Should the public name remain `Dream Customs`, or should visible UI switch fully to `梦境问答台 / Dream QA`?
176
- - Should final demo language be Chinese-first, English-first, or bilingual?
177
  - Which ASR adapter is acceptable for the hackathon submission?
 
 
10
 
11
  > 输入梦境,回答或跳过几个温和追问,最后得到一个和梦境细节相关的今日小 Tips。
12
 
13
+ The public hackathon experience is English-first. Keep the visible `English / 中文` toggle so Chinese remains a first-class path, but do not make judges land on a Chinese-only first screen.
14
+
15
  ## Deprecated Context
16
 
17
  Earlier implementation and smoke docs mention:
 
49
 
50
  Final positioning:
51
 
52
+ > Dream QA helps you record a dream, answer or skip one gentle question, and leave with one grounded Today Tip.
53
 
54
  ## Model Decision
55
 
 
84
  9. MiniCPM5-1B generates a final `TodayTipCard`.
85
  10. Gradio renders a mobile-readable result card and plain text.
86
 
87
+ Language behavior:
88
+
89
+ - Default: English.
90
+ - Toggle: `中文`.
91
+ - The same intake/state/card pipeline serves both languages.
92
+ - MiniCPM prompts should instruct the selected output language instead of duplicating the product flow.
93
+
94
  ## Required Output Fields
95
 
96
  - `dream_summary`
 
151
  5. Render one clear final tip, not a multi-field customs certificate.
152
  6. Keep mobile readability.
153
  7. Run tests and local Gradio smoke before any Space sync.
154
+ 8. Run `python scripts/evaluate_today_tip_quality.py` before deployment.
155
 
156
  ## Repository Status
157
 
 
178
 
179
  The public Space can run on ZeroGPU after `dream_customs.zerogpu` registers the lightweight `@spaces.GPU` startup probe. Modal credits still pay for the hidden GPU backend; the ZeroGPU probe is only there to make the HF hardware setting valid for this Gradio frontend.
180
 
181
+ ## Resolved Decisions
182
+
183
+ - Public UI name: `Dream QA`, with `Dream Customs` retained as project/Space continuity.
184
+ - Language: English-first for the international hackathon, with Chinese available through an in-app toggle.
185
+ - Recommendation quality: deterministic Today Tip eval is required before deployment.
186
+
187
  ## Open Questions For User
188
 
189
  These do not block document alignment:
190
 
 
 
191
  - Which ASR adapter is acceptable for the hackathon submission?
192
+ - Should `refs/pr/*` cleanup be done after HF Space merges, or left for auditability?
docs/prd.md CHANGED
@@ -10,10 +10,12 @@ Users who wake up from vivid dreams may feel unsettled, curious, or mentally clu
10
 
11
  ### Proposed Solution
12
 
13
- Dream QA is a Gradio app that accepts voice, image, and text dream fragments, normalizes them into a structured dream intake, asks gentle follow-up questions, drafts a grounded interpretation, and ends with one `今日小 Tips`.
14
 
15
  The repository and Space may continue to use `Dream Customs` as the deployed project name, but the product experience should no longer be built around permits, contraband, or sealed pact cards.
16
 
 
 
17
  ### Success Criteria
18
 
19
  - A user can complete the core text-only Q&A flow in <= 90 seconds on the hosted demo.
@@ -22,6 +24,8 @@ The repository and Space may continue to use `Dream Customs` as the deployed pro
22
  - Final output gives exactly one primary today tip.
23
  - Ordinary cases contain no diagnostic, prophetic, or frightening language.
24
  - Distress-edge cases show a support note.
 
 
25
  - The app runs with total model parameters <= 32B and is packaged for Gradio/Hugging Face Space.
26
 
27
  ## 2. User Experience & Functionality
@@ -39,6 +43,7 @@ The repository and Space may continue to use `Dream Customs` as the deployed pro
39
  3. Demo viewer / judge.
40
  - Needs to understand the interaction in under one minute.
41
  - Looks for clear small-model fit and Gradio polish.
 
42
 
43
  ### User Stories
44
 
@@ -98,6 +103,18 @@ Acceptance criteria:
98
  - Result avoids medical, prophetic, or deterministic framing.
99
  - Today tip references a concrete dream anchor.
100
 
 
 
 
 
 
 
 
 
 
 
 
 
101
  ### Non-Goals
102
 
103
  - Medical diagnosis or therapy.
@@ -129,6 +146,7 @@ Prompts must:
129
  - Include a safety boundary for severe distress.
130
  - Keep final advice small enough to execute today.
131
  - Require at least one dream anchor in the final tip.
 
132
 
133
  ### Evaluation Strategy
134
 
@@ -147,6 +165,8 @@ Measure:
147
  - Dream-anchor grounding.
148
  - Practicality of today tip.
149
  - Ability to recover from missing modality.
 
 
150
 
151
  Pass thresholds:
152
 
@@ -156,6 +176,14 @@ Pass thresholds:
156
  - At least one concrete dream anchor in final result: >= 90%.
157
  - Human usefulness/delight rating on 5 demo cases: average >= 4/5.
158
 
 
 
 
 
 
 
 
 
159
  ## 4. Technical Specifications
160
 
161
  ### Architecture Overview
 
10
 
11
  ### Proposed Solution
12
 
13
+ Dream QA is a Gradio app that accepts voice, image, and text dream fragments, normalizes them into a structured dream intake, asks gentle follow-up questions, drafts a grounded interpretation, and ends with one Today Tip.
14
 
15
  The repository and Space may continue to use `Dream Customs` as the deployed project name, but the product experience should no longer be built around permits, contraband, or sealed pact cards.
16
 
17
+ The public demo defaults to English for the international hackathon audience. A visible language toggle preserves the Chinese experience.
18
+
19
  ### Success Criteria
20
 
21
  - A user can complete the core text-only Q&A flow in <= 90 seconds on the hosted demo.
 
24
  - Final output gives exactly one primary today tip.
25
  - Ordinary cases contain no diagnostic, prophetic, or frightening language.
26
  - Distress-edge cases show a support note.
27
+ - English is the default public language; Chinese remains available through one visible toggle.
28
+ - `python scripts/evaluate_today_tip_quality.py` passes before deployment.
29
  - The app runs with total model parameters <= 32B and is packaged for Gradio/Hugging Face Space.
30
 
31
  ## 2. User Experience & Functionality
 
43
  3. Demo viewer / judge.
44
  - Needs to understand the interaction in under one minute.
45
  - Looks for clear small-model fit and Gradio polish.
46
+ - Should land on an English first screen without needing translation.
47
 
48
  ### User Stories
49
 
 
103
  - Result avoids medical, prophetic, or deterministic framing.
104
  - Today tip references a concrete dream anchor.
105
 
106
+ #### Story 6: Bilingual Demo
107
+
108
+ As an international judge, I want the app to open in English, while Chinese users can still switch to 中文.
109
+
110
+ Acceptance criteria:
111
+
112
+ - Default UI title is `Dream QA`.
113
+ - A visible `English / 中文` control exists.
114
+ - English mode renders English labels and card headings.
115
+ - Chinese mode renders `梦境问答台` and Chinese Today Tip labels.
116
+ - Both modes use the same model/pipeline route.
117
+
118
  ### Non-Goals
119
 
120
  - Medical diagnosis or therapy.
 
146
  - Include a safety boundary for severe distress.
147
  - Keep final advice small enough to execute today.
148
  - Require at least one dream anchor in the final tip.
149
+ - Follow selected language: English by default, Chinese when selected.
150
 
151
  ### Evaluation Strategy
152
 
 
165
  - Dream-anchor grounding.
166
  - Practicality of today tip.
167
  - Ability to recover from missing modality.
168
+ - Old customs words do not appear in the Dream QA result: permit, contraband, clearance, sealed, pact.
169
+ - English default output does not contain Chinese UI labels.
170
 
171
  Pass thresholds:
172
 
 
176
  - At least one concrete dream anchor in final result: >= 90%.
177
  - Human usefulness/delight rating on 5 demo cases: average >= 4/5.
178
 
179
+ Current deterministic gate:
180
+
181
+ ```bash
182
+ python scripts/evaluate_today_tip_quality.py
183
+ ```
184
+
185
+ This gate covers 10 representative cases and should be expanded toward the 12-case target as image/voice remote smoke matures.
186
+
187
  ## 4. Technical Specifications
188
 
189
  ### Architecture Overview
docs/smoke/2026-06-08-post-refactor-polish-smoke.md ADDED
@@ -0,0 +1,137 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Dream QA Post-Refactor Polish Smoke - 2026-06-08
2
+
3
+ ## Scope
4
+
5
+ Follow-up polish after the Dream QA refactor:
6
+
7
+ - English-first public hackathon experience.
8
+ - Visible `English / 中文` language toggle.
9
+ - Chinese mode remains available.
10
+ - Deterministic Today Tip quality gate.
11
+ - Follow-up questions and Today Tips remain grounded in dream anchors.
12
+
13
+ ## Local Runtime
14
+
15
+ Command:
16
+
17
+ ```bash
18
+ GRADIO_SERVER_PORT=7862 .venv/bin/python app.py
19
+ ```
20
+
21
+ Observed:
22
+
23
+ ```text
24
+ Running on local URL: http://0.0.0.0:7862
25
+ ```
26
+
27
+ ## Local Config Check
28
+
29
+ Command:
30
+
31
+ ```bash
32
+ .venv/bin/python - <<'PY'
33
+ import json
34
+ from urllib.request import urlopen
35
+ with urlopen("http://127.0.0.1:7862/config", timeout=30) as response:
36
+ cfg = json.load(response)
37
+ print({"title": cfg.get("title"), "version": cfg.get("version"), "mode": cfg.get("mode"), "component_count": len(cfg.get("components", []))})
38
+ PY
39
+ ```
40
+
41
+ Observed config included:
42
+
43
+ ```text
44
+ title=Dream QA
45
+ version=4.44.1
46
+ mode=blocks
47
+ component_count=72
48
+ ```
49
+
50
+ The config also showed English default UI copy including `Dream QA`, `Record`, `Question`, `Interpret`, `Today Tip`, `Dream note`, `Try example`, `Continue`, `Language`, and choices `English` / `中文`.
51
+
52
+ ## Automated Checks
53
+
54
+ Command:
55
+
56
+ ```bash
57
+ .venv/bin/python -m pytest -q
58
+ ```
59
+
60
+ Observed:
61
+
62
+ ```text
63
+ 94 passed, 2 warnings
64
+ ```
65
+
66
+ Command:
67
+
68
+ ```bash
69
+ .venv/bin/python scripts/evaluate_today_tip_quality.py
70
+ ```
71
+
72
+ Observed:
73
+
74
+ ```json
75
+ {
76
+ "case_count": 10,
77
+ "failures": {},
78
+ "passes": true
79
+ }
80
+ ```
81
+
82
+ Command:
83
+
84
+ ```bash
85
+ git diff --check
86
+ ```
87
+
88
+ Observed: no output.
89
+
90
+ ## Action Smoke
91
+
92
+ English default input:
93
+
94
+ ```text
95
+ I dreamed I was in an old apartment building. The elevator button melted like wax, and the floor number stayed on 14. I woke up anxious.
96
+ ```
97
+
98
+ Observed action payload:
99
+
100
+ ```text
101
+ submit_status=ask
102
+ language=en
103
+ question=When you think about the elevator and the melted button, what real thing today feels hard to start?
104
+ tip_status=tip
105
+ title=Today Tip
106
+ has_elevator=True
107
+ has_old_words=False
108
+ ```
109
+
110
+ Chinese toggle input:
111
+
112
+ ```text
113
+ 我梦到电梯按钮融化,楼层数字停在 14。
114
+ ```
115
+
116
+ Observed action payload:
117
+
118
+ ```text
119
+ zh_status=tip
120
+ zh_title=今日小 Tips
121
+ has_zh_anchor=True
122
+ ```
123
+
124
+ ## Browser Notes
125
+
126
+ - In-app Browser reached `http://127.0.0.1:7862/` and saw title `Dream QA`, but the DOM snapshot stayed at Gradio's `加载中...` placeholder during this run.
127
+ - Chrome extension control returned a closed native pipe while opening the same local URL.
128
+ - Because both browser automation surfaces were unstable, no visual screenshot claim is made here. The local Gradio `/config`, app build check, action flow, full pytest suite, and deterministic quality evaluator passed.
129
+
130
+ ## Deployment Status
131
+
132
+ Not yet deployed in this smoke file. After commit and GitHub/HF sync, update this file or add a follow-up remote smoke section with:
133
+
134
+ - GitHub PR URL and merge commit.
135
+ - HF Space PR discussion URL and Space `main` SHA.
136
+ - Public Space `/config` result.
137
+ - Public app flow check.
docs/spec.md CHANGED
@@ -4,7 +4,9 @@ Last updated: 2026-06-08
4
 
5
  ## 1. One-Line Concept
6
 
7
- 梦境问答台帮用户循序渐进地理解昨晚的梦:用户用文字、图片或语音记录梦境,系统先追问关键细节,再给出一个非诊断、非宿命论的解读草稿,最后生成一个温和的今日小 Tips。
 
 
8
 
9
  ## 2. Contest Fit
10
 
@@ -29,6 +31,7 @@ The user wakes up after a vivid or unsettling dream and wants help making sense
29
  5. Ground every final answer in concrete dream details.
30
  6. End with one small today tip, not a pile of advice.
31
  7. Keep the MVP stable enough for Gradio Space and demo-video recording.
 
32
 
33
  ## 5. User Inputs
34
 
@@ -61,6 +64,10 @@ The user speaks the dream immediately after waking.
61
 
62
  Implementation note: MiniCPM-V-4.6 and MiniCPM5-1B are not raw audio ASR models. Voice input needs a small transcription adapter. The adapter only converts audio to text; dream understanding remains MiniCPM-driven.
63
 
 
 
 
 
64
  ## 6. Data Model
65
 
66
  The refactor should move from `DreamIntake -> PactCard` toward:
@@ -124,7 +131,7 @@ Responsibilities:
124
 
125
  - Read sketches, notes, screenshots, and photos.
126
  - Extract objects, locations, visible text, colors, spatial relationships, and emotional cues.
127
- - Return concise JSON-compatible visual clues.
128
 
129
  Non-responsibilities:
130
 
@@ -141,9 +148,11 @@ Responsibilities:
141
  - Summarize the dream without overexplaining.
142
  - Infer or ask the user's main question.
143
  - Ask 1-3 gentle follow-up questions.
 
144
  - Draft a non-certain interpretation grounded in dream anchors.
145
  - Generate exactly one primary today tip, with optional tiny action and caring note.
146
  - Keep tone playful, gentle, and non-clinical.
 
147
 
148
  ## 8. MVP User Flow
149
 
@@ -177,6 +186,7 @@ The final result must:
177
  - Avoid diagnosis, prophecy, or frightening certainty.
178
  - Avoid generic advice that could fit any dream.
179
  - Keep the today tip small enough to try today.
 
180
 
181
  ## 10. UX Structure
182
 
@@ -192,7 +202,7 @@ Inputs:
192
 
193
  Primary action:
194
 
195
- - "继续解梦" / "Continue"
196
 
197
  ### Screen 2: Ask
198
 
@@ -218,6 +228,14 @@ Content:
218
  - One primary today tip.
219
  - Optional tiny action.
220
  - Optional caring note.
 
 
 
 
 
 
 
 
221
  - Copy text button.
222
  - Reset action.
223
 
 
4
 
5
  ## 1. One-Line Concept
6
 
7
+ Dream QA helps users unpack last night's dream step by step: they record a dream with text, image, or voice; the system asks one grounded follow-up question; then it gives a non-diagnostic, non-fatalistic interpretation draft and one gentle Today Tip.
8
+
9
+ The public hackathon demo is English-first because the event is international. Chinese remains available through the in-app `English / 中文` toggle.
10
 
11
  ## 2. Contest Fit
12
 
 
31
  5. Ground every final answer in concrete dream details.
32
  6. End with one small today tip, not a pile of advice.
33
  7. Keep the MVP stable enough for Gradio Space and demo-video recording.
34
+ 8. Default to English for public judging; keep Chinese as a first-class toggle.
35
 
36
  ## 5. User Inputs
37
 
 
64
 
65
  Implementation note: MiniCPM-V-4.6 and MiniCPM5-1B are not raw audio ASR models. Voice input needs a small transcription adapter. The adapter only converts audio to text; dream understanding remains MiniCPM-driven.
66
 
67
+ ### 5.4 Language Input
68
+
69
+ The app defaults to English. A visible language toggle allows `中文`. The same `DreamQuestionIntake -> DreamQAState -> TodayTipCard` pipeline is used for both languages; only prompt instructions, UI labels, and rendered output labels change.
70
+
71
  ## 6. Data Model
72
 
73
  The refactor should move from `DreamIntake -> PactCard` toward:
 
131
 
132
  - Read sketches, notes, screenshots, and photos.
133
  - Extract objects, locations, visible text, colors, spatial relationships, and emotional cues.
134
+ - Return concise JSON-compatible visual witness reports.
135
 
136
  Non-responsibilities:
137
 
 
148
  - Summarize the dream without overexplaining.
149
  - Infer or ask the user's main question.
150
  - Ask 1-3 gentle follow-up questions.
151
+ - Prefer exactly one visible follow-up question in the public MVP, with another-angle actions available after the first result.
152
  - Draft a non-certain interpretation grounded in dream anchors.
153
  - Generate exactly one primary today tip, with optional tiny action and caring note.
154
  - Keep tone playful, gentle, and non-clinical.
155
+ - Follow the selected language: English by default, Chinese when selected.
156
 
157
  ## 8. MVP User Flow
158
 
 
186
  - Avoid diagnosis, prophecy, or frightening certainty.
187
  - Avoid generic advice that could fit any dream.
188
  - Keep the today tip small enough to try today.
189
+ - Use English UI/output labels by default and Chinese labels only when `中文` is selected.
190
 
191
  ## 10. UX Structure
192
 
 
202
 
203
  Primary action:
204
 
205
+ - "Continue"
206
 
207
  ### Screen 2: Ask
208
 
 
228
  - One primary today tip.
229
  - Optional tiny action.
230
  - Optional caring note.
231
+
232
+ ### Screen 0: Language
233
+
234
+ Default: `English`.
235
+
236
+ Secondary option: `中文`.
237
+
238
+ The toggle should be visible but not distract from the dream composer.
239
  - Copy text button.
240
  - Reset action.
241
 
docs/superpowers/plans/2026-06-08-post-refactor-polish.md ADDED
@@ -0,0 +1,254 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Post-Refactor Polish Implementation Plan
2
+
3
+ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
+
5
+ **Goal:** Finish the highest-value Dream QA follow-up after the refactor: align GitHub/HF state, verify the deployed flow, make English the default hackathon experience with a Chinese toggle, and improve recommendation/follow-up quality.
6
+
7
+ **Architecture:** Keep the current Dream QA state machine. Add a lightweight language layer around UI copy, prompt language, card rendering, and fake/model fallbacks rather than cloning the app into separate English and Chinese flows. Add quality gates as deterministic tests and a small evaluator script so product quality does not regress after deployment.
8
+
9
+ **Tech Stack:** Python 3.9, Gradio 4.44.1, Pydantic, MiniCPM5-1B text route, MiniCPM-V-4.6 vision witness route, Hugging Face Space, GitHub PR workflow.
10
+
11
+ ---
12
+
13
+ ## Current Evidence
14
+
15
+ - Current branch: `feature/dream-customs-ui-voice-settings`.
16
+ - Current latest commit: `122266f feat: refactor dream qa flow`.
17
+ - GitHub `main` previously merged only through `ff796db`; this branch has post-merge commits that still need a GitHub PR/merge.
18
+ - HF Space PR #16 has already merged this branch to Space `main` at `19c54925bbb525405bc2540391a434ddaeba4139`.
19
+ - Public `/config` returns title `梦境问答台`, Gradio `4.44.1`, and 70 components.
20
+ - Local checks before this plan: `91 passed, 2 warnings`; `git diff --check` clean.
21
+
22
+ ## Task 1: GitHub Main Alignment
23
+
24
+ **Files:**
25
+ - No source edits required.
26
+ - Record result in `docs/smoke/2026-06-08-post-refactor-polish-smoke.md`.
27
+
28
+ - [ ] **Step 1: Verify branch relationship**
29
+
30
+ Run:
31
+
32
+ ```bash
33
+ git fetch origin
34
+ git log --oneline origin/main..HEAD
35
+ ```
36
+
37
+ Expected: shows the post-merge Dream QA commits that are on `feature/dream-customs-ui-voice-settings` but not `origin/main`.
38
+
39
+ - [ ] **Step 2: Create or update GitHub PR**
40
+
41
+ Run:
42
+
43
+ ```bash
44
+ gh pr list --head feature/dream-customs-ui-voice-settings --base main --json number,url,state,title
45
+ ```
46
+
47
+ If no open PR exists, create one:
48
+
49
+ ```bash
50
+ gh pr create \
51
+ --base main \
52
+ --head feature/dream-customs-ui-voice-settings \
53
+ --title "Dream QA post-refactor polish" \
54
+ --body-file /tmp/dream-qa-post-refactor-pr.md
55
+ ```
56
+
57
+ - [ ] **Step 3: Merge GitHub PR**
58
+
59
+ Use the authenticated browser/CLI path available in the session. Verify with:
60
+
61
+ ```bash
62
+ gh pr view <number> --json state,mergedAt,mergeCommit,url
63
+ ```
64
+
65
+ Expected: `state` is `MERGED`.
66
+
67
+ ## Task 2: Deployed Space Smoke
68
+
69
+ **Files:**
70
+ - Create or update: `docs/smoke/2026-06-08-post-refactor-polish-smoke.md`
71
+ - Test: no new unit test required for this task.
72
+
73
+ - [ ] **Step 1: Verify deployed config**
74
+
75
+ Run:
76
+
77
+ ```bash
78
+ .venv/bin/python - <<'PY'
79
+ import json
80
+ from urllib.request import urlopen
81
+ with urlopen("https://build-small-hackathon-dream-customs.hf.space/config", timeout=30) as response:
82
+ cfg = json.load(response)
83
+ print({"title": cfg.get("title"), "version": cfg.get("version"), "mode": cfg.get("mode"), "component_count": len(cfg.get("components", []))})
84
+ PY
85
+ ```
86
+
87
+ Expected: public app config responds with Dream QA metadata.
88
+
89
+ - [ ] **Step 2: Run a browser smoke on the public app**
90
+
91
+ Use Chrome or in-app browser to open:
92
+
93
+ ```text
94
+ https://build-small-hackathon-dream-customs.hf.space/?v=<space-main-sha>
95
+ ```
96
+
97
+ Submit an English dream, answer or skip one follow-up, and verify the result:
98
+
99
+ ```text
100
+ I dreamed I was in an old apartment building. The elevator button melted like wax, and the floor number stayed on 14. I woke up anxious.
101
+ ```
102
+
103
+ Expected: the app reaches a Today Tip card, references elevator/button/floor 14, and does not show old permit/contraband/seal language.
104
+
105
+ ## Task 3: English-First Bilingual Experience
106
+
107
+ **Files:**
108
+ - Modify: `dream_customs/ui/copy.py`
109
+ - Modify: `dream_customs/ui/app.py`
110
+ - Modify: `dream_customs/ui/actions.py`
111
+ - Modify: `dream_customs/render.py`
112
+ - Modify: `dream_customs/prompts.py`
113
+ - Modify: `dream_customs/pipeline.py`
114
+ - Modify: `dream_customs/models.py`
115
+ - Test: `tests/test_dream_qa_refactor.py`
116
+ - Test: `tests/test_ui_actions.py`
117
+ - Test: `tests/test_render.py`
118
+
119
+ - [x] **Step 1: Add language copy contract**
120
+
121
+ Add a `LANGUAGE_OPTIONS`, `DEFAULT_LANGUAGE = "en"`, and copy helpers in `dream_customs/ui/copy.py`. English copy must be the default; Chinese copy must remain available through a visible control.
122
+
123
+ - [x] **Step 2: Thread language through actions**
124
+
125
+ Add `language` to `initial_mobile_state`, `submit_dream_action`, `answer_to_card_action`, `skip_to_card_action`, `revise_card_action`, `reset_mobile_action`, `_view_payload`, and `_seal_view`.
126
+
127
+ - [x] **Step 3: Localize prompts and fallbacks**
128
+
129
+ Pass `language` into `dream_qa_state_prompt`, `today_tip_prompt`, `generate_today_tip`, and fallback Today Tip helpers. English output must avoid Chinese labels by default; Chinese mode must keep the current gentle Chinese tone.
130
+
131
+ - [x] **Step 4: Add visible UI language toggle**
132
+
133
+ Add a top-level `gr.Radio` or segmented-style control with English selected by default and Chinese as the second option. The visible first screen should read as English for hackathon judges, while the Chinese option remains one click away.
134
+
135
+ - [x] **Step 5: Add tests**
136
+
137
+ Add tests proving:
138
+
139
+ ```python
140
+ assert DEFAULT_LANGUAGE == "en"
141
+ assert "Dream QA" in APP_COPY["en"]["title"]
142
+ assert "梦境问答台" in APP_COPY["zh"]["title"]
143
+ ```
144
+
145
+ Also test that a default `submit_dream_action(..., text_backend="demo") -> skip_to_card_action(...)` returns English labels and English card text, while `language="zh"` keeps Chinese output.
146
+
147
+ ## Task 4: Today Tip Quality Gate
148
+
149
+ **Files:**
150
+ - Create: `tests/fixtures/today_tip_eval_cases.json`
151
+ - Create: `scripts/evaluate_today_tip_quality.py`
152
+ - Test: `tests/test_today_tip_quality_eval.py`
153
+
154
+ - [x] **Step 1: Add 10 representative cases**
155
+
156
+ Cover elevator, exam, train, phone full of water, missing shoes, locked room, rain note, abstract image clue, voice fragment, and severe distress.
157
+
158
+ - [x] **Step 2: Implement deterministic evaluator**
159
+
160
+ The evaluator must fail when:
161
+
162
+ - `today_tip` does not reference at least one required anchor.
163
+ - final text contains old customs words: `permit`, `contraband`, `clearance`, `sealed`, `pact`.
164
+ - ordinary cases include diagnosis, prophecy, or frightening certainty.
165
+ - English default output contains Chinese UI labels.
166
+
167
+ - [x] **Step 3: Wire test coverage**
168
+
169
+ Run:
170
+
171
+ ```bash
172
+ .venv/bin/python scripts/evaluate_today_tip_quality.py
173
+ .venv/bin/python -m pytest tests/test_today_tip_quality_eval.py -q
174
+ ```
175
+
176
+ Expected: evaluator prints zero failures and pytest passes.
177
+
178
+ ## Task 5: MiniCPM Follow-Up Quality
179
+
180
+ **Files:**
181
+ - Modify: `dream_customs/prompts.py`
182
+ - Modify: `dream_customs/pipeline.py`
183
+ - Modify: `dream_customs/models.py`
184
+ - Test: `tests/test_pipeline.py`
185
+ - Test: `tests/test_dream_qa_refactor.py`
186
+
187
+ - [x] **Step 1: Strengthen follow-up prompt**
188
+
189
+ Update the question prompt to ask exactly one useful follow-up in the selected language, grounded in dream anchors and user mood.
190
+
191
+ - [x] **Step 2: Add fallback question quality guard**
192
+
193
+ Ensure fallback questions cite an anchor and avoid generic "how do you feel" unless tied to a detail.
194
+
195
+ - [x] **Step 3: Add tests**
196
+
197
+ Add tests proving an English elevator dream gets an English question mentioning elevator/button/floor 14, and a Chinese dream in `language="zh"` gets a Chinese question with the same anchors.
198
+
199
+ ## Task 6: Documentation And Deployment
200
+
201
+ **Files:**
202
+ - Modify: `README.md`
203
+ - Modify: `PRODUCT.md`
204
+ - Modify: `docs/spec.md`
205
+ - Modify: `docs/prd.md`
206
+ - Modify: `docs/handoff.md`
207
+ - Modify: `docs/superpowers/plans/2026-06-08-post-refactor-polish.md`
208
+ - Create/update: `docs/smoke/2026-06-08-post-refactor-polish-smoke.md`
209
+
210
+ - [x] **Step 1: Update docs**
211
+
212
+ Docs must state:
213
+
214
+ - English is the default public hackathon experience.
215
+ - Chinese is available through an in-app language toggle.
216
+ - MiniCPM5 handles follow-up and Today Tip drafting.
217
+ - MiniCPM-V handles visual witness reports.
218
+ - The app remains non-diagnostic and not therapy.
219
+
220
+ - [x] **Step 2: Verify locally**
221
+
222
+ Run:
223
+
224
+ ```bash
225
+ .venv/bin/python -m pytest -q
226
+ .venv/bin/python scripts/evaluate_today_tip_quality.py
227
+ git diff --check
228
+ ```
229
+
230
+ - [ ] **Step 3: Commit and push**
231
+
232
+ Run:
233
+
234
+ ```bash
235
+ git add <changed files>
236
+ git commit -m "feat: polish dream qa post-refactor flow"
237
+ git push origin feature/dream-customs-ui-voice-settings
238
+ ```
239
+
240
+ - [ ] **Step 4: Sync HF Space**
241
+
242
+ Use `huggingface_hub.upload_folder(..., create_pr=True)` if direct push is not appropriate. Merge the Space PR in Chrome and verify:
243
+
244
+ ```bash
245
+ git ls-remote space refs/heads/main
246
+ ```
247
+
248
+ Expected: Space `main` advances to the deployment commit.
249
+
250
+ ## Self-Review
251
+
252
+ - Spec coverage: covers GitHub main alignment, deployed smoke, English-first toggle, quality evaluation, MiniCPM follow-up quality, docs, tests, commit, push, and HF Space sync.
253
+ - Placeholder scan: no TBD/TODO placeholders remain.
254
+ - Type consistency: language values use `"en"` and `"zh"` across UI, actions, prompts, pipeline, tests, and docs.
dream_customs/app_logic.py CHANGED
@@ -229,6 +229,24 @@ def _notice(session: CustomsSession) -> str:
229
  return "<div class='dc-inline-notice'>File any fragment. Text-only stays available if image or voice fails.</div>"
230
 
231
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
232
  def _view(
233
  session: CustomsSession,
234
  text_backend: str = DEFAULT_TEXT_BACKEND,
@@ -437,4 +455,4 @@ def run_customs_once(
437
  "negotiation": negotiation,
438
  "today_tip": card.model_dump(),
439
  }
440
- return negotiation_text, card.to_plain_text(), html, json.dumps(debug, ensure_ascii=False, indent=2)
 
229
  return "<div class='dc-inline-notice'>File any fragment. Text-only stays available if image or voice fails.</div>"
230
 
231
 
232
+ def _today_tip_plain_text(card) -> str:
233
+ lines = [
234
+ "Today Tip",
235
+ f"Dream summary: {card.dream_summary}",
236
+ f"Question: {card.main_question}",
237
+ f"Dream anchors: {', '.join(card.dream_anchors)}",
238
+ f"Interpretation: {card.interpretation}",
239
+ f"Today Tip: {card.today_tip}",
240
+ ]
241
+ if card.tiny_action:
242
+ lines.append(f"Tiny action: {card.tiny_action}")
243
+ if card.caring_note:
244
+ lines.append(f"Caring note: {card.caring_note}")
245
+ if card.safety_note:
246
+ lines.append(f"Safety note: {card.safety_note}")
247
+ return "\n".join(lines)
248
+
249
+
250
  def _view(
251
  session: CustomsSession,
252
  text_backend: str = DEFAULT_TEXT_BACKEND,
 
455
  "negotiation": negotiation,
456
  "today_tip": card.model_dump(),
457
  }
458
+ return negotiation_text, _today_tip_plain_text(card), html, json.dumps(debug, ensure_ascii=False, indent=2)
dream_customs/models.py CHANGED
@@ -38,6 +38,9 @@ class FakeASRClient:
38
 
39
 
40
  class FakeTextClient:
 
 
 
41
  def generate_brief(self, prompt: str) -> DreamBrief:
42
  return DreamBrief(
43
  anchors=["elevator", "melted buttons", "floor 14"],
@@ -49,6 +52,16 @@ class FakeTextClient:
49
  )
50
 
51
  def generate_negotiation(self, prompt: str) -> Dict[str, Any]:
 
 
 
 
 
 
 
 
 
 
52
  return {
53
  "visitor_name": "迟到的电梯",
54
  "questions": [
@@ -72,6 +85,24 @@ class FakeTextClient:
72
  )
73
 
74
  def generate_today_tip(self, prompt: str) -> TodayTipCard:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
75
  return TodayTipCard(
76
  dream_summary="你梦见电梯迟迟不到,按钮像蜡一样融化,楼层数字停在 14。",
77
  main_question="这个梦是否和最近卡在开始之前有关?",
 
38
 
39
 
40
  class FakeTextClient:
41
+ def _wants_zh(self, prompt: str) -> bool:
42
+ return "自然中文" in prompt or "所有面向用户的字段都用自然中文" in prompt
43
+
44
  def generate_brief(self, prompt: str) -> DreamBrief:
45
  return DreamBrief(
46
  anchors=["elevator", "melted buttons", "floor 14"],
 
52
  )
53
 
54
  def generate_negotiation(self, prompt: str) -> Dict[str, Any]:
55
+ if not self._wants_zh(prompt):
56
+ return {
57
+ "visitor_name": "Late elevator",
58
+ "questions": [
59
+ "When you think about the elevator and the melted button, what real thing today feels hard to start?",
60
+ "Was the strongest feeling urgency, tiredness, curiosity, or something else?",
61
+ "Would a practical suggestion, a tiny action, or a caring sentence help most today?",
62
+ ],
63
+ "tone_note": "The dream may be asking you to make the first step smaller, not to finish everything at once.",
64
+ }
65
  return {
66
  "visitor_name": "迟到的电梯",
67
  "questions": [
 
85
  )
86
 
87
  def generate_today_tip(self, prompt: str) -> TodayTipCard:
88
+ if not self._wants_zh(prompt):
89
+ return TodayTipCard(
90
+ dream_summary="You dreamed that an elevator would not arrive, its button melted like wax, and floor 14 stayed frozen.",
91
+ main_question="Could this dream connect to feeling stuck before starting?",
92
+ dream_anchors=["elevator", "melted button", "floor 14"],
93
+ followup_questions=[
94
+ "Was the strongest feeling urgency, fear, or absurdity?",
95
+ "Is there one real task that feels late before it has even started?",
96
+ ],
97
+ user_answers=[],
98
+ interpretation=(
99
+ "Maybe this dream is not predicting lateness. It turns the feeling of being stuck before "
100
+ "starting into an elevator paused at floor 14."
101
+ ),
102
+ today_tip="For today, press one very small elevator button: open the task, without asking yourself to arrive yet.",
103
+ tiny_action="Spend five minutes writing which floor you want to stop at first today.",
104
+ caring_note="You can move one floor at a time; the whole building does not need to be solved this morning.",
105
+ )
106
  return TodayTipCard(
107
  dream_summary="你梦见电梯迟迟不到,按钮像蜡一样融化,楼层数字停在 14。",
108
  main_question="这个梦是否和最近卡在开始之前有关?",
dream_customs/pipeline.py CHANGED
@@ -19,6 +19,14 @@ from dream_customs.safety import needs_escalation, safety_note
19
  from dream_customs.schema import CustomsSession, DreamIntake, DreamQAState, EvidenceItem, PactCard, TimelineEvent, TodayTipCard
20
 
21
 
 
 
 
 
 
 
 
 
22
  def build_intake(
23
  dream_text: str = "",
24
  voice_transcript: str = "",
@@ -142,7 +150,9 @@ def _extract_dream_anchors(intake: DreamIntake) -> List[str]:
142
  r"\b([a-z][a-z'-]+)\s+("
143
  r"paper|papers|promise|promises|window|windows|suitcase|suitcases|"
144
  r"clerk|clerks|sunrise|elevator|elevators|button|buttons|hallway|"
145
- r"gate|gates|floor|floors|stamp|stamps|number|numbers"
 
 
146
  r")\b"
147
  )
148
  for match in pair_pattern.finditer(text):
@@ -152,7 +162,8 @@ def _extract_dream_anchors(intake: DreamIntake) -> List[str]:
152
  candidates.append(phrase)
153
 
154
  noun_pattern = re.compile(
155
- r"\b(customs|suitcase|clerk|sunrise|elevator|button|hallway|gate|stamp|number|floor)\b"
 
156
  )
157
  candidates.extend(match.group(1) for match in noun_pattern.finditer(text))
158
  candidates.extend(clue.lower() for clue in intake.visual_clues if clue.strip())
@@ -160,50 +171,63 @@ def _extract_dream_anchors(intake: DreamIntake) -> List[str]:
160
  return _dedupe_preserve_order(candidates)[:5]
161
 
162
 
163
- def _primary_anchor(intake: DreamIntake) -> str:
164
  anchors = _extract_dream_anchors(intake)
165
- return anchors[0] if anchors else "梦里的那个细节"
 
 
166
 
167
 
168
- def _secondary_anchor(intake: DreamIntake) -> str:
169
  anchors = _extract_dream_anchors(intake)
170
- return anchors[1] if len(anchors) > 1 else _primary_anchor(intake)
171
 
172
 
173
  def _title_anchor(text: str) -> str:
174
  return " ".join(part.capitalize() for part in text.split())
175
 
176
 
177
- def _summary_from_intake(intake: DreamIntake) -> str:
178
  merged = intake.dream_text.strip() or intake.voice_transcript.strip()
179
  if not merged and intake.visual_clues:
180
- merged = "、".join(intake.visual_clues[:3])
181
  if not merged:
182
- return "你记录了一个还在整理中的梦。"
183
  clean = re.sub(r"\s+", " ", merged).strip()
184
  if len(clean) > 72:
185
  clean = clean[:69].rstrip() + "..."
 
 
186
  return f"你梦见{clean}" if not clean.startswith(("你", "我", "I ", "i ")) else clean
187
 
188
 
189
- def _main_question_from_intake(intake: DreamIntake) -> str:
190
  if intake.main_question.strip():
191
  return intake.main_question.strip()
192
- primary = _primary_anchor(intake)
 
 
193
  return f"这个梦里的「{primary}」可能在提醒我什么?"
194
 
195
 
196
- def _fallback_interpretation(intake: DreamIntake) -> str:
197
- primary = _primary_anchor(intake)
198
- secondary = _secondary_anchor(intake)
 
 
 
 
 
199
  return (
200
  f"也许这个梦不是在给你一个确定答案,而是把「{primary}」和「{secondary}」放到一起,"
201
  "提醒你先看见今天最卡住的一小处。"
202
  )
203
 
204
 
205
- def _grounded_today_tip(intake: DreamIntake) -> str:
206
- primary = _primary_anchor(intake)
 
 
207
  return f"今天先从「{primary}」借一个动作:只做最小的第一步,不急着把整件事完成。"
208
 
209
 
@@ -352,20 +376,27 @@ def _grounded_alliance_reading(intake: DreamIntake) -> str:
352
  )
353
 
354
 
355
- def _grounded_question(intake: DreamIntake, question: str) -> str:
356
  anchors = _extract_dream_anchors(intake)
357
  if not anchors or _text_uses_anchor(question, anchors):
358
  return question
359
- primary = _primary_anchor(intake)
360
- secondary = _secondary_anchor(intake)
 
 
 
 
 
361
  return (
362
  f"当你想到「{primary}」和「{secondary}」时,今天有没有一件真实的小事,"
363
  "你希望它变得更容易开始?"
364
  )
365
 
366
 
367
- def _grounded_followup_question(intake: DreamIntake) -> str:
368
- primary = _primary_anchor(intake)
 
 
369
  return f"如果「{primary}」能递给你一个更小的第一步,今天那一步会是什么?"
370
 
371
 
@@ -446,11 +477,16 @@ def intake_from_modalities(
446
  )
447
 
448
 
449
- def build_qa_state(intake: DreamIntake, questions: Optional[List[str]] = None, answers: Optional[List[str]] = None) -> DreamQAState:
 
 
 
 
 
450
  anchors = _extract_dream_anchors(intake)
451
  return DreamQAState(
452
- dream_summary=_summary_from_intake(intake),
453
- main_question=_main_question_from_intake(intake),
454
  dream_anchors=anchors,
455
  followup_questions=questions or [],
456
  user_answers=answers or [],
@@ -458,37 +494,50 @@ def build_qa_state(intake: DreamIntake, questions: Optional[List[str]] = None, a
458
  )
459
 
460
 
461
- def _polish_today_tip(card: TodayTipCard, intake: DreamIntake, answers: str = "") -> TodayTipCard:
462
  polished = card.model_copy(deep=True)
463
- anchors = polished.dream_anchors or _extract_dream_anchors(intake)
 
 
 
 
 
464
  if not anchors:
465
- anchors = [_primary_anchor(intake)]
466
  polished.dream_anchors = anchors
467
  if not polished.dream_summary.strip():
468
- polished.dream_summary = _summary_from_intake(intake)
469
  if not polished.main_question.strip():
470
- polished.main_question = _main_question_from_intake(intake)
471
  if not polished.interpretation.strip() or not _anchor_in_text(polished.interpretation, anchors):
472
- polished.interpretation = _fallback_interpretation(intake)
473
  generic_tip_markers = ["drink water", "hydrate", "多休息", "保持积极", "take a walk"]
474
  if (
475
  not polished.today_tip.strip()
476
  or any(marker in polished.today_tip.lower() for marker in generic_tip_markers)
477
  or not _anchor_in_text(polished.today_tip, anchors)
478
  ):
479
- polished.today_tip = _grounded_today_tip(intake)
480
  if not polished.tiny_action.strip():
481
- polished.tiny_action = f"用 5 分钟写下:今天和「{anchors[0]}」有关的第一小步是什么?"
 
 
 
482
  if not polished.caring_note.strip():
483
- polished.caring_note = "你不需要一醒来就解决整个梦,先把一个细节��亮就很好。"
 
 
 
 
484
  merged = "\n".join([intake.merged_text(), answers or ""])
485
  polished.safety_note = safety_note() if needs_escalation(merged) else ""
486
  return polished
487
 
488
 
489
- def generate_today_tip(intake: DreamIntake, answers: str, text_client) -> TodayTipCard:
490
- qa_state = build_qa_state(intake, answers=[answer for answer in [answers] if answer])
491
- prompt = today_tip_prompt(qa_state)
 
492
  try:
493
  if hasattr(text_client, "generate_today_tip"):
494
  card = text_client.generate_today_tip(prompt)
@@ -513,10 +562,10 @@ def generate_today_tip(intake: DreamIntake, answers: str, text_client) -> TodayT
513
  dream_anchors=qa_state.dream_anchors,
514
  followup_questions=qa_state.followup_questions,
515
  user_answers=qa_state.user_answers,
516
- interpretation=_fallback_interpretation(intake),
517
- today_tip=_grounded_today_tip(intake),
518
- )
519
- return _polish_today_tip(card, intake, answers)
520
 
521
 
522
  def generate_negotiation(intake: DreamIntake, text_client) -> Dict:
@@ -579,13 +628,18 @@ def _supports_model_led_pact(text_client) -> bool:
579
  )
580
 
581
 
582
- def create_session() -> CustomsSession:
 
583
  return CustomsSession(
584
  events=[
585
  TimelineEvent(
586
  role="system",
587
- title="梦境问答台已打开",
588
- body="先记录一个梦境片段。文字永远可用,图片和语音会变成同一个梦境 intake 的线索。",
 
 
 
 
589
  status="ready",
590
  )
591
  ]
@@ -640,18 +694,34 @@ def add_evidence(
640
  mood: str = "",
641
  vision_client=None,
642
  asr_client=None,
 
643
  ) -> CustomsSession:
 
644
  next_session = session.model_copy(deep=True)
645
  added_items: List[EvidenceItem] = []
646
 
647
  if dream_text and dream_text.strip():
648
  clean_text = dream_text.strip()
649
  next_session.intake.dream_text = _append_text(next_session.intake.dream_text, clean_text)
650
- added_items.append(EvidenceItem(type="text", label="Dream note", status="selected", content=clean_text))
 
 
 
 
 
 
 
651
 
652
  if mood and mood.strip() and mood.strip() != next_session.intake.mood:
653
  next_session.intake.mood = mood.strip()
654
- added_items.append(EvidenceItem(type="mood", label=f"Mood: {mood.strip()}", status="selected", content=mood.strip()))
 
 
 
 
 
 
 
655
 
656
  if image_path:
657
  clues: List[str] = []
@@ -666,7 +736,7 @@ def add_evidence(
666
  added_items.append(
667
  EvidenceItem(
668
  type="image",
669
- label=f"Image clues ({len(clues)})",
670
  status="extracted",
671
  content=", ".join(clues),
672
  source_path=image_path,
@@ -676,10 +746,15 @@ def add_evidence(
676
  added_items.append(
677
  EvidenceItem(
678
  type="image",
679
- label="Image evidence",
680
  status="failed",
681
  source_path=image_path,
682
- error=error or "No visual clues extracted. Continue with text or voice.",
 
 
 
 
 
683
  )
684
  )
685
 
@@ -696,7 +771,7 @@ def add_evidence(
696
  added_items.append(
697
  EvidenceItem(
698
  type="audio",
699
- label="Voice transcript",
700
  status="extracted",
701
  content=clean_transcript,
702
  source_path=audio_path,
@@ -706,52 +781,78 @@ def add_evidence(
706
  added_items.append(
707
  EvidenceItem(
708
  type="audio",
709
- label="Voice evidence",
710
  status="failed",
711
  source_path=audio_path,
712
- error=error or "No transcript returned. Continue by typing the fragment.",
 
 
 
 
 
713
  )
714
  )
715
 
716
  if not added_items:
717
  next_session.phase = "error"
718
  next_session.events.append(
719
- _event("error", "还没有梦境材料", "请先添加文字、图片或语音;text-only 路径始终可用。", status="empty")
 
 
 
 
 
 
 
720
  )
721
  return next_session
722
 
723
  next_session.evidence_items.extend(added_items)
724
  next_session.phase = "record"
725
- next_session.qa_state = build_qa_state(next_session.intake)
726
  summary = "\n".join(f"{item.label}: {item.content or item.error}" for item in added_items)
727
- next_session.events.append(_event("user", "梦境已记录", summary, status="record"))
 
 
728
  _record_safety(next_session)
729
  return next_session
730
 
731
 
732
- def ask_questions(session: CustomsSession, text_client, force_another: bool = False) -> CustomsSession:
 
733
  next_session = session.model_copy(deep=True)
734
  if not next_session.intake.merged_text():
735
  next_session.phase = "error"
736
  next_session.events.append(
737
- _event("error", "还没有梦境记录", "请先添加一个梦境片段,再进入追问。", status="empty")
 
 
 
 
 
 
 
738
  )
739
  return next_session
740
 
741
  prompt = (
742
- followup_question_prompt(next_session.intake, next_session.question_history, next_session.answer_history)
743
  if force_another
744
- else negotiation_prompt(next_session.intake)
745
  )
746
  negotiation = text_client.generate_negotiation(prompt)
747
  questions = [question for question in negotiation.get("questions", []) if question]
748
  fresh_questions = [question for question in questions if question not in next_session.question_history]
749
  if force_another and not fresh_questions:
750
- fresh_questions = ["如果今天只需要一个更小的第一步,它会是什么?"]
 
 
 
 
751
  if not fresh_questions:
752
  fresh_questions = questions[:3]
753
  if fresh_questions:
754
- fresh_questions = [_grounded_question(next_session.intake, fresh_questions[0])] + fresh_questions[1:]
755
  seen_questions = set(next_session.question_history)
756
  deduped_questions: List[str] = []
757
  for question in fresh_questions:
@@ -759,7 +860,7 @@ def ask_questions(session: CustomsSession, text_client, force_another: bool = Fa
759
  deduped_questions.append(question)
760
  seen_questions.add(question)
761
  if force_another and not deduped_questions:
762
- deduped_questions = [_grounded_followup_question(next_session.intake)]
763
  fresh_questions = deduped_questions
764
 
765
  next_session.question_history.extend(fresh_questions[:3])
@@ -768,12 +869,15 @@ def ask_questions(session: CustomsSession, text_client, force_another: bool = Fa
768
  next_session.intake,
769
  questions=next_session.question_history,
770
  answers=next_session.answer_history,
 
771
  )
772
  next_session.qa_state.current_step = "ask"
773
  next_session.events.append(
774
  _event(
775
  "assistant",
776
- "梦境助手追问" if len(fresh_questions) == 1 else "梦境助手追问清单",
 
 
777
  "\n".join(fresh_questions[:3]),
778
  meta=str(negotiation.get("visitor_name", "")),
779
  status="question",
@@ -782,7 +886,8 @@ def ask_questions(session: CustomsSession, text_client, force_another: bool = Fa
782
  return next_session
783
 
784
 
785
- def answer_question(session: CustomsSession, answer: str) -> CustomsSession:
 
786
  next_session = session.model_copy(deep=True)
787
  if not answer or not answer.strip():
788
  next_session.phase = "error"
@@ -792,33 +897,48 @@ def answer_question(session: CustomsSession, answer: str) -> CustomsSession:
792
  return next_session
793
  next_session.answer_history.append(answer.strip())
794
  next_session.phase = "ask"
795
- next_session.qa_state = build_qa_state(next_session.intake, next_session.question_history, next_session.answer_history)
796
- next_session.events.append(_event("user", "追问回答", answer.strip(), status="answered"))
 
 
797
  _record_safety(next_session)
798
  return next_session
799
 
800
 
801
- def skip_question(session: CustomsSession) -> CustomsSession:
 
802
  next_session = session.model_copy(deep=True)
803
- skip_text = "用户选择跳过这个追问。"
804
  next_session.answer_history.append(skip_text)
805
  next_session.phase = "ask"
806
- next_session.qa_state = build_qa_state(next_session.intake, next_session.question_history, next_session.answer_history)
807
- next_session.events.append(_event("user", "跳过追问", skip_text, status="skipped"))
 
 
808
  return next_session
809
 
810
 
811
- def finish_today_tip(session: CustomsSession, text_client) -> CustomsSession:
 
812
  next_session = session.model_copy(deep=True)
813
  if not next_session.intake.merged_text():
814
  next_session.phase = "error"
815
  next_session.events.append(
816
- _event("error", "今日小 Tips 需要梦境材料", "请先添加至少一个梦境片段。", status="empty")
 
 
 
 
 
 
 
817
  )
818
  return next_session
819
  answers = next_session.answers_text()
820
- card = generate_today_tip(next_session.intake, answers, text_client)
821
- next_session.qa_state = build_qa_state(next_session.intake, next_session.question_history, next_session.answer_history)
 
 
822
  next_session.qa_state.dream_summary = card.dream_summary
823
  next_session.qa_state.main_question = card.main_question
824
  next_session.qa_state.dream_anchors = card.dream_anchors
@@ -831,7 +951,7 @@ def finish_today_tip(session: CustomsSession, text_client) -> CustomsSession:
831
  next_session.events.append(
832
  _event(
833
  "tip",
834
- "今日小 Tips 已生成",
835
  f"{card.interpretation}\n{card.today_tip}",
836
  status="tip",
837
  )
 
19
  from dream_customs.schema import CustomsSession, DreamIntake, DreamQAState, EvidenceItem, PactCard, TimelineEvent, TodayTipCard
20
 
21
 
22
+ def _normalize_language(language: str = "en") -> str:
23
+ return "zh" if language == "zh" else "en"
24
+
25
+
26
+ def _is_zh(language: str = "en") -> bool:
27
+ return _normalize_language(language) == "zh"
28
+
29
+
30
  def build_intake(
31
  dream_text: str = "",
32
  voice_transcript: str = "",
 
150
  r"\b([a-z][a-z'-]+)\s+("
151
  r"paper|papers|promise|promises|window|windows|suitcase|suitcases|"
152
  r"clerk|clerks|sunrise|elevator|elevators|button|buttons|hallway|"
153
+ r"gate|gates|floor|floors|stamp|stamps|number|numbers|exam|exams|"
154
+ r"pencil|pencils|train|trains|door|doors|phone|phones|water|shoe|shoes|"
155
+ r"stairwell|stairwells|room|rooms|key|keys|note|notes|rain|moon|moons|curtain|curtains"
156
  r")\b"
157
  )
158
  for match in pair_pattern.finditer(text):
 
162
  candidates.append(phrase)
163
 
164
  noun_pattern = re.compile(
165
+ r"\b(customs|suitcase|clerk|sunrise|elevator|button|hallway|gate|stamp|number|floor|"
166
+ r"exam|pencil|train|door|phone|water|shoe|stairwell|room|key|note|rain|moon|curtain|sleep|dream)\b"
167
  )
168
  candidates.extend(match.group(1) for match in noun_pattern.finditer(text))
169
  candidates.extend(clue.lower() for clue in intake.visual_clues if clue.strip())
 
171
  return _dedupe_preserve_order(candidates)[:5]
172
 
173
 
174
+ def _primary_anchor(intake: DreamIntake, language: str = "en") -> str:
175
  anchors = _extract_dream_anchors(intake)
176
+ if anchors:
177
+ return anchors[0]
178
+ return "梦里的那个细节" if _is_zh(language) else "that dream detail"
179
 
180
 
181
+ def _secondary_anchor(intake: DreamIntake, language: str = "en") -> str:
182
  anchors = _extract_dream_anchors(intake)
183
+ return anchors[1] if len(anchors) > 1 else _primary_anchor(intake, language)
184
 
185
 
186
  def _title_anchor(text: str) -> str:
187
  return " ".join(part.capitalize() for part in text.split())
188
 
189
 
190
+ def _summary_from_intake(intake: DreamIntake, language: str = "en") -> str:
191
  merged = intake.dream_text.strip() or intake.voice_transcript.strip()
192
  if not merged and intake.visual_clues:
193
+ merged = "、".join(intake.visual_clues[:3]) if _is_zh(language) else ", ".join(intake.visual_clues[:3])
194
  if not merged:
195
+ return "你记录了一个还在整理中的梦。" if _is_zh(language) else "You recorded a dream that is still taking shape."
196
  clean = re.sub(r"\s+", " ", merged).strip()
197
  if len(clean) > 72:
198
  clean = clean[:69].rstrip() + "..."
199
+ if not _is_zh(language):
200
+ return clean if clean.lower().startswith(("i ", "you ")) else f"You dreamed about {clean}"
201
  return f"你梦见{clean}" if not clean.startswith(("你", "我", "I ", "i ")) else clean
202
 
203
 
204
+ def _main_question_from_intake(intake: DreamIntake, language: str = "en") -> str:
205
  if intake.main_question.strip():
206
  return intake.main_question.strip()
207
+ primary = _primary_anchor(intake, language)
208
+ if not _is_zh(language):
209
+ return f"What might the {primary} be asking me to notice today?"
210
  return f"这个梦里的「{primary}」可能在提醒我什么?"
211
 
212
 
213
+ def _fallback_interpretation(intake: DreamIntake, language: str = "en") -> str:
214
+ primary = _primary_anchor(intake, language)
215
+ secondary = _secondary_anchor(intake, language)
216
+ if not _is_zh(language):
217
+ return (
218
+ f"Maybe this dream is not giving you a fixed answer. It is placing the {primary} "
219
+ f"beside the {secondary} so you can notice one small stuck point today."
220
+ )
221
  return (
222
  f"也许这个梦不是在给你一个确定答案,而是把「{primary}」和「{secondary}」放到一起,"
223
  "提醒你先看见今天最卡住的一小处。"
224
  )
225
 
226
 
227
+ def _grounded_today_tip(intake: DreamIntake, language: str = "en") -> str:
228
+ primary = _primary_anchor(intake, language)
229
+ if not _is_zh(language):
230
+ return f"For today, borrow one action from the {primary}: open the smallest first step and stop there."
231
  return f"今天先从「{primary}」借一个动作:只做最小的第一步,不急着把整件事完成。"
232
 
233
 
 
376
  )
377
 
378
 
379
+ def _grounded_question(intake: DreamIntake, question: str, language: str = "en") -> str:
380
  anchors = _extract_dream_anchors(intake)
381
  if not anchors or _text_uses_anchor(question, anchors):
382
  return question
383
+ primary = _primary_anchor(intake, language)
384
+ secondary = _secondary_anchor(intake, language)
385
+ if not _is_zh(language):
386
+ return (
387
+ f"When you think about the {primary} and the {secondary}, is there one real thing today "
388
+ "that you want to make easier to start?"
389
+ )
390
  return (
391
  f"当你想到「{primary}」和「{secondary}」时,今天有没有一件真实的小事,"
392
  "你希望它变得更容易开始?"
393
  )
394
 
395
 
396
+ def _grounded_followup_question(intake: DreamIntake, language: str = "en") -> str:
397
+ primary = _primary_anchor(intake, language)
398
+ if not _is_zh(language):
399
+ return f"If the {primary} could hand you one smaller first step today, what would that step be?"
400
  return f"如果「{primary}」能递给你一个更小的第一步,今天那一步会是什么?"
401
 
402
 
 
477
  )
478
 
479
 
480
+ def build_qa_state(
481
+ intake: DreamIntake,
482
+ questions: Optional[List[str]] = None,
483
+ answers: Optional[List[str]] = None,
484
+ language: str = "en",
485
+ ) -> DreamQAState:
486
  anchors = _extract_dream_anchors(intake)
487
  return DreamQAState(
488
+ dream_summary=_summary_from_intake(intake, language),
489
+ main_question=_main_question_from_intake(intake, language),
490
  dream_anchors=anchors,
491
  followup_questions=questions or [],
492
  user_answers=answers or [],
 
494
  )
495
 
496
 
497
+ def _polish_today_tip(card: TodayTipCard, intake: DreamIntake, answers: str = "", language: str = "en") -> TodayTipCard:
498
  polished = card.model_copy(deep=True)
499
+ intake_anchors = _extract_dream_anchors(intake)
500
+ card_anchors = polished.dream_anchors
501
+ if intake_anchors and not any(_text_uses_anchor(anchor, intake_anchors) for anchor in card_anchors):
502
+ anchors = intake_anchors
503
+ else:
504
+ anchors = card_anchors or intake_anchors
505
  if not anchors:
506
+ anchors = [_primary_anchor(intake, language)]
507
  polished.dream_anchors = anchors
508
  if not polished.dream_summary.strip():
509
+ polished.dream_summary = _summary_from_intake(intake, language)
510
  if not polished.main_question.strip():
511
+ polished.main_question = _main_question_from_intake(intake, language)
512
  if not polished.interpretation.strip() or not _anchor_in_text(polished.interpretation, anchors):
513
+ polished.interpretation = _fallback_interpretation(intake, language)
514
  generic_tip_markers = ["drink water", "hydrate", "多休息", "保持积极", "take a walk"]
515
  if (
516
  not polished.today_tip.strip()
517
  or any(marker in polished.today_tip.lower() for marker in generic_tip_markers)
518
  or not _anchor_in_text(polished.today_tip, anchors)
519
  ):
520
+ polished.today_tip = _grounded_today_tip(intake, language)
521
  if not polished.tiny_action.strip():
522
+ if _is_zh(language):
523
+ polished.tiny_action = f"用 5 分钟写下:今天和「{anchors[0]}」有关的第一小步是什么?"
524
+ else:
525
+ polished.tiny_action = f"Spend five minutes writing the first small step connected to the {anchors[0]}."
526
  if not polished.caring_note.strip():
527
+ polished.caring_note = (
528
+ "你不需要一醒来就解决整个梦,先把一个细节照亮就很好。"
529
+ if _is_zh(language)
530
+ else "You do not have to solve the whole dream this morning; noticing one detail is enough."
531
+ )
532
  merged = "\n".join([intake.merged_text(), answers or ""])
533
  polished.safety_note = safety_note() if needs_escalation(merged) else ""
534
  return polished
535
 
536
 
537
+ def generate_today_tip(intake: DreamIntake, answers: str, text_client, language: str = "en") -> TodayTipCard:
538
+ language = _normalize_language(language)
539
+ qa_state = build_qa_state(intake, answers=[answer for answer in [answers] if answer], language=language)
540
+ prompt = today_tip_prompt(qa_state, language=language)
541
  try:
542
  if hasattr(text_client, "generate_today_tip"):
543
  card = text_client.generate_today_tip(prompt)
 
562
  dream_anchors=qa_state.dream_anchors,
563
  followup_questions=qa_state.followup_questions,
564
  user_answers=qa_state.user_answers,
565
+ interpretation=_fallback_interpretation(intake, language),
566
+ today_tip=_grounded_today_tip(intake, language),
567
+ )
568
+ return _polish_today_tip(card, intake, answers, language)
569
 
570
 
571
  def generate_negotiation(intake: DreamIntake, text_client) -> Dict:
 
628
  )
629
 
630
 
631
+ def create_session(language: str = "en") -> CustomsSession:
632
+ language = _normalize_language(language)
633
  return CustomsSession(
634
  events=[
635
  TimelineEvent(
636
  role="system",
637
+ title="梦境问答台已打开" if _is_zh(language) else "Dream QA is open",
638
+ body=(
639
+ "先记录一个梦境片段。文字永远可用,图片和语音会变成同一个梦境 intake 的线索。"
640
+ if _is_zh(language)
641
+ else "Record a dream fragment first. Text always works; image and voice become clues in the same intake."
642
+ ),
643
  status="ready",
644
  )
645
  ]
 
694
  mood: str = "",
695
  vision_client=None,
696
  asr_client=None,
697
+ language: str = "en",
698
  ) -> CustomsSession:
699
+ language = _normalize_language(language)
700
  next_session = session.model_copy(deep=True)
701
  added_items: List[EvidenceItem] = []
702
 
703
  if dream_text and dream_text.strip():
704
  clean_text = dream_text.strip()
705
  next_session.intake.dream_text = _append_text(next_session.intake.dream_text, clean_text)
706
+ added_items.append(
707
+ EvidenceItem(
708
+ type="text",
709
+ label="Dream note" if not _is_zh(language) else "梦境记录",
710
+ status="selected",
711
+ content=clean_text,
712
+ )
713
+ )
714
 
715
  if mood and mood.strip() and mood.strip() != next_session.intake.mood:
716
  next_session.intake.mood = mood.strip()
717
+ added_items.append(
718
+ EvidenceItem(
719
+ type="mood",
720
+ label=f"Mood: {mood.strip()}" if not _is_zh(language) else f"心情:{mood.strip()}",
721
+ status="selected",
722
+ content=mood.strip(),
723
+ )
724
+ )
725
 
726
  if image_path:
727
  clues: List[str] = []
 
736
  added_items.append(
737
  EvidenceItem(
738
  type="image",
739
+ label=f"Image clues ({len(clues)})" if not _is_zh(language) else f"图片线索({len(clues)})",
740
  status="extracted",
741
  content=", ".join(clues),
742
  source_path=image_path,
 
746
  added_items.append(
747
  EvidenceItem(
748
  type="image",
749
+ label="Image evidence" if not _is_zh(language) else "图片证据",
750
  status="failed",
751
  source_path=image_path,
752
+ error=error
753
+ or (
754
+ "No visual clues extracted. Continue with text or voice."
755
+ if not _is_zh(language)
756
+ else "没有提取到视觉线索,可以继续使用文字或语音。"
757
+ ),
758
  )
759
  )
760
 
 
771
  added_items.append(
772
  EvidenceItem(
773
  type="audio",
774
+ label="Voice transcript" if not _is_zh(language) else "语音转写",
775
  status="extracted",
776
  content=clean_transcript,
777
  source_path=audio_path,
 
781
  added_items.append(
782
  EvidenceItem(
783
  type="audio",
784
+ label="Voice evidence" if not _is_zh(language) else "语音证据",
785
  status="failed",
786
  source_path=audio_path,
787
+ error=error
788
+ or (
789
+ "No transcript returned. Continue by typing the fragment."
790
+ if not _is_zh(language)
791
+ else "没有返回转写结果,可以继续手动输入片段。"
792
+ ),
793
  )
794
  )
795
 
796
  if not added_items:
797
  next_session.phase = "error"
798
  next_session.events.append(
799
+ _event(
800
+ "error",
801
+ "还没有梦境材料" if _is_zh(language) else "No dream material yet",
802
+ "请先添加文字、图片或语音;text-only 路径始终可用。"
803
+ if _is_zh(language)
804
+ else "Add text, image, or voice first; the text-only path always works.",
805
+ status="empty",
806
+ )
807
  )
808
  return next_session
809
 
810
  next_session.evidence_items.extend(added_items)
811
  next_session.phase = "record"
812
+ next_session.qa_state = build_qa_state(next_session.intake, language=language)
813
  summary = "\n".join(f"{item.label}: {item.content or item.error}" for item in added_items)
814
+ next_session.events.append(
815
+ _event("user", "梦境已记录" if _is_zh(language) else "Dream recorded", summary, status="record")
816
+ )
817
  _record_safety(next_session)
818
  return next_session
819
 
820
 
821
+ def ask_questions(session: CustomsSession, text_client, force_another: bool = False, language: str = "en") -> CustomsSession:
822
+ language = _normalize_language(language)
823
  next_session = session.model_copy(deep=True)
824
  if not next_session.intake.merged_text():
825
  next_session.phase = "error"
826
  next_session.events.append(
827
+ _event(
828
+ "error",
829
+ "还没有梦境记录" if _is_zh(language) else "No dream note yet",
830
+ "请先添加一个梦境片段,再进入追问。"
831
+ if _is_zh(language)
832
+ else "Add a dream fragment before the follow-up question.",
833
+ status="empty",
834
+ )
835
  )
836
  return next_session
837
 
838
  prompt = (
839
+ followup_question_prompt(next_session.intake, next_session.question_history, next_session.answer_history, language)
840
  if force_another
841
+ else negotiation_prompt(next_session.intake, language)
842
  )
843
  negotiation = text_client.generate_negotiation(prompt)
844
  questions = [question for question in negotiation.get("questions", []) if question]
845
  fresh_questions = [question for question in questions if question not in next_session.question_history]
846
  if force_another and not fresh_questions:
847
+ fresh_questions = [
848
+ "如果今天只需要一个更小的第一步,它会是什么?"
849
+ if _is_zh(language)
850
+ else "If today only needed one smaller first step, what would it be?"
851
+ ]
852
  if not fresh_questions:
853
  fresh_questions = questions[:3]
854
  if fresh_questions:
855
+ fresh_questions = [_grounded_question(next_session.intake, fresh_questions[0], language)] + fresh_questions[1:]
856
  seen_questions = set(next_session.question_history)
857
  deduped_questions: List[str] = []
858
  for question in fresh_questions:
 
860
  deduped_questions.append(question)
861
  seen_questions.add(question)
862
  if force_another and not deduped_questions:
863
+ deduped_questions = [_grounded_followup_question(next_session.intake, language)]
864
  fresh_questions = deduped_questions
865
 
866
  next_session.question_history.extend(fresh_questions[:3])
 
869
  next_session.intake,
870
  questions=next_session.question_history,
871
  answers=next_session.answer_history,
872
+ language=language,
873
  )
874
  next_session.qa_state.current_step = "ask"
875
  next_session.events.append(
876
  _event(
877
  "assistant",
878
+ ("梦境助手追问" if len(fresh_questions) == 1 else "梦境助手追问清单")
879
+ if _is_zh(language)
880
+ else ("Dream QA question" if len(fresh_questions) == 1 else "Dream QA questions"),
881
  "\n".join(fresh_questions[:3]),
882
  meta=str(negotiation.get("visitor_name", "")),
883
  status="question",
 
886
  return next_session
887
 
888
 
889
+ def answer_question(session: CustomsSession, answer: str, language: str = "en") -> CustomsSession:
890
+ language = _normalize_language(language)
891
  next_session = session.model_copy(deep=True)
892
  if not answer or not answer.strip():
893
  next_session.phase = "error"
 
897
  return next_session
898
  next_session.answer_history.append(answer.strip())
899
  next_session.phase = "ask"
900
+ next_session.qa_state = build_qa_state(
901
+ next_session.intake, next_session.question_history, next_session.answer_history, language=language
902
+ )
903
+ next_session.events.append(_event("user", "追问回答" if _is_zh(language) else "Question answered", answer.strip(), status="answered"))
904
  _record_safety(next_session)
905
  return next_session
906
 
907
 
908
+ def skip_question(session: CustomsSession, language: str = "en") -> CustomsSession:
909
+ language = _normalize_language(language)
910
  next_session = session.model_copy(deep=True)
911
+ skip_text = "用户选择跳过这个追问。" if _is_zh(language) else "The user chose to skip this question."
912
  next_session.answer_history.append(skip_text)
913
  next_session.phase = "ask"
914
+ next_session.qa_state = build_qa_state(
915
+ next_session.intake, next_session.question_history, next_session.answer_history, language=language
916
+ )
917
+ next_session.events.append(_event("user", "跳过追问" if _is_zh(language) else "Question skipped", skip_text, status="skipped"))
918
  return next_session
919
 
920
 
921
+ def finish_today_tip(session: CustomsSession, text_client, language: str = "en") -> CustomsSession:
922
+ language = _normalize_language(language)
923
  next_session = session.model_copy(deep=True)
924
  if not next_session.intake.merged_text():
925
  next_session.phase = "error"
926
  next_session.events.append(
927
+ _event(
928
+ "error",
929
+ "今日小 Tips 需要梦境材料" if _is_zh(language) else "Today Tip needs dream material",
930
+ "请先添加至少一个梦境片段。"
931
+ if _is_zh(language)
932
+ else "Add at least one dream fragment first.",
933
+ status="empty",
934
+ )
935
  )
936
  return next_session
937
  answers = next_session.answers_text()
938
+ card = generate_today_tip(next_session.intake, answers, text_client, language=language)
939
+ next_session.qa_state = build_qa_state(
940
+ next_session.intake, next_session.question_history, next_session.answer_history, language=language
941
+ )
942
  next_session.qa_state.dream_summary = card.dream_summary
943
  next_session.qa_state.main_question = card.main_question
944
  next_session.qa_state.dream_anchors = card.dream_anchors
 
951
  next_session.events.append(
952
  _event(
953
  "tip",
954
+ "今日小 Tips 已生成" if _is_zh(language) else "Today Tip generated",
955
  f"{card.interpretation}\n{card.today_tip}",
956
  status="tip",
957
  )
dream_customs/prompts.py CHANGED
@@ -18,12 +18,21 @@ def visual_witness_prompt() -> str:
18
  )
19
 
20
 
21
- def dream_qa_state_prompt(intake: DreamIntake) -> str:
 
 
 
 
 
 
 
 
22
  return f"""
23
  You are MiniCPM5-1B acting as Dream QA / 梦境问答台, a gentle question guide.
24
  Summarize the dream, infer the user's main question if they did not write one, and ask 1 to 3 warm follow-up questions.
25
  Do not diagnose, predict fate, frighten the user, or claim one fixed dream meaning.
26
  Ground every question in a concrete detail from the text, voice transcript, mood, or visual clues.
 
27
 
28
  Dream intake:
29
  {intake.merged_text()}
@@ -37,7 +46,7 @@ Return strict JSON with:
37
  """.strip()
38
 
39
 
40
- def today_tip_prompt(state: DreamQAState) -> str:
41
  return f"""
42
  You are MiniCPM5-1B writing the final Dream QA result.
43
  Write a non-diagnostic interpretation draft and exactly one primary 今日小 Tips.
@@ -46,6 +55,7 @@ The today_tip must cite at least one concrete dream anchor.
46
  Avoid prophecy, frightening certainty, medical advice, therapy framing, and generic wellness filler.
47
  Keep the tiny_action small enough to try today.
48
  Use safety_note only for self-harm, harm to others, severe distress, severe insomnia, panic, or inability to function.
 
49
 
50
  Dream QA state:
51
  {_json_block(state)}
@@ -152,7 +162,7 @@ def visual_clue_prompt() -> str:
152
  )
153
 
154
 
155
- def negotiation_prompt(intake: DreamIntake) -> str:
156
  return f"""
157
  You are the Dream QA question guide. The user is not asking for diagnosis.
158
  Help the user record the dream, clarify the main question, and answer one gentle follow-up before the final 今日小 Tips.
@@ -161,18 +171,24 @@ Ask questions that an ordinary person can understand without knowing any app lor
161
  Prefer questions about the strongest feeling, one confusing scene, or one safe next-day reference.
162
  Ground every question in a concrete detail from the intake when possible, such as an object,
163
  place, action, color, or phrase the user actually provided.
 
164
 
165
  Dream intake:
166
  {intake.merged_text()}
167
 
168
  Return JSON with:
169
  - visitor_name: short vivid anchor label
170
- - questions: 2 or 3 gentle, specific, easy-to-understand questions
171
  - tone_note: one sentence explaining why the questions may help without certainty
172
  """.strip()
173
 
174
 
175
- def followup_question_prompt(intake: DreamIntake, question_history: list[str], answer_history: list[str]) -> str:
 
 
 
 
 
176
  return f"""
177
  You are the Dream QA question guide. Ask one more gentle follow-up question.
178
  Do not diagnose. Do not repeat previous questions.
@@ -181,6 +197,7 @@ or whether one concrete dream detail connects to today.
181
  Do not use unclear metaphors about fate, symbols, hidden meanings, stamps, release, or permits.
182
  Reuse one concrete dream detail from the intake so the user can feel the question belongs
183
  to this dream rather than to a generic reflection form.
 
184
 
185
  Dream intake:
186
  {intake.merged_text()}
@@ -193,7 +210,7 @@ User answers:
193
 
194
  Return JSON with:
195
  - visitor_name: short vivid name
196
- - questions: one gentle, specific question
197
  - tone_note: one sentence explaining why this question matters today
198
  """.strip()
199
 
 
18
  )
19
 
20
 
21
+ def _language_instruction(language: str = "en") -> str:
22
+ return (
23
+ "Write all user-facing fields in English. Keep any user-provided dream anchors as written."
24
+ if language != "zh"
25
+ else "所有面向用户的字段都用自然中文书写。可以保留用户原文里的梦境关键词。"
26
+ )
27
+
28
+
29
+ def dream_qa_state_prompt(intake: DreamIntake, language: str = "en") -> str:
30
  return f"""
31
  You are MiniCPM5-1B acting as Dream QA / 梦境问答台, a gentle question guide.
32
  Summarize the dream, infer the user's main question if they did not write one, and ask 1 to 3 warm follow-up questions.
33
  Do not diagnose, predict fate, frighten the user, or claim one fixed dream meaning.
34
  Ground every question in a concrete detail from the text, voice transcript, mood, or visual clues.
35
+ {_language_instruction(language)}
36
 
37
  Dream intake:
38
  {intake.merged_text()}
 
46
  """.strip()
47
 
48
 
49
+ def today_tip_prompt(state: DreamQAState, language: str = "en") -> str:
50
  return f"""
51
  You are MiniCPM5-1B writing the final Dream QA result.
52
  Write a non-diagnostic interpretation draft and exactly one primary 今日小 Tips.
 
55
  Avoid prophecy, frightening certainty, medical advice, therapy framing, and generic wellness filler.
56
  Keep the tiny_action small enough to try today.
57
  Use safety_note only for self-harm, harm to others, severe distress, severe insomnia, panic, or inability to function.
58
+ {_language_instruction(language)}
59
 
60
  Dream QA state:
61
  {_json_block(state)}
 
162
  )
163
 
164
 
165
+ def negotiation_prompt(intake: DreamIntake, language: str = "en") -> str:
166
  return f"""
167
  You are the Dream QA question guide. The user is not asking for diagnosis.
168
  Help the user record the dream, clarify the main question, and answer one gentle follow-up before the final 今日小 Tips.
 
171
  Prefer questions about the strongest feeling, one confusing scene, or one safe next-day reference.
172
  Ground every question in a concrete detail from the intake when possible, such as an object,
173
  place, action, color, or phrase the user actually provided.
174
+ {_language_instruction(language)}
175
 
176
  Dream intake:
177
  {intake.merged_text()}
178
 
179
  Return JSON with:
180
  - visitor_name: short vivid anchor label
181
+ - questions: 1 to 3 gentle, specific, easy-to-understand questions
182
  - tone_note: one sentence explaining why the questions may help without certainty
183
  """.strip()
184
 
185
 
186
+ def followup_question_prompt(
187
+ intake: DreamIntake,
188
+ question_history: list[str],
189
+ answer_history: list[str],
190
+ language: str = "en",
191
+ ) -> str:
192
  return f"""
193
  You are the Dream QA question guide. Ask one more gentle follow-up question.
194
  Do not diagnose. Do not repeat previous questions.
 
197
  Do not use unclear metaphors about fate, symbols, hidden meanings, stamps, release, or permits.
198
  Reuse one concrete dream detail from the intake so the user can feel the question belongs
199
  to this dream rather than to a generic reflection form.
200
+ {_language_instruction(language)}
201
 
202
  Dream intake:
203
  {intake.merged_text()}
 
210
 
211
  Return JSON with:
212
  - visitor_name: short vivid name
213
+ - questions: one gentle, specific question in a single-item list
214
  - tone_note: one sentence explaining why this question matters today
215
  """.strip()
216
 
dream_customs/render.py CHANGED
@@ -23,12 +23,29 @@ def _phase_label(phase: str) -> str:
23
  return labels.get(phase, phase.title())
24
 
25
 
26
- def render_today_tip_card(card: TodayTipCard) -> str:
 
27
  anchors = " ".join(escape(anchor) for anchor in card.dream_anchors)
28
  questions = "".join(f"<li>{escape(question)}</li>" for question in card.followup_questions)
29
  answers = "".join(f"<li>{escape(answer)}</li>" for answer in card.user_answers)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
30
  tiny_action = (
31
- f"<section class='dqa-result-card'><div class='dqa-card-icon'>□</div><div><h3>没试过的小事</h3><p>{escape(card.tiny_action)}</p></div></section>"
32
  if card.tiny_action
33
  else ""
34
  )
@@ -38,38 +55,38 @@ def render_today_tip_card(card: TodayTipCard) -> str:
38
  else ""
39
  )
40
  safety = (
41
- f"<div class='dqa-safety-note'>这不是诊断。{escape(card.safety_note)}</div>"
42
  if card.safety_note
43
- else "<div class='dqa-safety-note'>这不是诊断,只是一个温和的今日参考。</div>"
44
  )
45
  qa_history = (
46
- f"<details class='dqa-qa-history'><summary>追问记录</summary><ol>{questions}</ol><ul>{answers}</ul></details>"
47
  if questions or answers
48
  else ""
49
  )
50
  return f"""
51
- <section class="dqa-tip-page" aria-label="今日小 Tips">
52
  <div class="dqa-tip-hero">
53
  <div>
54
  <div class="dqa-sun">☼</div>
55
- <h2>今日小 Tips</h2>
56
- <p>谢谢你的分享。根据你的梦境,我们为你整理了今天的参考。</p>
57
  </div>
58
  </div>
59
  <article class="dqa-result-card is-interpretation">
60
  <div class="dqa-card-icon">☘</div>
61
  <div>
62
- <h3>也许这个梦在提醒你</h3>
63
  <p>{escape(card.interpretation)}</p>
64
- <div class="dqa-anchor-strip">关键词: {anchors}</div>
65
  </div>
66
  </article>
67
  <article class="dqa-result-card is-primary-tip">
68
  <div class="dqa-card-icon">☼</div>
69
  <div>
70
- <h3>今天的小建议</h3>
71
  <p>{escape(card.today_tip)}</p>
72
- <div class="dqa-tip-highlight">★ {escape(card.dream_anchors[0] if card.dream_anchors else "梦境细节")}</div>
73
  </div>
74
  </article>
75
  {tiny_action}
 
23
  return labels.get(phase, phase.title())
24
 
25
 
26
+ def render_today_tip_card(card: TodayTipCard, language: str = "en") -> str:
27
+ is_zh = language == "zh"
28
  anchors = " ".join(escape(anchor) for anchor in card.dream_anchors)
29
  questions = "".join(f"<li>{escape(question)}</li>" for question in card.followup_questions)
30
  answers = "".join(f"<li>{escape(answer)}</li>" for answer in card.user_answers)
31
+ labels = {
32
+ "page": "今日小 Tips" if is_zh else "Today Tip",
33
+ "thanks": "谢谢你的分享。根据你的梦境,我们为你整理了今天的参考。"
34
+ if is_zh
35
+ else "Thanks for sharing this. Here is one grounded suggestion for today.",
36
+ "interpretation": "也许这个梦在提醒你" if is_zh else "Maybe this dream is pointing to",
37
+ "today_tip": "今天的小建议" if is_zh else "Today's small suggestion",
38
+ "tiny_action": "没试过的小事" if is_zh else "Tiny action",
39
+ "anchors": "关键词:" if is_zh else "Anchors:",
40
+ "history": "追问记录" if is_zh else "Question record",
41
+ "safety_default": "这不是诊断,只是一个温和的今日参考。"
42
+ if is_zh
43
+ else "This is not diagnosis, therapy, or prophecy; just a gentle reference for today.",
44
+ "safety_prefix": "这不是诊断。" if is_zh else "This is not diagnosis. ",
45
+ "highlight": "梦境细节" if is_zh else "dream detail",
46
+ }
47
  tiny_action = (
48
+ f"<section class='dqa-result-card'><div class='dqa-card-icon'>□</div><div><h3>{labels['tiny_action']}</h3><p>{escape(card.tiny_action)}</p></div></section>"
49
  if card.tiny_action
50
  else ""
51
  )
 
55
  else ""
56
  )
57
  safety = (
58
+ f"<div class='dqa-safety-note'>{labels['safety_prefix']}{escape(card.safety_note)}</div>"
59
  if card.safety_note
60
+ else f"<div class='dqa-safety-note'>{labels['safety_default']}</div>"
61
  )
62
  qa_history = (
63
+ f"<details class='dqa-qa-history'><summary>{labels['history']}</summary><ol>{questions}</ol><ul>{answers}</ul></details>"
64
  if questions or answers
65
  else ""
66
  )
67
  return f"""
68
+ <section class="dqa-tip-page" aria-label="{labels['page']}">
69
  <div class="dqa-tip-hero">
70
  <div>
71
  <div class="dqa-sun">☼</div>
72
+ <h2>{labels['page']}</h2>
73
+ <p>{labels['thanks']}</p>
74
  </div>
75
  </div>
76
  <article class="dqa-result-card is-interpretation">
77
  <div class="dqa-card-icon">☘</div>
78
  <div>
79
+ <h3>{labels['interpretation']}</h3>
80
  <p>{escape(card.interpretation)}</p>
81
+ <div class="dqa-anchor-strip">{labels['anchors']} {anchors}</div>
82
  </div>
83
  </article>
84
  <article class="dqa-result-card is-primary-tip">
85
  <div class="dqa-card-icon">☼</div>
86
  <div>
87
+ <h3>{labels['today_tip']}</h3>
88
  <p>{escape(card.today_tip)}</p>
89
+ <div class="dqa-tip-highlight">★ {escape(card.dream_anchors[0] if card.dream_anchors else labels['highlight'])}</div>
90
  </div>
91
  </article>
92
  {tiny_action}
dream_customs/ui/actions.py CHANGED
@@ -16,6 +16,7 @@ from dream_customs.pipeline import (
16
  )
17
  from dream_customs.render import render_today_tip_card
18
  from dream_customs.schema import CustomsSession, TodayTipCard
 
19
 
20
 
21
  def _state_json(session: CustomsSession) -> str:
@@ -42,60 +43,94 @@ def _trim_to_one_visible_question(session: CustomsSession, previous_count: int)
42
  return next_session
43
 
44
 
45
- def _card_plain_text(card: TodayTipCard) -> str:
46
- return card.to_plain_text()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
47
 
48
 
49
- def _render_today_pass(card: TodayTipCard) -> str:
50
- return render_today_tip_card(card)
51
 
52
 
53
  def _questions(session: CustomsSession) -> List[str]:
54
  return session.question_history[-1:] if session.question_history else []
55
 
56
 
57
- def _view_payload(session: CustomsSession, text_backend: str, vision_backend: str, **settings) -> Dict[str, Any]:
 
 
 
 
 
 
 
 
58
  card = session.sealed_tip or session.draft_tip
59
  error = _latest_error(session)
60
  status = "error" if error else "tip" if session.sealed_tip else "ask" if session.question_history else "record"
61
  return {
62
  "status": status,
63
  "phase": session.phase,
 
64
  "question": _questions(session)[0] if _questions(session) else "",
65
  "questions": _questions(session),
66
- "card_title": "今日小 Tips" if card else "",
67
- "card_text": _card_plain_text(card) if card else "",
68
- "card_html": _render_today_pass(card) if card else "",
69
  "error": error,
70
- "notice": _notice_for_status(status, error),
71
  "debug": json.loads(_debug_json(session, text_backend, vision_backend, **settings)),
72
  }
73
 
74
 
75
- def _view(session: CustomsSession, text_backend: str, vision_backend: str, **settings) -> Tuple[str, str]:
 
 
 
 
 
 
76
  return _state_json(session), json.dumps(
77
- _view_payload(session, text_backend, vision_backend, **settings),
78
  ensure_ascii=False,
79
  indent=2,
80
  )
81
 
82
 
83
- def _notice_for_status(status: str, error: str = "") -> str:
 
84
  if status == "error":
85
- return error or "梦境问答台还没有收到片段。"
86
  if status == "ask":
87
- return "可以回答这个追问,也可以跳过,直接生成今日小 Tips。"
88
  if status == "tip":
89
- return "今日小 Tips 已生成。把它当作温和参考,不是诊断或预言。"
90
- return "写一句、几行,或上传图片/语音。Text-only 路径始终可用。"
91
 
92
 
93
  def initial_mobile_state(
94
  text_backend: str = DEFAULT_TEXT_BACKEND,
95
  vision_backend: str = DEFAULT_VISION_BACKEND,
 
96
  **settings,
97
  ) -> Tuple[str, str]:
98
- return _view(create_session(), text_backend, vision_backend, **settings)
 
99
 
100
 
101
  def submit_dream_action(
@@ -105,33 +140,38 @@ def submit_dream_action(
105
  mood: str = "",
106
  text_backend: str = DEFAULT_TEXT_BACKEND,
107
  vision_backend: str = DEFAULT_VISION_BACKEND,
 
108
  **settings,
109
  ) -> Tuple[str, str]:
 
110
  text_client, vision_client, asr_client = _clients(text_backend, vision_backend, **settings)
111
  session = add_evidence(
112
- create_session(),
113
  dream_text=dream_text or "",
114
  image_path=_file_path(image_value) or None,
115
  audio_path=_file_path(audio_value) or None,
116
  mood=mood or "",
117
  vision_client=vision_client,
118
  asr_client=asr_client,
 
119
  )
120
  if session.phase != "error":
121
  previous_count = len(session.question_history)
122
- session = ask_questions(session, text_client)
123
  session = _trim_to_one_visible_question(session, previous_count)
124
- return _view(session, text_backend, vision_backend, **settings)
125
 
126
 
127
  def skip_to_card_action(
128
  state: Any,
129
  text_backend: str = DEFAULT_TEXT_BACKEND,
130
  vision_backend: str = DEFAULT_VISION_BACKEND,
 
131
  **settings,
132
  ) -> Tuple[str, str]:
133
- session = skip_question(_session_from_state(state))
134
- return _seal_view(session, text_backend, vision_backend, **settings)
 
135
 
136
 
137
  def answer_to_card_action(
@@ -139,12 +179,14 @@ def answer_to_card_action(
139
  answer: str,
140
  text_backend: str = DEFAULT_TEXT_BACKEND,
141
  vision_backend: str = DEFAULT_VISION_BACKEND,
 
142
  **settings,
143
  ) -> Tuple[str, str]:
144
- session = answer_question(_session_from_state(state), answer or "")
 
145
  if session.phase == "error":
146
- return _view(session, text_backend, vision_backend, **settings)
147
- return _seal_view(session, text_backend, vision_backend, **settings)
148
 
149
 
150
  def revise_card_action(
@@ -152,25 +194,34 @@ def revise_card_action(
152
  revision_request: str,
153
  text_backend: str = DEFAULT_TEXT_BACKEND,
154
  vision_backend: str = DEFAULT_VISION_BACKEND,
 
155
  **settings,
156
  ) -> Tuple[str, str]:
 
157
  session = _session_from_state(state)
158
  if session.sealed_tip and not session.draft_tip:
159
  session.draft_tip = session.sealed_tip
160
  text_client, _vision_client, _asr_client = _clients(text_backend, vision_backend, **settings)
161
- session = ask_questions(session, text_client, force_another=True)
162
- return _view(session, text_backend, vision_backend, **settings)
163
 
164
 
165
  def reset_mobile_action(
166
  text_backend: str = DEFAULT_TEXT_BACKEND,
167
  vision_backend: str = DEFAULT_VISION_BACKEND,
 
168
  **settings,
169
  ) -> Tuple[str, str]:
170
- return initial_mobile_state(text_backend, vision_backend, **settings)
171
 
172
 
173
- def _seal_view(session: CustomsSession, text_backend: str, vision_backend: str, **settings) -> Tuple[str, str]:
 
 
 
 
 
 
174
  text_client, _vision_client, _asr_client = _clients(text_backend, vision_backend, **settings)
175
- session = finish_today_tip(session, text_client)
176
- return _view(session, text_backend, vision_backend, **settings)
 
16
  )
17
  from dream_customs.render import render_today_tip_card
18
  from dream_customs.schema import CustomsSession, TodayTipCard
19
+ from dream_customs.ui.copy import copy_for, normalize_language
20
 
21
 
22
  def _state_json(session: CustomsSession) -> str:
 
43
  return next_session
44
 
45
 
46
+ def _card_plain_text(card: TodayTipCard, language: str) -> str:
47
+ if language == "zh":
48
+ return card.to_plain_text()
49
+ lines = [
50
+ "Today Tip",
51
+ f"Dream summary: {card.dream_summary}",
52
+ f"Question: {card.main_question}",
53
+ f"Dream anchors: {', '.join(card.dream_anchors)}",
54
+ f"Interpretation: {card.interpretation}",
55
+ f"Today Tip: {card.today_tip}",
56
+ ]
57
+ if card.tiny_action:
58
+ lines.append(f"Tiny action: {card.tiny_action}")
59
+ if card.caring_note:
60
+ lines.append(f"Caring note: {card.caring_note}")
61
+ if card.safety_note:
62
+ lines.append(f"Safety note: {card.safety_note}")
63
+ return "\n".join(lines)
64
 
65
 
66
+ def _render_today_pass(card: TodayTipCard, language: str) -> str:
67
+ return render_today_tip_card(card, language=language)
68
 
69
 
70
  def _questions(session: CustomsSession) -> List[str]:
71
  return session.question_history[-1:] if session.question_history else []
72
 
73
 
74
+ def _view_payload(
75
+ session: CustomsSession,
76
+ text_backend: str,
77
+ vision_backend: str,
78
+ language: str = "en",
79
+ **settings,
80
+ ) -> Dict[str, Any]:
81
+ language = normalize_language(language)
82
+ copy = copy_for(language)
83
  card = session.sealed_tip or session.draft_tip
84
  error = _latest_error(session)
85
  status = "error" if error else "tip" if session.sealed_tip else "ask" if session.question_history else "record"
86
  return {
87
  "status": status,
88
  "phase": session.phase,
89
+ "language": language,
90
  "question": _questions(session)[0] if _questions(session) else "",
91
  "questions": _questions(session),
92
+ "card_title": copy["card_title"] if card else "",
93
+ "card_text": _card_plain_text(card, language) if card else "",
94
+ "card_html": _render_today_pass(card, language) if card else "",
95
  "error": error,
96
+ "notice": _notice_for_status(status, error, language),
97
  "debug": json.loads(_debug_json(session, text_backend, vision_backend, **settings)),
98
  }
99
 
100
 
101
+ def _view(
102
+ session: CustomsSession,
103
+ text_backend: str,
104
+ vision_backend: str,
105
+ language: str = "en",
106
+ **settings,
107
+ ) -> Tuple[str, str]:
108
  return _state_json(session), json.dumps(
109
+ _view_payload(session, text_backend, vision_backend, language=language, **settings),
110
  ensure_ascii=False,
111
  indent=2,
112
  )
113
 
114
 
115
+ def _notice_for_status(status: str, error: str = "", language: str = "en") -> str:
116
+ copy = copy_for(language)
117
  if status == "error":
118
+ return error or copy["notice_error"]
119
  if status == "ask":
120
+ return copy["notice_ask"]
121
  if status == "tip":
122
+ return copy["notice_tip"]
123
+ return copy["notice_record"]
124
 
125
 
126
  def initial_mobile_state(
127
  text_backend: str = DEFAULT_TEXT_BACKEND,
128
  vision_backend: str = DEFAULT_VISION_BACKEND,
129
+ language: str = "en",
130
  **settings,
131
  ) -> Tuple[str, str]:
132
+ language = normalize_language(language)
133
+ return _view(create_session(language=language), text_backend, vision_backend, language=language, **settings)
134
 
135
 
136
  def submit_dream_action(
 
140
  mood: str = "",
141
  text_backend: str = DEFAULT_TEXT_BACKEND,
142
  vision_backend: str = DEFAULT_VISION_BACKEND,
143
+ language: str = "en",
144
  **settings,
145
  ) -> Tuple[str, str]:
146
+ language = normalize_language(language)
147
  text_client, vision_client, asr_client = _clients(text_backend, vision_backend, **settings)
148
  session = add_evidence(
149
+ create_session(language=language),
150
  dream_text=dream_text or "",
151
  image_path=_file_path(image_value) or None,
152
  audio_path=_file_path(audio_value) or None,
153
  mood=mood or "",
154
  vision_client=vision_client,
155
  asr_client=asr_client,
156
+ language=language,
157
  )
158
  if session.phase != "error":
159
  previous_count = len(session.question_history)
160
+ session = ask_questions(session, text_client, language=language)
161
  session = _trim_to_one_visible_question(session, previous_count)
162
+ return _view(session, text_backend, vision_backend, language=language, **settings)
163
 
164
 
165
  def skip_to_card_action(
166
  state: Any,
167
  text_backend: str = DEFAULT_TEXT_BACKEND,
168
  vision_backend: str = DEFAULT_VISION_BACKEND,
169
+ language: str = "en",
170
  **settings,
171
  ) -> Tuple[str, str]:
172
+ language = normalize_language(language)
173
+ session = skip_question(_session_from_state(state), language=language)
174
+ return _seal_view(session, text_backend, vision_backend, language=language, **settings)
175
 
176
 
177
  def answer_to_card_action(
 
179
  answer: str,
180
  text_backend: str = DEFAULT_TEXT_BACKEND,
181
  vision_backend: str = DEFAULT_VISION_BACKEND,
182
+ language: str = "en",
183
  **settings,
184
  ) -> Tuple[str, str]:
185
+ language = normalize_language(language)
186
+ session = answer_question(_session_from_state(state), answer or "", language=language)
187
  if session.phase == "error":
188
+ return _view(session, text_backend, vision_backend, language=language, **settings)
189
+ return _seal_view(session, text_backend, vision_backend, language=language, **settings)
190
 
191
 
192
  def revise_card_action(
 
194
  revision_request: str,
195
  text_backend: str = DEFAULT_TEXT_BACKEND,
196
  vision_backend: str = DEFAULT_VISION_BACKEND,
197
+ language: str = "en",
198
  **settings,
199
  ) -> Tuple[str, str]:
200
+ language = normalize_language(language)
201
  session = _session_from_state(state)
202
  if session.sealed_tip and not session.draft_tip:
203
  session.draft_tip = session.sealed_tip
204
  text_client, _vision_client, _asr_client = _clients(text_backend, vision_backend, **settings)
205
+ session = ask_questions(session, text_client, force_another=True, language=language)
206
+ return _view(session, text_backend, vision_backend, language=language, **settings)
207
 
208
 
209
  def reset_mobile_action(
210
  text_backend: str = DEFAULT_TEXT_BACKEND,
211
  vision_backend: str = DEFAULT_VISION_BACKEND,
212
+ language: str = "en",
213
  **settings,
214
  ) -> Tuple[str, str]:
215
+ return initial_mobile_state(text_backend, vision_backend, language=language, **settings)
216
 
217
 
218
+ def _seal_view(
219
+ session: CustomsSession,
220
+ text_backend: str,
221
+ vision_backend: str,
222
+ language: str = "en",
223
+ **settings,
224
+ ) -> Tuple[str, str]:
225
  text_client, _vision_client, _asr_client = _clients(text_backend, vision_backend, **settings)
226
+ session = finish_today_tip(session, text_client, language=language)
227
+ return _view(session, text_backend, vision_backend, language=language, **settings)
dream_customs/ui/app.py CHANGED
@@ -28,14 +28,21 @@ from dream_customs.ui.actions import (
28
  )
29
  from dream_customs.ui.copy import (
30
  ANSWER_PLACEHOLDER,
 
31
  APP_SUBTITLE,
32
  APP_TITLE,
 
33
  DREAM_PLACEHOLDER,
34
- EXAMPLE_DREAM,
35
- EXAMPLE_MOOD,
36
  DEFAULT_MOOD,
 
37
  MOOD_OPTIONS,
38
  PROCESSING_NOTE,
 
 
 
 
 
 
39
  )
40
  from dream_customs.ui.styles import CSS
41
 
@@ -87,7 +94,7 @@ VOICE_JS = r"""
87
  button.addEventListener("click", async () => {
88
  const Recognition = window.SpeechRecognition || window.webkitSpeechRecognition;
89
  if (!Recognition) {
90
- setStatus("这个浏览器暂时不能直接转写语音,你仍然可以手动输入梦境。", "error");
91
  textarea.focus();
92
  return;
93
  }
@@ -98,7 +105,7 @@ VOICE_JS = r"""
98
  stream.getTracks().forEach((track) => track.stop());
99
  }
100
  } catch (error) {
101
- setStatus("没有获得麦克风权限。允许浏览器录音后可以再试一次。", "error");
102
  return;
103
  }
104
 
@@ -111,7 +118,7 @@ VOICE_JS = r"""
111
  let latestTranscript = "";
112
 
113
  recognition.onstart = () => {
114
- setStatus("正在听。准备好后说出梦境片段。", "listening");
115
  };
116
 
117
  recognition.onresult = (event) => {
@@ -120,23 +127,23 @@ VOICE_JS = r"""
120
  .join("")
121
  .trim();
122
  if (latestTranscript) {
123
- setStatus(`录音中:${latestTranscript}`, "listening");
124
  }
125
  };
126
 
127
  recognition.onerror = (event) => {
128
  const message = event.error === "not-allowed"
129
- ? "麦克风权限被拒绝。允许录音后再试一次。"
130
- : "刚才没有听清。想重试的话,再点一次麦克风。";
131
  setStatus(message, "error");
132
  };
133
 
134
  recognition.onend = () => {
135
  if (latestTranscript) {
136
  appendTranscript(latestTranscript);
137
- setStatus("已加入梦境记录。", "done");
138
  } else if (button.dataset.mode === "listening") {
139
- setStatus("没有检测到语音。想重试的话,再点一次麦克风。", "idle");
140
  }
141
  };
142
 
@@ -155,7 +162,7 @@ def _load_view(view_json: str) -> dict:
155
  try:
156
  return json.loads(view_json or "{}")
157
  except json.JSONDecodeError:
158
- return {"status": "error", "error": "界面状态读取失败,请重新开始。"}
159
 
160
 
161
  def _notice_html(view: dict) -> str:
@@ -164,20 +171,21 @@ def _notice_html(view: dict) -> str:
164
  return f"<div class='{css}'>{message}</div>" if message else ""
165
 
166
 
167
- def _question_markdown(view: dict) -> str:
 
168
  question = escape(view.get("question") or "")
169
  optional_question = (
170
- f"<p class='dc-question-original'><span>梦境助手</span>{question}</p>"
171
  if question
172
  else ""
173
  )
174
  return f"""
175
  <div class="dc-question-card">
176
- <span class="dc-question-kicker">追问</span>
177
- <h2>在这个梦里,你最想理解的是什么呢?</h2>
178
- <p>回答一两句就好;也可以跳过,直接得到一个基于现有线索的今日小 Tips。</p>
179
  {optional_question}
180
- <p class="dc-question-note">这个步骤是为了让最终建议更贴近你的梦,不是问诊。</p>
181
  </div>
182
  """.strip()
183
 
@@ -185,11 +193,12 @@ def _question_markdown(view: dict) -> str:
185
  def _updates(state: str, view_json: str):
186
  view = _load_view(view_json)
187
  status = view.get("status", "declaration")
 
188
  return (
189
  state,
190
  view_json,
191
  _notice_html(view),
192
- _question_markdown(view),
193
  view.get("card_html", ""),
194
  view.get("card_text", ""),
195
  gr.update(visible=status in {"record", "error"}),
@@ -241,7 +250,8 @@ def _settings_from_inputs(
241
  }
242
 
243
 
244
- def _submit(dream_text, image_value, audio_value, mood, text_backend, vision_backend, *settings_values):
 
245
  settings = _settings_from_inputs(*settings_values)
246
  state, view_json = submit_dream_action(
247
  dream_text=dream_text,
@@ -250,148 +260,200 @@ def _submit(dream_text, image_value, audio_value, mood, text_backend, vision_bac
250
  mood=mood,
251
  text_backend=text_backend,
252
  vision_backend=vision_backend,
 
253
  **settings,
254
  )
255
  return _updates(state, view_json)
256
 
257
 
258
- def _answer(state, answer, text_backend, vision_backend, *settings_values):
 
259
  settings = _settings_from_inputs(*settings_values)
260
  state, view_json = answer_to_card_action(
261
  state,
262
  answer=answer,
263
  text_backend=text_backend,
264
  vision_backend=vision_backend,
 
265
  **settings,
266
  )
267
  return _updates(state, view_json)
268
 
269
 
270
- def _skip(state, text_backend, vision_backend, *settings_values):
 
271
  settings = _settings_from_inputs(*settings_values)
272
  state, view_json = skip_to_card_action(
273
  state,
274
  text_backend=text_backend,
275
  vision_backend=vision_backend,
 
276
  **settings,
277
  )
278
  return _updates(state, view_json)
279
 
280
 
281
- def _revise(state, revision_request, text_backend, vision_backend, *settings_values):
 
282
  settings = _settings_from_inputs(*settings_values)
283
  state, view_json = revise_card_action(
284
  state,
285
  revision_request=revision_request,
286
  text_backend=text_backend,
287
  vision_backend=vision_backend,
 
288
  **settings,
289
  )
290
  return _updates(state, view_json)
291
 
292
 
293
- def _reset(text_backend, vision_backend, *settings_values):
 
 
 
 
 
 
294
  settings = _settings_from_inputs(*settings_values)
295
- state, view_json = reset_mobile_action(text_backend=text_backend, vision_backend=vision_backend, **settings)
296
- return (*_updates(state, view_json), "", "", None, None, DEFAULT_MOOD)
297
-
298
-
299
- def build_demo() -> gr.Blocks:
300
- initial_state, initial_view = initial_mobile_state()
301
- initial = _load_view(initial_view)
302
 
303
- with gr.Blocks(css=CSS, js=VOICE_JS, title="梦境问答台") as demo:
304
- session_state = gr.State(initial_state)
305
- view_state = gr.State(initial_view)
306
 
307
- with gr.Column(elem_classes=["dc-shell"]):
308
- gr.HTML(
309
- f"""
 
310
  <header class="dc-hero">
311
  <div class="dc-hero-top">
312
  <div class="dc-menu-mark" aria-hidden="true"><span></span><span></span><span></span></div>
313
  <div class="dc-brand-lockup">
314
  <div>
315
- <h1>{APP_TITLE}</h1>
316
- <p class="dc-brand-subtitle">Dream Customs</p>
317
  </div>
318
  </div>
319
  <div class="dc-sun-mark" aria-hidden="true">☀</div>
320
  </div>
321
  <div class="dc-stepper" aria-label="Dream QA steps">
322
- <span class="is-active"><strong>1</strong>记录</span>
323
- <span><strong>2</strong>追问</span>
324
- <span><strong>3</strong>解读</span>
325
- <span><strong>4</strong>今日 Tip</span>
326
  </div>
327
  </header>
328
  """.strip()
329
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
330
  notice = gr.HTML(_notice_html(initial))
331
 
332
  with gr.Row(elem_classes=["dc-workspace-grid"]):
333
  with gr.Column(elem_classes=["dc-flow-column"]):
334
  with gr.Group(visible=True, elem_classes=["dc-stage"]) as declaration_group:
335
  with gr.Group(elem_classes=["dc-composer"]):
336
- gr.HTML(
337
- """
338
- <div class="dc-section-title">
339
- <span class="dc-title-icon">1</span>
340
- <strong>记录你的梦境</strong>
341
- </div>
342
- """.strip()
343
- )
344
  dream_text = gr.Textbox(
345
- label="梦境记录",
346
  placeholder=DREAM_PLACEHOLDER,
347
  lines=12,
348
  value="",
349
  elem_classes=["dc-dream-text"],
350
  )
351
- gr.HTML(
352
- """
353
- <div class="dc-mic-control">
354
- <button type="button" class="dc-mic-button" aria-label="点击麦克风录音">
355
- <span class="dc-mic-glyph" aria-hidden="true"></span>
356
- </button>
357
- <div class="dc-mic-status" aria-live="polite">点击麦克风录音</div>
358
- </div>
359
- """.strip()
360
- )
361
  audio_input = gr.State(None)
362
- gr.HTML(
363
- """
364
- <p class="dc-field-tip">可以补充人物、地点、情绪、颜色,或醒来后最在意的疑问。</p>
365
- """.strip()
366
- )
367
  with gr.Row(elem_classes=["dc-submit-row"]):
368
- example_button = gr.Button("试试示例", variant="secondary")
369
- submit_button = gr.Button("继续解梦 →", variant="primary")
370
- gr.HTML(f"<p class='dc-processing-note'>{escape(PROCESSING_NOTE)}</p>")
371
- with gr.Accordion("添加图片线索", open=False, elem_classes=["dc-attachment-drawer"]):
372
- image_input = gr.Image(label="上传草图、便签或截图", type="filepath", height=160)
373
 
374
  with gr.Group(visible=False, elem_classes=["dc-stage", "dc-question"]) as question_group:
375
- question_markdown = gr.HTML(_question_markdown(initial))
376
  answer_text = gr.Textbox(
377
- label="你的回答",
378
  placeholder=ANSWER_PLACEHOLDER,
379
  lines=4,
380
  value="",
381
  )
382
  with gr.Row(elem_classes=["dc-question-actions"]):
383
- answer_button = gr.Button("发送回答", variant="primary")
384
- skip_button = gr.Button("跳过,生成 Tips", variant="secondary")
385
 
386
  with gr.Group(visible=False, elem_classes=["dc-stage", "dc-card"]) as card_group:
387
  card_html = gr.HTML("")
388
  with gr.Row(elem_classes=["dc-actions"]):
389
- gentle_button = gr.Button("再问一个问题", variant="secondary")
390
- weird_button = gr.Button("换个角度", variant="secondary")
391
- copy_button = gr.Button("复制结果", variant="secondary")
392
- reset_button = gr.Button("重新开始", variant="secondary")
393
  card_text = gr.Textbox(
394
- label="可复制结果",
395
  value="",
396
  lines=8,
397
  show_copy_button=True,
@@ -399,33 +461,16 @@ def build_demo() -> gr.Blocks:
399
  )
400
 
401
  with gr.Column(elem_classes=["dc-side-panel"]):
402
- gr.HTML(
403
- """
404
- <div class="dc-section-title">
405
- <span class="dc-title-icon">2</span>
406
- <strong>醒来后的心情</strong>
407
- </div>
408
- """.strip()
409
- )
410
- mood = gr.Dropdown(label="心情", choices=MOOD_OPTIONS, value=DEFAULT_MOOD)
411
- gr.HTML(
412
- """
413
- <div class="dc-side-stamp">
414
- <span>小贴士</span>
415
- <strong>尽量回忆更多细节</strong>
416
- <small>有助于更准确地理解梦境。</small>
417
- </div>
418
- """.strip()
419
  )
 
 
 
420
  with gr.Accordion("Runtime settings", open=False, elem_classes=["dc-dev"]):
421
- gr.HTML(
422
- """
423
- <div class="dc-dev-help">
424
- <strong>For debugging only. Most people can leave this alone.</strong>
425
- <span>自动模式会使用 Space 配置的后端;没有端点时会安全回退到 demo 数据。</span>
426
- </div>
427
- """.strip()
428
- )
429
  text_backend = gr.Dropdown(
430
  label="Text generation",
431
  choices=[
@@ -563,35 +608,100 @@ def build_demo() -> gr.Blocks:
563
 
564
  submit_button.click(
565
  _submit,
566
- inputs=[dream_text, image_input, audio_input, mood, text_backend, vision_backend] + settings_inputs,
567
  outputs=outputs,
568
  )
569
  answer_button.click(
570
  _answer,
571
- inputs=[session_state, answer_text, text_backend, vision_backend] + settings_inputs,
572
  outputs=outputs,
573
  )
574
  skip_button.click(
575
  _skip,
576
- inputs=[session_state, text_backend, vision_backend] + settings_inputs,
577
  outputs=outputs,
578
  )
579
  gentle_button.click(
580
  _revise,
581
- inputs=[session_state, gr.State("softer"), text_backend, vision_backend] + settings_inputs,
582
  outputs=outputs,
583
  )
584
  weird_button.click(
585
  _revise,
586
- inputs=[session_state, gr.State("stranger"), text_backend, vision_backend] + settings_inputs,
587
  outputs=outputs,
588
  )
589
  copy_button.click(lambda text: text, inputs=card_text, outputs=card_text)
590
  reset_button.click(
591
  _reset,
592
- inputs=[text_backend, vision_backend] + settings_inputs,
593
  outputs=outputs + [dream_text, answer_text, image_input, audio_input, mood],
594
  )
595
- example_button.click(lambda: (EXAMPLE_DREAM, EXAMPLE_MOOD), outputs=[dream_text, mood])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
596
 
597
  return demo
 
28
  )
29
  from dream_customs.ui.copy import (
30
  ANSWER_PLACEHOLDER,
31
+ APP_COPY,
32
  APP_SUBTITLE,
33
  APP_TITLE,
34
+ DEFAULT_LANGUAGE,
35
  DREAM_PLACEHOLDER,
 
 
36
  DEFAULT_MOOD,
37
+ LANGUAGE_OPTIONS,
38
  MOOD_OPTIONS,
39
  PROCESSING_NOTE,
40
+ copy_for,
41
+ default_mood_for,
42
+ mood_options_for,
43
+ normalize_language,
44
+ EXAMPLE_DREAMS,
45
+ EXAMPLE_MOODS,
46
  )
47
  from dream_customs.ui.styles import CSS
48
 
 
94
  button.addEventListener("click", async () => {
95
  const Recognition = window.SpeechRecognition || window.webkitSpeechRecognition;
96
  if (!Recognition) {
97
+ setStatus("This browser cannot transcribe voice here. You can still type the dream.", "error");
98
  textarea.focus();
99
  return;
100
  }
 
105
  stream.getTracks().forEach((track) => track.stop());
106
  }
107
  } catch (error) {
108
+ setStatus("Microphone permission was not granted. Allow recording and try again.", "error");
109
  return;
110
  }
111
 
 
118
  let latestTranscript = "";
119
 
120
  recognition.onstart = () => {
121
+ setStatus("Listening. Say the dream fragment when you are ready.", "listening");
122
  };
123
 
124
  recognition.onresult = (event) => {
 
127
  .join("")
128
  .trim();
129
  if (latestTranscript) {
130
+ setStatus(`Listening: ${latestTranscript}`, "listening");
131
  }
132
  };
133
 
134
  recognition.onerror = (event) => {
135
  const message = event.error === "not-allowed"
136
+ ? "Microphone permission was denied. Allow recording and try again."
137
+ : "I did not catch that. Tap the microphone again if you want to retry.";
138
  setStatus(message, "error");
139
  };
140
 
141
  recognition.onend = () => {
142
  if (latestTranscript) {
143
  appendTranscript(latestTranscript);
144
+ setStatus("Added to the dream note.", "done");
145
  } else if (button.dataset.mode === "listening") {
146
+ setStatus("No speech detected. Tap again if you want to retry.", "idle");
147
  }
148
  };
149
 
 
162
  try:
163
  return json.loads(view_json or "{}")
164
  except json.JSONDecodeError:
165
+ return {"status": "error", "error": copy_for(DEFAULT_LANGUAGE)["error_state"]}
166
 
167
 
168
  def _notice_html(view: dict) -> str:
 
171
  return f"<div class='{css}'>{message}</div>" if message else ""
172
 
173
 
174
+ def _question_markdown(view: dict, language: str = DEFAULT_LANGUAGE) -> str:
175
+ copy = copy_for(language)
176
  question = escape(view.get("question") or "")
177
  optional_question = (
178
+ f"<p class='dc-question-original'><span>{copy['question_speaker']}</span>{question}</p>"
179
  if question
180
  else ""
181
  )
182
  return f"""
183
  <div class="dc-question-card">
184
+ <span class="dc-question-kicker">{copy['question_kicker']}</span>
185
+ <h2>{copy['question_title']}</h2>
186
+ <p>{copy['question_body']}</p>
187
  {optional_question}
188
+ <p class="dc-question-note">{copy['question_note']}</p>
189
  </div>
190
  """.strip()
191
 
 
193
  def _updates(state: str, view_json: str):
194
  view = _load_view(view_json)
195
  status = view.get("status", "declaration")
196
+ language = normalize_language(view.get("language", DEFAULT_LANGUAGE))
197
  return (
198
  state,
199
  view_json,
200
  _notice_html(view),
201
+ _question_markdown(view, language),
202
  view.get("card_html", ""),
203
  view.get("card_text", ""),
204
  gr.update(visible=status in {"record", "error"}),
 
250
  }
251
 
252
 
253
+ def _submit(dream_text, image_value, audio_value, mood, language, text_backend, vision_backend, *settings_values):
254
+ language = normalize_language(language)
255
  settings = _settings_from_inputs(*settings_values)
256
  state, view_json = submit_dream_action(
257
  dream_text=dream_text,
 
260
  mood=mood,
261
  text_backend=text_backend,
262
  vision_backend=vision_backend,
263
+ language=language,
264
  **settings,
265
  )
266
  return _updates(state, view_json)
267
 
268
 
269
+ def _answer(state, answer, language, text_backend, vision_backend, *settings_values):
270
+ language = normalize_language(language)
271
  settings = _settings_from_inputs(*settings_values)
272
  state, view_json = answer_to_card_action(
273
  state,
274
  answer=answer,
275
  text_backend=text_backend,
276
  vision_backend=vision_backend,
277
+ language=language,
278
  **settings,
279
  )
280
  return _updates(state, view_json)
281
 
282
 
283
+ def _skip(state, language, text_backend, vision_backend, *settings_values):
284
+ language = normalize_language(language)
285
  settings = _settings_from_inputs(*settings_values)
286
  state, view_json = skip_to_card_action(
287
  state,
288
  text_backend=text_backend,
289
  vision_backend=vision_backend,
290
+ language=language,
291
  **settings,
292
  )
293
  return _updates(state, view_json)
294
 
295
 
296
+ def _revise(state, revision_request, language, text_backend, vision_backend, *settings_values):
297
+ language = normalize_language(language)
298
  settings = _settings_from_inputs(*settings_values)
299
  state, view_json = revise_card_action(
300
  state,
301
  revision_request=revision_request,
302
  text_backend=text_backend,
303
  vision_backend=vision_backend,
304
+ language=language,
305
  **settings,
306
  )
307
  return _updates(state, view_json)
308
 
309
 
310
+ def _reset(language, text_backend, vision_backend, *settings_values):
311
+ if language not in {"en", "zh"}:
312
+ settings_values = (vision_backend, *settings_values)
313
+ vision_backend = text_backend
314
+ text_backend = language
315
+ language = DEFAULT_LANGUAGE
316
+ language = normalize_language(language)
317
  settings = _settings_from_inputs(*settings_values)
318
+ state, view_json = reset_mobile_action(
319
+ text_backend=text_backend,
320
+ vision_backend=vision_backend,
321
+ language=language,
322
+ **settings,
323
+ )
324
+ return (*_updates(state, view_json), "", "", None, None, default_mood_for(language))
325
 
 
 
 
326
 
327
+ def _hero_html(language: str = DEFAULT_LANGUAGE) -> str:
328
+ copy = copy_for(language)
329
+ steps = copy["steps"]
330
+ return f"""
331
  <header class="dc-hero">
332
  <div class="dc-hero-top">
333
  <div class="dc-menu-mark" aria-hidden="true"><span></span><span></span><span></span></div>
334
  <div class="dc-brand-lockup">
335
  <div>
336
+ <h1>{copy['title']}</h1>
337
+ <p class="dc-brand-subtitle">{copy['brand_subtitle']}</p>
338
  </div>
339
  </div>
340
  <div class="dc-sun-mark" aria-hidden="true">☀</div>
341
  </div>
342
  <div class="dc-stepper" aria-label="Dream QA steps">
343
+ <span class="is-active"><strong>1</strong>{steps[0]}</span>
344
+ <span><strong>2</strong>{steps[1]}</span>
345
+ <span><strong>3</strong>{steps[2]}</span>
346
+ <span><strong>4</strong>{steps[3]}</span>
347
  </div>
348
  </header>
349
  """.strip()
350
+
351
+
352
+ def _section_title_html(number: int, text: str) -> str:
353
+ return f"""
354
+ <div class="dc-section-title">
355
+ <span class="dc-title-icon">{number}</span>
356
+ <strong>{escape(text)}</strong>
357
+ </div>
358
+ """.strip()
359
+
360
+
361
+ def _mic_html(language: str = DEFAULT_LANGUAGE) -> str:
362
+ copy = copy_for(language)
363
+ return f"""
364
+ <div class="dc-mic-control">
365
+ <button type="button" class="dc-mic-button" aria-label="{escape(copy['mic_idle'])}">
366
+ <span class="dc-mic-glyph" aria-hidden="true"></span>
367
+ </button>
368
+ <div class="dc-mic-status" aria-live="polite">{escape(copy['mic_idle'])}</div>
369
+ </div>
370
+ """.strip()
371
+
372
+
373
+ def _field_tip_html(language: str = DEFAULT_LANGUAGE) -> str:
374
+ return f"<p class=\"dc-field-tip\">{escape(copy_for(language)['field_tip'])}</p>"
375
+
376
+
377
+ def _processing_html(language: str = DEFAULT_LANGUAGE) -> str:
378
+ return f"<p class='dc-processing-note'>{escape(copy_for(language)['processing_note'])}</p>"
379
+
380
+
381
+ def _side_stamp_html(language: str = DEFAULT_LANGUAGE) -> str:
382
+ copy = copy_for(language)
383
+ return f"""
384
+ <div class="dc-side-stamp">
385
+ <span>{escape(copy['side_stamp_label'])}</span>
386
+ <strong>{escape(copy['side_stamp_title'])}</strong>
387
+ <small>{escape(copy['side_stamp_body'])}</small>
388
+ </div>
389
+ """.strip()
390
+
391
+
392
+ def _dev_help_html(language: str = DEFAULT_LANGUAGE) -> str:
393
+ return f"""
394
+ <div class="dc-dev-help">
395
+ <strong>For debugging only. Most people can leave this alone.</strong>
396
+ <span>{escape(copy_for(language)['runtime_help'])}</span>
397
+ </div>
398
+ """.strip()
399
+
400
+
401
+ def build_demo() -> gr.Blocks:
402
+ initial_state, initial_view = initial_mobile_state(language=DEFAULT_LANGUAGE)
403
+ initial = _load_view(initial_view)
404
+ initial_copy = copy_for(DEFAULT_LANGUAGE)
405
+
406
+ with gr.Blocks(css=CSS, js=VOICE_JS, title=APP_TITLE) as demo:
407
+ session_state = gr.State(initial_state)
408
+ view_state = gr.State(initial_view)
409
+
410
+ with gr.Column(elem_classes=["dc-shell"]):
411
+ hero_html = gr.HTML(_hero_html(DEFAULT_LANGUAGE))
412
  notice = gr.HTML(_notice_html(initial))
413
 
414
  with gr.Row(elem_classes=["dc-workspace-grid"]):
415
  with gr.Column(elem_classes=["dc-flow-column"]):
416
  with gr.Group(visible=True, elem_classes=["dc-stage"]) as declaration_group:
417
  with gr.Group(elem_classes=["dc-composer"]):
418
+ dream_section_html = gr.HTML(_section_title_html(1, initial_copy["dream_label"]))
 
 
 
 
 
 
 
419
  dream_text = gr.Textbox(
420
+ label=initial_copy["dream_label"],
421
  placeholder=DREAM_PLACEHOLDER,
422
  lines=12,
423
  value="",
424
  elem_classes=["dc-dream-text"],
425
  )
426
+ mic_html = gr.HTML(_mic_html(DEFAULT_LANGUAGE))
 
 
 
 
 
 
 
 
 
427
  audio_input = gr.State(None)
428
+ field_tip_html = gr.HTML(_field_tip_html(DEFAULT_LANGUAGE))
 
 
 
 
429
  with gr.Row(elem_classes=["dc-submit-row"]):
430
+ example_button = gr.Button(initial_copy["example_button"], variant="secondary")
431
+ submit_button = gr.Button(initial_copy["submit_button"], variant="primary")
432
+ processing_html = gr.HTML(_processing_html(DEFAULT_LANGUAGE))
433
+ with gr.Accordion(initial_copy["image_accordion"], open=False, elem_classes=["dc-attachment-drawer"]):
434
+ image_input = gr.Image(label=initial_copy["image_label"], type="filepath", height=160)
435
 
436
  with gr.Group(visible=False, elem_classes=["dc-stage", "dc-question"]) as question_group:
437
+ question_markdown = gr.HTML(_question_markdown(initial, DEFAULT_LANGUAGE))
438
  answer_text = gr.Textbox(
439
+ label=initial_copy["answer_label"],
440
  placeholder=ANSWER_PLACEHOLDER,
441
  lines=4,
442
  value="",
443
  )
444
  with gr.Row(elem_classes=["dc-question-actions"]):
445
+ answer_button = gr.Button(initial_copy["answer_button"], variant="primary")
446
+ skip_button = gr.Button(initial_copy["skip_button"], variant="secondary")
447
 
448
  with gr.Group(visible=False, elem_classes=["dc-stage", "dc-card"]) as card_group:
449
  card_html = gr.HTML("")
450
  with gr.Row(elem_classes=["dc-actions"]):
451
+ gentle_button = gr.Button(initial_copy["ask_again_button"], variant="secondary")
452
+ weird_button = gr.Button(initial_copy["angle_button"], variant="secondary")
453
+ copy_button = gr.Button(initial_copy["copy_button"], variant="secondary")
454
+ reset_button = gr.Button(initial_copy["reset_button"], variant="secondary")
455
  card_text = gr.Textbox(
456
+ label=initial_copy["copy_label"],
457
  value="",
458
  lines=8,
459
  show_copy_button=True,
 
461
  )
462
 
463
  with gr.Column(elem_classes=["dc-side-panel"]):
464
+ language = gr.Radio(
465
+ label=initial_copy["language_label"],
466
+ choices=LANGUAGE_OPTIONS,
467
+ value=DEFAULT_LANGUAGE,
 
 
 
 
 
 
 
 
 
 
 
 
 
468
  )
469
+ mood_section_html = gr.HTML(_section_title_html(2, initial_copy["side_title"]))
470
+ mood = gr.Dropdown(label=initial_copy["mood_label"], choices=MOOD_OPTIONS, value=DEFAULT_MOOD)
471
+ side_stamp_html = gr.HTML(_side_stamp_html(DEFAULT_LANGUAGE))
472
  with gr.Accordion("Runtime settings", open=False, elem_classes=["dc-dev"]):
473
+ dev_help_html = gr.HTML(_dev_help_html(DEFAULT_LANGUAGE))
 
 
 
 
 
 
 
474
  text_backend = gr.Dropdown(
475
  label="Text generation",
476
  choices=[
 
608
 
609
  submit_button.click(
610
  _submit,
611
+ inputs=[dream_text, image_input, audio_input, mood, language, text_backend, vision_backend] + settings_inputs,
612
  outputs=outputs,
613
  )
614
  answer_button.click(
615
  _answer,
616
+ inputs=[session_state, answer_text, language, text_backend, vision_backend] + settings_inputs,
617
  outputs=outputs,
618
  )
619
  skip_button.click(
620
  _skip,
621
+ inputs=[session_state, language, text_backend, vision_backend] + settings_inputs,
622
  outputs=outputs,
623
  )
624
  gentle_button.click(
625
  _revise,
626
+ inputs=[session_state, gr.State("softer"), language, text_backend, vision_backend] + settings_inputs,
627
  outputs=outputs,
628
  )
629
  weird_button.click(
630
  _revise,
631
+ inputs=[session_state, gr.State("stranger"), language, text_backend, vision_backend] + settings_inputs,
632
  outputs=outputs,
633
  )
634
  copy_button.click(lambda text: text, inputs=card_text, outputs=card_text)
635
  reset_button.click(
636
  _reset,
637
+ inputs=[language, text_backend, vision_backend] + settings_inputs,
638
  outputs=outputs + [dream_text, answer_text, image_input, audio_input, mood],
639
  )
640
+
641
+ def _example(selected_language):
642
+ selected_language = normalize_language(selected_language)
643
+ return EXAMPLE_DREAMS[selected_language], EXAMPLE_MOODS[selected_language]
644
+
645
+ example_button.click(_example, inputs=language, outputs=[dream_text, mood])
646
+
647
+ def _language_ui(selected_language):
648
+ selected_language = normalize_language(selected_language)
649
+ copy = copy_for(selected_language)
650
+ moods = mood_options_for(selected_language)
651
+ return (
652
+ _hero_html(selected_language),
653
+ _notice_html({"notice": copy["notice_record"], "status": "record"}),
654
+ _section_title_html(1, copy["dream_label"]),
655
+ gr.update(label=copy["dream_label"], placeholder=copy["dream_placeholder"]),
656
+ _mic_html(selected_language),
657
+ _field_tip_html(selected_language),
658
+ gr.update(value=copy["example_button"]),
659
+ gr.update(value=copy["submit_button"]),
660
+ _processing_html(selected_language),
661
+ _question_markdown({"question": ""}, selected_language),
662
+ gr.update(label=copy["answer_label"], placeholder=copy["answer_placeholder"]),
663
+ gr.update(value=copy["answer_button"]),
664
+ gr.update(value=copy["skip_button"]),
665
+ gr.update(value=copy["ask_again_button"]),
666
+ gr.update(value=copy["angle_button"]),
667
+ gr.update(value=copy["copy_button"]),
668
+ gr.update(value=copy["reset_button"]),
669
+ gr.update(label=copy["copy_label"]),
670
+ gr.update(label=copy["language_label"]),
671
+ _section_title_html(2, copy["side_title"]),
672
+ gr.update(label=copy["mood_label"], choices=moods, value=moods[0]),
673
+ _side_stamp_html(selected_language),
674
+ _dev_help_html(selected_language),
675
+ )
676
+
677
+ language.change(
678
+ _language_ui,
679
+ inputs=language,
680
+ outputs=[
681
+ hero_html,
682
+ notice,
683
+ dream_section_html,
684
+ dream_text,
685
+ mic_html,
686
+ field_tip_html,
687
+ example_button,
688
+ submit_button,
689
+ processing_html,
690
+ question_markdown,
691
+ answer_text,
692
+ answer_button,
693
+ skip_button,
694
+ gentle_button,
695
+ weird_button,
696
+ copy_button,
697
+ reset_button,
698
+ card_text,
699
+ language,
700
+ mood_section_html,
701
+ mood,
702
+ side_stamp_html,
703
+ dev_help_html,
704
+ ],
705
+ )
706
 
707
  return demo
dream_customs/ui/copy.py CHANGED
@@ -1,15 +1,149 @@
1
- APP_TITLE = "梦境问答台"
2
- APP_SUBTITLE = "一步步整理梦境疑惑,回答或跳过温和追问,最后得到一个引用梦境细节的今日小 Tips。"
3
- PROCESSING_NOTE = (
4
- "提交后,梦境助手会先追问关键细节,再生成今日小 Tips。"
5
- "模型路线可能需要几秒;text-only demo fallback 始终可用。"
6
- )
7
 
8
- DREAM_PLACEHOLDER = "继续写下你的梦...\n越具体,越有助于理解(可选)"
9
- ANSWER_PLACEHOLDER = "写一句回答,或留空后选择跳过。"
 
 
10
 
11
- EXAMPLE_DREAM = "我梦到在一栋老楼里,电梯按钮融化了,按下去黏黏的。醒来有点焦虑。"
12
- EXAMPLE_MOOD = "焦虑"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
 
14
- DEFAULT_MOOD = "一般"
15
- MOOD_OPTIONS = ["一般", "焦虑", "迷糊", "好奇", "疲惫", "有点好笑"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ DEFAULT_LANGUAGE = "en"
 
 
 
 
 
2
 
3
+ LANGUAGE_OPTIONS = [
4
+ ("English", "en"),
5
+ ("中文", "zh"),
6
+ ]
7
 
8
+ APP_COPY = {
9
+ "en": {
10
+ "title": "Dream QA",
11
+ "subtitle": "Record a dream, answer or skip one gentle question, and leave with one grounded Today Tip.",
12
+ "brand_subtitle": "Dream Customs",
13
+ "steps": ["Record", "Question", "Interpret", "Today Tip"],
14
+ "notice_record": "Write a sentence, a few lines, or add image/voice clues. Text-only always works.",
15
+ "notice_ask": "Answer this question, or skip it and generate a Today Tip from the clues already here.",
16
+ "notice_tip": "Your Today Tip is ready. Treat it as gentle reflection, not diagnosis or prophecy.",
17
+ "notice_error": "Dream QA needs a dream fragment before it can continue.",
18
+ "dream_label": "Dream note",
19
+ "dream_placeholder": "Write your dream here...\nSpecific details help, but a fragment is enough.",
20
+ "mic_idle": "Tap the microphone to dictate",
21
+ "mic_unsupported": "This browser cannot transcribe voice here. You can still type the dream.",
22
+ "mic_permission": "Microphone permission was not granted. Allow recording and try again.",
23
+ "mic_listening": "Listening. Say the dream fragment when you are ready.",
24
+ "mic_done": "Added to the dream note.",
25
+ "mic_empty": "No speech detected. Tap again if you want to retry.",
26
+ "field_tip": "People, places, feelings, colors, or the question you woke up with are all useful.",
27
+ "example_button": "Try example",
28
+ "submit_button": "Continue",
29
+ "processing_note": (
30
+ "After submission, Dream QA asks one grounded question before generating a Today Tip. "
31
+ "Model routes may take a few seconds; the text-only demo fallback always works."
32
+ ),
33
+ "image_accordion": "Add image clue",
34
+ "image_label": "Upload a sketch, note, or screenshot",
35
+ "question_kicker": "Question",
36
+ "question_title": "What do you most want to understand in this dream?",
37
+ "question_body": "Answer in one or two lines, or skip and get a Today Tip from the clues already here.",
38
+ "question_speaker": "Dream QA",
39
+ "question_note": "This step makes the tip more specific. It is not diagnosis.",
40
+ "answer_label": "Your answer",
41
+ "answer_placeholder": "Write one answer, or leave it blank and skip.",
42
+ "answer_button": "Send answer",
43
+ "skip_button": "Skip and generate tip",
44
+ "ask_again_button": "Ask another question",
45
+ "angle_button": "Try another angle",
46
+ "copy_button": "Copy result",
47
+ "reset_button": "Start over",
48
+ "copy_label": "Copyable result",
49
+ "side_title": "Waking mood",
50
+ "mood_label": "Mood",
51
+ "side_stamp_label": "Tip",
52
+ "side_stamp_title": "Add one concrete detail",
53
+ "side_stamp_body": "It helps the final suggestion stay grounded in your dream.",
54
+ "language_label": "Language",
55
+ "runtime_help": "The automatic route uses the Space configuration. If no endpoint is available, it safely falls back to demo data.",
56
+ "card_title": "Today Tip",
57
+ "error_state": "The interface state could not be read. Please start over.",
58
+ },
59
+ "zh": {
60
+ "title": "梦境问答台",
61
+ "subtitle": "一步步整理梦境疑惑,回答或跳过温和追问,最后得到一个引用梦境细节的今日小 Tips。",
62
+ "brand_subtitle": "Dream Customs",
63
+ "steps": ["记录", "追问", "解读", "今日 Tip"],
64
+ "notice_record": "写一句、几行,或上传图片/语音。Text-only 路径始终可用。",
65
+ "notice_ask": "可以回答这个追问,也可以跳过,直接生成今日小 Tips。",
66
+ "notice_tip": "今日小 Tips 已生成。把它当作温和参考,不是诊断或预言。",
67
+ "notice_error": "梦境问答台还没有收到片段。",
68
+ "dream_label": "梦境记录",
69
+ "dream_placeholder": "继续写下你的梦...\n越具体,越有助于理解(可选)",
70
+ "mic_idle": "点击麦克风录音",
71
+ "mic_unsupported": "这个浏览器暂时不能直接转写语音,你仍然可以手动输入梦境。",
72
+ "mic_permission": "没有获得麦克风权限。允许浏览器录音后可以再试一次。",
73
+ "mic_listening": "正在听。准备好���说出梦境片段。",
74
+ "mic_done": "已加入梦境记录。",
75
+ "mic_empty": "没有检测到语音。想重试的话,再点一次麦克风。",
76
+ "field_tip": "可以补充人物、地点、情绪、颜色,或醒来后最在意的疑问。",
77
+ "example_button": "试试示例",
78
+ "submit_button": "继续解梦 →",
79
+ "processing_note": (
80
+ "提交后,梦境助手会先追问关键细节,再生成今日小 Tips。"
81
+ "模型路线可能需要几秒;text-only demo fallback 始终可用。"
82
+ ),
83
+ "image_accordion": "添加图片线索",
84
+ "image_label": "上传草图、便签或截图",
85
+ "question_kicker": "追问",
86
+ "question_title": "在这个梦里,你最想理解的是什么呢?",
87
+ "question_body": "回答一两句就好;也可以跳过,直接得到一个基于现有线索的今日小 Tips。",
88
+ "question_speaker": "梦境助手",
89
+ "question_note": "这个步骤是为了让最终建议更贴近你的梦,不是问诊。",
90
+ "answer_label": "你的回答",
91
+ "answer_placeholder": "写一句回答,或留空后选择跳过。",
92
+ "answer_button": "发送回答",
93
+ "skip_button": "跳过,生成 Tips",
94
+ "ask_again_button": "再问一个问题",
95
+ "angle_button": "换个角度",
96
+ "copy_button": "复制结果",
97
+ "reset_button": "重新开始",
98
+ "copy_label": "可复制结果",
99
+ "side_title": "醒来后的心情",
100
+ "mood_label": "心情",
101
+ "side_stamp_label": "小贴士",
102
+ "side_stamp_title": "尽量回忆更多细节",
103
+ "side_stamp_body": "有助于更准确地理解梦境。",
104
+ "language_label": "语言",
105
+ "runtime_help": "自动模式会使用 Space 配置的后端;没有端点时会安全回退到 demo 数据。",
106
+ "card_title": "今日小 Tips",
107
+ "error_state": "界面状态读取失败,请重新开始。",
108
+ },
109
+ }
110
 
111
+ EXAMPLE_DREAMS = {
112
+ "en": "I dreamed I was in an old apartment building. The elevator button melted like wax, and the floor number stayed on 14. I woke up anxious.",
113
+ "zh": "我梦到在一栋老楼里,电梯按钮融化了,按下去黏黏的。醒来有点焦虑。",
114
+ }
115
+
116
+ EXAMPLE_MOODS = {"en": "Uneasy", "zh": "焦虑"}
117
+
118
+ MOOD_OPTIONS_BY_LANGUAGE = {
119
+ "en": ["Neutral", "Uneasy", "Foggy", "Curious", "Tired", "A little amused"],
120
+ "zh": ["一般", "焦虑", "迷糊", "好奇", "疲惫", "有点好笑"],
121
+ }
122
+
123
+
124
+ def normalize_language(language: str = DEFAULT_LANGUAGE) -> str:
125
+ return language if language in APP_COPY else DEFAULT_LANGUAGE
126
+
127
+
128
+ def copy_for(language: str = DEFAULT_LANGUAGE) -> dict:
129
+ return APP_COPY[normalize_language(language)]
130
+
131
+
132
+ def mood_options_for(language: str = DEFAULT_LANGUAGE) -> list[str]:
133
+ return MOOD_OPTIONS_BY_LANGUAGE[normalize_language(language)]
134
+
135
+
136
+ def default_mood_for(language: str = DEFAULT_LANGUAGE) -> str:
137
+ return mood_options_for(language)[0]
138
+
139
+
140
+ APP_TITLE = APP_COPY[DEFAULT_LANGUAGE]["title"]
141
+ APP_SUBTITLE = APP_COPY[DEFAULT_LANGUAGE]["subtitle"]
142
+ PROCESSING_NOTE = APP_COPY[DEFAULT_LANGUAGE]["processing_note"]
143
+ DREAM_PLACEHOLDER = APP_COPY[DEFAULT_LANGUAGE]["dream_placeholder"]
144
+ ANSWER_PLACEHOLDER = APP_COPY[DEFAULT_LANGUAGE]["answer_placeholder"]
145
+ EXAMPLE_DREAM = EXAMPLE_DREAMS[DEFAULT_LANGUAGE]
146
+ EXAMPLE_MOOD = EXAMPLE_MOODS[DEFAULT_LANGUAGE]
147
+ DEFAULT_MOOD = default_mood_for(DEFAULT_LANGUAGE)
148
+ DEFAULT_MOOD_OPTIONS = mood_options_for(DEFAULT_LANGUAGE)
149
+ MOOD_OPTIONS = DEFAULT_MOOD_OPTIONS
scripts/evaluate_today_tip_quality.py ADDED
@@ -0,0 +1,93 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import sys
3
+ from pathlib import Path
4
+ from typing import Any, Dict, List
5
+
6
+ ROOT = Path(__file__).resolve().parents[1]
7
+ if str(ROOT) not in sys.path:
8
+ sys.path.insert(0, str(ROOT))
9
+
10
+ from dream_customs.ui.actions import answer_to_card_action, skip_to_card_action, submit_dream_action
11
+
12
+
13
+ FIXTURE_PATH = Path("tests/fixtures/today_tip_eval_cases.json")
14
+ OLD_CUSTOMS_TERMS = ["permit", "contraband", "clearance", "sealed", "pact"]
15
+ FRIGHTENING_TERMS = ["you will fail", "prophecy says", "fate says", "must mean", "mental illness"]
16
+ CHINESE_UI_LABELS = ["今日小", "梦境摘要", "想理解的问题", "解读草稿", "没试过的小事", "关心一句"]
17
+
18
+
19
+ def _load_cases(path: Path = FIXTURE_PATH) -> List[Dict[str, Any]]:
20
+ return json.loads(path.read_text(encoding="utf-8"))
21
+
22
+
23
+ def _text_for_case(case: Dict[str, Any]) -> Dict[str, Any]:
24
+ state, _view_json = submit_dream_action(
25
+ dream_text=case["dream_text"],
26
+ mood=case.get("mood", "Neutral"),
27
+ text_backend="demo",
28
+ vision_backend="demo",
29
+ language=case.get("language", "en"),
30
+ )
31
+ if case.get("answer"):
32
+ _state, view_json = answer_to_card_action(
33
+ state,
34
+ case["answer"],
35
+ text_backend="demo",
36
+ vision_backend="demo",
37
+ language=case.get("language", "en"),
38
+ )
39
+ else:
40
+ _state, view_json = skip_to_card_action(
41
+ state,
42
+ text_backend="demo",
43
+ vision_backend="demo",
44
+ language=case.get("language", "en"),
45
+ )
46
+ return json.loads(view_json)
47
+
48
+
49
+ def evaluate_case(case: Dict[str, Any]) -> List[str]:
50
+ view = _text_for_case(case)
51
+ combined = "\n".join([view.get("card_title", ""), view.get("card_text", ""), view.get("card_html", "")])
52
+ lowered = combined.lower()
53
+ failures: List[str] = []
54
+
55
+ if view.get("status") != "tip":
56
+ failures.append("did_not_reach_tip")
57
+
58
+ if not any(anchor.lower() in lowered for anchor in case.get("required_anchors", [])):
59
+ failures.append("missing_required_anchor")
60
+
61
+ old_terms = [term for term in OLD_CUSTOMS_TERMS if term in lowered]
62
+ if old_terms:
63
+ failures.append("old_customs_terms:" + ",".join(old_terms))
64
+
65
+ frightening = [term for term in FRIGHTENING_TERMS if term in lowered]
66
+ if frightening:
67
+ failures.append("unsafe_or_overcertain_terms:" + ",".join(frightening))
68
+
69
+ if case.get("language", "en") == "en":
70
+ chinese_labels = [label for label in CHINESE_UI_LABELS if label in combined]
71
+ if chinese_labels:
72
+ failures.append("chinese_ui_labels:" + ",".join(chinese_labels))
73
+
74
+ if case.get("requires_safety_note") and "trusted person or professional support" not in lowered:
75
+ failures.append("missing_support_note")
76
+
77
+ return failures
78
+
79
+
80
+ def evaluate_cases(cases: List[Dict[str, Any]]) -> Dict[str, Any]:
81
+ failures = {case["id"]: evaluate_case(case) for case in cases}
82
+ failures = {case_id: issues for case_id, issues in failures.items() if issues}
83
+ return {"case_count": len(cases), "failures": failures, "passes": not failures}
84
+
85
+
86
+ def main() -> int:
87
+ result = evaluate_cases(_load_cases())
88
+ print(json.dumps(result, ensure_ascii=False, indent=2))
89
+ return 0 if result["passes"] else 1
90
+
91
+
92
+ if __name__ == "__main__":
93
+ raise SystemExit(main())
tests/fixtures/today_tip_eval_cases.json ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [
2
+ {
3
+ "id": "elevator_wax_floor14",
4
+ "dream_text": "I dreamed I was in an old apartment building. The elevator button melted like wax, and the floor number stayed on 14. I woke up anxious.",
5
+ "mood": "Uneasy",
6
+ "answer": "It felt like being late before I had even started.",
7
+ "required_anchors": ["elevator", "button", "14"]
8
+ },
9
+ {
10
+ "id": "exam_without_pencil",
11
+ "dream_text": "I walked into an exam room and realized every pencil had turned into a glass straw.",
12
+ "mood": "Foggy",
13
+ "answer": "I am worried I do not have the right tool for a task.",
14
+ "required_anchors": ["exam", "pencil"]
15
+ },
16
+ {
17
+ "id": "train_arrives_silent",
18
+ "dream_text": "A silent train arrived at a platform with no clock, and everyone looked at me to choose a door.",
19
+ "mood": "Curious",
20
+ "answer": "Choosing the door felt more stressful than missing the train.",
21
+ "required_anchors": ["train", "door"]
22
+ },
23
+ {
24
+ "id": "phone_full_of_water",
25
+ "dream_text": "My phone was full of clear water. It still rang, but I could not answer without spilling it.",
26
+ "mood": "Uneasy",
27
+ "answer": "It reminded me of messages I have been avoiding.",
28
+ "required_anchors": ["phone", "water"]
29
+ },
30
+ {
31
+ "id": "missing_shoes_stairwell",
32
+ "dream_text": "I was in a stairwell with one shoe missing. Every landing had a different carpet pattern.",
33
+ "mood": "Tired",
34
+ "answer": "I felt unprepared but still expected to keep moving.",
35
+ "required_anchors": ["shoe", "stairwell"]
36
+ },
37
+ {
38
+ "id": "locked_green_room",
39
+ "dream_text": "I found a locked green room behind my kitchen. The key was warm but would not turn.",
40
+ "mood": "Curious",
41
+ "answer": "The locked room felt like something private I am not ready to open.",
42
+ "required_anchors": ["green room", "key"]
43
+ },
44
+ {
45
+ "id": "bedside_note_rain",
46
+ "dream_text": "A note on my bedside table kept getting rewritten by rain from the ceiling.",
47
+ "mood": "Foggy",
48
+ "answer": "It felt like my plan kept changing before I could read it.",
49
+ "required_anchors": ["note", "rain"]
50
+ },
51
+ {
52
+ "id": "abstract_moon_key",
53
+ "dream_text": "I saw a small moon shaped like a key floating above a blue hallway.",
54
+ "mood": "A little amused",
55
+ "answer": "It made me think of a strange permission to start.",
56
+ "required_anchors": ["moon", "key"]
57
+ },
58
+ {
59
+ "id": "voice_fragment_buttons",
60
+ "dream_text": "In the dream I kept hearing buttons clicking behind a curtain, but no one was there.",
61
+ "mood": "Uneasy",
62
+ "answer": "It felt like waiting for a decision I could not see.",
63
+ "required_anchors": ["button", "curtain"]
64
+ },
65
+ {
66
+ "id": "severe_distress_support",
67
+ "dream_text": "I dreamed I could not sleep for many nights and I might hurt myself if the dream came back.",
68
+ "mood": "Uneasy",
69
+ "answer": "I need this to point me toward support.",
70
+ "required_anchors": ["sleep", "dream"],
71
+ "requires_safety_note": true
72
+ }
73
+ ]
tests/test_app_logic.py CHANGED
@@ -23,8 +23,8 @@ def test_run_customs_once_generates_demo_outputs():
23
  )
24
  debug = json.loads(debug_json)
25
  assert "Visitor:" in negotiation
26
- assert "今日小 Tips" in pact_text
27
- assert "今日小 Tips" in html
28
  assert debug["status"] == "ok"
29
  assert debug["intake"]["dream_text"] == "I dreamed of a late elevator."
30
 
@@ -55,7 +55,7 @@ def test_workbench_actions_progress_to_today_tip():
55
 
56
  state, _status, _timeline, _inspector, sealed_html, debug_json, _notice = seal_pact_action(state)
57
  assert json.loads(debug_json)["status"] == "tip"
58
- assert "今日小 Tips" in sealed_html
59
 
60
 
61
  def test_workbench_model_route_without_endpoint_falls_back_to_demo():
 
23
  )
24
  debug = json.loads(debug_json)
25
  assert "Visitor:" in negotiation
26
+ assert "Today Tip" in pact_text
27
+ assert "Today Tip" in html
28
  assert debug["status"] == "ok"
29
  assert debug["intake"]["dream_text"] == "I dreamed of a late elevator."
30
 
 
55
 
56
  state, _status, _timeline, _inspector, sealed_html, debug_json, _notice = seal_pact_action(state)
57
  assert json.loads(debug_json)["status"] == "tip"
58
+ assert "Today Tip" in sealed_html
59
 
60
 
61
  def test_workbench_model_route_without_endpoint_falls_back_to_demo():
tests/test_dream_qa_refactor.py CHANGED
@@ -12,7 +12,7 @@ from dream_customs.pipeline import (
12
  from dream_customs.render import render_today_tip_card
13
  from dream_customs.schema import DreamQuestionIntake, DreamQAState, TodayTipCard
14
  from dream_customs.ui.actions import submit_dream_action, skip_to_card_action
15
- from dream_customs.ui.copy import APP_TITLE, PROCESSING_NOTE
16
 
17
 
18
  def test_dream_qa_schema_contract_requires_grounded_tip():
@@ -57,7 +57,16 @@ def test_pipeline_progresses_record_ask_interpret_tip_without_pact_language():
57
  session = answer_question(session, "像是还没开始就觉得来不及。")
58
  card = generate_today_tip(session.intake, session.answers_text(), FakeTextClient())
59
  assert card.references_dream_anchor()
60
- assert "电梯" in card.today_tip or "按钮" in card.today_tip or "14" in card.today_tip
 
 
 
 
 
 
 
 
 
61
 
62
  session = finish_today_tip(session, FakeTextClient())
63
  assert session.phase == "tip"
@@ -80,7 +89,7 @@ def test_render_today_tip_card_matches_new_public_contract():
80
  caring_note="你不需要一下子抵达所有楼层。",
81
  )
82
 
83
- html = render_today_tip_card(card)
84
 
85
  assert "今日小 Tips" in html
86
  assert "电梯按钮" in html
@@ -106,17 +115,32 @@ def test_mobile_action_payload_uses_today_tip_not_clearance_pass():
106
  view = json.loads(view_json)
107
 
108
  assert view["status"] == "tip"
109
- assert view["card_title"] == "今日小 Tips"
110
- assert "电梯" in view["card_text"] or "14" in view["card_text"]
111
  assert "clearance" not in view["card_text"].lower()
112
  assert "permit" not in view["card_html"].lower()
113
  assert "sealed" not in view["card_html"].lower()
114
 
 
 
 
 
 
 
 
 
 
 
 
 
115
 
116
  def test_public_copy_is_dream_qa_not_customs_ritual():
117
- assert APP_TITLE == "梦境问答台"
 
 
 
118
  lowered = PROCESSING_NOTE.lower()
119
- assert "追问" in PROCESSING_NOTE
120
- assert "今日小 tips" in lowered
121
  assert "clerk" not in lowered
122
  assert "pass" not in lowered
 
12
  from dream_customs.render import render_today_tip_card
13
  from dream_customs.schema import DreamQuestionIntake, DreamQAState, TodayTipCard
14
  from dream_customs.ui.actions import submit_dream_action, skip_to_card_action
15
+ from dream_customs.ui.copy import APP_COPY, APP_TITLE, DEFAULT_LANGUAGE, PROCESSING_NOTE
16
 
17
 
18
  def test_dream_qa_schema_contract_requires_grounded_tip():
 
57
  session = answer_question(session, "像是还没开始就觉得来不及。")
58
  card = generate_today_tip(session.intake, session.answers_text(), FakeTextClient())
59
  assert card.references_dream_anchor()
60
+ assert (
61
+ "elevator" in card.today_tip.lower()
62
+ or "button" in card.today_tip.lower()
63
+ or "电梯" in card.today_tip
64
+ or "按钮" in card.today_tip
65
+ or "14" in card.today_tip
66
+ )
67
+
68
+ zh_card = generate_today_tip(session.intake, session.answers_text(), FakeTextClient(), language="zh")
69
+ assert "电梯" in zh_card.today_tip or "按钮" in zh_card.today_tip or "14" in zh_card.today_tip
70
 
71
  session = finish_today_tip(session, FakeTextClient())
72
  assert session.phase == "tip"
 
89
  caring_note="你不需要一下子抵达所有楼层。",
90
  )
91
 
92
+ html = render_today_tip_card(card, language="zh")
93
 
94
  assert "今日小 Tips" in html
95
  assert "电梯按钮" in html
 
115
  view = json.loads(view_json)
116
 
117
  assert view["status"] == "tip"
118
+ assert view["card_title"] == "Today Tip"
119
+ assert "elevator" in view["card_text"].lower() or "14" in view["card_text"]
120
  assert "clearance" not in view["card_text"].lower()
121
  assert "permit" not in view["card_html"].lower()
122
  assert "sealed" not in view["card_html"].lower()
123
 
124
+ state, _view_json = submit_dream_action(
125
+ dream_text="我梦到电梯按钮融化,楼层数字停在 14。",
126
+ mood="焦虑",
127
+ text_backend="demo",
128
+ vision_backend="demo",
129
+ language="zh",
130
+ )
131
+ _state, view_json = skip_to_card_action(state, text_backend="demo", vision_backend="demo", language="zh")
132
+ zh_view = json.loads(view_json)
133
+ assert zh_view["card_title"] == "今日小 Tips"
134
+ assert "电梯" in zh_view["card_text"] or "14" in zh_view["card_text"]
135
+
136
 
137
  def test_public_copy_is_dream_qa_not_customs_ritual():
138
+ assert DEFAULT_LANGUAGE == "en"
139
+ assert APP_TITLE == "Dream QA"
140
+ assert APP_COPY["en"]["title"] == "Dream QA"
141
+ assert APP_COPY["zh"]["title"] == "梦境问答台"
142
  lowered = PROCESSING_NOTE.lower()
143
+ assert "grounded question" in lowered
144
+ assert "today tip" in lowered
145
  assert "clerk" not in lowered
146
  assert "pass" not in lowered
tests/test_pipeline.py CHANGED
@@ -216,7 +216,10 @@ def test_ask_answer_skip_draft_revise_and_seal_actions():
216
  assert len(session.question_history) == 4
217
 
218
  session = skip_question(session)
219
- assert "跳过" in session.answer_history[-1]
 
 
 
220
 
221
  session = draft_pact(session, FakeTextClient())
222
  assert session.phase == "drafting"
 
216
  assert len(session.question_history) == 4
217
 
218
  session = skip_question(session)
219
+ assert "skip" in session.answer_history[-1].lower()
220
+
221
+ zh_session = skip_question(session, language="zh")
222
+ assert "跳过" in zh_session.answer_history[-1]
223
 
224
  session = draft_pact(session, FakeTextClient())
225
  assert session.phase == "drafting"
tests/test_today_tip_quality_eval.py ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from scripts.evaluate_today_tip_quality import _load_cases, evaluate_case, evaluate_cases
2
+
3
+
4
+ def test_today_tip_quality_eval_passes_fixture_cases():
5
+ result = evaluate_cases(_load_cases())
6
+
7
+ assert result["case_count"] == 10
8
+ assert result["failures"] == {}
9
+ assert result["passes"] is True
10
+
11
+
12
+ def test_today_tip_quality_eval_fails_missing_anchor():
13
+ case = {
14
+ "id": "impossible_anchor",
15
+ "dream_text": "I dreamed of an elevator button melting.",
16
+ "mood": "Uneasy",
17
+ "answer": "It felt hard to start.",
18
+ "required_anchors": ["volcano"],
19
+ }
20
+
21
+ assert "missing_required_anchor" in evaluate_case(case)
tests/test_ui_actions.py CHANGED
@@ -25,9 +25,9 @@ def test_runtime_settings_are_collapsed_for_public_flow():
25
  def test_processing_note_is_story_copy_not_backend_jargon():
26
  lowered = PROCESSING_NOTE.lower()
27
 
28
- assert "追问" in lowered
29
- assert "今日小 tips" in lowered
30
- assert "模型路线" in PROCESSING_NOTE
31
  assert "fallback" in lowered
32
  assert "token" not in lowered
33
  assert "endpoint" not in lowered
@@ -85,9 +85,30 @@ def test_mobile_mvp_submit_then_skip_generates_today_tip():
85
 
86
  assert view["status"] == "tip"
87
  assert view["phase"] == "tip"
88
- assert "今日小 Tips" in view["card_title"]
89
  assert "电梯" in view["card_text"] or "elevator" in view["card_text"].lower()
90
  assert "DC-DEMO-014" not in view["card_text"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
91
  assert "今日小 Tips" in view["card_html"]
92
 
93
 
 
25
  def test_processing_note_is_story_copy_not_backend_jargon():
26
  lowered = PROCESSING_NOTE.lower()
27
 
28
+ assert "grounded question" in lowered
29
+ assert "today tip" in lowered
30
+ assert "model routes" in lowered
31
  assert "fallback" in lowered
32
  assert "token" not in lowered
33
  assert "endpoint" not in lowered
 
85
 
86
  assert view["status"] == "tip"
87
  assert view["phase"] == "tip"
88
+ assert "Today Tip" in view["card_title"]
89
  assert "电梯" in view["card_text"] or "elevator" in view["card_text"].lower()
90
  assert "DC-DEMO-014" not in view["card_text"]
91
+ assert "Today Tip" in view["card_html"]
92
+
93
+
94
+ def test_mobile_mvp_zh_language_switch_keeps_chinese_today_tip():
95
+ state, _view_json = submit_dream_action(
96
+ dream_text="我梦到电梯按钮融化,楼层数字停在 14。",
97
+ mood="焦虑",
98
+ text_backend="demo",
99
+ vision_backend="demo",
100
+ language="zh",
101
+ )
102
+ _state, view_json = skip_to_card_action(
103
+ state,
104
+ text_backend="demo",
105
+ vision_backend="demo",
106
+ language="zh",
107
+ )
108
+ view = json.loads(view_json)
109
+
110
+ assert view["language"] == "zh"
111
+ assert view["card_title"] == "今日小 Tips"
112
  assert "今日小 Tips" in view["card_html"]
113
 
114