Deploy Dream Customs dream QA refactor

#16
Files changed (42) hide show
  1. .gitattributes +6 -0
  2. AGENTS.md +42 -24
  3. DESIGN.md +94 -90
  4. PRODUCT.md +13 -10
  5. README.md +36 -13
  6. design-qa.md +17 -14
  7. docs/design/2026-06-05-uiux-v2-brief.md +2 -0
  8. docs/goal-prompts/2026-06-08-model-led-pact-v2.md +23 -0
  9. docs/handoff.md +80 -61
  10. docs/prd.md +63 -57
  11. docs/prototypes/2026-06-08-dream-qa-desktop-workbench.png +3 -0
  12. docs/prototypes/2026-06-08-dream-qa-mobile-flow.png +3 -0
  13. docs/prototypes/2026-06-08-dream-qa-tips-card.png +3 -0
  14. docs/smoke/2026-06-08-dream-qa-refactor-smoke.md +90 -0
  15. docs/smoke/2026-06-08-model-led-pact-v2-smoke.md +60 -0
  16. docs/spec.md +110 -76
  17. docs/superpowers/plans/2026-06-08-dream-qa-refactor-doc-plan.md +202 -0
  18. docs/superpowers/plans/2026-06-08-dream-qa-refactor.md +195 -0
  19. docs/superpowers/plans/2026-06-08-model-led-pact-v2.md +1357 -0
  20. dream_customs/app_logic.py +16 -15
  21. dream_customs/models.py +334 -28
  22. dream_customs/pipeline.py +291 -28
  23. dream_customs/prompts.py +155 -15
  24. dream_customs/render.py +79 -16
  25. dream_customs/safety.py +1 -1
  26. dream_customs/schema.py +152 -4
  27. dream_customs/ui/actions.py +24 -82
  28. dream_customs/ui/app.py +49 -54
  29. dream_customs/ui/copy.py +10 -10
  30. dream_customs/ui/styles.py +486 -0
  31. scripts/evaluate_demo_quality.py +64 -0
  32. tests/fixtures/demo_eval_cases.json +83 -0
  33. tests/test_app_logic.py +10 -14
  34. tests/test_demo_quality_eval.py +49 -0
  35. tests/test_dream_qa_refactor.py +122 -0
  36. tests/test_model_led_pact.py +199 -0
  37. tests/test_ollama_models.py +145 -1
  38. tests/test_pipeline.py +123 -6
  39. tests/test_render.py +2 -2
  40. tests/test_schema.py +75 -0
  41. tests/test_ui_actions.py +14 -12
  42. tests/test_vision_witness.py +42 -0
.gitattributes CHANGED
@@ -33,3 +33,9 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ docs/design/assets/dream-customs-probe-morning-console.png filter=lfs diff=lfs merge=lfs -text
37
+ docs/design/assets/dream-customs-probe-night-border.png filter=lfs diff=lfs merge=lfs -text
38
+ docs/design/assets/dream-customs-probe-night-desk.png filter=lfs diff=lfs merge=lfs -text
39
+ docs/prototypes/2026-06-08-dream-qa-desktop-workbench.png filter=lfs diff=lfs merge=lfs -text
40
+ docs/prototypes/2026-06-08-dream-qa-mobile-flow.png filter=lfs diff=lfs merge=lfs -text
41
+ docs/prototypes/2026-06-08-dream-qa-tips-card.png filter=lfs diff=lfs merge=lfs -text
AGENTS.md CHANGED
@@ -1,4 +1,4 @@
1
- # AGENTS.md - Dream Customs / 梦境海关
2
 
3
  ## 语言规则
4
 
@@ -7,9 +7,10 @@
7
 
8
  ## 项目定位
9
 
10
- - 当前项目是 Build Small Hackathon 的 `Dream Customs / 梦境海关`。
11
- - 核心定位:帮用户和昨晚的结盟把不安转成第二天的小建议,把怪梦转成件有趣的小
12
- - 这不心理诊断、医疗建议或治疗工具。
 
13
 
14
  ## 竞赛约束
15
 
@@ -17,13 +18,13 @@
17
  - 模型总参数必须 <= 32B。
18
  - 优先叙事赛道:An Adventure in Thousand Token Wood。
19
  - 次级叙事:Backyard AI,因为项目来自真实睡眠/做梦困扰。
20
- - demo 必须能在短视频中快速展示:输入梦境,获得今日盟约卡
21
 
22
  ## 模型规则
23
 
24
  - 首选模型组合:
25
  - `openbmb/MiniCPM-V-4.6`:图片、草图、便签、截图理解。
26
- - `openbmb/MiniCPM5-1B`:文本推理、梦境外交官人格、结构化输出和建议生成
27
  - 不要在 MiniCPM 路线未验证前扩展到任意其他小模型。
28
  - 语音输入需要 ASR 适配器;ASR 只做转写,不承担梦境理解。
29
  - 首版不要实现语音输出,避免扩大部署风险。
@@ -33,23 +34,32 @@
33
  - 必须支持文字输入。
34
  - 应支持图片输入,用 MiniCPM-V-4.6 提取视觉线索。
35
  - 应支持语音输入,通过小 ASR 适配器转成文本。
36
- - 所有输入统一进入 `DreamIntake`,不要做成三套割裂产品。
 
 
 
 
 
 
 
37
  - 输出必须包含:
38
- - dream visitor / 梦境来访者
39
- - permit id / 入境编号
40
- - contraband / 携带情绪违禁品
41
- - risk level / 风险等级
42
- - alliance reading / 结盟解读
43
- - practical suggestion / 今日认真建议
44
- - weird task / 5 分钟怪趣任务
45
- - bedtime release / 睡前放仪式
46
- - safety note / 必要时安全提示
 
47
 
48
  ## 安全规则
49
 
50
  - 不做心理疾病诊断。
51
  - 不声称梦境有唯一含义。
52
  - 不输出恐吓性、宿命论、神秘绝对化内容。
 
53
  - 普通输出使用“也许”“可以把它当作”“今天先试试”这类非确定措辞。
54
  - 如果用户表达自伤、伤人、长期严重失眠、无法正常生活或极强痛苦,必须显示寻求可信任的人或专业支持的提示。
55
 
@@ -57,11 +67,15 @@
57
 
58
  实现前先阅读:
59
 
60
- 1. `docs/spec.md`
61
- 2. `docs/prd.md`
62
- 3. `docs/handoff.md`
63
- 4. `docs/superpowers/plans/2026-06-05-dream-customs-mvp.md`
64
- 5. `docs/dream-customs-concept/index.html`
 
 
 
 
65
 
66
  ## 开发流程
67
 
@@ -82,6 +96,7 @@
82
  - JSON 解析失败时必须有修复或降级路径。
83
  - Text-only path 必须永远可用,作为 demo fallback。
84
  - Gradio 页面应优先移动端可用,因为用户可能在手机上记录梦。
 
85
 
86
  ## 验收命令
87
 
@@ -94,8 +109,11 @@ python app.py
94
 
95
  人工验收:
96
 
97
- - 输入一段文字梦境,能生成盟约卡
98
- - 上传一张草图或便签视觉线索进入 `DreamIntake`
 
99
  - 上传/录制语音后,有转写或清晰降级提示。
100
- - 输出包含诊断恐吓或医疗化建议
 
 
101
  - 页面在手机宽度下可读。
 
1
+ # AGENTS.md - Dream QA / 梦境问答台
2
 
3
  ## 语言规则
4
 
 
7
 
8
  ## 项目定位
9
 
10
+ - 当前项目是 Build Small Hackathon 的 `Dream QA / 梦境问答台`,代码仓库和 Space 仍沿用 `Dream Customs` 名称以保持部署连续性
11
+ - 核心定位:做一个循序渐进的解梦 Gradio app,用户一步步说清境、表达疑惑、回答追问最后得到个温和 `今日 Tips`
12
+ - `今日小 Tips` 可以一个认真建议、一个用户未尝试过的小事,一句关心用户的短句;它必须引用梦里的体细节,不能变成泛泛鸡汤
13
+ - 这不是心理诊断、医疗建议、治疗工具、占卜工具或宿命论解释器。
14
 
15
  ## 竞赛约束
16
 
 
18
  - 模型总参数必须 <= 32B。
19
  - 优先叙事赛道:An Adventure in Thousand Token Wood。
20
  - 次级叙事:Backyard AI,因为项目来自真实睡眠/做梦困扰。
21
+ - demo 必须能在短视频中快速展示:输入梦境,回答或跳过追问,获得今日小 Tips
22
 
23
  ## 模型规则
24
 
25
  - 首选模型组合:
26
  - `openbmb/MiniCPM-V-4.6`:图片、草图、便签、截图理解。
27
+ - `openbmb/MiniCPM5-1B`:文本推理、追问生成、梦境解读草稿、结构化今日 Tips
28
  - 不要在 MiniCPM 路线未验证前扩展到任意其他小模型。
29
  - 语音输入需要 ASR 适配器;ASR 只做转写,不承担梦境理解。
30
  - 首版不要实现语音输出,避免扩大部署风险。
 
34
  - 必须支持文字输入。
35
  - 应支持图片输入,用 MiniCPM-V-4.6 提取视觉线索。
36
  - 应支持语音输入,通过小 ASR 适配器转成文本。
37
+ - 所有输入统一进入同一个梦境 intake,不要做成三套割裂产品。
38
+ - 用户流程必须是渐进式的:
39
+ 1. 记录梦境。
40
+ 2. 明确用户最想理解的问题。
41
+ 3. 生成 1-3 个温和追问。
42
+ 4. 用户回答、跳过或请求换个角度。
43
+ 5. 生成梦境解读草稿。
44
+ 6. 输出一个今日小 Tips。
45
  - 输出必须包含:
46
+ - `dream_summary` / 梦境摘要
47
+ - `main_question` / 用户最想理解的问题
48
+ - `dream_anchors` / 梦境具体线索
49
+ - `followup_questions` / 追问
50
+ - `user_answers` / 用户回答或跳过记录
51
+ - `interpretation` / 非确定性的解读草稿
52
+ - `today_tip` / 今日小 Tips
53
+ - `tiny_action` / 可选的小
54
+ - `caring_note` / 可选关心语句
55
+ - `safety_note` / 必要时的安全提示
56
 
57
  ## 安全规则
58
 
59
  - 不做心理疾病诊断。
60
  - 不声称梦境有唯一含义。
61
  - 不输出恐吓性、宿命论、神秘绝对化内容。
62
+ - 不把梦解释成“预兆”“证明”“创伤证据”或医学结论。
63
  - 普通输出使用“也许”“可以把它当作”“今天先试试”这类非确定措辞。
64
  - 如果用户表达自伤、伤人、长期严重失眠、无法正常生活或极强痛苦,必须显示寻求可信任的人或专业支持的提示。
65
 
 
67
 
68
  实现前先阅读:
69
 
70
+ 1. `docs/handoff.md`
71
+ 2. `docs/spec.md`
72
+ 3. `docs/prd.md`
73
+ 4. `PRODUCT.md`
74
+ 5. `DESIGN.md`
75
+ 6. `docs/superpowers/plans/2026-06-08-dream-qa-refactor.md`
76
+ 7. `docs/superpowers/plans/2026-06-08-dream-qa-refactor-doc-plan.md`
77
+
78
+ 旧的 pact/customs 计划、smoke 和概念页是历史记录。除非用户明确要求复盘历史,否则不要再把 permit、contraband、sealed pact 当作新实现目标。
79
 
80
  ## 开发流程
81
 
 
96
  - JSON 解析失败时必须有修复或降级路径。
97
  - Text-only path 必须永远可用,作为 demo fallback。
98
  - Gradio 页面应优先移动端可用,因为用户可能在手机上记录梦。
99
+ - 今日 Tips 必须有梦境锚点,不能是“多休息、多喝水、保持积极”这类脱离梦境的通用话。
100
 
101
  ## 验收命令
102
 
 
109
 
110
  人工验收:
111
 
112
+ - 输入一段文字梦境app 提出至少一个相关追问
113
+ - 用户可以回答追问也可以跳过
114
+ - 上传一张草图或便签后,视觉线索进入同一个 intake。
115
  - 上传/录制语音后,有转写或清晰降级提示。
116
+ - 最终输出包含梦境摘要解读草稿和一个今日小 Tips
117
+ - 今日小 Tips 引用至少一个具体梦境细节。
118
+ - 输出不包含诊断、恐吓、预言或医疗化建议。
119
  - 页面在手机宽度下可读。
DESIGN.md CHANGED
@@ -1,32 +1,31 @@
1
  ---
2
- name: Dream Customs
3
- description: A nocturnal dream customs workbench for turning dream evidence into a next-day pact.
4
  colors:
5
- bg-night: "oklch(0.185 0.018 55)"
6
- bg-void: "oklch(0.130 0.012 55)"
7
- surface-ink: "oklch(0.235 0.018 58)"
8
- surface-raised: "oklch(0.295 0.020 62)"
9
- primary-sage: "oklch(0.690 0.105 150)"
10
- primary-sage-deep: "oklch(0.455 0.090 155)"
11
- accent-coral-stamp: "oklch(0.700 0.140 32)"
12
- accent-amber-lamp: "oklch(0.830 0.120 76)"
13
- accent-aurora: "oklch(0.745 0.080 185)"
14
- ink-main: "oklch(0.960 0.010 78)"
15
- ink-muted: "oklch(0.760 0.018 78)"
16
- border-dream: "oklch(0.465 0.024 70)"
17
  warning: "oklch(0.760 0.150 75)"
18
  typography:
19
  display:
20
  fontFamily: "Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif"
21
- fontSize: "2.25rem"
22
  fontWeight: 760
23
- lineHeight: 1.05
24
  letterSpacing: "0"
25
  headline:
26
  fontFamily: "Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif"
27
- fontSize: "1.5rem"
28
  fontWeight: 720
29
- lineHeight: 1.18
30
  title:
31
  fontFamily: "Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif"
32
  fontSize: "1rem"
@@ -44,8 +43,8 @@ typography:
44
  lineHeight: 1.25
45
  rounded:
46
  sm: "6px"
47
- md: "10px"
48
- lg: "14px"
49
  pill: "999px"
50
  spacing:
51
  xs: "6px"
@@ -56,101 +55,96 @@ spacing:
56
  components:
57
  button-primary:
58
  backgroundColor: "{colors.primary-sage-deep}"
59
- textColor: "{colors.ink-main}"
60
  rounded: "{rounded.md}"
61
  padding: "12px 16px"
62
- button-accent:
63
- backgroundColor: "{colors.accent-coral-stamp}"
64
- textColor: "{colors.bg-void}"
65
  rounded: "{rounded.md}"
66
  padding: "12px 16px"
67
  input-composer:
68
- backgroundColor: "{colors.surface-ink}"
69
- textColor: "{colors.ink-main}"
70
  rounded: "{rounded.lg}"
71
  padding: "14px 16px"
72
  ---
73
 
74
- # Design System: Dream Customs
75
 
76
  ## 1. Overview
77
 
78
- **Creative North Star: "The Morning Customs Desk"**
79
 
80
- Dream Customs should feel like a small warm customs desk that appears after waking: quiet, slightly strange, and easy enough to use before the day fully starts. The surface can stay dark like Codex, but it should read as warm charcoal and lamplight rather than a cold technical dashboard. Sage action color, amber light, muted coral seal ink, and a little aurora evidence color carry the identity.
81
 
82
- This is product UI first. The user needs to type, attach, optionally add voice, answer, and decide what happens next. The dream atmosphere belongs in the background wash, pact inspector, stamps, and evidence chips, while controls stay clear and predictable.
83
-
84
- **V4 Unified Composer Rule.** The dream input should behave like a Codex-style multimodal composer, not like a Gradio form. Text, image upload, voice upload, mood, add-material, and send belong visually inside one rounded input surface. Technical model routes and advanced workflow shortcuts should be collapsed by default.
85
 
86
  **Key Characteristics:**
87
 
88
- - Unified composer as the primary input, modeled after Codex app behavior.
89
- - Conversation timeline for declarations, questions, and pact drafts.
90
- - Evidence tray for text, image, audio, mood, and model status.
91
- - Pact inspector that can be drafted, revised, or sealed.
92
- - Generated bitmap art as atmospheric header and state art, never as unreadable UI chrome.
93
 
94
  ## 2. Colors
95
 
96
- The palette is nocturnal and atmospheric: neutral dark surfaces, cobalt primary actions, aurora hints, and coral stamp moments.
97
 
98
  ### Primary
99
 
100
- - **Sage Gate** (`oklch(0.690 0.105 150)`): primary selection, active step, focus accents, and subtle interactive outlines.
101
- - **Deep Sage Gate** (`oklch(0.455 0.090 155)`): filled primary buttons and selected composer controls with near-white text.
102
 
103
  ### Secondary
104
 
105
- - **Coral Stamp Ink** (`oklch(0.700 0.140 32)`): final seal action, permit stamp, alert edge for safety notes, and one memorable highlight per screen.
106
- - **Amber Lamp** (`oklch(0.830 0.120 76)`): warmth, trust cue, and section highlights.
107
- - **Aurora Evidence Light** (`oklch(0.745 0.080 185)`): image/audio evidence status, successful extraction, and soft dream-glow details.
108
 
109
  ### Neutral
110
 
111
- - **Warm Night Background** (`oklch(0.185 0.018 55)`): app body and mobile shell.
112
- - **Soft Void Rail** (`oklch(0.130 0.012 55)`): app outer edge, header underlay, and screenshot-safe negative space.
113
- - **Charcoal Ink Surface** (`oklch(0.235 0.018 58)`): composer, timeline items, and tool panels.
114
- - **Raised Warm Surface** (`oklch(0.295 0.020 62)`): active inspector panels and output cards.
115
- - **Main Ink** (`oklch(0.960 0.010 78)`): body text on dark surfaces.
116
- - **Muted Ink** (`oklch(0.760 0.018 78)`): secondary labels that still remain readable.
117
 
118
  ### Named Rules
119
 
120
- **The Stamp Rarity Rule.** Coral is used for sealing, warnings, and permit marks only. It must not become a general decoration color.
121
 
122
- **The No Parchment Rule.** Do not return to cream, beige, or faux paper backgrounds. Dream color comes from night, ink, and evidence light.
123
 
124
  ## 3. Typography
125
 
126
- **Display Font:** Inter with system fallbacks
127
 
128
- **Body Font:** Inter with system fallbacks
129
- **Label/Mono Font:** Use system mono only for permit IDs, debug JSON, and model telemetry.
130
 
131
- **Character:** The interface uses one sans family so the product feels controlled. Personality comes from naming, layout, stamps, and generated imagery rather than decorative fonts.
132
 
133
  ### Hierarchy
134
 
135
- - **Display** (760, 2.25rem, 1.05): app title and sealed pact headline only.
136
- - **Headline** (720, 1.5rem, 1.18): step titles and pact section names.
137
- - **Title** (680, 1rem, 1.3): timeline item headers, buttons, inspector labels.
138
- - **Body** (450, 1rem, 1.55): dream text, questions, pact explanations, max 70ch for prose.
139
- - **Label** (650, 0.8125rem, 1.25): compact field labels, evidence chips, status text.
140
 
141
  ### Named Rules
142
 
143
- **The Low-Light Readability Rule.** Muted text must remain legible on dark surfaces. Never use low-contrast gray for field labels or placeholders.
144
 
145
  ## 4. Elevation
146
 
147
- Depth is mostly tonal, not shadow-heavy. The app should feel layered like a dark desk under soft light, not like floating cards on a SaaS page. Shadows appear only for the active composer, a hovered evidence item, or a sealed pact.
148
 
149
  ### Shadow Vocabulary
150
 
151
- - **composer-focus** (`0 0 0 1px oklch(0.690 0.105 232), 0 12px 32px rgba(0, 0, 0, 0.28)`): focused bottom composer.
152
- - **pact-lift** (`0 10px 28px rgba(0, 0, 0, 0.24)`): sealed pact card only.
153
- - **stamp-glow** (`0 0 20px rgba(255, 107, 84, 0.24)`): coral seal hover or success state.
154
 
155
  ### Named Rules
156
 
@@ -160,58 +154,68 @@ Depth is mostly tonal, not shadow-heavy. The app should feel layered like a dark
160
 
161
  ### Buttons
162
 
163
- - **Shape:** medium rectangle, 10px radius.
164
- - **Primary:** Deep Cobalt Gate fill, Main Ink text, 12px 16px padding.
165
- - **Accent:** Coral Stamp Ink for `Seal today's pact` only.
166
- - **Hover / Focus:** tonal lift plus visible cobalt focus ring.
167
- - **Secondary:** transparent or Ink Surface fill with full border, never ghost text alone.
168
 
169
  ### Chips
170
 
171
  - **Style:** small rounded pills with icon, label, and state dot.
172
- - **State:** queued, extracted, failed, or selected. Evidence type uses icon and color, not long explanatory text.
 
173
 
174
  ### Cards / Containers
175
 
176
- - **Corner Style:** 10px to 14px. No 32px rounded cards.
177
- - **Background:** Ink Surface for timeline items, Raised Surface for active inspector.
178
- - **Shadow Strategy:** flat by default, lifted only when active or sealed.
179
- - **Border:** full border using Border Dream, not a side stripe.
180
  - **Internal Padding:** 16px mobile, 24px desktop.
181
 
182
  ### Inputs / Fields
183
 
184
- - **Style:** Codex-like bottom composer with large multiline input, attachment controls, model selector menu, and primary action clustered on the right.
185
- - **Focus:** cobalt ring, slightly raised surface, no layout shift.
186
  - **Error / Disabled:** inline message below composer, disabled actions stay readable and explain why.
187
 
188
  ### Navigation
189
 
190
- - **Style:** compact status rail with current phase: Declare, Inspect, Negotiate, Draft, Seal.
191
- - **Mobile:** phase rail collapses to segmented steps above the composer.
 
 
 
 
 
192
 
193
- ### Signature Component: Pact Inspector
 
 
 
 
 
 
194
 
195
- The pact inspector is a live customs file, not just a final HTML card. It shows visitor, permit ID, evidence count, risk, suggested action, weird task, and whether the pact is draft or sealed. It gives the user three choices: add material, ask another question, or seal today's pact.
196
 
197
  ## 6. Do's and Don'ts
198
 
199
  ### Do:
200
 
201
- - **Do** make the unified composer the most obvious control on mobile and desktop.
202
- - **Do** keep the first declaration controls above the timeline so a tired user can start immediately.
203
  - **Do** keep every phase actionable with one clear next step.
204
- - **Do** use generated raster imagery for the dream atmosphere, header state, or empty state.
205
- - **Do** verify dark-mode contrast for body text, labels, placeholders, chips, and disabled controls.
206
  - **Do** preserve a deterministic demo backend while adding a model-backed route.
 
207
 
208
  ### Don't:
209
 
210
  - **Don't** ship a plain Gradio two-column form as the primary experience.
211
- - **Don't** use beige parchment, tarot, faux therapy, or generic purple AI visuals.
212
- - **Don't** use one-shot generation as the whole flow.
213
  - **Don't** expose technical model routes as first-level controls unless the user opens model settings.
214
  - **Don't** render navigation-looking text that cannot be clicked.
215
- - **Don't** reserve tall empty panels for future content on the first screen.
216
- - **Don't** pair a 1px border with a large soft shadow on every card.
217
- - **Don't** use side-stripe borders, gradient text, or decorative glass panels.
 
1
  ---
2
+ name: Dream QA
3
+ description: A guided dream Q&A interface for turning dream fragments into one grounded today tip.
4
  colors:
5
+ morning-mist: "oklch(0.965 0.014 210)"
6
+ warm-paper: "oklch(0.985 0.010 82)"
7
+ soft-ink: "oklch(0.205 0.020 70)"
8
+ muted-ink: "oklch(0.500 0.030 82)"
9
+ surface-card: "oklch(0.995 0.006 82)"
10
+ surface-sage: "oklch(0.930 0.035 145)"
11
+ primary-sage: "oklch(0.555 0.105 145)"
12
+ primary-sage-deep: "oklch(0.390 0.095 145)"
13
+ accent-amber: "oklch(0.820 0.120 78)"
14
+ accent-coral: "oklch(0.710 0.135 32)"
15
+ border-soft: "oklch(0.825 0.020 85)"
 
16
  warning: "oklch(0.760 0.150 75)"
17
  typography:
18
  display:
19
  fontFamily: "Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif"
20
+ fontSize: "2rem"
21
  fontWeight: 760
22
+ lineHeight: 1.08
23
  letterSpacing: "0"
24
  headline:
25
  fontFamily: "Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif"
26
+ fontSize: "1.375rem"
27
  fontWeight: 720
28
+ lineHeight: 1.2
29
  title:
30
  fontFamily: "Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif"
31
  fontSize: "1rem"
 
43
  lineHeight: 1.25
44
  rounded:
45
  sm: "6px"
46
+ md: "8px"
47
+ lg: "12px"
48
  pill: "999px"
49
  spacing:
50
  xs: "6px"
 
55
  components:
56
  button-primary:
57
  backgroundColor: "{colors.primary-sage-deep}"
58
+ textColor: "{colors.warm-paper}"
59
  rounded: "{rounded.md}"
60
  padding: "12px 16px"
61
+ button-secondary:
62
+ backgroundColor: "{colors.surface-card}"
63
+ textColor: "{colors.primary-sage-deep}"
64
  rounded: "{rounded.md}"
65
  padding: "12px 16px"
66
  input-composer:
67
+ backgroundColor: "{colors.surface-card}"
68
+ textColor: "{colors.soft-ink}"
69
  rounded: "{rounded.lg}"
70
  padding: "14px 16px"
71
  ---
72
 
73
+ # Design System: Dream QA
74
 
75
  ## 1. Overview
76
 
77
+ **Creative North Star: "The Morning Question Desk"**
78
 
79
+ Dream QA should feel like a calm morning workspace for unpacking a dream while it is still fresh. The surface is light, readable, and ordinary-user friendly, with enough dream atmosphere to feel memorable. The interface is a product experience first: the user needs to record, answer, skip, add detail, and receive one today tip without decoding a metaphor-heavy ritual.
80
 
81
+ The old customs/pact visual language is deprecated for new work. A small `Dream Customs` label may remain for repo/Space continuity, but the primary visible experience should be `梦境问答台 / Dream QA`.
 
 
82
 
83
  **Key Characteristics:**
84
 
85
+ - Stepper with four phases: Record, Ask, Interpret, Tip.
86
+ - Conversation timeline for dream fragments, follow-up questions, answers, and interpretation draft.
87
+ - Unified multimodal composer for text, image, voice, mood, and primary action.
88
+ - Right-side desktop panel for interpretation draft and Today Tip preview.
89
+ - Mobile final screen that prioritizes the Today Tip before optional explanation.
90
 
91
  ## 2. Colors
92
 
93
+ The palette is soft morning product UI: warm white surfaces, charcoal text, sage actions, amber note highlights, and coral used sparingly for warmth or safety.
94
 
95
  ### Primary
96
 
97
+ - **Sage Guide** (`oklch(0.555 0.105 145)`): stepper progress, selected chips, focus accents.
98
+ - **Deep Sage Guide** (`oklch(0.390 0.095 145)`): primary buttons and active controls.
99
 
100
  ### Secondary
101
 
102
+ - **Amber Note** (`oklch(0.820 0.120 78)`): follow-up question highlight, Today Tip accent, gentle attention.
103
+ - **Coral Care** (`oklch(0.710 0.135 32)`): caring note, safety edge, and one small emotional highlight per result.
 
104
 
105
  ### Neutral
106
 
107
+ - **Morning Mist** (`oklch(0.965 0.014 210)`): app background.
108
+ - **Warm Paper** (`oklch(0.985 0.010 82)`): main reading surface.
109
+ - **Soft Ink** (`oklch(0.205 0.020 70)`): primary text.
110
+ - **Muted Ink** (`oklch(0.500 0.030 82)`): secondary labels with readable contrast.
111
+ - **Soft Border** (`oklch(0.825 0.020 85)`): panel and input borders.
 
112
 
113
  ### Named Rules
114
 
115
+ **The One Tip Rule.** Visual emphasis should guide the eye to one primary Today Tip. Do not create multiple equally loud recommendation blocks.
116
 
117
+ **The No Oracle Rule.** Avoid tarot, prophecy, mystic symbols, parchment certificates, and theatrical stamps.
118
 
119
  ## 3. Typography
120
 
121
+ **Display Font:** Inter with system fallbacks.
122
 
123
+ **Body Font:** Inter with system fallbacks.
 
124
 
125
+ **Label/Mono Font:** Use system mono only for debug JSON, model telemetry, or structured IDs.
126
 
127
  ### Hierarchy
128
 
129
+ - **Display** (760, 2rem, 1.08): app title and final Today Tip headline.
130
+ - **Headline** (720, 1.375rem, 1.2): step titles and result section names.
131
+ - **Title** (680, 1rem, 1.3): timeline item headers, buttons, and compact panel labels.
132
+ - **Body** (450, 1rem, 1.55): dream text, questions, interpretations, max 70ch for prose.
133
+ - **Label** (650, 0.8125rem, 1.25): chips, status text, and small metadata.
134
 
135
  ### Named Rules
136
 
137
+ **The Morning Readability Rule.** The user may be half-awake. Body text, buttons, placeholders, and chips must stay readable on mobile.
138
 
139
  ## 4. Elevation
140
 
141
+ Depth is subtle and functional. Panels should feel placed on a calm desk, not floating in a SaaS layout.
142
 
143
  ### Shadow Vocabulary
144
 
145
+ - **composer-focus** (`0 0 0 1px rgba(65, 115, 78, 0.35), 0 12px 28px rgba(45, 56, 42, 0.14)`): focused composer.
146
+ - **tip-lift** (`0 10px 24px rgba(45, 56, 42, 0.16)`): final Today Tip card only.
147
+ - **question-glow** (`0 0 18px rgba(230, 178, 75, 0.22)`): active follow-up question.
148
 
149
  ### Named Rules
150
 
 
154
 
155
  ### Buttons
156
 
157
+ - **Shape:** 8px radius.
158
+ - **Primary:** Deep Sage Guide fill, Warm Paper text, 12px 16px padding.
159
+ - **Secondary:** Warm Paper fill, Deep Sage text, full border.
160
+ - **Care / Safety:** Coral Care only for safety/support actions or caring note markers.
161
+ - **Hover / Focus:** tonal lift plus visible focus ring.
162
 
163
  ### Chips
164
 
165
  - **Style:** small rounded pills with icon, label, and state dot.
166
+ - **Use:** mood, image clue status, voice transcript status, answer/skip choices, and alternate angle request.
167
+ - **Text:** short labels only. Long explanatory text belongs in timeline or result body.
168
 
169
  ### Cards / Containers
170
 
171
+ - **Corner Style:** 8px to 12px. No oversized rounded blobs.
172
+ - **Background:** Warm Paper or Surface Card.
173
+ - **Shadow Strategy:** flat by default, lifted only when active or final.
174
+ - **Border:** full soft border.
175
  - **Internal Padding:** 16px mobile, 24px desktop.
176
 
177
  ### Inputs / Fields
178
 
179
+ - **Style:** unified composer with multiline dream input, attachment controls, mood, and primary action.
180
+ - **Focus:** sage ring, slightly raised surface, no layout shift.
181
  - **Error / Disabled:** inline message below composer, disabled actions stay readable and explain why.
182
 
183
  ### Navigation
184
 
185
+ - **Style:** visible stepper with current phase: Record, Ask, Interpret, Tip.
186
+ - **Mobile:** stepper collapses to compact segmented steps above the conversation.
187
+ - **Desktop:** left rail or top rail is acceptable, but it must map to real states.
188
+
189
+ ### Signature Component: Today Tip Panel
190
+
191
+ The Today Tip Panel is the final surface. It shows:
192
 
193
+ - Dream summary.
194
+ - Main question.
195
+ - Grounded interpretation.
196
+ - One primary Today Tip.
197
+ - Optional tiny action.
198
+ - Optional caring note.
199
+ - Safety note only when needed.
200
 
201
+ The tip should be first-class and easy to screenshot, but it must not look like a diagnosis card or mystical certificate.
202
 
203
  ## 6. Do's and Don'ts
204
 
205
  ### Do:
206
 
207
+ - **Do** make the first action obvious on mobile and desktop.
208
+ - **Do** show the gradual Q&A flow so users understand why the final tip is grounded.
209
  - **Do** keep every phase actionable with one clear next step.
210
+ - **Do** cite concrete dream anchors in the final result.
 
211
  - **Do** preserve a deterministic demo backend while adding a model-backed route.
212
+ - **Do** verify text fits inside controls at 390px mobile width.
213
 
214
  ### Don't:
215
 
216
  - **Don't** ship a plain Gradio two-column form as the primary experience.
217
+ - **Don't** use tarot, parchment, prophecy, medical intake, or generic healing-app aesthetics.
 
218
  - **Don't** expose technical model routes as first-level controls unless the user opens model settings.
219
  - **Don't** render navigation-looking text that cannot be clicked.
220
+ - **Don't** create several competing tips in the final result.
221
+ - **Don't** let the final advice become generic wellness copy unrelated to the dream.
 
PRODUCT.md CHANGED
@@ -6,35 +6,38 @@ product
6
 
7
  ## Users
8
 
9
- Dream Customs serves people who wake up with vivid, strange, or unsettling dreams and want a small morning ritual 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
- Dream Customs helps a user form a playful alliance with last night's dream. It accepts text, image, and voice evidence, turns those fragments into a structured dream declaration, asks a few customs questions, and lets the user choose whether to add more material, continue negotiation, or seal a next-day pact.
14
 
15
- Success means the user receives something more useful than a one-shot interpretation: a clear visitor, a practical suggestion for today, a weird five-minute task, and a bedtime release phrase. The experience should feel like a small ceremony, not a diagnosis.
16
 
17
  ## Brand Personality
18
 
19
  Gentle, strange, lucid.
20
 
21
- The voice is an overnight border clerk who takes odd evidence seriously without becoming mystical or clinical. It should feel curious and companionable, with enough dream color to be memorable and enough product discipline to be usable on a phone.
22
 
23
  ## Anti-references
24
 
25
  - Do not look like a plain Gradio form with two columns and one submit button.
26
  - Do not look like a beige parchment certificate, generic tarot card, therapy intake form, or purple AI SaaS dashboard.
27
- - Do not use medical, diagnostic, frightening, or fate-telling language.
 
28
  - Do not bury the main action below many controls.
29
- - Do not end the flow after one answer when the user still wants to add evidence or keep negotiating.
 
30
 
31
  ## Design Principles
32
 
33
- 1. The AI is a clerk in a ritual, not a text generator behind a form.
34
- 2. The user keeps agency: add evidence, answer, skip, continue, revise, or seal.
35
  3. The interface should be dream-colored but product-trustworthy.
36
- 4. Every output should lead to a tiny next-day action.
37
- 5. The demo path must survive Space constraints and still have a model-backed route.
 
38
 
39
  ## Accessibility & Inclusion
40
 
 
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
 
17
  ## Brand Personality
18
 
19
  Gentle, strange, lucid.
20
 
21
+ The voice is a calm dream companion who asks good questions and takes odd details seriously without becoming mystical or clinical. It should feel curious, ordinary-user friendly, and companionable: less secret ritual, more warm morning dialogue.
22
 
23
  ## Anti-references
24
 
25
  - Do not look like a plain Gradio form with two columns and one submit button.
26
  - Do not look like a beige parchment certificate, generic tarot card, therapy intake form, or purple AI SaaS dashboard.
27
+ - Do not sound like generic healing-app advice that could fit any input.
28
+ - Do not use medical, diagnostic, frightening, fate-telling, or prophecy language.
29
  - Do not bury the main action below many controls.
30
+ - Do not force the user to complete a long questionnaire before seeing value.
31
+ - Do not end with multiple competing recommendations; the final result needs one primary today tip.
32
 
33
  ## Design Principles
34
 
35
+ 1. The AI is a question guide, not an oracle.
36
+ 2. The user keeps agency: answer, skip, add detail, ask for another angle, or finish.
37
  3. The interface should be dream-colored but product-trustworthy.
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
 
README.md CHANGED
@@ -1,15 +1,15 @@
1
  ---
2
  title: Dream Customs
3
- emoji:
4
- colorFrom: blue
5
- colorTo: pink
6
  sdk: gradio
7
  sdk_version: 4.44.1
8
  python_version: "3.10"
9
  app_file: app.py
10
  pinned: false
11
  license: mit
12
- short_description: Turn dream declarations into a playful next-day pact.
13
  models:
14
  - openbmb/MiniCPM5-1B
15
  - openbmb/MiniCPM-V-4.6
@@ -20,22 +20,45 @@ tags:
20
  - dream-journal
21
  ---
22
 
23
- # Dream Customs
24
 
25
- A Build Small Hackathon Gradio app that helps users form a playful alliance with last night's dream.
 
 
26
 
27
  ## Concept
28
 
29
- Dream Customs accepts dream declarations by text, image, or voice. It turns the dream into a gentle "customs negotiation" and returns a Today's Pact card: one practical suggestion, one weird 5-minute task, and one bedtime release phrase.
 
 
30
 
31
  ## Models
32
 
33
  - `openbmb/MiniCPM-V-4.6` for image/sketch/note understanding.
34
- - `openbmb/MiniCPM5-1B` for dream negotiation and pact generation.
35
  - A small ASR adapter may be used only for voice transcription.
36
  - The app defaults to a stable demo backend so the local Gradio flow always works.
37
  - Optional Ollama adapters are included for local MiniCPM testing.
38
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
39
  ## Run
40
 
41
  ```bash
@@ -89,7 +112,7 @@ client = HostedMiniCPMTextClient(
89
  token=os.getenv("DREAM_CUSTOMS_HOSTED_TOKEN", ""),
90
  )
91
  result = client.generate_negotiation("I missed an elevator in a foggy dream.")
92
- print(result["visitor_name"])
93
  PY
94
  ```
95
 
@@ -116,12 +139,12 @@ python -m pytest -q
116
 
117
  ## Deployment Smoke Status
118
 
119
- 2026-06-05 local V2 verification passed: tests were green and the workbench flow reached a sealed pact through `Send to customs`, `Ask another question`, `Add material`, `Draft pact`, `Revise pact`, and `Seal today's pact`.
120
 
121
- The public Space now serves the V2 workbench from Space `main` commit `8ad6f00628f800abc2dbefab05163aba94a5723f`. Public browser smoke, mobile readability, diagnostics, raw remote queue prediction, and a hosted text route smoke all reached a sealed pact on 2026-06-05. The current Modal backend pass requires a real `openbmb/MiniCPM-V-4.6` vision route smoke before delivery; demo vision fallback is runtime resilience, not a substitute for that smoke.
122
 
123
- Current smoke details are tracked in `docs/smoke/2026-06-05-space-deployment-smoke.md`.
124
 
125
  ## Safety
126
 
127
- This is not a therapy or diagnosis product. It gives playful reflection, small actions, and escalation copy for severe distress.
 
1
  ---
2
  title: Dream Customs
3
+ emoji: 🌤️
4
+ colorFrom: green
5
+ colorTo: yellow
6
  sdk: gradio
7
  sdk_version: 4.44.1
8
  python_version: "3.10"
9
  app_file: app.py
10
  pinned: false
11
  license: mit
12
+ short_description: Guided dream Q&A with one gentle today tip.
13
  models:
14
  - openbmb/MiniCPM5-1B
15
  - openbmb/MiniCPM-V-4.6
 
20
  - dream-journal
21
  ---
22
 
23
+ # Dream QA / 梦境问答台
24
 
25
+ A Build Small Hackathon Gradio app that helps users unpack a dream step by step and leave with one grounded tip for today.
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
 
35
  ## Models
36
 
37
  - `openbmb/MiniCPM-V-4.6` for image/sketch/note understanding.
38
+ - `openbmb/MiniCPM5-1B` for dream summary, follow-up questions, interpretation, and Today Tip generation.
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
+
45
+ 1. Enter a dream by text, image, voice, or a mix.
46
+ 2. State or let the app infer the main question.
47
+ 3. Answer or skip one or more follow-up questions.
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`
54
+ - PRD: `docs/prd.md`
55
+ - Handoff: `docs/handoff.md`
56
+ - Design system: `DESIGN.md`
57
+ - Prototype images:
58
+ - `docs/prototypes/2026-06-08-dream-qa-mobile-flow.png`
59
+ - `docs/prototypes/2026-06-08-dream-qa-desktop-workbench.png`
60
+ - `docs/prototypes/2026-06-08-dream-qa-tips-card.png`
61
+
62
  ## Run
63
 
64
  ```bash
 
112
  token=os.getenv("DREAM_CUSTOMS_HOSTED_TOKEN", ""),
113
  )
114
  result = client.generate_negotiation("I missed an elevator in a foggy dream.")
115
+ print(result)
116
  PY
117
  ```
118
 
 
139
 
140
  ## Deployment Smoke Status
141
 
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
 
148
  ## Safety
149
 
150
+ This is not a therapy or diagnosis product. It gives playful reflection, one small today tip, and escalation copy for severe distress.
design-qa.md CHANGED
@@ -1,24 +1,27 @@
1
- # Dream Customs V4 Design QA
2
 
3
- Date: 2026-06-05
4
 
5
- Reference: user-provided Codex-style multimodal composer screenshot.
6
 
7
  ## Checks
8
 
9
- - First screen uses one rounded multimodal composer instead of separate form blocks.
10
- - Visible composer tools are limited to Image, Voice, Mood, Add, and Send.
11
- - Technical Text/Vision routes are collapsed under Model routes.
12
- - Pact inspector remains visible beside the composer on desktop.
13
- - Timeline, diagnostics, examples, workflow shortcuts, and refinement controls are secondary.
14
- - Text-only Chrome flow completed: Send -> Draft pact -> Seal today's pact.
15
- - Sealed output renders a screenshot-friendly Today's Pact card.
16
- - No desktop horizontal overflow in Chrome at the inspected viewport.
 
 
17
 
18
  ## Notes
19
 
20
- - Image and voice use compact upload buttons to avoid large Gradio media drop zones in the first screen.
21
- - Voice upload remains available as an input to the existing ASR adapter path.
 
22
  - The UI keeps safety copy non-diagnostic.
23
 
24
- final result: passed
 
1
+ # Dream QA Design QA
2
 
3
+ Date: 2026-06-08
4
 
5
+ Reference: 2026-06-08 Dream QA prototype images and updated source docs.
6
 
7
  ## Checks
8
 
9
+ - First screen should introduce `梦境问答台 / Dream QA`, with `Dream Customs` only as continuity context if needed.
10
+ - The primary loop is Record -> Ask -> Interpret -> Tip.
11
+ - The main surface is a conversational Q&A timeline, not a permit or pact inspector.
12
+ - The composer supports text, image, voice, mood, and one clear continue action.
13
+ - Follow-up questions are visible and skippable.
14
+ - The final result prioritizes one Today Tip, with optional tiny action and caring note.
15
+ - Interpretation text uses non-certain language and cites concrete dream anchors.
16
+ - Technical text/vision routes remain collapsed under developer/model settings.
17
+ - Text-only flow must remain available as the fallback path.
18
+ - Mobile width should keep buttons, chips, and Today Tip text readable.
19
 
20
  ## Notes
21
 
22
+ - The older V4 customs workbench passed its historical design QA, but it is no longer the current target.
23
+ - Do not reintroduce permit, contraband, sealed pact, or bedtime release as required UX fields.
24
+ - Do not replace the new Q&A flow with a generic healing-app result card; the tip must remain dream-grounded.
25
  - The UI keeps safety copy non-diagnostic.
26
 
27
+ Current desired result: pending implementation.
docs/design/2026-06-05-uiux-v2-brief.md CHANGED
@@ -2,6 +2,8 @@
2
 
3
  Last updated: 2026-06-05
4
 
 
 
5
  ## 1. Feature Summary
6
 
7
  Redesign Dream Customs from a one-shot Gradio form into a dream customs workbench. The user should be able to declare a dream through text, image, or voice, receive customs questions, add more evidence, continue negotiation, revise the pact draft, or seal the final pact.
 
2
 
3
  Last updated: 2026-06-05
4
 
5
+ > Historical note: this V2 brief documents the earlier customs/pact workbench direction. As of 2026-06-08, the current product target is `Dream QA / 梦境问答台`: Record -> Ask -> Interpret -> Today Tip. Use `DESIGN.md`, `docs/handoff.md`, and `docs/superpowers/plans/2026-06-08-dream-qa-refactor.md` for new work.
6
+
7
  ## 1. Feature Summary
8
 
9
  Redesign Dream Customs from a one-shot Gradio form into a dream customs workbench. The user should be able to declare a dream through text, image, or voice, receive customs questions, add more evidence, continue negotiation, revise the pact draft, or seal the final pact.
docs/goal-prompts/2026-06-08-model-led-pact-v2.md ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Goal Prompt: Model-Led Pact V2
2
+
3
+ > Superseded on 2026-06-08: do not use this for new work unless the user explicitly revives the pact direction. Current source of truth is `docs/superpowers/plans/2026-06-08-dream-qa-refactor.md`.
4
+
5
+ Work in `/Users/junhaocheng/working-dir/ai-competitions/build-small-hackthon`.
6
+
7
+ Use the repo plan `docs/superpowers/plans/2026-06-08-model-led-pact-v2.md`. Implement it task by task with `superpowers:subagent-driven-development` or `superpowers:executing-plans`. Keep the public demo language English-first: users may input any language, but the final clearance pass should be polished natural English for judges and demo video.
8
+
9
+ Do not broaden beyond MiniCPM before exhausting the current pair:
10
+
11
+ - `openbmb/MiniCPM5-1B` owns dream brief, pact draft, critique, and rewrite.
12
+ - `openbmb/MiniCPM-V-4.6` owns visual witness reports.
13
+ - ASR remains transcription only.
14
+
15
+ Main acceptance case:
16
+
17
+ ```text
18
+ I kept missing an elevator. The buttons melted like wax, and the floor number froze at 14.
19
+ ```
20
+
21
+ The final card must not contain `the an`, `the the`, invented `lever`, diagnosis, prophecy, or frightening certainty. It must mention at least two concrete details from the dream, give one real next step for today, and include one harmless five-minute odd task grounded in the dream.
22
+
23
+ Before changes: confirm cwd, branch, remotes, and run `git pull`. During implementation: use tests first, keep commits small, and preserve text-only fallback. After implementation: run `.venv/bin/python -m pytest -q`, `git diff --check`, local Gradio smoke on port `7862`, push GitHub branch, then sync or prepare HF Space update. Stop and ask before exposing secrets, confirming paid deploy/build costs, force-pushing public Space `main`, or handling a manual PR merge.
docs/handoff.md CHANGED
@@ -1,53 +1,60 @@
1
- # Dream Customs / 梦境海关 Handoff
2
 
3
- Last updated: 2026-06-05
4
 
5
- ## Current State
6
 
7
- The project has shipped a working Gradio MVP and a V4 local UI pass. The current interface centers a warm Codex-like multimodal composer: text, image upload, voice upload, mood, add-material, and send live inside one rounded input surface. Model routes, workflow shortcuts, diagnostics, and examples are collapsed by default.
8
 
9
- Current V4 UX decisions:
10
 
11
- - The first screen prioritizes the dream declaration composer and pact inspector.
12
- - Image and voice use compact upload buttons to avoid large Gradio media drop zones in the main composer.
13
- - `Draft pact` and `Seal today's pact` sit beside the live pact inspector.
14
- - The timeline is a supporting "Story so far" section below the first-screen workflow.
15
- - Technical model route controls are available but not first-level visual chrome.
16
 
17
- The original visual HTML concept note remains useful background:
18
 
19
- - `docs/dream-customs-concept/index.html`
20
 
21
- Supporting images:
 
 
 
 
 
22
 
23
- - `docs/dream-customs-concept/assets/dream-passport.svg`
24
- - `docs/dream-customs-concept/assets/multimodal-intake.svg`
25
- - `docs/dream-customs-concept/assets/alliance-card.svg`
26
 
27
- Project context documents:
28
 
29
- - `docs/spec.md`
30
- - `docs/prd.md`
31
- - `docs/handoff.md`
32
- - `docs/superpowers/plans/2026-06-05-dream-customs-mvp.md`
33
- - `docs/superpowers/plans/2026-06-05-dream-customs-uiux-v2.md`
34
- - `docs/superpowers/plans/2026-06-05-dream-customs-uiux-v3.md`
35
- - `AGENTS.md`
 
 
 
 
 
 
 
 
36
 
37
  ## Product Decision
38
 
39
- Build Dream Customs as a "dream alliance" app, not a dream diagnosis app.
40
 
41
  Final positioning:
42
 
43
- > 梦境海关帮你和昨晚的结盟把不安转成明天的小建议,把怪转成件有趣的小
44
 
45
  ## Model Decision
46
 
47
  Use:
48
 
49
  - `openbmb/MiniCPM-V-4.6` for image/sketch/note understanding.
50
- - `openbmb/MiniCPM5-1B` for text reasoning, negotiation, and final output.
51
 
52
  Do not broaden to arbitrary small models unless MiniCPM paths fail.
53
 
@@ -67,31 +74,36 @@ Voice input is allowed, but current MiniCPM pair does not directly transcribe ra
67
  1. User submits dream using text, image, voice, or a combination.
68
  2. Audio is transcribed by a small adapter.
69
  3. Image is converted into visual clues by MiniCPM-V-4.6.
70
- 4. App builds a `DreamIntake` object.
71
- 5. MiniCPM5-1B generates dream visitor and 2-3 negotiation questions.
72
- 6. User answers or skips.
73
- 7. MiniCPM5-1B generates final "Today's Pact" card.
74
- 8. Gradio renders a screenshot-friendly HTML card.
 
 
75
 
76
  ## Required Output Fields
77
 
78
- - `visitor_name`
79
- - `permit_id`
80
- - `contraband`
81
- - `risk_level`
82
- - `alliance_reading`
83
- - `practical_suggestion`
84
- - `weird_task`
85
- - `bedtime_release`
 
86
  - `safety_note`
87
 
 
 
88
  ## Safety Boundary
89
 
90
  The product must never present itself as therapy, medical advice, or diagnosis. It should use playful, non-certain language. Severe distress should trigger a professional-help suggestion.
91
 
92
- ## Recommended First Implementation
93
 
94
- The minimal Python package already exists:
95
 
96
  ```text
97
  app.py
@@ -105,37 +117,45 @@ dream_customs/
105
  render.py
106
  pipeline.py
107
  models.py
 
108
  tests/
109
- test_schema.py
110
- test_safety.py
111
- test_render.py
112
- test_pipeline.py
113
  ```
114
 
115
- The current implementation keeps deterministic demo clients as the default and exposes optional model routes. For V3, do not rebuild the pipeline from scratch. Fix the app shell, control hierarchy, phase actions, and visual polish while preserving the existing `CustomsSession` state flow.
 
 
 
 
 
 
 
 
 
 
116
 
117
  ## Implementation Priorities
118
 
119
- 1. First-screen trust and immediate declaration controls.
120
- 2. Clickable phase actions.
121
- 3. Mobile-readable composer and inspector.
122
- 4. Removal of long blank page regions.
123
- 5. Tests and local browser verification.
124
- 6. Branch commit/push and Space update when credentials allow.
 
125
 
126
  ## Repository Status
127
 
128
- This directory is now a dedicated Dream Customs repository:
129
 
130
  - Local path: `/Users/junhaocheng/working-dir/ai-competitions/build-small-hackthon`
131
  - GitHub remote: `https://github.com/adjcjh777/dream-customs-build-small.git`
132
  - Hugging Face Space remote: `https://huggingface.co/spaces/build-small-hackathon/dream-customs`
133
 
134
- Continue to confirm `git remote -v` before pushing, but the earlier VLA remote warning is no longer the active state.
135
 
136
  ## Modal Backend Route
137
 
138
- The intended production-quality route is:
139
 
140
  ```text
141
  Hugging Face Space Gradio UI
@@ -146,13 +166,12 @@ Hugging Face Space Gradio UI
146
 
147
  The Space defaults to `model` for both text and vision backends, so a configured Space calls Modal immediately. The `demo` backend remains available in developer settings as the deterministic fallback path.
148
 
149
- 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 L4 GPU backend; the ZeroGPU probe is only there to make the HF hardware setting valid for this Gradio frontend.
150
 
151
  ## Open Questions For User
152
 
153
- These do not block initial scaffolding:
154
 
155
- - Should the demo language be Chinese-only or bilingual?
156
- - Which Hugging Face Space name should be used?
157
- - Should we reuse any previous `build-small-relics` assets, or keep Dream Customs separate?
158
  - Which ASR adapter is acceptable for the hackathon submission?
 
1
+ # Dream QA / 梦境问答台 Handoff
2
 
3
+ Last updated: 2026-06-08
4
 
5
+ ## Current Product Direction
6
 
7
+ The project is pivoting from a customs/pact-centered ritual into a step-by-step dream Q&A Gradio app.
8
 
9
+ The repo and Space still use the `Dream Customs` name for continuity, but future product work should use the `Dream QA / 梦境问答台` direction:
10
 
11
+ > 输入梦境,回答或跳过几个温和追问,最后得到一个和梦境细节相关的今日小 Tips。
 
 
 
 
12
 
13
+ ## Deprecated Context
14
 
15
+ Earlier implementation and smoke docs mention:
16
 
17
+ - dream visitor
18
+ - customs negotiation
19
+ - permit ID
20
+ - contraband
21
+ - sealed pact
22
+ - bedtime release
23
 
24
+ Those are historical context, not the new target. Do not build new work around permit/contraband/sealed-pact unless the user explicitly reverses this pivot.
 
 
25
 
26
+ ## Current Source Documents
27
 
28
+ Read in this order:
29
+
30
+ 1. `AGENTS.md`
31
+ 2. `docs/spec.md`
32
+ 3. `docs/prd.md`
33
+ 4. `PRODUCT.md`
34
+ 5. `DESIGN.md`
35
+ 6. `docs/superpowers/plans/2026-06-08-dream-qa-refactor.md`
36
+ 7. `docs/superpowers/plans/2026-06-08-dream-qa-refactor-doc-plan.md`
37
+
38
+ Prototype references:
39
+
40
+ - `docs/prototypes/2026-06-08-dream-qa-mobile-flow.png`
41
+ - `docs/prototypes/2026-06-08-dream-qa-desktop-workbench.png`
42
+ - `docs/prototypes/2026-06-08-dream-qa-tips-card.png`
43
 
44
  ## Product Decision
45
 
46
+ Build a guided dream interpretation app, not a diagnosis app, not a fortune-telling app, and not a one-shot pact generator.
47
 
48
  Final positioning:
49
 
50
+ > 梦境问答台帮你一步步整理境疑惑先记录,回答几个温和追问,再得到个今天可以参考的小 Tips
51
 
52
  ## Model Decision
53
 
54
  Use:
55
 
56
  - `openbmb/MiniCPM-V-4.6` for image/sketch/note understanding.
57
+ - `openbmb/MiniCPM5-1B` for text reasoning, follow-up questions, interpretation, and final Today Tip.
58
 
59
  Do not broaden to arbitrary small models unless MiniCPM paths fail.
60
 
 
74
  1. User submits dream using text, image, voice, or a combination.
75
  2. Audio is transcribed by a small adapter.
76
  3. Image is converted into visual clues by MiniCPM-V-4.6.
77
+ 4. App builds a `DreamQuestionIntake`.
78
+ 5. MiniCPM5-1B creates a short dream summary and identifies the user's likely main question.
79
+ 6. MiniCPM5-1B asks 1-3 gentle follow-up questions.
80
+ 7. User answers, skips, adds detail, or asks for another angle.
81
+ 8. MiniCPM5-1B drafts a grounded interpretation.
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`
88
+ - `main_question`
89
+ - `dream_anchors`
90
+ - `followup_questions`
91
+ - `user_answers`
92
+ - `interpretation`
93
+ - `today_tip`
94
+ - `tiny_action`
95
+ - `caring_note`
96
  - `safety_note`
97
 
98
+ The final `today_tip` must reference at least one concrete dream anchor. Generic wellness advice is a regression.
99
+
100
  ## Safety Boundary
101
 
102
  The product must never present itself as therapy, medical advice, or diagnosis. It should use playful, non-certain language. Severe distress should trigger a professional-help suggestion.
103
 
104
+ ## Recommended Implementation Scope
105
 
106
+ The existing package should be refactored rather than replaced from scratch:
107
 
108
  ```text
109
  app.py
 
117
  render.py
118
  pipeline.py
119
  models.py
120
+ ui/
121
  tests/
 
 
 
 
122
  ```
123
 
124
+ Likely code files to change:
125
+
126
+ - `dream_customs/schema.py`
127
+ - `dream_customs/prompts.py`
128
+ - `dream_customs/pipeline.py`
129
+ - `dream_customs/render.py`
130
+ - `dream_customs/ui/actions.py`
131
+ - `dream_customs/ui/app.py`
132
+ - `dream_customs/ui/copy.py`
133
+ - `dream_customs/ui/styles.py`
134
+ - tests covering schema, prompts, pipeline, render, UI actions, and safety
135
 
136
  ## Implementation Priorities
137
 
138
+ 1. Preserve text-only demo fallback.
139
+ 2. Replace pact schema with Q&A state and Today Tip output.
140
+ 3. Preserve image and voice as optional evidence in the same intake.
141
+ 4. Make follow-up questions a first-class user action.
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
 
148
+ This directory is a dedicated Dream Customs/Dream QA repository:
149
 
150
  - Local path: `/Users/junhaocheng/working-dir/ai-competitions/build-small-hackthon`
151
  - GitHub remote: `https://github.com/adjcjh777/dream-customs-build-small.git`
152
  - Hugging Face Space remote: `https://huggingface.co/spaces/build-small-hackathon/dream-customs`
153
 
154
+ Continue to confirm `git remote -v` before pushing.
155
 
156
  ## Modal Backend Route
157
 
158
+ The intended production-quality route remains:
159
 
160
  ```text
161
  Hugging Face Space Gradio UI
 
166
 
167
  The Space defaults to `model` for both text and vision backends, so a configured Space calls Modal immediately. The `demo` backend remains available in developer settings as the deterministic fallback path.
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?
docs/prd.md CHANGED
@@ -1,23 +1,27 @@
1
- # Dream Customs / 梦境海关 PRD
2
 
3
- Last updated: 2026-06-05
4
 
5
  ## 1. Executive Summary
6
 
7
  ### Problem Statement
8
 
9
- Users who wake up from frequent or vivid dreams may feel unsettled, tired, or mentally cluttered. They need a lightweight way to turn dream residue into a gentle next-day action without receiving pseudo-clinical dream diagnosis.
10
 
11
  ### Proposed Solution
12
 
13
- Dream Customs is a Gradio app that accepts voice, image, and text dream declarations, normalizes them into a structured dream intake, and uses MiniCPM models to generate a playful "dream alliance" card with practical advice, a weird 5-minute task, and a bedtime release phrase.
 
 
14
 
15
  ### Success Criteria
16
 
17
- - A user can complete the core flow from input to final card in <= 90 seconds on the hosted demo.
18
- - At least 90% of demo runs produce a parseable final output object matching the required schema.
19
- - At least 5 real users can understand the output without extra explanation and rate it >= 4/5 for delight.
20
- - At least 3 real users report that the "today's pact" feels actionable.
 
 
21
  - The app runs with total model parameters <= 32B and is packaged for Gradio/Hugging Face Space.
22
 
23
  ## 2. User Experience & Functionality
@@ -26,7 +30,7 @@ Dream Customs is a Gradio app that accepts voice, image, and text dream declarat
26
 
27
  1. Primary persona: vivid dreamer.
28
  - Recently sleeps poorly or wakes with strong dream fragments.
29
- - Wants a small morning ritual, not therapy.
30
 
31
  2. Secondary persona: playful AI explorer.
32
  - Wants a strange AI experience worth showing a friend.
@@ -38,28 +42,30 @@ Dream Customs is a Gradio app that accepts voice, image, and text dream declarat
38
 
39
  ### User Stories
40
 
41
- #### Story 1: Text Dream Declaration
42
 
43
- As a vivid dreamer, I want to type my dream so that I can get a next-day pact even if I have no image or audio.
44
 
45
  Acceptance criteria:
46
 
47
  - User can submit text-only input.
48
- - App creates a valid `DreamIntake` object.
49
- - Final card includes dream visitor, practical suggestion, weird task, and bedtime release phrase.
 
 
50
 
51
- #### Story 2: Image Dream Declaration
52
 
53
  As a user who remembers images better than words, I want to upload a sketch or bedside note so that the system can use visual dream clues.
54
 
55
  Acceptance criteria:
56
 
57
  - User can upload one image.
58
- - MiniCPM-V-4.6 extracts at least 3 visual clues when visible content exists.
59
- - Visual clues are included in the final `DreamIntake`.
60
  - If image extraction fails, text-only flow still works.
61
 
62
- #### Story 3: Voice Dream Declaration
63
 
64
  As a half-awake user, I want to speak my dream so that I do not need to type immediately after waking.
65
 
@@ -67,33 +73,35 @@ Acceptance criteria:
67
 
68
  - User can record or upload an audio clip.
69
  - ASR adapter returns a transcript.
70
- - Transcript is visible/editable before final submission or transparently included in the declaration.
71
  - If ASR fails, user receives a concise fallback message and can type manually.
72
 
73
- #### Story 4: Alliance Negotiation
74
 
75
- As a user, I want the AI to ask me a few questions so that the final advice feels connected to my dream.
76
 
77
  Acceptance criteria:
78
 
79
- - App generates 2-3 questions.
80
- - User can answer all, answer some, or skip.
81
- - Skip path still generates a final card.
 
82
 
83
- #### Story 5: Today's Pact Card
84
 
85
- As a user, I want a visually clear output card so that I can screenshot or share the result.
86
 
87
  Acceptance criteria:
88
 
89
- - Card includes all required output fields.
90
- - Card is readable on mobile and desktop.
91
- - Card avoids medical or diagnostic framing.
92
- - Card includes a short safety note only when needed.
93
 
94
  ### Non-Goals
95
 
96
  - Medical diagnosis or therapy.
 
97
  - Sleep-stage tracking.
98
  - Long-term journaling database.
99
  - Account creation.
@@ -106,10 +114,10 @@ Acceptance criteria:
106
  ### Tool Requirements
107
 
108
  - MiniCPM-V-4.6 for image-to-text visual clue extraction.
109
- - MiniCPM5-1B for text reasoning, persona, structured output, and alliance card generation.
110
  - A small ASR adapter for voice-to-text. This adapter is not the core AI experience.
111
  - Gradio for UI.
112
- - Optional HTML/CSS card renderer.
113
 
114
  ### Prompt Requirements
115
 
@@ -119,7 +127,8 @@ Prompts must:
119
  - Keep the tone playful, gentle, and non-clinical.
120
  - Avoid strong claims about dream meaning.
121
  - Include a safety boundary for severe distress.
122
- - Keep final advice small enough to execute in 5-10 minutes.
 
123
 
124
  ### Evaluation Strategy
125
 
@@ -133,9 +142,10 @@ Create an eval set with at least 12 dream inputs:
133
  Measure:
134
 
135
  - Schema validity.
 
136
  - Tone safety.
137
- - Practicality of advice.
138
- - Weird-task delight.
139
  - Ability to recover from missing modality.
140
 
141
  Pass thresholds:
@@ -143,7 +153,8 @@ Pass thresholds:
143
  - Schema validity: >= 90%.
144
  - No diagnostic language in ordinary cases: 100%.
145
  - Safety note appears in distress-edge cases: >= 90%.
146
- - Human delight rating on 5 demo cases: average >= 4/5.
 
147
 
148
  ## 4. Technical Specifications
149
 
@@ -154,28 +165,31 @@ Gradio inputs
154
  -> text input
155
  -> image upload -> MiniCPM-V-4.6 visual clue extractor
156
  -> audio upload/record -> ASR adapter
157
- -> DreamIntake builder
158
- -> MiniCPM5-1B customs officer prompt
159
- -> negotiation questions
160
- -> final MiniCPM5-1B pact prompt
161
- -> HTML card renderer
 
 
162
  ```
163
 
164
  ### Core Components
165
 
166
- - `app.py`: Gradio UI composition and event wiring.
167
- - `dream_customs/schema.py`: dataclasses or Pydantic models for inputs and outputs.
168
  - `dream_customs/models.py`: model/client loading and inference wrappers.
169
  - `dream_customs/prompts.py`: prompt templates.
170
  - `dream_customs/pipeline.py`: orchestration logic.
171
  - `dream_customs/safety.py`: lightweight safety checks and copy.
172
- - `dream_customs/render.py`: HTML card rendering.
173
- - `tests/`: schema, prompt, safety, and pipeline unit tests with mocked model outputs.
 
174
 
175
  ### Integration Points
176
 
177
  - Hugging Face Space runtime.
178
- - Hugging Face model downloads or inference endpoints.
179
  - Gradio audio, image, and text components.
180
  - Optional model cache in Space environment.
181
 
@@ -195,22 +209,22 @@ Gradio inputs
195
  - Text, image, and voice input.
196
  - Dream intake normalization.
197
  - MiniCPM-V-4.6 image clue extraction.
198
- - MiniCPM5-1B negotiation and final card.
199
  - HTML output card.
200
  - Safety boundary.
201
 
202
  #### v1.1
203
 
204
- - More polished card export.
205
  - Better example gallery.
206
  - Optional bilingual output.
207
  - More robust eval harness.
 
208
 
209
  #### v2.0
210
 
211
  - Voice output through VoxCPM2.
212
  - Local dream history stored only in browser/session.
213
- - Multiple customs officer styles.
214
 
215
  ### Technical Risks
216
 
@@ -218,12 +232,4 @@ Gradio inputs
218
  - MiniCPM5-1B output may fail strict JSON without repair.
219
  - ASR adapter may complicate dependencies.
220
  - Audio input on mobile browsers may behave inconsistently.
221
- - Safety classification may be too weak if only prompt-based.
222
-
223
- ### Mitigations
224
-
225
- - Keep text-only demo path always functional.
226
- - Add JSON repair and schema validation.
227
- - Use examples with fixed sample media for demo recording.
228
- - Make ASR optional and fallback to manual transcript.
229
- - Keep image extraction output concise to reduce latency.
 
1
+ # Dream QA / 梦境问答台 PRD
2
 
3
+ Last updated: 2026-06-08
4
 
5
  ## 1. Executive Summary
6
 
7
  ### Problem Statement
8
 
9
+ Users who wake up from vivid dreams may feel unsettled, curious, or mentally cluttered. They often do not need a definitive "meaning"; they need a short, safe conversation that helps them identify what question the dream left behind and what tiny thing they can do today.
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.
20
+ - At least one follow-up question feels related to the dream in >= 90% of demo runs.
21
+ - Final output references at least one concrete dream detail.
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
 
30
 
31
  1. Primary persona: vivid dreamer.
32
  - Recently sleeps poorly or wakes with strong dream fragments.
33
+ - Wants a small morning reflection, not therapy or fortune-telling.
34
 
35
  2. Secondary persona: playful AI explorer.
36
  - Wants a strange AI experience worth showing a friend.
 
42
 
43
  ### User Stories
44
 
45
+ #### Story 1: Text Dream Q&A
46
 
47
+ As a vivid dreamer, I want to type my dream so that the app can help me figure out what I am actually wondering about.
48
 
49
  Acceptance criteria:
50
 
51
  - User can submit text-only input.
52
+ - App creates a valid dream intake object.
53
+ - App asks at least one relevant follow-up question.
54
+ - User can answer or skip.
55
+ - Final output includes a grounded today tip.
56
 
57
+ #### Story 2: Image-Assisted Dream Clues
58
 
59
  As a user who remembers images better than words, I want to upload a sketch or bedside note so that the system can use visual dream clues.
60
 
61
  Acceptance criteria:
62
 
63
  - User can upload one image.
64
+ - MiniCPM-V-4.6 extracts visible clues when content exists.
65
+ - Visual clues are included in the dream intake and final interpretation.
66
  - If image extraction fails, text-only flow still works.
67
 
68
+ #### Story 3: Voice Dream Capture
69
 
70
  As a half-awake user, I want to speak my dream so that I do not need to type immediately after waking.
71
 
 
73
 
74
  - User can record or upload an audio clip.
75
  - ASR adapter returns a transcript.
76
+ - Transcript is visible/editable or transparently included in the dream intake.
77
  - If ASR fails, user receives a concise fallback message and can type manually.
78
 
79
+ #### Story 4: Follow-Up Question Flow
80
 
81
+ As a user, I want the AI to ask me a few questions so that the final interpretation feels connected to my dream rather than generic.
82
 
83
  Acceptance criteria:
84
 
85
+ - App generates 1-3 follow-up questions.
86
+ - User can answer all, answer some, skip, or ask for another angle.
87
+ - Skip path still generates a final tip.
88
+ - Questions avoid clinical or diagnostic phrasing.
89
 
90
+ #### Story 5: Today Tip Card
91
 
92
+ As a user, I want a clear final result so that I leave with one small thing for today.
93
 
94
  Acceptance criteria:
95
 
96
+ - Result includes dream summary, main question, interpretation, today tip, optional tiny action, optional caring note, and safety note when needed.
97
+ - Result is readable on mobile and desktop.
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.
104
+ - Dream prophecy or symbolic certainty.
105
  - Sleep-stage tracking.
106
  - Long-term journaling database.
107
  - Account creation.
 
114
  ### Tool Requirements
115
 
116
  - MiniCPM-V-4.6 for image-to-text visual clue extraction.
117
+ - MiniCPM5-1B for text reasoning, follow-up questions, interpretation, and Today Tip generation.
118
  - A small ASR adapter for voice-to-text. This adapter is not the core AI experience.
119
  - Gradio for UI.
120
+ - Optional HTML/CSS result renderer.
121
 
122
  ### Prompt Requirements
123
 
 
127
  - Keep the tone playful, gentle, and non-clinical.
128
  - Avoid strong claims about dream meaning.
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
 
 
142
  Measure:
143
 
144
  - Schema validity.
145
+ - Follow-up relevance.
146
  - Tone safety.
147
+ - Dream-anchor grounding.
148
+ - Practicality of today tip.
149
  - Ability to recover from missing modality.
150
 
151
  Pass thresholds:
 
153
  - Schema validity: >= 90%.
154
  - No diagnostic language in ordinary cases: 100%.
155
  - Safety note appears in distress-edge cases: >= 90%.
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
 
 
165
  -> text input
166
  -> image upload -> MiniCPM-V-4.6 visual clue extractor
167
  -> audio upload/record -> ASR adapter
168
+ -> DreamQuestionIntake builder
169
+ -> MiniCPM5-1B summary/main-question prompt
170
+ -> follow-up question flow
171
+ -> user answer/skip/another-angle
172
+ -> MiniCPM5-1B interpretation prompt
173
+ -> TodayTipCard prompt
174
+ -> HTML result renderer
175
  ```
176
 
177
  ### Core Components
178
 
179
+ - `app.py`: thin Gradio entrypoint.
180
+ - `dream_customs/schema.py`: Pydantic models for dream intake, Q&A state, and Today Tip output.
181
  - `dream_customs/models.py`: model/client loading and inference wrappers.
182
  - `dream_customs/prompts.py`: prompt templates.
183
  - `dream_customs/pipeline.py`: orchestration logic.
184
  - `dream_customs/safety.py`: lightweight safety checks and copy.
185
+ - `dream_customs/render.py`: HTML result rendering.
186
+ - `dream_customs/ui/`: Gradio UI shell, copy, actions, and styles.
187
+ - `tests/`: schema, prompt, safety, pipeline, render, and UI action unit tests with mocked model outputs.
188
 
189
  ### Integration Points
190
 
191
  - Hugging Face Space runtime.
192
+ - Hugging Face model downloads or private Modal endpoints.
193
  - Gradio audio, image, and text components.
194
  - Optional model cache in Space environment.
195
 
 
209
  - Text, image, and voice input.
210
  - Dream intake normalization.
211
  - MiniCPM-V-4.6 image clue extraction.
212
+ - MiniCPM5-1B follow-up and final Today Tip.
213
  - HTML output card.
214
  - Safety boundary.
215
 
216
  #### v1.1
217
 
 
218
  - Better example gallery.
219
  - Optional bilingual output.
220
  - More robust eval harness.
221
+ - Better result export.
222
 
223
  #### v2.0
224
 
225
  - Voice output through VoxCPM2.
226
  - Local dream history stored only in browser/session.
227
+ - Multiple question-guide styles.
228
 
229
  ### Technical Risks
230
 
 
232
  - MiniCPM5-1B output may fail strict JSON without repair.
233
  - ASR adapter may complicate dependencies.
234
  - Audio input on mobile browsers may behave inconsistently.
235
+ - Final tips may become generic unless prompts and repair logic enforce dream anchors.
 
 
 
 
 
 
 
 
docs/prototypes/2026-06-08-dream-qa-desktop-workbench.png ADDED

Git LFS Details

  • SHA256: 39e04ecb3eef3a66e635553ce82db80aa86c7f1abe6057dec22035f34f1f9e7c
  • Pointer size: 132 Bytes
  • Size of remote file: 1.12 MB
docs/prototypes/2026-06-08-dream-qa-mobile-flow.png ADDED

Git LFS Details

  • SHA256: 5e41fcc9cef046e2d8d31c6760a93278232830f13b6a01c266fcdf8c2b7afa97
  • Pointer size: 132 Bytes
  • Size of remote file: 1.27 MB
docs/prototypes/2026-06-08-dream-qa-tips-card.png ADDED

Git LFS Details

  • SHA256: 788066226e98163a1bdbf47bbd82c93b1ca6d8fef6666b1fc45223b2164e56d5
  • Pointer size: 132 Bytes
  • Size of remote file: 1.36 MB
docs/smoke/2026-06-08-dream-qa-refactor-smoke.md ADDED
@@ -0,0 +1,90 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Dream QA Refactor Smoke - 2026-06-08
2
+
3
+ ## Scope
4
+
5
+ Refactor the public Dream Customs app experience into Dream QA / 梦境问答台:
6
+
7
+ - Record -> Ask -> Interpret -> Today Tip product flow.
8
+ - Text-only demo fallback remains available.
9
+ - Image clues still route through the MiniCPM-V-4.6 witness adapter.
10
+ - Voice input remains transcription-only through ASR/browser dictation.
11
+ - Final result is `TodayTipCard`, not a permit, pact, contraband list, or seal.
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
+ HTTP check:
28
+
29
+ ```bash
30
+ curl -sS -I http://127.0.0.1:7862
31
+ ```
32
+
33
+ Observed:
34
+
35
+ ```text
36
+ HTTP/1.1 200 OK
37
+ content-type: text/html; charset=utf-8
38
+ ```
39
+
40
+ ## Automated Checks
41
+
42
+ Command:
43
+
44
+ ```bash
45
+ .venv/bin/python -m pytest -q
46
+ ```
47
+
48
+ Observed:
49
+
50
+ ```text
51
+ 91 passed, 2 warnings in 1.26s
52
+ ```
53
+
54
+ Command:
55
+
56
+ ```bash
57
+ git diff --check
58
+ ```
59
+
60
+ Observed: no output.
61
+
62
+ ## Flow Smoke
63
+
64
+ Input:
65
+
66
+ ```text
67
+ 我梦到在一栋老楼里,电梯按钮融化了,楼层数字停在 14。醒来有点焦虑。
68
+ ```
69
+
70
+ Answer:
71
+
72
+ ```text
73
+ 像是还没开始就觉得来不及。
74
+ ```
75
+
76
+ Observed action payload:
77
+
78
+ ```text
79
+ submit_status= ask
80
+ question= 当你想到「电梯按钮」和「按钮融化」时,今天有没有一件真实的小事,你希望它变得更容易开始?
81
+ tip_status= tip
82
+ has_anchor= True
83
+ old_words= False
84
+ ```
85
+
86
+ The final text included concrete anchors: `电梯`, `融化的按钮`, and `数字 14`.
87
+
88
+ ## Browser Note
89
+
90
+ The in-app Browser automation surface timed out while attaching to the local webview twice during this run. Because that layer did not attach, visual screenshot verification was not claimed. Local HTTP and Python action smoke were completed instead.
docs/smoke/2026-06-08-model-led-pact-v2-smoke.md ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Model-Led Pact V2 Smoke
2
+
3
+ Date: 2026-06-08
4
+ Branch: feature/dream-customs-ui-voice-settings
5
+
6
+ ## Local
7
+
8
+ - Command: `.venv/bin/python -m pytest -q`
9
+ - Result: PASS, `86 passed, 2 warnings`
10
+ - Command: `git diff --check`
11
+ - Result: PASS
12
+ - Command: `.venv/bin/python scripts/evaluate_demo_quality.py`
13
+ - Result: PASS, `case_count` is `10`, `passes` is `true`, `failures` is empty
14
+ - Command: `GRADIO_SERVER_PORT=7862 .venv/bin/python app.py`
15
+ - Result: PASS, local URL served at `http://0.0.0.0:7862`
16
+
17
+ ## Manual Demo Case
18
+
19
+ Input:
20
+
21
+ ```text
22
+ I kept missing an elevator. The buttons melted like wax, and the floor number froze at 14.
23
+ ```
24
+
25
+ Acceptance:
26
+
27
+ - Natural English card: PASS
28
+ - No repeated article regression: PASS
29
+ - No invented lever detail: PASS
30
+ - Uses at least two dream details: PASS, elevator and melted buttons
31
+ - Practical suggestion is actionable: PASS
32
+ - Weird task is harmless and dream-grounded: PASS
33
+ - Local UI loads at `http://127.0.0.1:7862`: PASS
34
+
35
+ Observed final pass excerpt:
36
+
37
+ ```text
38
+ Visitor: Late Elevator
39
+ Alliance reading: You can treat the elevator and the floor as last night's way of asking for one promise to become smaller and easier to carry today.
40
+ Life tip for today: Pick one real task that feels like the elevator, then define only its first step for the next 10 minutes.
41
+ 5-minute odd task: Draw a tiny elevator button on paper, press it once, and work for five minutes.
42
+ Emotional contraband: unfiled pressure, melted buttons, a pocket of unstarted tasks
43
+ Bedtime release: The elevator has docked for tonight. Unfinished floors can report tomorrow.
44
+ ```
45
+
46
+ ## Hugging Face Space
47
+
48
+ - Push target checked: `space https://huggingface.co/spaces/build-small-hackathon/dream-customs`
49
+ - Space sync command: `git push space feature/dream-customs-ui-voice-settings`
50
+ - Space sync result: BLOCKED
51
+ - Blocker:
52
+
53
+ ```text
54
+ You are not authorized to push to this repo.
55
+ Make sure that you are properly logged in.
56
+ ```
57
+
58
+ - Live Space config checked: not re-checked for this implementation SHA because Space sync is blocked by push authorization.
59
+ - Live Space manual case checked: not re-checked for this implementation SHA because the implementation branch is not deployed to the Space.
60
+ - Notes: GitHub branch `feature/dream-customs-ui-voice-settings` is pushed through commit `19e6e5c`. Do not force-push public Space `main` or handle tokens in chat; Space sync needs an authenticated HF credential or manual PR/merge by someone with access.
docs/spec.md CHANGED
@@ -1,10 +1,10 @@
1
- # Dream Customs / 梦境海关 Product Spec
2
 
3
- Last updated: 2026-06-05
4
 
5
  ## 1. One-Line Concept
6
 
7
- 梦境海关帮用户昨晚的梦结盟:用户用语音、图片或文字申报梦境,系统把不安转成第二天小建议把怪梦转成一件 5 分钟能完成有趣
8
 
9
  ## 2. Contest Fit
10
 
@@ -12,26 +12,27 @@ Last updated: 2026-06-05
12
  - Build window: 2026-06-05 to 2026-06-15.
13
  - Deployment target: Gradio app on Hugging Face Space.
14
  - Model constraint: total model parameters must be <= 32B.
15
- - Primary track fit: An Adventure in Thousand Token Wood, because the AI is the load-bearing delight of the experience.
16
- - Secondary story: Backyard AI, because the concept comes from a real sleep problem: frequent vivid dreams and poor sleep.
17
  - Model family constraint: prioritize MiniCPM models before considering any unrelated small model.
18
 
19
  ## 3. Core User Problem
20
 
21
- The user wakes up after vivid or unsettling dreams and wants a quick, non-clinical way to make sense of the dream before the day begins. Existing dream tools often feel either too mystical, too diagnostic, or too generic. This product should feel like a playful ritual that gives emotional order without pretending to be therapy.
22
 
23
  ## 4. Product Principles
24
 
25
  1. Do not diagnose.
26
  2. Do not frighten the user.
27
- 3. Convert dream residue into a small next-day action.
28
- 4. Keep the output weird enough to show a friend.
29
- 5. Make the AI the main experience, not a helper hidden behind a form.
30
- 6. Keep the MVP stable enough for Gradio Space and demo-video recording.
 
31
 
32
  ## 5. User Inputs
33
 
34
- All inputs are normalized into one `DreamIntake` object so the app does not become three separate products.
35
 
36
  ### 5.1 Text Input
37
 
@@ -39,6 +40,7 @@ The user writes:
39
 
40
  - Dream narrative.
41
  - Wake-up mood.
 
42
  - Repeated symbols.
43
  - Any second-day concern or wish.
44
 
@@ -59,7 +61,15 @@ The user speaks the dream immediately after waking.
59
 
60
  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.
61
 
62
- ## 6. Dream Intake Schema
 
 
 
 
 
 
 
 
63
 
64
  ```json
65
  {
@@ -67,12 +77,43 @@ Implementation note: MiniCPM-V-4.6 and MiniCPM5-1B are not raw audio ASR models.
67
  "voice_transcript": "我梦见我一直赶不上电梯...",
68
  "visual_clues": ["歪斜电梯门", "蓝色楼道", "融化的数字 14"],
69
  "mood": "焦虑但有点滑稽",
 
70
  "recurring_symbols": ["电梯", "迟到", "融化的按钮"],
71
  "uncertainty": "梦的后半段记不清",
72
  "user_context": "最近晚上常做梦,睡醒后想获得第二天建议"
73
  }
74
  ```
75
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
76
  ## 7. Model Responsibilities
77
 
78
  ### MiniCPM-V-4.6
@@ -87,19 +128,21 @@ Responsibilities:
87
 
88
  Non-responsibilities:
89
 
90
- - Do not generate the final alliance card.
91
- - Do not provide psychological interpretation.
92
 
93
  ### MiniCPM5-1B
94
 
95
- Role: dream diplomat.
96
 
97
  Responsibilities:
98
 
99
  - Merge dream text, voice transcript, and visual clues.
100
- - Name the dream visitor.
101
- - Ask 2-3 alliance negotiation questions.
102
- - Generate the final alliance card.
 
 
103
  - Keep tone playful, gentle, and non-clinical.
104
 
105
  ## 8. MVP User Flow
@@ -108,45 +151,36 @@ Responsibilities:
108
  2. User submits any combination of text, image, and voice.
109
  3. App transcribes voice if present.
110
  4. App calls MiniCPM-V-4.6 if image is present.
111
- 5. App builds `DreamIntake`.
112
- 6. MiniCPM5-1B generates a dream visitor profile and 2-3 follow-up questions.
113
- 7. User answers questions or clicks "skip / refuse cooperation".
114
- 8. MiniCPM5-1B generates a final alliance card.
115
- 9. App renders the card as a shareable visual block and plain text.
 
 
116
 
117
  ## 9. Output Contract
118
 
119
- The final card must include:
120
 
121
- - Dream visitor name.
122
- - Entry permit number.
123
- - Carried emotional contraband.
124
- - Risk level, phrased playfully.
125
- - Alliance reading: what the dream may be trying to protect.
126
- - One practical next-day suggestion.
127
- - One 5-minute weird task.
128
- - One bedtime release phrase.
129
  - Safety note if the input indicates severe distress.
130
 
131
- Example:
132
 
133
- ```json
134
- {
135
- "visitor_name": "迟到的电梯",
136
- "permit_id": "DC-0605-014",
137
- "contraband": ["未申报的焦虑", "融化的按钮", "一小袋没来得及开始的事"],
138
- "risk_level": "橙色:需要被安置,但不需要被害怕",
139
- "alliance_reading": "这个梦像是在提醒你,今天不要把启动一件事和完成一件事混成同一个压力。",
140
- "practical_suggestion": "提前 10 分钟开始一件最小的任务,只要求打开它。",
141
- "weird_task": "给电梯写一句道歉信:抱歉总让你替我背迟到的锅。",
142
- "bedtime_release": "今日电梯已停靠,未完成事项明日再报关。",
143
- "safety_note": ""
144
- }
145
- ```
146
 
147
  ## 10. UX Structure
148
 
149
- ### Screen 1: Dream Declaration Desk
150
 
151
  Inputs:
152
 
@@ -154,40 +188,51 @@ Inputs:
154
  - Image upload for dream sketch or bedside note.
155
  - Audio input for spoken dream.
156
  - Optional mood selector.
 
157
 
158
  Primary action:
159
 
160
- - "Submit to Customs"
161
 
162
- ### Screen 2: Alliance Negotiation
163
 
164
  Content:
165
 
166
- - Dream visitor name.
167
- - 2-3 questions from the customs officer.
168
- - Buttons: answer, skip, refuse cooperation.
 
169
 
170
- ### Screen 3: Today's Pact Card
171
 
172
  Content:
173
 
174
- - Card-style result.
 
 
 
 
 
 
 
 
 
 
175
  - Copy text button.
176
- - Download/share image target if feasible.
177
- - "Release this dream for tonight" reset action.
178
 
179
  ## 11. Safety Boundaries
180
 
181
  The app must:
182
 
183
  - Avoid diagnoses, medical claims, or certainty about dream meaning.
184
- - Avoid telling users that a dream proves trauma, illness, or danger.
185
  - Use language such as "may be", "could be treated as", and "for today, try".
186
  - If the user mentions self-harm, intent to harm others, severe insomnia, panic, or inability to function, show a supportive escalation note.
187
 
188
  Escalation copy:
189
 
190
- > This dream sounds heavier than a playful customs ritual should handle. If you feel unsafe, unable to sleep for many nights, or worried you may hurt yourself or someone else, please reach out to a trusted person or professional support now.
191
 
192
  ## 12. Recommended MVP Scope
193
 
@@ -197,8 +242,9 @@ Included:
197
  - Text input.
198
  - Image input through MiniCPM-V-4.6.
199
  - Voice input through small ASR adapter.
200
- - MiniCPM5-1B prompt flow.
201
- - Result card in HTML.
 
202
  - Example mode for demo stability.
203
  - Basic safety classifier prompt or rule layer.
204
 
@@ -210,27 +256,15 @@ Excluded from MVP:
210
  - Dream history database.
211
  - Personalized therapy plans.
212
  - Multi-user social feed.
 
213
 
214
  ## 13. Grill Questions And Recommended Answers
215
 
216
  1. Is this a therapy tool?
217
- - Recommended answer: no. It is a playful reflection and next-day ritual tool.
218
 
219
  2. Which track should the submission emphasize?
220
  - Recommended answer: Thousand Token Wood first, Backyard AI second.
221
 
222
- 3. Should voice be core?
223
- - Recommended answer: voice input yes, voice output no for MVP.
224
-
225
- 4. Should the image input be mandatory?
226
- - Recommended answer: no. Image is a strong MiniCPM-V demo path but text alone must work.
227
-
228
- 5. What is the winning demo moment?
229
- - Recommended answer: user speaks a dream, uploads a quick sketch, and receives a strange but useful "Today's Pact" card.
230
-
231
- ## 14. Open Questions
232
-
233
- - Which ASR adapter should be used on Hugging Face Space for the MVP?
234
- - Should the first demo be in Chinese, English, or bilingual?
235
- - Do we want to submit under the same team/repo as the earlier Ordinary Relics MVP, or create a new Space?
236
- - Should the visual card support image download in MVP or only screenshot-friendly HTML?
 
1
+ # Dream QA / 梦境问答台 Product Spec
2
 
3
+ Last updated: 2026-06-08
4
 
5
  ## 1. One-Line Concept
6
 
7
+ 梦境问答台帮用户循序渐进地理解昨晚的梦:用户用文字、图片或语音记录梦境,系统先追问关键细节,再给出一个非诊断、非宿命论解读草稿最后生成一个温和今日 Tips
8
 
9
  ## 2. Contest Fit
10
 
 
12
  - Build window: 2026-06-05 to 2026-06-15.
13
  - Deployment target: Gradio app on Hugging Face Space.
14
  - Model constraint: total model parameters must be <= 32B.
15
+ - Primary track fit: An Adventure in Thousand Token Wood, because the AI-driven conversation is the main experience.
16
+ - Secondary story: Backyard AI, because the concept comes from a real sleep problem: frequent vivid dreams and morning confusion.
17
  - Model family constraint: prioritize MiniCPM models before considering any unrelated small model.
18
 
19
  ## 3. Core User Problem
20
 
21
+ The user wakes up after a vivid or unsettling dream and wants help making sense of it without receiving mystical certainty, therapy language, or generic advice. They may not know the right question yet, so the app should help them gradually clarify what they are curious or worried about.
22
 
23
  ## 4. Product Principles
24
 
25
  1. Do not diagnose.
26
  2. Do not frighten the user.
27
+ 3. Do not claim the dream has one fixed meaning.
28
+ 4. Ask before interpreting.
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
 
35
+ All inputs are normalized into one dream intake object so the app does not become three separate products.
36
 
37
  ### 5.1 Text Input
38
 
 
40
 
41
  - Dream narrative.
42
  - Wake-up mood.
43
+ - What feels confusing or important.
44
  - Repeated symbols.
45
  - Any second-day concern or wish.
46
 
 
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:
67
+
68
+ ```text
69
+ DreamQuestionIntake -> DreamQAState -> TodayTipCard
70
+ ```
71
+
72
+ ### 6.1 DreamQuestionIntake
73
 
74
  ```json
75
  {
 
77
  "voice_transcript": "我梦见我一直赶不上电梯...",
78
  "visual_clues": ["歪斜电梯门", "蓝色楼道", "融化的数字 14"],
79
  "mood": "焦虑但有点滑稽",
80
+ "main_question": "这个梦是不是和我最近拖延有关?",
81
  "recurring_symbols": ["电梯", "迟到", "融化的按钮"],
82
  "uncertainty": "梦的后半段记不清",
83
  "user_context": "最近晚上常做梦,睡醒后想获得第二天建议"
84
  }
85
  ```
86
 
87
+ ### 6.2 DreamQAState
88
+
89
+ ```json
90
+ {
91
+ "dream_summary": "你梦见自己赶不上电梯,楼层按钮像蜡一样融化。",
92
+ "dream_anchors": ["赶不上电梯", "融化的按钮", "焦虑但滑稽"],
93
+ "main_question": "这个梦和最近拖延有关吗?",
94
+ "followup_questions": [
95
+ "梦里最强烈的感受是着急、害怕,还是荒诞?",
96
+ "最近有没有一件事让你觉得还没开始就已经迟到?"
97
+ ],
98
+ "user_answers": ["更像是还没开始就觉得迟到。"],
99
+ "current_step": "interpret"
100
+ }
101
+ ```
102
+
103
+ ### 6.3 TodayTipCard
104
+
105
+ ```json
106
+ {
107
+ "dream_summary": "你梦见赶不上电梯,按钮融化,醒来有点焦虑。",
108
+ "main_question": "它是否和最近拖延有关?",
109
+ "interpretation": "也许这个梦不是在证明你真的迟到了,而是在把“还没开始就担心来不及”的感觉演成了电梯。",
110
+ "today_tip": "今天先选一件小事,只���打开它的第一步,别要求自己马上抵达。",
111
+ "tiny_action": "用 5 分钟写下:如果电梯只停一层,我今天先停在哪一层?",
112
+ "caring_note": "你不需要一醒来就解决所有问题,先把节奏找回来就很好。",
113
+ "safety_note": ""
114
+ }
115
+ ```
116
+
117
  ## 7. Model Responsibilities
118
 
119
  ### MiniCPM-V-4.6
 
128
 
129
  Non-responsibilities:
130
 
131
+ - Do not generate the final TodayTipCard.
132
+ - Do not provide psychological diagnosis.
133
 
134
  ### MiniCPM5-1B
135
 
136
+ Role: dream question guide.
137
 
138
  Responsibilities:
139
 
140
  - Merge dream text, voice transcript, and visual clues.
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
 
151
  2. User submits any combination of text, image, and voice.
152
  3. App transcribes voice if present.
153
  4. App calls MiniCPM-V-4.6 if image is present.
154
+ 5. App builds `DreamQuestionIntake`.
155
+ 6. MiniCPM5-1B summarizes the dream and identifies the likely `main_question`.
156
+ 7. App asks 1-3 follow-up questions.
157
+ 8. User answers, skips, adds detail, or asks for another angle.
158
+ 9. MiniCPM5-1B drafts a grounded interpretation.
159
+ 10. MiniCPM5-1B generates a final `TodayTipCard`.
160
+ 11. Gradio renders a mobile-readable result block and plain text.
161
 
162
  ## 9. Output Contract
163
 
164
+ The final result must include:
165
 
166
+ - Dream summary.
167
+ - Main question.
168
+ - Grounded interpretation.
169
+ - One primary today tip.
170
+ - Optional tiny action.
171
+ - Optional caring note.
 
 
172
  - Safety note if the input indicates severe distress.
173
 
174
+ The final result must:
175
 
176
+ - Reference at least one concrete dream anchor.
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
 
183
+ ### Screen 1: Record
184
 
185
  Inputs:
186
 
 
188
  - Image upload for dream sketch or bedside note.
189
  - Audio input for spoken dream.
190
  - Optional mood selector.
191
+ - Optional field: "你最想理解什么?"
192
 
193
  Primary action:
194
 
195
+ - "继续解梦" / "Continue"
196
 
197
+ ### Screen 2: Ask
198
 
199
  Content:
200
 
201
+ - Short dream summary.
202
+ - Main question guess.
203
+ - 1-3 follow-up questions.
204
+ - Buttons: answer, skip, ask another angle.
205
 
206
+ ### Screen 3: Interpret
207
 
208
  Content:
209
 
210
+ - Dream anchors detected.
211
+ - Draft interpretation using "也许 / 可以把它当作 / 今天先试试" language.
212
+ - Controls: add detail, regenerate angle, finish.
213
+
214
+ ### Screen 4: Today Tip
215
+
216
+ Content:
217
+
218
+ - One primary today tip.
219
+ - Optional tiny action.
220
+ - Optional caring note.
221
  - Copy text button.
222
+ - Reset action.
 
223
 
224
  ## 11. Safety Boundaries
225
 
226
  The app must:
227
 
228
  - Avoid diagnoses, medical claims, or certainty about dream meaning.
229
+ - Avoid telling users that a dream proves trauma, illness, danger, fate, or prophecy.
230
  - Use language such as "may be", "could be treated as", and "for today, try".
231
  - If the user mentions self-harm, intent to harm others, severe insomnia, panic, or inability to function, show a supportive escalation note.
232
 
233
  Escalation copy:
234
 
235
+ > This dream sounds heavier than a playful reflection tool should handle. If you feel unsafe, unable to sleep for many nights, or worried you may hurt yourself or someone else, please reach out to a trusted person or professional support now.
236
 
237
  ## 12. Recommended MVP Scope
238
 
 
242
  - Text input.
243
  - Image input through MiniCPM-V-4.6.
244
  - Voice input through small ASR adapter.
245
+ - Dream intake normalization.
246
+ - Progressive follow-up questions.
247
+ - Today Tip result in HTML.
248
  - Example mode for demo stability.
249
  - Basic safety classifier prompt or rule layer.
250
 
 
256
  - Dream history database.
257
  - Personalized therapy plans.
258
  - Multi-user social feed.
259
+ - Fine-tuning during the hackathon MVP.
260
 
261
  ## 13. Grill Questions And Recommended Answers
262
 
263
  1. Is this a therapy tool?
264
+ - Recommended answer: no. It is a playful reflection and next-day tip tool.
265
 
266
  2. Which track should the submission emphasize?
267
  - Recommended answer: Thousand Token Wood first, Backyard AI second.
268
 
269
+ 3. What should the demo show?
270
+ - Recommended answer: user enters a dream, answers or skips one follow-up question, and receives a grounded today tip.
 
 
 
 
 
 
 
 
 
 
 
 
 
docs/superpowers/plans/2026-06-08-dream-qa-refactor-doc-plan.md ADDED
@@ -0,0 +1,202 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Dream QA Refactor Document Plan
2
+
3
+ Last updated: 2026-06-08
4
+
5
+ ## Goal
6
+
7
+ Refactor Dream Customs from a customs/pact-centered ritual into a step-by-step dream Q&A Gradio app.
8
+
9
+ The new product should help the user gradually answer doubts about a dream, then end with one gentle "today tip". The tip can be a practical suggestion, a tiny thing the user has not tried before, or a short caring sentence. It must stay non-clinical and avoid claiming a single fixed dream meaning.
10
+
11
+ ## Prototype Images
12
+
13
+ Use these generated mockups as discussion references before implementation:
14
+
15
+ - `docs/prototypes/2026-06-08-dream-qa-mobile-flow.png`
16
+ - `docs/prototypes/2026-06-08-dream-qa-desktop-workbench.png`
17
+ - `docs/prototypes/2026-06-08-dream-qa-tips-card.png`
18
+
19
+ ## Product Shift
20
+
21
+ Current docs emphasize:
22
+
23
+ - Dream visitor.
24
+ - Customs negotiation.
25
+ - Permit ID.
26
+ - Contraband.
27
+ - Today's pact card.
28
+ - Seal/revise pact actions.
29
+
30
+ New direction should emphasize:
31
+
32
+ - Dream question intake.
33
+ - One clear uncertainty the user wants to understand.
34
+ - 2-4 progressive follow-up questions.
35
+ - A grounded interpretation draft that references specific dream details.
36
+ - One final "today tip" with optional tiny action.
37
+ - Safety note only when needed.
38
+
39
+ Keep the good parts:
40
+
41
+ - Text-only path must always work.
42
+ - Image and voice can enrich the same intake object.
43
+ - MiniCPM-V-4.6 remains the image clue extractor.
44
+ - MiniCPM5-1B remains the main reasoning and generation model.
45
+ - No diagnosis, prophecy, therapy framing, or fear-based copy.
46
+
47
+ ## Document Modification Plan
48
+
49
+ ### 1. `AGENTS.md`
50
+
51
+ Rewrite the project positioning and product boundary sections.
52
+
53
+ Change from:
54
+
55
+ - "和昨晚的梦结盟"
56
+ - "获得今日盟约卡"
57
+ - required output fields such as `permit_id`, `contraband`, `risk_level`, `bedtime_release`
58
+
59
+ Change to:
60
+
61
+ - "循序渐进帮用户理解梦境疑惑"
62
+ - final output is `today_tip` / `今日小 Tips`
63
+ - required output fields:
64
+ - `dream_summary`
65
+ - `main_question`
66
+ - `followup_questions`
67
+ - `user_answers`
68
+ - `interpretation`
69
+ - `today_tip`
70
+ - `tiny_action`
71
+ - `caring_note`
72
+ - `safety_note`
73
+
74
+ Also update manual acceptance:
75
+
76
+ - Submit a dream.
77
+ - App asks at least one useful follow-up question.
78
+ - User can answer or skip.
79
+ - Final result references concrete dream details and gives one today tip.
80
+ - Output avoids diagnosis, prophecy, and medicalized advice.
81
+
82
+ ### 2. `docs/spec.md`
83
+
84
+ Rewrite the product spec around a `DreamQuestionIntake -> DreamQAState -> TodayTipCard` flow.
85
+
86
+ Needed edits:
87
+
88
+ - Replace pact/card schema with Q&A schema.
89
+ - Replace "MVP User Flow" with progressive steps:
90
+ 1. User records dream.
91
+ 2. User picks or writes the doubt they want answered.
92
+ 3. Model asks a gentle follow-up.
93
+ 4. User answers, skips, or asks for a different angle.
94
+ 5. Model drafts an interpretation grounded in dream details.
95
+ 6. Model produces one today tip.
96
+ - Keep MiniCPM and multimodal constraints.
97
+ - Add a rule that final advice must cite at least one dream anchor.
98
+
99
+ ### 3. `docs/prd.md`
100
+
101
+ Update user stories and success criteria.
102
+
103
+ New user stories:
104
+
105
+ - Text dream Q&A.
106
+ - Image-assisted dream clue extraction.
107
+ - Voice dream capture.
108
+ - Follow-up question flow.
109
+ - Final Today Tip card.
110
+
111
+ New success criteria:
112
+
113
+ - User can complete a text-only Q&A in <= 90 seconds.
114
+ - At least one follow-up question feels related to the dream.
115
+ - Final tip references concrete dream details.
116
+ - Ordinary cases never include diagnostic or frightening language.
117
+ - Distress cases show support guidance.
118
+
119
+ ### 4. `PRODUCT.md`
120
+
121
+ Rewrite product purpose and anti-references.
122
+
123
+ Keep:
124
+
125
+ - Gentle, strange, lucid.
126
+ - Mobile-first morning use.
127
+ - Not therapy.
128
+
129
+ Change:
130
+
131
+ - Reduce customs clerk metaphor.
132
+ - Use "dream companion / question guide" framing.
133
+ - Keep playful language, but make the experience easier for ordinary users to understand.
134
+ - Anti-reference should include generic healing app output, not only plain Gradio/tarot/therapy.
135
+
136
+ ### 5. `DESIGN.md`
137
+
138
+ Update the north star from "Morning Customs Desk" to a softer Q&A interface.
139
+
140
+ Needed design changes:
141
+
142
+ - Primary screen is a conversational Q&A flow with a visible stepper: record, ask, interpret, tip.
143
+ - Right-side desktop panel becomes "interpretation draft / today tip preview", not "pact inspector".
144
+ - Mobile final screen should prioritize Today Tip first, then optional explanation.
145
+ - Keep high contrast, 8-12px radii, readable controls, and no purple AI SaaS theme.
146
+
147
+ ### 6. `docs/handoff.md`
148
+
149
+ Rewrite handoff as the source of truth for the refactor.
150
+
151
+ Needed edits:
152
+
153
+ - Mark current V4 customs flow as deprecated context.
154
+ - List the new Q&A flow and schema.
155
+ - Identify files likely to change:
156
+ - `dream_customs/schema.py`
157
+ - `dream_customs/prompts.py`
158
+ - `dream_customs/pipeline.py`
159
+ - `dream_customs/render.py`
160
+ - `dream_customs/ui/app.py`
161
+ - `dream_customs/ui/copy.py`
162
+ - `dream_customs/ui/styles.py`
163
+ - tests covering schema, prompts, pipeline, UI actions, and safety
164
+ - Preserve Modal/HF deployment constraints.
165
+
166
+ ### 7. `README.md`
167
+
168
+ Update public-facing positioning after implementation is ready.
169
+
170
+ Needed edits:
171
+
172
+ - Short description should shift from "pact" to "guided dream Q&A".
173
+ - Usage section should show:
174
+ - enter dream
175
+ - answer follow-up
176
+ - receive today tip
177
+ - Keep model and deployment details accurate.
178
+
179
+ ### 8. Existing execution plans
180
+
181
+ Do not rewrite old completed plan files as if they never happened.
182
+
183
+ Add a new implementation plan instead:
184
+
185
+ - `docs/superpowers/plans/2026-06-08-dream-qa-refactor.md`
186
+
187
+ That plan should include code steps, tests, local Gradio verification, GitHub push, and HF Space sync/merge path.
188
+
189
+ ## Implementation Acceptance Targets
190
+
191
+ - Text-only path works with no image/audio.
192
+ - Image upload contributes visual clues to the Q&A.
193
+ - Voice transcript, when available, contributes to the same state.
194
+ - User can answer or skip follow-up questions.
195
+ - Final output has exactly one primary today tip.
196
+ - Final output includes a concrete dream anchor.
197
+ - Ordinary output avoids diagnosis, therapy claims, prophecy, and fear.
198
+ - Severe distress triggers support guidance.
199
+ - Mobile width remains readable.
200
+ - `python -m pytest -q` passes.
201
+ - Local Gradio smoke passes before Space sync.
202
+
docs/superpowers/plans/2026-06-08-dream-qa-refactor.md ADDED
@@ -0,0 +1,195 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Dream QA Refactor Implementation Plan
2
+
3
+ Last updated: 2026-06-08
4
+
5
+ ## Goal
6
+
7
+ Refactor the current Dream Customs Gradio app into Dream QA / 梦境问答台: a step-by-step dream interpretation app that records a dream, asks gentle follow-up questions, drafts a grounded interpretation, and ends with one Today Tip.
8
+
9
+ ## Source Of Truth
10
+
11
+ Read before implementation:
12
+
13
+ 1. `AGENTS.md`
14
+ 2. `docs/handoff.md`
15
+ 3. `docs/spec.md`
16
+ 4. `docs/prd.md`
17
+ 5. `PRODUCT.md`
18
+ 6. `DESIGN.md`
19
+ 7. `docs/superpowers/plans/2026-06-08-dream-qa-refactor-doc-plan.md`
20
+
21
+ Prototype references:
22
+
23
+ - `docs/prototypes/2026-06-08-dream-qa-mobile-flow.png`
24
+ - `docs/prototypes/2026-06-08-dream-qa-desktop-workbench.png`
25
+ - `docs/prototypes/2026-06-08-dream-qa-tips-card.png`
26
+
27
+ ## Non-Goals
28
+
29
+ - Do not rebuild the whole repo from scratch.
30
+ - Do not change model family away from MiniCPM.
31
+ - Do not implement voice output.
32
+ - Do not build account, history, therapy plan, or diagnosis flows.
33
+ - Do not preserve permit/contraband/sealed pact as required user-facing fields.
34
+
35
+ ## Task 1: Schema And State
36
+
37
+ Files:
38
+
39
+ - `dream_customs/schema.py`
40
+ - `tests/test_schema.py`
41
+
42
+ Steps:
43
+
44
+ - Add or rename models for `DreamQuestionIntake`, `DreamQAState`, and `TodayTipCard`.
45
+ - Keep compatibility wrappers only if needed to avoid breaking intermediate UI code.
46
+ - Ensure final output has `dream_summary`, `main_question`, `dream_anchors`, `interpretation`, `today_tip`, `tiny_action`, `caring_note`, and `safety_note`.
47
+ - Add tests for missing modalities, skipped answers, and required dream anchors.
48
+
49
+ Verification:
50
+
51
+ ```bash
52
+ python -m pytest tests/test_schema.py -q
53
+ ```
54
+
55
+ ## Task 2: Prompts And Safety
56
+
57
+ Files:
58
+
59
+ - `dream_customs/prompts.py`
60
+ - `dream_customs/safety.py`
61
+ - relevant prompt/safety tests
62
+
63
+ Steps:
64
+
65
+ - Replace pact/permit prompt language with Q&A language.
66
+ - Add prompts for dream summary, main-question detection, follow-up questions, interpretation draft, and Today Tip.
67
+ - Require non-certain language: "maybe", "could treat it as", "for today, try".
68
+ - Require at least one dream anchor in final tips.
69
+ - Preserve severe distress support copy.
70
+
71
+ Verification:
72
+
73
+ ```bash
74
+ python -m pytest tests/test_safety.py tests/test_pipeline.py -q
75
+ ```
76
+
77
+ ## Task 3: Pipeline Refactor
78
+
79
+ Files:
80
+
81
+ - `dream_customs/pipeline.py`
82
+ - `dream_customs/models.py`
83
+ - pipeline/model tests
84
+
85
+ Steps:
86
+
87
+ - Route all modalities into `DreamQuestionIntake`.
88
+ - Add actions for record, answer, skip, ask another angle, interpret, and finish.
89
+ - Keep deterministic fake clients as the default fallback path.
90
+ - Repair generic model output so final tips cite dream anchors.
91
+ - Ensure hosted Modal/Ollama route failures fall back safely.
92
+
93
+ Verification:
94
+
95
+ ```bash
96
+ python -m pytest tests/test_pipeline.py tests/test_ollama_models.py -q
97
+ ```
98
+
99
+ ## Task 4: Rendering And UI Copy
100
+
101
+ Files:
102
+
103
+ - `dream_customs/render.py`
104
+ - `dream_customs/ui/copy.py`
105
+ - render/copy tests
106
+
107
+ Steps:
108
+
109
+ - Render Today Tip cards instead of pact cards.
110
+ - Prioritize one primary `today_tip`.
111
+ - Include optional tiny action and caring note.
112
+ - Keep safety note visible only when needed.
113
+ - Remove visible permit/contraband/seal copy from current UI copy.
114
+
115
+ Verification:
116
+
117
+ ```bash
118
+ python -m pytest tests/test_render.py tests/test_ui_actions.py -q
119
+ ```
120
+
121
+ ## Task 5: Gradio UI
122
+
123
+ Files:
124
+
125
+ - `dream_customs/ui/app.py`
126
+ - `dream_customs/ui/actions.py`
127
+ - `dream_customs/ui/styles.py`
128
+ - `app.py` only if needed
129
+
130
+ Steps:
131
+
132
+ - Build the visible flow around Record -> Ask -> Interpret -> Tip.
133
+ - Keep text, image, voice, mood, and primary action in one composer.
134
+ - Make follow-up questions answerable and skippable.
135
+ - Keep developer/model routes collapsed by default.
136
+ - Ensure 390px mobile readability.
137
+
138
+ Verification:
139
+
140
+ ```bash
141
+ python -m pytest tests/test_ui_actions.py -q
142
+ python app.py
143
+ ```
144
+
145
+ Manual smoke:
146
+
147
+ - Submit a text-only dream.
148
+ - Answer or skip one follow-up.
149
+ - Reach a Today Tip card.
150
+ - Confirm the tip references dream details.
151
+ - Confirm no diagnosis, prophecy, or frightening certainty appears.
152
+
153
+ ## Task 6: Docs And Deployment
154
+
155
+ Files:
156
+
157
+ - `README.md`
158
+ - `docs/handoff.md`
159
+ - `docs/smoke/<new-smoke-file>.md`
160
+
161
+ Steps:
162
+
163
+ - Update any implementation-specific docs after code lands.
164
+ - Record local pytest and Gradio smoke.
165
+ - Commit and push the feature branch.
166
+ - Sync to Hugging Face Space through the safest available path.
167
+
168
+ Verification:
169
+
170
+ ```bash
171
+ python -m pytest -q
172
+ git diff --check
173
+ git remote -v
174
+ ```
175
+
176
+ HF Space sync rules:
177
+
178
+ - Confirm `space` remote is `https://huggingface.co/spaces/build-small-hackathon/dream-customs`.
179
+ - Do not print or save any token.
180
+ - If direct push or API merge returns 403, create/leave a Space PR and report the exact blocker.
181
+ - Do not force overwrite public `main` without explicit user confirmation.
182
+
183
+ ## Acceptance Criteria
184
+
185
+ - Text-only path works with no image/audio.
186
+ - Image upload contributes visual clues to the Q&A.
187
+ - Voice transcript, when available, contributes to the same state.
188
+ - User can answer or skip follow-up questions.
189
+ - Final output has exactly one primary today tip.
190
+ - Final output includes a concrete dream anchor.
191
+ - Ordinary output avoids diagnosis, therapy claims, prophecy, and fear.
192
+ - Severe distress triggers support guidance.
193
+ - Mobile width remains readable.
194
+ - `python -m pytest -q` passes.
195
+ - Local Gradio smoke passes before Space sync.
docs/superpowers/plans/2026-06-08-model-led-pact-v2.md ADDED
@@ -0,0 +1,1357 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Model-Led Pact V2 Implementation Plan
2
+
3
+ > Superseded on 2026-06-08: this plan records the earlier pact/card direction. New work should follow `docs/superpowers/plans/2026-06-08-dream-qa-refactor.md`, where the target is Record -> Ask -> Interpret -> Today Tip.
4
+
5
+ > **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.
6
+
7
+ **Goal:** Make Dream Customs generate natural English demo cards by letting MiniCPM5-1B own briefing, drafting, and critique while MiniCPM-V-4.6 contributes richer visual witness evidence.
8
+
9
+ **Architecture:** Keep the public Hugging Face Space and Modal-hosted MiniCPM endpoints unchanged. Add a model-led generation path that builds a structured `DreamBrief`, drafts a `PactCard`, asks the text model to critique and rewrite weak output, and uses deterministic guards only for schema, safety, grammar, and fallback resilience. Upgrade vision output from flat clues to a witness report that still feeds the existing `DreamIntake.visual_clues` fallback path.
10
+
11
+ **Tech Stack:** Python, Pydantic, Gradio, pytest, MiniCPM5-1B hosted text route, MiniCPM-V-4.6 hosted vision route, existing Modal/HF Space deployment flow.
12
+
13
+ ---
14
+
15
+ ## File Structure
16
+
17
+ - Modify `dream_customs/schema.py`: add `VisionWitness`, `DreamBrief`, and `PactCritique` models without breaking existing `DreamIntake`, `PactCard`, or `CustomsSession` serialization.
18
+ - Modify `dream_customs/prompts.py`: add prompts for visual witness reports, dream briefs, pact drafts, pact critique, and pact rewrite.
19
+ - Modify `dream_customs/models.py`: add generic JSON generation methods and optional model-led methods on `FakeTextClient`, `OllamaTextClient`, `HostedMiniCPMTextClient`, `FakeVisionClient`, `OllamaVisionClient`, and `HostedMiniCPMVisionClient`.
20
+ - Modify `dream_customs/pipeline.py`: add the model-led pact flow, English grammar guards, and a fallback path that preserves the current text-only demo behavior.
21
+ - Modify `dream_customs/app_logic.py` and `dream_customs/ui/actions.py` only if needed to route richer debug data; do not add visible developer jargon to the public mobile flow.
22
+ - Create `tests/test_model_led_pact.py`: focused tests for brief, critique, rewrite, grammar cleanup, and the screenshot elevator regression.
23
+ - Create `tests/test_vision_witness.py`: focused tests for MiniCPM-V witness parsing and fallback clues.
24
+ - Create `tests/fixtures/demo_eval_cases.json`: ten English-demo quality cases used by a local evaluation script.
25
+ - Create `scripts/evaluate_demo_quality.py`: deterministic quality checks for the ten demo cases.
26
+ - Create `docs/smoke/2026-06-08-model-led-pact-v2-smoke.md`: record local and live Space acceptance evidence after implementation.
27
+
28
+ ## Task 0: Baseline And Branch Hygiene
29
+
30
+ **Files:**
31
+ - Read: `AGENTS.md`
32
+ - Read: `PRODUCT.md`
33
+ - Read: `docs/spec.md`
34
+ - Read: `docs/handoff.md`
35
+ - Read: `docs/superpowers/plans/2026-06-08-dream-customs-story-ux-polish.md`
36
+ - Verify: git branch and remotes
37
+
38
+ - [ ] **Step 1: Confirm repository and branch**
39
+
40
+ Run:
41
+
42
+ ```bash
43
+ pwd
44
+ git status --short --branch
45
+ git remote -v
46
+ ```
47
+
48
+ Expected:
49
+
50
+ ```text
51
+ /Users/junhaocheng/working-dir/ai-competitions/build-small-hackthon
52
+ ## feature/dream-customs-ui-voice-settings...origin/feature/dream-customs-ui-voice-settings
53
+ origin https://github.com/adjcjh777/dream-customs-build-small.git
54
+ space https://huggingface.co/spaces/build-small-hackathon/dream-customs
55
+ ```
56
+
57
+ - [ ] **Step 2: Sync latest code**
58
+
59
+ Run:
60
+
61
+ ```bash
62
+ git pull
63
+ ```
64
+
65
+ Expected:
66
+
67
+ ```text
68
+ Already up to date.
69
+ ```
70
+
71
+ - [ ] **Step 3: Run baseline tests**
72
+
73
+ Run:
74
+
75
+ ```bash
76
+ .venv/bin/python -m pytest -q
77
+ ```
78
+
79
+ Expected: all existing tests pass before the model-led changes begin.
80
+
81
+ ## Task 1: Add Model-Led Data Contracts
82
+
83
+ **Files:**
84
+ - Modify: `dream_customs/schema.py`
85
+ - Modify: `tests/test_schema.py`
86
+
87
+ - [ ] **Step 1: Add failing schema tests**
88
+
89
+ Append to `tests/test_schema.py`:
90
+
91
+ ```python
92
+ from dream_customs.schema import DreamBrief, PactCritique, VisionWitness
93
+
94
+
95
+ def test_vision_witness_flattens_report_into_demo_clues():
96
+ witness = VisionWitness(
97
+ scene_summary="A hand-drawn elevator panel is stuck on floor 14.",
98
+ objects=["elevator button", "wax"],
99
+ visible_text=["14"],
100
+ spatial_relations=["button below the frozen number"],
101
+ mood_cues=["stuck", "cold"],
102
+ uncertain_details=["whether the floor is a basement"],
103
+ surprising_detail="The buttons look melted rather than broken.",
104
+ )
105
+
106
+ clues = witness.to_visual_clues()
107
+
108
+ assert clues[0] == "Scene: A hand-drawn elevator panel is stuck on floor 14."
109
+ assert "Object: elevator button" in clues
110
+ assert "Visible text: 14" in clues
111
+ assert "Surprising detail: The buttons look melted rather than broken." in clues
112
+
113
+
114
+ def test_dream_brief_carries_evidence_and_demo_language():
115
+ brief = DreamBrief(
116
+ anchors=["elevator", "melted wax", "floor 14"],
117
+ emotional_hypothesis="The dream may be protecting a fear of getting stuck.",
118
+ today_bridge="Choose one stalled task and name the next small movement.",
119
+ visual_evidence=["Visible text: 14"],
120
+ safety_flags=[],
121
+ language="en",
122
+ )
123
+
124
+ assert brief.language == "en"
125
+ assert "floor 14" in brief.anchors
126
+ assert brief.visual_evidence == ["Visible text: 14"]
127
+
128
+
129
+ def test_pact_critique_flags_template_and_grammar_failures():
130
+ critique = PactCritique(
131
+ passes=False,
132
+ issues=["repeated article", "template fallback"],
133
+ rewrite_instruction="Rewrite in natural English using elevator, wax, and floor 14.",
134
+ )
135
+
136
+ assert not critique.passes
137
+ assert "repeated article" in critique.issues
138
+ assert "natural English" in critique.rewrite_instruction
139
+ ```
140
+
141
+ - [ ] **Step 2: Run schema tests and verify failure**
142
+
143
+ Run:
144
+
145
+ ```bash
146
+ .venv/bin/python -m pytest tests/test_schema.py::test_vision_witness_flattens_report_into_demo_clues tests/test_schema.py::test_dream_brief_carries_evidence_and_demo_language tests/test_schema.py::test_pact_critique_flags_template_and_grammar_failures -q
147
+ ```
148
+
149
+ Expected: FAIL with import errors for `VisionWitness`, `DreamBrief`, and `PactCritique`.
150
+
151
+ - [ ] **Step 3: Implement schema models**
152
+
153
+ Add to `dream_customs/schema.py` after `DreamIntake`:
154
+
155
+ ```python
156
+ class VisionWitness(BaseModel):
157
+ scene_summary: str = ""
158
+ objects: list[str] = Field(default_factory=list)
159
+ visible_text: list[str] = Field(default_factory=list)
160
+ spatial_relations: list[str] = Field(default_factory=list)
161
+ mood_cues: list[str] = Field(default_factory=list)
162
+ uncertain_details: list[str] = Field(default_factory=list)
163
+ surprising_detail: str = ""
164
+
165
+ def to_visual_clues(self) -> list[str]:
166
+ clues: list[str] = []
167
+ if self.scene_summary.strip():
168
+ clues.append(f"Scene: {self.scene_summary.strip()}")
169
+ for label, values in [
170
+ ("Object", self.objects),
171
+ ("Visible text", self.visible_text),
172
+ ("Spatial relation", self.spatial_relations),
173
+ ("Mood cue", self.mood_cues),
174
+ ("Uncertain detail", self.uncertain_details),
175
+ ]:
176
+ clues.extend(f"{label}: {value.strip()}" for value in values if value.strip())
177
+ if self.surprising_detail.strip():
178
+ clues.append(f"Surprising detail: {self.surprising_detail.strip()}")
179
+ return clues[:12]
180
+
181
+
182
+ class DreamBrief(BaseModel):
183
+ anchors: list[str] = Field(default_factory=list)
184
+ emotional_hypothesis: str = ""
185
+ today_bridge: str = ""
186
+ visual_evidence: list[str] = Field(default_factory=list)
187
+ safety_flags: list[str] = Field(default_factory=list)
188
+ language: str = "en"
189
+
190
+
191
+ class PactCritique(BaseModel):
192
+ passes: bool = True
193
+ issues: list[str] = Field(default_factory=list)
194
+ rewrite_instruction: str = ""
195
+ ```
196
+
197
+ - [ ] **Step 4: Run schema tests and verify pass**
198
+
199
+ Run:
200
+
201
+ ```bash
202
+ .venv/bin/python -m pytest tests/test_schema.py::test_vision_witness_flattens_report_into_demo_clues tests/test_schema.py::test_dream_brief_carries_evidence_and_demo_language tests/test_schema.py::test_pact_critique_flags_template_and_grammar_failures -q
203
+ ```
204
+
205
+ Expected: PASS.
206
+
207
+ - [ ] **Step 5: Commit schema contracts**
208
+
209
+ Run:
210
+
211
+ ```bash
212
+ git add dream_customs/schema.py tests/test_schema.py
213
+ git commit -m "feat: add model-led pact schemas"
214
+ ```
215
+
216
+ Expected: commit succeeds.
217
+
218
+ ## Task 2: Add English Demo Prompt Contracts
219
+
220
+ **Files:**
221
+ - Modify: `dream_customs/prompts.py`
222
+ - Create: `tests/test_model_led_pact.py`
223
+ - Create: `tests/test_vision_witness.py`
224
+
225
+ - [ ] **Step 1: Add failing prompt tests**
226
+
227
+ Create `tests/test_model_led_pact.py` with:
228
+
229
+ ```python
230
+ from dream_customs.pipeline import build_intake
231
+ from dream_customs.prompts import dream_brief_prompt, pact_critique_prompt, pact_draft_prompt, pact_rewrite_prompt
232
+ from dream_customs.schema import DreamBrief, PactCard, PactCritique
233
+
234
+
235
+ def test_dream_brief_prompt_requires_english_demo_brief():
236
+ intake = build_intake(
237
+ dream_text="I kept missing an elevator. The buttons melted like wax, and floor 14 froze.",
238
+ visual_clues=["Visible text: 14", "Object: melted button"],
239
+ mood="Uneasy",
240
+ )
241
+
242
+ prompt = dream_brief_prompt(intake)
243
+
244
+ assert "English demo" in prompt
245
+ assert "anchors" in prompt
246
+ assert "visual_evidence" in prompt
247
+ assert "Do not diagnose" in prompt
248
+
249
+
250
+ def test_pact_draft_prompt_uses_brief_not_template_fallback():
251
+ brief = DreamBrief(
252
+ anchors=["elevator", "melted wax", "floor 14"],
253
+ emotional_hypothesis="The dream may be protecting a fear of getting stuck.",
254
+ today_bridge="Choose one stalled task and name the next small movement.",
255
+ visual_evidence=["Visible text: 14"],
256
+ )
257
+
258
+ prompt = pact_draft_prompt(brief, "I want to stop freezing before a task.")
259
+
260
+ assert "Write natural English" in prompt
261
+ assert "Use at least two anchors" in prompt
262
+ assert "Do not use template phrases" in prompt
263
+ assert "elevator" in prompt
264
+
265
+
266
+ def test_pact_critique_prompt_checks_screenshot_regression():
267
+ card = PactCard(
268
+ visitor_name="An Elevator",
269
+ permit_id="DREAM20260608-015",
270
+ contraband=["melted buttons", "froze at 14"],
271
+ risk_level="medium: handle gently, without treating it as a warning sign",
272
+ alliance_reading="I am safe, but the wax is sticky and the floor is cold.",
273
+ practical_suggestion="Pick one real task that feels like the an elevator.",
274
+ weird_task="I will try to push the floor button with my hand instead of the lever.",
275
+ bedtime_release="Tonight, the an elevator and the the button are logged.",
276
+ )
277
+ brief = DreamBrief(anchors=["elevator", "melted wax", "floor 14"])
278
+
279
+ prompt = pact_critique_prompt(brief, card)
280
+
281
+ assert "the an" in prompt
282
+ assert "the the" in prompt
283
+ assert "invented details" in prompt
284
+ assert "natural English" in prompt
285
+
286
+
287
+ def test_pact_rewrite_prompt_uses_critique_instruction():
288
+ card = PactCard(
289
+ visitor_name="An Elevator",
290
+ permit_id="DREAM20260608-015",
291
+ contraband=["melted buttons", "froze at 14"],
292
+ risk_level="medium: handle gently, without treating it as a warning sign",
293
+ alliance_reading="I am safe, but the wax is sticky and the floor is cold.",
294
+ practical_suggestion="Pick one real task that feels like the an elevator.",
295
+ weird_task="I will try to push the floor button with my hand instead of the lever.",
296
+ bedtime_release="Tonight, the an elevator and the the button are logged.",
297
+ )
298
+ brief = DreamBrief(anchors=["elevator", "melted wax", "floor 14"])
299
+ critique = PactCritique(
300
+ passes=False,
301
+ issues=["repeated article", "invented lever"],
302
+ rewrite_instruction="Rewrite without repeated articles or invented lever details.",
303
+ )
304
+
305
+ prompt = pact_rewrite_prompt(brief, card, critique)
306
+
307
+ assert "Rewrite without repeated articles" in prompt
308
+ assert "Return strict JSON" in prompt
309
+ assert "floor 14" in prompt
310
+ ```
311
+
312
+ Create `tests/test_vision_witness.py` with:
313
+
314
+ ```python
315
+ from dream_customs.prompts import visual_witness_prompt
316
+
317
+
318
+ def test_visual_witness_prompt_requests_structured_report():
319
+ prompt = visual_witness_prompt()
320
+
321
+ assert "MiniCPM-V-4.6" in prompt
322
+ assert "scene_summary" in prompt
323
+ assert "spatial_relations" in prompt
324
+ assert "surprising_detail" in prompt
325
+ assert "Do not diagnose" in prompt
326
+ ```
327
+
328
+ - [ ] **Step 2: Run prompt tests and verify failure**
329
+
330
+ Run:
331
+
332
+ ```bash
333
+ .venv/bin/python -m pytest tests/test_model_led_pact.py tests/test_vision_witness.py -q
334
+ ```
335
+
336
+ Expected: FAIL with import errors for the new prompt functions.
337
+
338
+ - [ ] **Step 3: Implement new prompt functions**
339
+
340
+ Add to `dream_customs/prompts.py`:
341
+
342
+ ```python
343
+ from dream_customs.schema import DreamBrief, PactCard, PactCritique
344
+
345
+
346
+ def visual_witness_prompt() -> str:
347
+ return """
348
+ You are MiniCPM-V-4.6 acting as the witness clerk at Dream Customs.
349
+ Read the image as dream evidence: a sketch, bedside note, screenshot, or photo.
350
+ Do not diagnose the user. Do not infer a fixed symbolic meaning.
351
+ Return strict JSON with:
352
+ scene_summary, objects, visible_text, spatial_relations, mood_cues,
353
+ uncertain_details, surprising_detail.
354
+ Keep each list item short, concrete, and visibly grounded in the image.
355
+ """.strip()
356
+
357
+
358
+ def dream_brief_prompt(intake: DreamIntake) -> str:
359
+ return f"""
360
+ You are MiniCPM5-1B preparing an English demo brief for Dream Customs.
361
+ Do not diagnose. Do not claim the dream has one certain meaning.
362
+ Turn the intake into a compact reasoning brief that a later writer can use.
363
+ Return strict JSON with:
364
+ anchors, emotional_hypothesis, today_bridge, visual_evidence, safety_flags, language.
365
+
366
+ Rules:
367
+ - language must be "en".
368
+ - anchors must be concrete dream details, not generic feelings.
369
+ - visual_evidence must reuse image clues when present.
370
+ - today_bridge must connect the dream to one realistic action for today.
371
+ - safety_flags is empty unless the user mentions self-harm, harming others,
372
+ severe distress, severe insomnia, panic, or inability to function.
373
+
374
+ Dream intake:
375
+ {intake.merged_text()}
376
+ """.strip()
377
+
378
+
379
+ def pact_draft_prompt(brief: DreamBrief, answers: str) -> str:
380
+ return f"""
381
+ You are the Dream Customs diplomat writing an English demo clearance pass.
382
+ Write natural English. Keep the tone gentle, playful, and useful.
383
+ Do not use template phrases such as "feels like the", "logged and cleared",
384
+ or "the dream may be pointing to" unless they fit naturally.
385
+ Use at least two anchors from the brief. Use at least one visual_evidence item
386
+ when visual evidence is present. Do not invent objects or actions.
387
+ Return strict JSON with:
388
+ visitor_name, permit_id, contraband, risk_level, alliance_reading,
389
+ practical_suggestion, weird_task, bedtime_release, safety_note.
390
+
391
+ Dream brief:
392
+ {brief.model_dump_json()}
393
+
394
+ User answers:
395
+ {answers or "No answers filed."}
396
+ """.strip()
397
+
398
+
399
+ def pact_critique_prompt(brief: DreamBrief, card: PactCard) -> str:
400
+ return f"""
401
+ You are the Dream Customs quality critic for an English demo card.
402
+ Check for repeated articles such as "the an" and "the the", awkward grammar,
403
+ template fallback language, invented details, diagnosis, frightening certainty,
404
+ generic wellness advice, and missing dream anchors.
405
+ Return strict JSON with:
406
+ passes, issues, rewrite_instruction.
407
+ If the card is already natural, specific, safe, and grounded, passes must be true
408
+ and rewrite_instruction must be an empty string.
409
+
410
+ Dream brief:
411
+ {brief.model_dump_json()}
412
+
413
+ Draft card:
414
+ {card.model_dump_json()}
415
+ """.strip()
416
+
417
+
418
+ def pact_rewrite_prompt(brief: DreamBrief, card: PactCard, critique: PactCritique) -> str:
419
+ return f"""
420
+ You are the Dream Customs diplomat revising an English demo clearance pass.
421
+ Follow the critique exactly while preserving safe, playful, non-diagnostic tone.
422
+ Use at least two real anchors from the brief. Do not add objects that are not in
423
+ the brief or current card. Return strict JSON with:
424
+ visitor_name, permit_id, contraband, risk_level, alliance_reading,
425
+ practical_suggestion, weird_task, bedtime_release, safety_note.
426
+
427
+ Dream brief:
428
+ {brief.model_dump_json()}
429
+
430
+ Current card:
431
+ {card.model_dump_json()}
432
+
433
+ Critique:
434
+ {critique.model_dump_json()}
435
+ """.strip()
436
+ ```
437
+
438
+ - [ ] **Step 4: Run prompt tests and verify pass**
439
+
440
+ Run:
441
+
442
+ ```bash
443
+ .venv/bin/python -m pytest tests/test_model_led_pact.py tests/test_vision_witness.py -q
444
+ ```
445
+
446
+ Expected: PASS.
447
+
448
+ - [ ] **Step 5: Commit prompt contracts**
449
+
450
+ Run:
451
+
452
+ ```bash
453
+ git add dream_customs/prompts.py tests/test_model_led_pact.py tests/test_vision_witness.py
454
+ git commit -m "feat: add model-led pact prompts"
455
+ ```
456
+
457
+ Expected: commit succeeds.
458
+
459
+ ## Task 3: Add Model-Led Client Methods
460
+
461
+ **Files:**
462
+ - Modify: `dream_customs/models.py`
463
+ - Modify: `tests/test_ollama_models.py`
464
+ - Modify: `tests/test_model_led_pact.py`
465
+ - Modify: `tests/test_vision_witness.py`
466
+
467
+ - [ ] **Step 1: Add failing client tests**
468
+
469
+ Append to `tests/test_model_led_pact.py`:
470
+
471
+ ```python
472
+ from dream_customs.models import FakeTextClient
473
+
474
+
475
+ def test_fake_text_client_supports_model_led_methods():
476
+ client = FakeTextClient()
477
+
478
+ brief = client.generate_brief("brief prompt")
479
+ card = client.generate_pact_draft("draft prompt")
480
+ critique = client.critique_pact("critique prompt")
481
+
482
+ assert brief.language == "en"
483
+ assert brief.anchors
484
+ assert card.visitor_name
485
+ assert critique.passes
486
+ ```
487
+
488
+ Append to `tests/test_vision_witness.py`:
489
+
490
+ ```python
491
+ from dream_customs.models import FakeVisionClient
492
+
493
+
494
+ def test_fake_vision_client_returns_witness_report():
495
+ witness = FakeVisionClient().extract_witness("demo.png")
496
+
497
+ assert witness.scene_summary
498
+ assert "blue hallway" in " ".join(witness.to_visual_clues()).lower()
499
+ ```
500
+
501
+ Append to `tests/test_ollama_models.py`:
502
+
503
+ ```python
504
+ from dream_customs.schema import DreamBrief, PactCritique, VisionWitness
505
+
506
+
507
+ def test_hosted_text_client_parses_model_led_brief():
508
+ class StubHostedBriefClient(HostedMiniCPMTextClient):
509
+ def _post_json(self, prompt, max_tokens=700):
510
+ return {
511
+ "response": (
512
+ '{"anchors":["elevator","melted wax","floor 14"],'
513
+ '"emotional_hypothesis":"The dream may be protecting a stuck feeling.",'
514
+ '"today_bridge":"Name one next movement.",'
515
+ '"visual_evidence":["Visible text: 14"],'
516
+ '"safety_flags":[],"language":"en"}'
517
+ )
518
+ }
519
+
520
+ brief = StubHostedBriefClient(endpoint="https://example.test").generate_brief("prompt")
521
+
522
+ assert isinstance(brief, DreamBrief)
523
+ assert brief.anchors == ["elevator", "melted wax", "floor 14"]
524
+
525
+
526
+ def test_hosted_text_client_parses_pact_critique():
527
+ class StubHostedCritiqueClient(HostedMiniCPMTextClient):
528
+ def _post_json(self, prompt, max_tokens=700):
529
+ return {
530
+ "response": (
531
+ '{"passes":false,'
532
+ '"issues":["repeated article"],'
533
+ '"rewrite_instruction":"Fix repeated articles."}'
534
+ )
535
+ }
536
+
537
+ critique = StubHostedCritiqueClient(endpoint="https://example.test").critique_pact("prompt")
538
+
539
+ assert isinstance(critique, PactCritique)
540
+ assert not critique.passes
541
+
542
+
543
+ def test_hosted_vision_client_parses_witness_report():
544
+ class StubHostedVisionWitnessClient(HostedMiniCPMVisionClient):
545
+ def _post_image(self, image_path):
546
+ return {
547
+ "response": (
548
+ '{"scene_summary":"A blue hallway with a frozen elevator panel.",'
549
+ '"objects":["elevator button"],'
550
+ '"visible_text":["14"],'
551
+ '"spatial_relations":["button below number"],'
552
+ '"mood_cues":["cold"],'
553
+ '"uncertain_details":[],'
554
+ '"surprising_detail":"The button looks waxy."}'
555
+ )
556
+ }
557
+
558
+ witness = StubHostedVisionWitnessClient(endpoint="https://example.test").extract_witness("demo.png")
559
+
560
+ assert isinstance(witness, VisionWitness)
561
+ assert "Visible text: 14" in witness.to_visual_clues()
562
+ ```
563
+
564
+ - [ ] **Step 2: Run client tests and verify failure**
565
+
566
+ Run:
567
+
568
+ ```bash
569
+ .venv/bin/python -m pytest tests/test_model_led_pact.py::test_fake_text_client_supports_model_led_methods tests/test_vision_witness.py::test_fake_vision_client_returns_witness_report tests/test_ollama_models.py::test_hosted_text_client_parses_model_led_brief tests/test_ollama_models.py::test_hosted_text_client_parses_pact_critique tests/test_ollama_models.py::test_hosted_vision_client_parses_witness_report -q
570
+ ```
571
+
572
+ Expected: FAIL because the client methods are not implemented.
573
+
574
+ - [ ] **Step 3: Implement fake client methods**
575
+
576
+ In `dream_customs/models.py`, import the new schemas:
577
+
578
+ ```python
579
+ from dream_customs.schema import DreamBrief, PactCard, PactCritique, VisionWitness
580
+ ```
581
+
582
+ Add to `FakeTextClient`:
583
+
584
+ ```python
585
+ def generate_brief(self, prompt: str) -> DreamBrief:
586
+ return DreamBrief(
587
+ anchors=["elevator", "melted buttons", "floor 14"],
588
+ emotional_hypothesis="The dream may be protecting the user from freezing at the start of a task.",
589
+ today_bridge="Choose one stalled task and name the next small movement.",
590
+ visual_evidence=["Visible text: 14"],
591
+ safety_flags=[],
592
+ language="en",
593
+ )
594
+
595
+ def generate_pact_draft(self, prompt: str) -> PactCard:
596
+ return self.generate_pact(prompt)
597
+
598
+ def critique_pact(self, prompt: str) -> PactCritique:
599
+ return PactCritique(passes=True, issues=[], rewrite_instruction="")
600
+
601
+ def rewrite_pact(self, prompt: str) -> PactCard:
602
+ return self.generate_pact(prompt)
603
+ ```
604
+
605
+ Add to `FakeVisionClient`:
606
+
607
+ ```python
608
+ def extract_witness(self, image_path: Optional[str]) -> VisionWitness:
609
+ if not image_path:
610
+ return VisionWitness()
611
+ return VisionWitness(
612
+ scene_summary="A blue hallway with a melted elevator button.",
613
+ objects=["elevator button", "blue hallway"],
614
+ visible_text=["14"],
615
+ spatial_relations=["button near the frozen floor number"],
616
+ mood_cues=["stuck", "uncertain"],
617
+ uncertain_details=[],
618
+ surprising_detail="The button looks soft, almost waxy.",
619
+ )
620
+ ```
621
+
622
+ - [ ] **Step 4: Implement hosted and Ollama model-led text parsing**
623
+
624
+ Add helper methods to `HostedMiniCPMTextClient` and mirror the same signatures in `OllamaTextClient`:
625
+
626
+ ```python
627
+ def generate_brief(self, prompt: str) -> DreamBrief:
628
+ parsed = self._generate_json(
629
+ prompt,
630
+ (
631
+ '{"anchors":["string"],"emotional_hypothesis":"string",'
632
+ '"today_bridge":"string","visual_evidence":["string"],'
633
+ '"safety_flags":["string"],"language":"en"}'
634
+ ),
635
+ max_tokens=520,
636
+ )
637
+ if not parsed:
638
+ return self.fallback.generate_brief(prompt)
639
+ try:
640
+ return DreamBrief(
641
+ anchors=_as_string_list(parsed.get("anchors")),
642
+ emotional_hypothesis=str(parsed.get("emotional_hypothesis", "")).strip(),
643
+ today_bridge=str(parsed.get("today_bridge", "")).strip(),
644
+ visual_evidence=_as_string_list(parsed.get("visual_evidence")),
645
+ safety_flags=_as_string_list(parsed.get("safety_flags")),
646
+ language=str(parsed.get("language", "en")).strip() or "en",
647
+ )
648
+ except (TypeError, ValueError):
649
+ return self.fallback.generate_brief(prompt)
650
+
651
+ def generate_pact_draft(self, prompt: str) -> PactCard:
652
+ return self.generate_pact(prompt)
653
+
654
+ def critique_pact(self, prompt: str) -> PactCritique:
655
+ parsed = self._generate_json(
656
+ prompt,
657
+ '{"passes":true,"issues":["string"],"rewrite_instruction":"string"}',
658
+ max_tokens=360,
659
+ )
660
+ if not parsed:
661
+ return self.fallback.critique_pact(prompt)
662
+ return PactCritique(
663
+ passes=bool(parsed.get("passes", True)),
664
+ issues=_as_string_list(parsed.get("issues")),
665
+ rewrite_instruction=str(parsed.get("rewrite_instruction", "")).strip(),
666
+ )
667
+
668
+ def rewrite_pact(self, prompt: str) -> PactCard:
669
+ return self.generate_pact(prompt)
670
+ ```
671
+
672
+ For `OllamaTextClient`, use the same body but pass `num_predict` instead of `max_tokens` in `_generate_json()` calls.
673
+
674
+ - [ ] **Step 5: Implement hosted and Ollama vision witness parsing**
675
+
676
+ Add to `HostedMiniCPMVisionClient` and mirror the same method in `OllamaVisionClient`:
677
+
678
+ ```python
679
+ def extract_witness(self, image_path: Optional[str]) -> VisionWitness:
680
+ if not image_path:
681
+ return VisionWitness()
682
+ payload = self._post_image(image_path)
683
+ if not payload:
684
+ return self.fallback.extract_witness(image_path)
685
+ parsed = _extract_json_object(_hosted_text_from_response(payload))
686
+ if not parsed:
687
+ text = _hosted_text_from_response(payload)
688
+ clues = [part.strip() for part in re.split(r"[,,\n]", _strip_markdown_and_thinking(text)) if part.strip()]
689
+ return VisionWitness(scene_summary="; ".join(clues[:2]), objects=clues[2:6])
690
+ return VisionWitness(
691
+ scene_summary=str(parsed.get("scene_summary", "")).strip(),
692
+ objects=_as_string_list(parsed.get("objects")),
693
+ visible_text=_as_string_list(parsed.get("visible_text")),
694
+ spatial_relations=_as_string_list(parsed.get("spatial_relations")),
695
+ mood_cues=_as_string_list(parsed.get("mood_cues")),
696
+ uncertain_details=_as_string_list(parsed.get("uncertain_details")),
697
+ surprising_detail=str(parsed.get("surprising_detail", "")).strip(),
698
+ )
699
+ ```
700
+
701
+ Update `extract_clues()` to call `extract_witness(image_path).to_visual_clues()` first, then fall back to the old flat parsing if the witness is empty.
702
+
703
+ - [ ] **Step 6: Run client tests and verify pass**
704
+
705
+ Run:
706
+
707
+ ```bash
708
+ .venv/bin/python -m pytest tests/test_model_led_pact.py::test_fake_text_client_supports_model_led_methods tests/test_vision_witness.py::test_fake_vision_client_returns_witness_report tests/test_ollama_models.py::test_hosted_text_client_parses_model_led_brief tests/test_ollama_models.py::test_hosted_text_client_parses_pact_critique tests/test_ollama_models.py::test_hosted_vision_client_parses_witness_report -q
709
+ ```
710
+
711
+ Expected: PASS.
712
+
713
+ - [ ] **Step 7: Commit client methods**
714
+
715
+ Run:
716
+
717
+ ```bash
718
+ git add dream_customs/models.py tests/test_model_led_pact.py tests/test_vision_witness.py tests/test_ollama_models.py
719
+ git commit -m "feat: add model-led MiniCPM clients"
720
+ ```
721
+
722
+ Expected: commit succeeds.
723
+
724
+ ## Task 4: Route The Model-Led Pact Flow
725
+
726
+ **Files:**
727
+ - Modify: `dream_customs/pipeline.py`
728
+ - Modify: `tests/test_model_led_pact.py`
729
+ - Modify: `tests/test_pipeline.py`
730
+
731
+ - [ ] **Step 1: Add failing model-led flow regression**
732
+
733
+ Append to `tests/test_model_led_pact.py`:
734
+
735
+ ```python
736
+ from dream_customs.pipeline import build_intake, generate_model_led_pact
737
+
738
+
739
+ def test_model_led_pact_rewrites_screenshot_elevator_regression():
740
+ class CriticTextClient(FakeTextClient):
741
+ def generate_pact_draft(self, prompt):
742
+ return PactCard(
743
+ visitor_name="An Elevator",
744
+ permit_id="DREAM20260608-015",
745
+ contraband=["melted buttons", "froze at 14"],
746
+ risk_level="medium: handle gently, without treating it as a warning sign",
747
+ alliance_reading="I am safe, but the wax is sticky and the floor is cold.",
748
+ practical_suggestion="Pick one real task that feels like the an elevator.",
749
+ weird_task="I will try to push the floor button with my hand instead of the lever.",
750
+ bedtime_release="Tonight, the an elevator and the the button are logged.",
751
+ )
752
+
753
+ def critique_pact(self, prompt):
754
+ return PactCritique(
755
+ passes=False,
756
+ issues=["repeated article", "invented lever"],
757
+ rewrite_instruction="Rewrite without repeated articles or the invented lever.",
758
+ )
759
+
760
+ def rewrite_pact(self, prompt):
761
+ return PactCard(
762
+ visitor_name="The Elevator Stuck at 14",
763
+ permit_id="DREAM20260608-015",
764
+ contraband=["melted buttons", "floor 14", "the urge to freeze before starting"],
765
+ risk_level="medium: handle gently, without treating it as a warning sign",
766
+ alliance_reading=(
767
+ "The elevator, wax-soft buttons, and frozen 14 can be treated as a small scene "
768
+ "about getting stuck before the first move."
769
+ ),
770
+ practical_suggestion="Choose one stalled task and write only the next button-sized action.",
771
+ weird_task="Draw floor 14 on a sticky note, tap it once, and spend five minutes on the first step.",
772
+ bedtime_release="Tonight, the elevator can stay on floor 14 while tomorrow's first button waits quietly.",
773
+ )
774
+
775
+ intake = build_intake(
776
+ dream_text="I kept missing an elevator. The buttons melted like wax, and the floor number froze at 14.",
777
+ visual_clues=["Visible text: 14", "Object: melted elevator button"],
778
+ mood="Uneasy",
779
+ )
780
+
781
+ card, html = generate_model_led_pact(intake, "", CriticTextClient())
782
+ joined = card.to_plain_text().lower()
783
+
784
+ assert "the an" not in joined
785
+ assert "the the" not in joined
786
+ assert "lever" not in joined
787
+ assert "floor 14" in joined
788
+ assert "melted" in joined or "wax" in joined
789
+ assert "Today's Clearance Pass" in html or "Today's Pact" in html
790
+ ```
791
+
792
+ - [ ] **Step 2: Run model-led flow test and verify failure**
793
+
794
+ Run:
795
+
796
+ ```bash
797
+ .venv/bin/python -m pytest tests/test_model_led_pact.py::test_model_led_pact_rewrites_screenshot_elevator_regression -q
798
+ ```
799
+
800
+ Expected: FAIL because `generate_model_led_pact` does not exist.
801
+
802
+ - [ ] **Step 3: Implement grammar guard helpers**
803
+
804
+ Add to `dream_customs/pipeline.py`:
805
+
806
+ ```python
807
+ def _clean_repeated_articles(text: str) -> str:
808
+ clean = re.sub(r"\bthe\s+an\s+", "an ", text, flags=re.IGNORECASE)
809
+ clean = re.sub(r"\bthe\s+the\s+", "the ", clean, flags=re.IGNORECASE)
810
+ clean = re.sub(r"\ban\s+an\s+", "an ", clean, flags=re.IGNORECASE)
811
+ clean = re.sub(r"\ba\s+a\s+", "a ", clean, flags=re.IGNORECASE)
812
+ return re.sub(r"\s+", " ", clean).strip()
813
+
814
+
815
+ def _clean_card_grammar(card: PactCard) -> PactCard:
816
+ cleaned = card.model_copy(deep=True)
817
+ cleaned.visitor_name = _clean_repeated_articles(cleaned.visitor_name)
818
+ cleaned.risk_level = _clean_repeated_articles(cleaned.risk_level)
819
+ cleaned.alliance_reading = _clean_repeated_articles(cleaned.alliance_reading)
820
+ cleaned.practical_suggestion = _clean_repeated_articles(cleaned.practical_suggestion)
821
+ cleaned.weird_task = _clean_repeated_articles(cleaned.weird_task)
822
+ cleaned.bedtime_release = _clean_repeated_articles(cleaned.bedtime_release)
823
+ cleaned.contraband = [_clean_repeated_articles(item) for item in cleaned.contraband]
824
+ return cleaned
825
+ ```
826
+
827
+ - [ ] **Step 4: Implement `generate_model_led_pact`**
828
+
829
+ Add imports to `dream_customs/pipeline.py`:
830
+
831
+ ```python
832
+ from dream_customs.prompts import (
833
+ dream_brief_prompt,
834
+ followup_question_prompt,
835
+ negotiation_prompt,
836
+ pact_critique_prompt,
837
+ pact_draft_prompt,
838
+ pact_prompt,
839
+ pact_revision_prompt,
840
+ pact_rewrite_prompt,
841
+ )
842
+ ```
843
+
844
+ Add the function:
845
+
846
+ ```python
847
+ def generate_model_led_pact(intake: DreamIntake, answers: str, text_client) -> Tuple[PactCard, str]:
848
+ brief = text_client.generate_brief(dream_brief_prompt(intake))
849
+ card = text_client.generate_pact_draft(pact_draft_prompt(brief, answers))
850
+ critique = text_client.critique_pact(pact_critique_prompt(brief, card))
851
+ if not critique.passes and critique.rewrite_instruction.strip():
852
+ card = text_client.rewrite_pact(pact_rewrite_prompt(brief, card, critique))
853
+ card = _clean_card_grammar(card)
854
+ card = _polish_card_for_daily_use(card, intake, answers)
855
+ merged = intake.merged_text() + "\n" + answers
856
+ if needs_escalation(merged):
857
+ card.safety_note = safety_note()
858
+ elif not needs_escalation(merged):
859
+ card.safety_note = ""
860
+ card = _stamp_card_for_today(card)
861
+ return card, render_pact_card(card)
862
+ ```
863
+
864
+ - [ ] **Step 5: Route draft generation through model-led flow with fallback**
865
+
866
+ Find the existing `draft_pact()` code path in `dream_customs/pipeline.py`. Replace its direct `generate_pact()` call with:
867
+
868
+ ```python
869
+ try:
870
+ card, _html = generate_model_led_pact(next_session.intake, next_session.answers_text(), text_client)
871
+ except AttributeError:
872
+ card, _html = generate_pact(next_session.intake, next_session.answers_text(), text_client)
873
+ ```
874
+
875
+ Keep the existing session phase, events, and safety behavior unchanged around this block.
876
+
877
+ - [ ] **Step 6: Run focused flow tests**
878
+
879
+ Run:
880
+
881
+ ```bash
882
+ .venv/bin/python -m pytest tests/test_model_led_pact.py::test_model_led_pact_rewrites_screenshot_elevator_regression tests/test_pipeline.py::test_ask_answer_skip_draft_revise_and_seal_actions -q
883
+ ```
884
+
885
+ Expected: PASS.
886
+
887
+ - [ ] **Step 7: Run all pipeline tests**
888
+
889
+ Run:
890
+
891
+ ```bash
892
+ .venv/bin/python -m pytest tests/test_pipeline.py tests/test_model_led_pact.py -q
893
+ ```
894
+
895
+ Expected: PASS.
896
+
897
+ - [ ] **Step 8: Commit model-led flow**
898
+
899
+ Run:
900
+
901
+ ```bash
902
+ git add dream_customs/pipeline.py tests/test_model_led_pact.py tests/test_pipeline.py
903
+ git commit -m "feat: route model-led pact generation"
904
+ ```
905
+
906
+ Expected: commit succeeds.
907
+
908
+ ## Task 5: Upgrade Vision Intake To Witness Reports
909
+
910
+ **Files:**
911
+ - Modify: `dream_customs/pipeline.py`
912
+ - Modify: `dream_customs/models.py`
913
+ - Modify: `tests/test_vision_witness.py`
914
+ - Modify: `tests/test_pipeline.py`
915
+
916
+ - [ ] **Step 1: Add failing intake witness test**
917
+
918
+ Append to `tests/test_vision_witness.py`:
919
+
920
+ ```python
921
+ from dream_customs.models import FakeASRClient, FakeVisionClient
922
+ from dream_customs.pipeline import intake_from_modalities
923
+
924
+
925
+ def test_intake_from_modalities_uses_vision_witness_clues():
926
+ intake = intake_from_modalities(
927
+ dream_text="I was waiting for an elevator.",
928
+ image_path="demo.png",
929
+ audio_path=None,
930
+ mood="Uneasy",
931
+ vision_client=FakeVisionClient(),
932
+ asr_client=FakeASRClient(),
933
+ )
934
+
935
+ merged = intake.merged_text().lower()
936
+
937
+ assert "scene:" in merged
938
+ assert "visible text: 14" in merged
939
+ assert "surprising detail" in merged
940
+ ```
941
+
942
+ - [ ] **Step 2: Run witness intake test and verify failure**
943
+
944
+ Run:
945
+
946
+ ```bash
947
+ .venv/bin/python -m pytest tests/test_vision_witness.py::test_intake_from_modalities_uses_vision_witness_clues -q
948
+ ```
949
+
950
+ Expected: FAIL if `intake_from_modalities()` still calls only `extract_clues()`.
951
+
952
+ - [ ] **Step 3: Add witness extraction helper**
953
+
954
+ Add to `dream_customs/pipeline.py`:
955
+
956
+ ```python
957
+ def _extract_visual_clues(vision_client, image_path: Optional[str]) -> List[str]:
958
+ if not image_path:
959
+ return []
960
+ try:
961
+ if hasattr(vision_client, "extract_witness"):
962
+ witness = vision_client.extract_witness(image_path)
963
+ clues = witness.to_visual_clues()
964
+ if clues:
965
+ return clues
966
+ except Exception:
967
+ pass
968
+ try:
969
+ return vision_client.extract_clues(image_path)
970
+ except Exception:
971
+ return []
972
+ ```
973
+
974
+ Update `intake_from_modalities()`:
975
+
976
+ ```python
977
+ return build_intake(
978
+ dream_text=dream_text or "",
979
+ voice_transcript=asr_client.transcribe(audio_path),
980
+ visual_clues=_extract_visual_clues(vision_client, image_path),
981
+ mood=mood or "",
982
+ user_context=user_context,
983
+ )
984
+ ```
985
+
986
+ - [ ] **Step 4: Keep text path alive when witness extraction fails**
987
+
988
+ Append to `tests/test_pipeline.py`:
989
+
990
+ ```python
991
+ def test_witness_failure_keeps_text_path_alive():
992
+ class BrokenWitnessVision:
993
+ def extract_witness(self, image_path):
994
+ raise RuntimeError("vision offline")
995
+
996
+ def extract_clues(self, image_path):
997
+ return ["fallback clue"]
998
+
999
+ intake = intake_from_modalities(
1000
+ dream_text="Text still works.",
1001
+ image_path="demo.png",
1002
+ audio_path=None,
1003
+ mood="Foggy",
1004
+ vision_client=BrokenWitnessVision(),
1005
+ asr_client=FakeASRClient(),
1006
+ )
1007
+
1008
+ assert "Text still works." in intake.merged_text()
1009
+ assert "fallback clue" in intake.merged_text()
1010
+ ```
1011
+
1012
+ - [ ] **Step 5: Run vision tests**
1013
+
1014
+ Run:
1015
+
1016
+ ```bash
1017
+ .venv/bin/python -m pytest tests/test_vision_witness.py tests/test_pipeline.py::test_witness_failure_keeps_text_path_alive -q
1018
+ ```
1019
+
1020
+ Expected: PASS.
1021
+
1022
+ - [ ] **Step 6: Commit witness routing**
1023
+
1024
+ Run:
1025
+
1026
+ ```bash
1027
+ git add dream_customs/pipeline.py dream_customs/models.py tests/test_vision_witness.py tests/test_pipeline.py
1028
+ git commit -m "feat: use MiniCPM vision witness reports"
1029
+ ```
1030
+
1031
+ Expected: commit succeeds.
1032
+
1033
+ ## Task 6: Add English Demo Quality Evaluation
1034
+
1035
+ **Files:**
1036
+ - Create: `tests/fixtures/demo_eval_cases.json`
1037
+ - Create: `scripts/evaluate_demo_quality.py`
1038
+ - Create: `tests/test_demo_quality_eval.py`
1039
+
1040
+ - [ ] **Step 1: Add demo fixture cases**
1041
+
1042
+ Create `tests/fixtures/demo_eval_cases.json`:
1043
+
1044
+ ```json
1045
+ [
1046
+ {
1047
+ "id": "elevator_wax_floor14",
1048
+ "dream_text": "I kept missing an elevator. The buttons melted like wax, and the floor number froze at 14.",
1049
+ "visual_clues": ["Visible text: 14", "Object: melted elevator button"],
1050
+ "mood": "Uneasy",
1051
+ "required_terms": ["elevator", "14"],
1052
+ "banned_terms": ["the an", "the the", "lever", "diagnosis"]
1053
+ },
1054
+ {
1055
+ "id": "customs_wet_paper",
1056
+ "dream_text": "I was at a customs window carrying a suitcase full of wet paper. The clerk asked me to declare every unfinished promise before sunrise.",
1057
+ "visual_clues": [],
1058
+ "mood": "Foggy",
1059
+ "required_terms": ["customs", "paper"],
1060
+ "banned_terms": ["hydrate", "fruit", "prophecy", "diagnosis"]
1061
+ },
1062
+ {
1063
+ "id": "exam_without_pencil",
1064
+ "dream_text": "I arrived at an exam with no pencil, but everyone else was already writing.",
1065
+ "visual_clues": [],
1066
+ "mood": "Restless",
1067
+ "required_terms": ["exam", "pencil"],
1068
+ "banned_terms": ["medical", "fate", "the an"]
1069
+ },
1070
+ {
1071
+ "id": "locked_green_room",
1072
+ "dream_text": "I found a small green room behind my kitchen, but the door kept locking itself.",
1073
+ "visual_clues": ["Color: green", "Object: small door"],
1074
+ "mood": "Curious",
1075
+ "required_terms": ["green", "door"],
1076
+ "banned_terms": ["diagnosis", "warning sign", "the the"]
1077
+ },
1078
+ {
1079
+ "id": "train_arrives_silent",
1080
+ "dream_text": "A silent train arrived with my name written on a ticket, but I could not read the destination.",
1081
+ "visual_clues": ["Visible text: user name on ticket"],
1082
+ "mood": "Uneasy",
1083
+ "required_terms": ["train", "ticket"],
1084
+ "banned_terms": ["destiny", "prophecy", "the an"]
1085
+ },
1086
+ {
1087
+ "id": "phone_full_of_water",
1088
+ "dream_text": "My phone was full of water, but it kept ringing from inside a drawer.",
1089
+ "visual_clues": [],
1090
+ "mood": "Tired",
1091
+ "required_terms": ["phone", "water"],
1092
+ "banned_terms": ["hydration", "diagnosis", "the the"]
1093
+ },
1094
+ {
1095
+ "id": "missing_shoes_stairwell",
1096
+ "dream_text": "I was in a stairwell without shoes, holding a map that only showed blank squares.",
1097
+ "visual_clues": ["Object: blank map", "Place: stairwell"],
1098
+ "mood": "Foggy",
1099
+ "required_terms": ["stairwell", "map"],
1100
+ "banned_terms": ["fate", "medical", "the an"]
1101
+ },
1102
+ {
1103
+ "id": "bedside_note_rain",
1104
+ "dream_text": "A note on my bedside table said 'bring the rain inside', and the window was already open.",
1105
+ "visual_clues": ["Visible text: bring the rain inside", "Object: open window"],
1106
+ "mood": "Calm",
1107
+ "required_terms": ["rain", "window"],
1108
+ "banned_terms": ["diagnosis", "prophecy", "the the"]
1109
+ },
1110
+ {
1111
+ "id": "voice_fragment_buttons",
1112
+ "dream_text": "",
1113
+ "voice_transcript": "The hallway was blue, and the elevator buttons were warm.",
1114
+ "visual_clues": ["Color: blue"],
1115
+ "mood": "Uneasy",
1116
+ "required_terms": ["hallway", "buttons"],
1117
+ "banned_terms": ["the an", "the the", "diagnosis"]
1118
+ },
1119
+ {
1120
+ "id": "abstract_sketch_moon_key",
1121
+ "dream_text": "I drew the dream because I only remember a moon-shaped key and a red square.",
1122
+ "visual_clues": ["Object: moon-shaped key", "Color: red square"],
1123
+ "mood": "Curious",
1124
+ "required_terms": ["key", "red"],
1125
+ "banned_terms": ["medical", "fate", "the an"]
1126
+ }
1127
+ ]
1128
+ ```
1129
+
1130
+ - [ ] **Step 2: Add evaluator tests**
1131
+
1132
+ Create `tests/test_demo_quality_eval.py`:
1133
+
1134
+ ```python
1135
+ from scripts.evaluate_demo_quality import evaluate_text
1136
+
1137
+
1138
+ def test_evaluate_text_passes_grounded_english_card():
1139
+ text = (
1140
+ "The Elevator Stuck at 14. The melted buttons and frozen floor 14 can stand for "
1141
+ "one stalled beginning. Choose one task and write the next button-sized action."
1142
+ )
1143
+
1144
+ result = evaluate_text(text, required_terms=["elevator", "14"], banned_terms=["the an", "the the"])
1145
+
1146
+ assert result["passes"]
1147
+ assert result["issues"] == []
1148
+
1149
+
1150
+ def test_evaluate_text_fails_repeated_articles_and_missing_anchor():
1151
+ text = "Pick one real task that feels like the an elevator."
1152
+
1153
+ result = evaluate_text(text, required_terms=["floor 14"], banned_terms=["the an"])
1154
+
1155
+ assert not result["passes"]
1156
+ assert "banned term: the an" in result["issues"]
1157
+ assert "missing required term: floor 14" in result["issues"]
1158
+ ```
1159
+
1160
+ - [ ] **Step 3: Implement evaluator script**
1161
+
1162
+ Create `scripts/evaluate_demo_quality.py`:
1163
+
1164
+ ```python
1165
+ import json
1166
+ from pathlib import Path
1167
+ from typing import Iterable
1168
+
1169
+
1170
+ def evaluate_text(text: str, required_terms: Iterable[str], banned_terms: Iterable[str]) -> dict:
1171
+ lowered = text.lower()
1172
+ issues: list[str] = []
1173
+ for term in required_terms:
1174
+ if term.lower() not in lowered:
1175
+ issues.append(f"missing required term: {term}")
1176
+ for term in banned_terms:
1177
+ if term.lower() in lowered:
1178
+ issues.append(f"banned term: {term}")
1179
+ if any(marker in lowered for marker in ["you have depression", "this means you will", "certainly predicts"]):
1180
+ issues.append("unsafe certainty or diagnosis")
1181
+ return {"passes": not issues, "issues": issues}
1182
+
1183
+
1184
+ def load_cases(path: str = "tests/fixtures/demo_eval_cases.json") -> list[dict]:
1185
+ return json.loads(Path(path).read_text(encoding="utf-8"))
1186
+
1187
+
1188
+ def main() -> int:
1189
+ cases = load_cases()
1190
+ print(json.dumps({"case_count": len(cases), "ids": [case["id"] for case in cases]}, indent=2))
1191
+ return 0
1192
+
1193
+
1194
+ if __name__ == "__main__":
1195
+ raise SystemExit(main())
1196
+ ```
1197
+
1198
+ - [ ] **Step 4: Run evaluator tests**
1199
+
1200
+ Run:
1201
+
1202
+ ```bash
1203
+ .venv/bin/python -m pytest tests/test_demo_quality_eval.py -q
1204
+ .venv/bin/python scripts/evaluate_demo_quality.py
1205
+ ```
1206
+
1207
+ Expected: pytest PASS; script prints `case_count` as `10`.
1208
+
1209
+ - [ ] **Step 5: Commit evaluator**
1210
+
1211
+ Run:
1212
+
1213
+ ```bash
1214
+ git add tests/fixtures/demo_eval_cases.json scripts/evaluate_demo_quality.py tests/test_demo_quality_eval.py
1215
+ git commit -m "test: add english demo quality eval cases"
1216
+ ```
1217
+
1218
+ Expected: commit succeeds.
1219
+
1220
+ ## Task 7: Full Verification, Local Smoke, And HF Space Sync
1221
+
1222
+ **Files:**
1223
+ - Verify: all modified files
1224
+ - Create: `docs/smoke/2026-06-08-model-led-pact-v2-smoke.md`
1225
+
1226
+ - [ ] **Step 1: Run full test suite**
1227
+
1228
+ Run:
1229
+
1230
+ ```bash
1231
+ .venv/bin/python -m pytest -q
1232
+ ```
1233
+
1234
+ Expected: all tests pass.
1235
+
1236
+ - [ ] **Step 2: Run whitespace check**
1237
+
1238
+ Run:
1239
+
1240
+ ```bash
1241
+ git diff --check
1242
+ ```
1243
+
1244
+ Expected: no output.
1245
+
1246
+ - [ ] **Step 3: Run local app smoke**
1247
+
1248
+ Run:
1249
+
1250
+ ```bash
1251
+ GRADIO_SERVER_PORT=7862 .venv/bin/python app.py
1252
+ ```
1253
+
1254
+ Open `http://127.0.0.1:7862` and submit:
1255
+
1256
+ ```text
1257
+ I kept missing an elevator. The buttons melted like wax, and the floor number froze at 14.
1258
+ ```
1259
+
1260
+ Attach an image only if a local sketch is available. Seal the pass and verify:
1261
+
1262
+ - The card is in polished English.
1263
+ - The card does not contain `the an`, `the the`, or invented `lever`.
1264
+ - The card mentions at least two of `elevator`, `melted buttons`, `wax`, and `floor 14`.
1265
+ - The practical suggestion is one real next step for today.
1266
+ - The weird task is playful, harmless, and grounded in the elevator or floor-14 image.
1267
+ - Mobile width remains readable.
1268
+
1269
+ - [ ] **Step 4: Record smoke evidence**
1270
+
1271
+ Create `docs/smoke/2026-06-08-model-led-pact-v2-smoke.md`:
1272
+
1273
+ ```markdown
1274
+ # Model-Led Pact V2 Smoke
1275
+
1276
+ Date: 2026-06-08
1277
+ Branch: feature/dream-customs-ui-voice-settings
1278
+
1279
+ ## Local
1280
+
1281
+ - Command: `.venv/bin/python -m pytest -q`
1282
+ - Result: PASS
1283
+ - Command: `git diff --check`
1284
+ - Result: PASS
1285
+ - Command: `GRADIO_SERVER_PORT=7862 .venv/bin/python app.py`
1286
+ - Result: PASS
1287
+
1288
+ ## Manual Demo Case
1289
+
1290
+ Input:
1291
+
1292
+ ```text
1293
+ I kept missing an elevator. The buttons melted like wax, and the floor number froze at 14.
1294
+ ```
1295
+
1296
+ Acceptance:
1297
+
1298
+ - Natural English card: PASS
1299
+ - No repeated article regression: PASS
1300
+ - No invented lever detail: PASS
1301
+ - Uses at least two dream details: PASS
1302
+ - Practical suggestion is actionable: PASS
1303
+ - Weird task is harmless and dream-grounded: PASS
1304
+ - Mobile readable: PASS
1305
+
1306
+ ## Hugging Face Space
1307
+
1308
+ - Push target checked: `space https://huggingface.co/spaces/build-small-hackathon/dream-customs`
1309
+ - Live Space config checked:
1310
+ - Live Space manual case checked:
1311
+ - Notes:
1312
+ ```
1313
+
1314
+ - [ ] **Step 5: Commit smoke doc**
1315
+
1316
+ Run:
1317
+
1318
+ ```bash
1319
+ git add docs/smoke/2026-06-08-model-led-pact-v2-smoke.md
1320
+ git commit -m "docs: record model-led pact smoke"
1321
+ ```
1322
+
1323
+ Expected: commit succeeds.
1324
+
1325
+ - [ ] **Step 6: Push GitHub branch**
1326
+
1327
+ Run:
1328
+
1329
+ ```bash
1330
+ git push origin feature/dream-customs-ui-voice-settings
1331
+ ```
1332
+
1333
+ Expected: push succeeds.
1334
+
1335
+ - [ ] **Step 7: Sync Hugging Face Space**
1336
+
1337
+ Before pushing, verify the remote:
1338
+
1339
+ ```bash
1340
+ git remote -v | grep '^space'
1341
+ ```
1342
+
1343
+ Expected:
1344
+
1345
+ ```text
1346
+ space https://huggingface.co/spaces/build-small-hackathon/dream-customs (fetch)
1347
+ space https://huggingface.co/spaces/build-small-hackathon/dream-customs (push)
1348
+ ```
1349
+
1350
+ Then push or create a Space PR according to the current auth state. If the operation requires a token, secret value, billing confirmation, force push to public `main`, or manual PR merge, stop and ask the user with the exact blocker. Do not print or save tokens.
1351
+
1352
+ ## Self-Review
1353
+
1354
+ - Spec coverage: The plan covers English demo quality, MiniCPM5 brief/draft/critique/rewrite, MiniCPM-V witness reports, screenshot elevator regression, fallback resilience, local tests, local smoke, and HF Space sync.
1355
+ - Placeholder scan: No unresolved placeholder strings are present in the task instructions.
1356
+ - Type consistency: `VisionWitness`, `DreamBrief`, `PactCritique`, and `PactCard` names are used consistently across schema, prompts, clients, pipeline, and tests.
1357
+ - Scope check: The plan is one coherent generation-quality pass. It does not redesign the UI, change Modal deployment architecture, or broaden beyond the MiniCPM family.
dream_customs/app_logic.py CHANGED
@@ -19,14 +19,16 @@ from dream_customs.pipeline import (
19
  ask_questions,
20
  create_session,
21
  draft_pact,
 
22
  generate_negotiation,
23
  generate_pact,
 
24
  intake_from_modalities,
25
  revise_pact,
26
  seal_pact,
27
  skip_question,
28
  )
29
- from dream_customs.render import render_pact_card, render_pact_inspector, render_status_bar, render_timeline
30
  from dream_customs.schema import CustomsSession
31
 
32
 
@@ -218,12 +220,12 @@ def _notice(session: CustomsSession) -> str:
218
  latest_error = next((event for event in reversed(session.events) if event.role == "error"), None)
219
  if latest_error:
220
  return f"<div class='dc-inline-notice is-error'>{latest_error.body}</div>"
221
- if session.phase == "sealed":
222
- return "<div class='dc-inline-notice is-sealed'>Today's pact is sealed. The card below is ready for a screenshot.</div>"
223
  if session.phase == "drafting":
224
- return "<div class='dc-inline-notice'>Draft ready. Revise it, ask another question, add material, or seal today's pact.</div>"
225
- if session.phase == "negotiating":
226
- return "<div class='dc-inline-notice'>The clerk has questions. Answer, skip, add material, or draft a pact.</div>"
227
  return "<div class='dc-inline-notice'>File any fragment. Text-only stays available if image or voice fails.</div>"
228
 
229
 
@@ -233,7 +235,7 @@ def _view(
233
  vision_backend: str = DEFAULT_VISION_BACKEND,
234
  **settings,
235
  ):
236
- sealed_html = render_pact_card(session.sealed_pact) if session.sealed_pact else ""
237
  return (
238
  json.dumps(session.model_dump(mode="json"), ensure_ascii=False),
239
  render_status_bar(session, text_backend, vision_backend),
@@ -369,9 +371,7 @@ def seal_pact_action(
369
  ):
370
  session = _session_from_state(state)
371
  text_client, _vision_client, _asr_client = _clients(text_backend, vision_backend, **settings)
372
- if not session.draft_pact and session.intake.merged_text():
373
- session = draft_pact(session, text_client)
374
- session = seal_pact(session)
375
  return _view(session, text_backend, vision_backend, **settings)
376
 
377
 
@@ -397,8 +397,8 @@ def run_customs_once(
397
  audio_path = _file_path(audio_value)
398
  if not any([dream_text and dream_text.strip(), image_path, audio_path]):
399
  return (
400
- "No declaration received.",
401
- "Please add text, an image, or a voice note before requesting clearance.",
402
  "",
403
  json.dumps({"status": "empty"}, ensure_ascii=False, indent=2),
404
  )
@@ -413,8 +413,9 @@ def run_customs_once(
413
  asr_client=asr_client,
414
  )
415
  negotiation = generate_negotiation(intake, text_client)
416
- answer_text = answers or "The user has not answered yet; infer a gentle pact from the declaration."
417
- card, html = generate_pact(intake, answer_text, text_client)
 
418
  questions = "\n".join(f"{index}. {question}" for index, question in enumerate(negotiation["questions"], start=1))
419
  negotiation_text = "\n".join(
420
  part
@@ -434,6 +435,6 @@ def run_customs_once(
434
  ],
435
  "intake": intake.model_dump(),
436
  "negotiation": negotiation,
437
- "pact": card.model_dump(),
438
  }
439
  return negotiation_text, card.to_plain_text(), html, json.dumps(debug, ensure_ascii=False, indent=2)
 
19
  ask_questions,
20
  create_session,
21
  draft_pact,
22
+ finish_today_tip,
23
  generate_negotiation,
24
  generate_pact,
25
+ generate_today_tip,
26
  intake_from_modalities,
27
  revise_pact,
28
  seal_pact,
29
  skip_question,
30
  )
31
+ from dream_customs.render import render_pact_card, render_pact_inspector, render_status_bar, render_timeline, render_today_tip_card
32
  from dream_customs.schema import CustomsSession
33
 
34
 
 
220
  latest_error = next((event for event in reversed(session.events) if event.role == "error"), None)
221
  if latest_error:
222
  return f"<div class='dc-inline-notice is-error'>{latest_error.body}</div>"
223
+ if session.phase == "tip":
224
+ return "<div class='dc-inline-notice is-sealed'>今日小 Tips 已生成。可以截图或复制。</div>"
225
  if session.phase == "drafting":
226
+ return "<div class='dc-inline-notice'>解读草稿已准备好。可以补充细节、再问一个问题,或生成今日小 Tips。</div>"
227
+ if session.phase in {"ask", "negotiating"}:
228
+ return "<div class='dc-inline-notice'>梦境助手有一个追问。你可以回答、跳过、补充材料,或生成今日小 Tips。</div>"
229
  return "<div class='dc-inline-notice'>File any fragment. Text-only stays available if image or voice fails.</div>"
230
 
231
 
 
235
  vision_backend: str = DEFAULT_VISION_BACKEND,
236
  **settings,
237
  ):
238
+ sealed_html = render_today_tip_card(session.sealed_tip) if session.sealed_tip else ""
239
  return (
240
  json.dumps(session.model_dump(mode="json"), ensure_ascii=False),
241
  render_status_bar(session, text_backend, vision_backend),
 
371
  ):
372
  session = _session_from_state(state)
373
  text_client, _vision_client, _asr_client = _clients(text_backend, vision_backend, **settings)
374
+ session = finish_today_tip(session, text_client)
 
 
375
  return _view(session, text_backend, vision_backend, **settings)
376
 
377
 
 
397
  audio_path = _file_path(audio_value)
398
  if not any([dream_text and dream_text.strip(), image_path, audio_path]):
399
  return (
400
+ "还没有收到梦境记录。",
401
+ "请先添加文字、图片或语音,再生成今日小 Tips。",
402
  "",
403
  json.dumps({"status": "empty"}, ensure_ascii=False, indent=2),
404
  )
 
413
  asr_client=asr_client,
414
  )
415
  negotiation = generate_negotiation(intake, text_client)
416
+ answer_text = answers or "用户还没有回答追问,请根据已有记录生成温和今日小 Tips。"
417
+ card = generate_today_tip(intake, answer_text, text_client)
418
+ html = render_today_tip_card(card)
419
  questions = "\n".join(f"{index}. {question}" for index, question in enumerate(negotiation["questions"], start=1))
420
  negotiation_text = "\n".join(
421
  part
 
435
  ],
436
  "intake": intake.model_dump(),
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)
dream_customs/models.py CHANGED
@@ -6,8 +6,8 @@ import urllib.error
6
  import urllib.request
7
  from typing import Any, Dict, List, Optional
8
 
9
- from dream_customs.prompts import visual_clue_prompt
10
- from dream_customs.schema import PactCard
11
 
12
 
13
  class FakeVisionClient:
@@ -16,6 +16,19 @@ class FakeVisionClient:
16
  return []
17
  return ["blue hallway", "melted elevator buttons", "number 14"]
18
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
 
20
  class FakeASRClient:
21
  def transcribe(self, audio_path: Optional[str]) -> str:
@@ -25,15 +38,25 @@ class FakeASRClient:
25
 
26
 
27
  class FakeTextClient:
 
 
 
 
 
 
 
 
 
 
28
  def generate_negotiation(self, prompt: str) -> Dict[str, Any]:
29
  return {
30
- "visitor_name": "Late Elevator",
31
  "questions": [
32
- "What feeling was strongest when you woke up: pressure, tiredness, curiosity, or something else?",
33
- "Is there one real-life thing you would like to make easier today?",
34
- "Would you rather receive a practical life tip, a tiny odd task, or both?",
35
  ],
36
- "tone_note": "This visitor may be asking for a smaller start, not a perfect finish.",
37
  }
38
 
39
  def generate_pact(self, prompt: str) -> PactCard:
@@ -48,6 +71,34 @@ class FakeTextClient:
48
  bedtime_release="The elevator has docked for tonight. Unfinished floors can report tomorrow.",
49
  )
50
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
51
 
52
  def _strip_markdown_and_thinking(text: str) -> str:
53
  cleaned = text.strip()
@@ -106,6 +157,110 @@ def _as_string_list(value: Any) -> List[str]:
106
  return []
107
 
108
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
109
  class OllamaTextClient:
110
  def __init__(
111
  self,
@@ -197,6 +352,58 @@ class OllamaTextClient:
197
  except (KeyError, TypeError, ValueError):
198
  return self.fallback.generate_pact(prompt)
199
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
200
 
201
  class OllamaVisionClient:
202
  def __init__(
@@ -204,10 +411,12 @@ class OllamaVisionClient:
204
  model_name: str = "openbmb/minicpm-v4.6",
205
  base_url: str = "http://localhost:11434",
206
  timeout: float = 60.0,
 
207
  ):
208
  self.model_name = model_name
209
  self.base_url = base_url.rstrip("/")
210
  self.timeout = timeout
 
211
 
212
  def _post_generate(self, prompt: str, image_b64: str, num_predict: int = 256) -> Dict[str, Any]:
213
  payload = {
@@ -227,24 +436,46 @@ class OllamaVisionClient:
227
  with urllib.request.urlopen(request, timeout=self.timeout) as response:
228
  return json.loads(response.read().decode("utf-8"))
229
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
230
  def extract_clues(self, image_path: Optional[str]) -> List[str]:
231
  if not image_path:
232
  return []
233
  try:
234
  with open(image_path, "rb") as image_file:
235
  image_b64 = base64.b64encode(image_file.read()).decode("ascii")
236
- response = self._post_generate(visual_clue_prompt(), image_b64)
237
  except (OSError, TimeoutError, urllib.error.URLError, json.JSONDecodeError):
238
- return []
239
 
240
  text = str(response.get("response", ""))
241
- parsed = _extract_json_object(text)
242
- if parsed:
243
- clues: List[str] = []
244
- for key in ("objects", "places", "visible_text", "colors", "mood_cues", "uncertain_details"):
245
- clues.extend(_as_string_list(parsed.get(key)))
246
- return clues[:8]
247
- return [part.strip() for part in re.split(r"[,,\n]", _strip_markdown_and_thinking(text)) if part.strip()][:8]
 
 
 
 
248
 
249
 
250
  class MiniCPMVisionClient:
@@ -400,6 +631,58 @@ class HostedMiniCPMTextClient:
400
  except (KeyError, TypeError, ValueError):
401
  return self.fallback.generate_pact(prompt)
402
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
403
 
404
  class HostedMiniCPMVisionClient:
405
  def __init__(
@@ -418,7 +701,7 @@ class HostedMiniCPMVisionClient:
418
  self.max_tokens = max(64, min(int(max_tokens), 800))
419
  self.fallback = fallback or FakeVisionClient()
420
 
421
- def _post_image(self, image_path: str) -> Optional[Dict[str, Any]]:
422
  if not self.endpoint:
423
  return None
424
  try:
@@ -427,7 +710,7 @@ class HostedMiniCPMVisionClient:
427
  except OSError:
428
  return None
429
  payload = {
430
- "prompt": visual_clue_prompt(),
431
  "image": image_b64,
432
  "images": [image_b64],
433
  "temperature": self.temperature,
@@ -448,22 +731,45 @@ class HostedMiniCPMVisionClient:
448
  except (OSError, TimeoutError, urllib.error.URLError, json.JSONDecodeError):
449
  return None
450
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
451
  def extract_clues(self, image_path: Optional[str]) -> List[str]:
452
  if not image_path:
453
  return []
454
- payload = self._post_image(image_path)
 
 
 
455
  if not payload:
456
  return self.fallback.extract_clues(image_path)
457
- parsed = _extract_json_object(_hosted_text_from_response(payload))
458
- if parsed:
459
- clues: List[str] = []
460
- for key in ("objects", "places", "visible_text", "colors", "mood_cues", "uncertain_details"):
461
- clues.extend(_as_string_list(parsed.get(key)))
462
- if clues:
463
- return clues[:8]
464
  text = _hosted_text_from_response(payload)
465
- clues = [part.strip() for part in re.split(r"[,,\n]", _strip_markdown_and_thinking(text)) if part.strip()]
466
- return clues[:8] or self.fallback.extract_clues(image_path)
 
 
 
 
 
 
 
 
 
 
467
 
468
 
469
  def _hosted_transcript_from_response(payload: Dict[str, Any]) -> str:
 
6
  import urllib.request
7
  from typing import Any, Dict, List, Optional
8
 
9
+ from dream_customs.prompts import visual_clue_prompt, visual_witness_prompt
10
+ from dream_customs.schema import DreamBrief, DreamQAState, PactCard, PactCritique, TodayTipCard, VisionWitness
11
 
12
 
13
  class FakeVisionClient:
 
16
  return []
17
  return ["blue hallway", "melted elevator buttons", "number 14"]
18
 
19
+ def extract_witness(self, image_path: Optional[str]) -> VisionWitness:
20
+ if not image_path:
21
+ return VisionWitness()
22
+ return VisionWitness(
23
+ scene_summary="A blue hallway with a melted elevator button.",
24
+ objects=["elevator button", "blue hallway"],
25
+ visible_text=["14"],
26
+ spatial_relations=["button near the frozen floor number"],
27
+ mood_cues=["stuck", "uncertain"],
28
+ uncertain_details=[],
29
+ surprising_detail="The button looks soft, almost waxy.",
30
+ )
31
+
32
 
33
  class FakeASRClient:
34
  def transcribe(self, audio_path: Optional[str]) -> str:
 
38
 
39
 
40
  class FakeTextClient:
41
+ def generate_brief(self, prompt: str) -> DreamBrief:
42
+ return DreamBrief(
43
+ anchors=["elevator", "melted buttons", "floor 14"],
44
+ emotional_hypothesis="The dream may be protecting the user from freezing at the start of a task.",
45
+ today_bridge="Choose one stalled task and name the next small movement.",
46
+ visual_evidence=["Visible text: 14"],
47
+ safety_flags=[],
48
+ language="en",
49
+ )
50
+
51
  def generate_negotiation(self, prompt: str) -> Dict[str, Any]:
52
  return {
53
+ "visitor_name": "迟到的电梯",
54
  "questions": [
55
+ "醒来时最强烈的感受是什么:着急、疲惫、好奇,还是别的?",
56
+ "最近有没有一件真实的小事,让你还没开始就觉得有点来不及?",
57
+ "你更想得到一个认真建议、一个没试过的小行动,还是一句被照顾到的话?",
58
  ],
59
+ "tone_note": "这个梦也许是在提醒你先把开始变小,而不是马上要求完成。",
60
  }
61
 
62
  def generate_pact(self, prompt: str) -> PactCard:
 
71
  bedtime_release="The elevator has docked for tonight. Unfinished floors can report tomorrow.",
72
  )
73
 
74
+ def generate_today_tip(self, prompt: str) -> TodayTipCard:
75
+ return TodayTipCard(
76
+ dream_summary="你梦见电梯迟迟不到,按钮像蜡一样融化,楼层数字停在 14。",
77
+ main_question="这个梦是否和最近卡在开始之前有关?",
78
+ dream_anchors=["电梯", "融化的按钮", "数字 14"],
79
+ followup_questions=[
80
+ "梦里最强烈的是着急、害怕,还是荒诞?",
81
+ "最近有没有一件事让你还没开始就觉得迟到?",
82
+ ],
83
+ user_answers=[],
84
+ interpretation=(
85
+ "也许这个梦不是在预言你会迟到,而是在把“还没开始就担心来不及”的感觉,"
86
+ "演成了一部停在 14 层的电梯。"
87
+ ),
88
+ today_tip="今天先按下一个很小的电梯按钮:只打开那件事,不要求自己马上抵达。",
89
+ tiny_action="用 5 分钟写下:如果今天只停一层,我先停在哪一层?",
90
+ caring_note="你可以慢慢前进,不需要一醒来就解决所有楼层。",
91
+ )
92
+
93
+ def generate_pact_draft(self, prompt: str) -> PactCard:
94
+ return self.generate_pact(prompt)
95
+
96
+ def critique_pact(self, prompt: str) -> PactCritique:
97
+ return PactCritique(passes=True, issues=[], rewrite_instruction="")
98
+
99
+ def rewrite_pact(self, prompt: str) -> PactCard:
100
+ return self.generate_pact(prompt)
101
+
102
 
103
  def _strip_markdown_and_thinking(text: str) -> str:
104
  cleaned = text.strip()
 
157
  return []
158
 
159
 
160
+ def _as_bool(value: Any, default: bool = True) -> bool:
161
+ if isinstance(value, bool):
162
+ return value
163
+ if isinstance(value, str):
164
+ normalized = value.strip().lower()
165
+ if normalized in {"false", "no", "0"}:
166
+ return False
167
+ if normalized in {"true", "yes", "1"}:
168
+ return True
169
+ if value is None:
170
+ return default
171
+ return bool(value)
172
+
173
+
174
+ def _dream_brief_from_parsed(parsed: Dict[str, Any]) -> DreamBrief:
175
+ return DreamBrief(
176
+ anchors=_as_string_list(parsed.get("anchors")),
177
+ emotional_hypothesis=str(parsed.get("emotional_hypothesis", "")).strip(),
178
+ today_bridge=str(parsed.get("today_bridge", "")).strip(),
179
+ visual_evidence=_as_string_list(parsed.get("visual_evidence")),
180
+ safety_flags=_as_string_list(parsed.get("safety_flags")),
181
+ language=str(parsed.get("language", "en")).strip() or "en",
182
+ )
183
+
184
+
185
+ def _pact_critique_from_parsed(parsed: Dict[str, Any]) -> PactCritique:
186
+ return PactCritique(
187
+ passes=_as_bool(parsed.get("passes"), default=True),
188
+ issues=_as_string_list(parsed.get("issues")),
189
+ rewrite_instruction=str(parsed.get("rewrite_instruction", "")).strip(),
190
+ )
191
+
192
+
193
+ def _today_tip_from_parsed(parsed: Dict[str, Any], fallback_state: Optional[DreamQAState] = None) -> TodayTipCard:
194
+ fallback_state = fallback_state or DreamQAState()
195
+ return TodayTipCard(
196
+ dream_summary=str(parsed.get("dream_summary") or fallback_state.dream_summary or "").strip(),
197
+ main_question=str(parsed.get("main_question") or fallback_state.main_question or "").strip(),
198
+ dream_anchors=_as_string_list(parsed.get("dream_anchors")) or list(fallback_state.dream_anchors),
199
+ followup_questions=_as_string_list(parsed.get("followup_questions")) or list(fallback_state.followup_questions),
200
+ user_answers=_as_string_list(parsed.get("user_answers")) or list(fallback_state.user_answers),
201
+ interpretation=str(parsed["interpretation"]).strip(),
202
+ today_tip=str(parsed["today_tip"]).strip(),
203
+ tiny_action=str(parsed.get("tiny_action", "")).strip(),
204
+ caring_note=str(parsed.get("caring_note", "")).strip(),
205
+ safety_note=str(parsed.get("safety_note", "")).strip(),
206
+ )
207
+
208
+
209
+ def _has_witness_report_fields(parsed: Dict[str, Any]) -> bool:
210
+ return bool(
211
+ str(parsed.get("scene_summary", "")).strip()
212
+ or _as_string_list(parsed.get("objects"))
213
+ or _as_string_list(parsed.get("visible_text"))
214
+ or _as_string_list(parsed.get("spatial_relations"))
215
+ or _as_string_list(parsed.get("mood_cues"))
216
+ or _as_string_list(parsed.get("uncertain_details"))
217
+ or str(parsed.get("surprising_detail", "")).strip()
218
+ )
219
+
220
+
221
+ def _has_legacy_flat_clue_fields(parsed: Dict[str, Any]) -> bool:
222
+ return bool(_as_string_list(parsed.get("places")) or _as_string_list(parsed.get("colors")))
223
+
224
+
225
+ def _vision_witness_from_parsed(parsed: Dict[str, Any]) -> VisionWitness:
226
+ return VisionWitness(
227
+ scene_summary=str(parsed.get("scene_summary", "")).strip(),
228
+ objects=_as_string_list(parsed.get("objects")),
229
+ visible_text=_as_string_list(parsed.get("visible_text")),
230
+ spatial_relations=_as_string_list(parsed.get("spatial_relations")),
231
+ mood_cues=_as_string_list(parsed.get("mood_cues")),
232
+ uncertain_details=_as_string_list(parsed.get("uncertain_details")),
233
+ surprising_detail=str(parsed.get("surprising_detail", "")).strip(),
234
+ )
235
+
236
+
237
+ def _flat_visual_clues_from_parsed(parsed: Dict[str, Any]) -> List[str]:
238
+ clues: List[str] = []
239
+ for key in ("objects", "places", "visible_text", "colors", "mood_cues", "uncertain_details"):
240
+ clues.extend(_as_string_list(parsed.get(key)))
241
+ return clues[:8]
242
+
243
+
244
+ def _flat_visual_clues_from_text(text: str) -> List[str]:
245
+ return [part.strip() for part in re.split(r"[,,\n]", _strip_markdown_and_thinking(text)) if part.strip()][:8]
246
+
247
+
248
+ def _simple_witness_from_text(text: str) -> VisionWitness:
249
+ clues = _flat_visual_clues_from_text(text)
250
+ if not clues:
251
+ return VisionWitness()
252
+ return VisionWitness(scene_summary="; ".join(clues[:2]), objects=clues[2:6])
253
+
254
+
255
+ def _visual_clues_from_model_text(text: str) -> List[str]:
256
+ parsed = _extract_json_object(text)
257
+ if parsed:
258
+ if _has_witness_report_fields(parsed) and not _has_legacy_flat_clue_fields(parsed):
259
+ return _vision_witness_from_parsed(parsed).to_visual_clues()
260
+ return _flat_visual_clues_from_parsed(parsed)
261
+ return _flat_visual_clues_from_text(text)
262
+
263
+
264
  class OllamaTextClient:
265
  def __init__(
266
  self,
 
352
  except (KeyError, TypeError, ValueError):
353
  return self.fallback.generate_pact(prompt)
354
 
355
+ def generate_today_tip(self, prompt: str) -> TodayTipCard:
356
+ parsed = self._generate_json(
357
+ prompt,
358
+ (
359
+ '{"dream_summary":"string","main_question":"string","dream_anchors":["string"],'
360
+ '"followup_questions":["string"],"user_answers":["string"],"interpretation":"string",'
361
+ '"today_tip":"string","tiny_action":"string","caring_note":"string","safety_note":"string"}'
362
+ ),
363
+ num_predict=780,
364
+ )
365
+ if not parsed:
366
+ return self.fallback.generate_today_tip(prompt)
367
+ try:
368
+ return _today_tip_from_parsed(parsed)
369
+ except (KeyError, TypeError, ValueError):
370
+ return self.fallback.generate_today_tip(prompt)
371
+ def generate_brief(self, prompt: str) -> DreamBrief:
372
+ parsed = self._generate_json(
373
+ prompt,
374
+ (
375
+ '{"anchors":["string"],"emotional_hypothesis":"string",'
376
+ '"today_bridge":"string","visual_evidence":["string"],'
377
+ '"safety_flags":["string"],"language":"en"}'
378
+ ),
379
+ num_predict=520,
380
+ )
381
+ if not parsed:
382
+ return self.fallback.generate_brief(prompt)
383
+ try:
384
+ return _dream_brief_from_parsed(parsed)
385
+ except (TypeError, ValueError):
386
+ return self.fallback.generate_brief(prompt)
387
+
388
+ def generate_pact_draft(self, prompt: str) -> PactCard:
389
+ return self.generate_pact(prompt)
390
+
391
+ def critique_pact(self, prompt: str) -> PactCritique:
392
+ parsed = self._generate_json(
393
+ prompt,
394
+ '{"passes":true,"issues":["string"],"rewrite_instruction":"string"}',
395
+ num_predict=360,
396
+ )
397
+ if not parsed:
398
+ return self.fallback.critique_pact(prompt)
399
+ try:
400
+ return _pact_critique_from_parsed(parsed)
401
+ except (TypeError, ValueError):
402
+ return self.fallback.critique_pact(prompt)
403
+
404
+ def rewrite_pact(self, prompt: str) -> PactCard:
405
+ return self.generate_pact(prompt)
406
+
407
 
408
  class OllamaVisionClient:
409
  def __init__(
 
411
  model_name: str = "openbmb/minicpm-v4.6",
412
  base_url: str = "http://localhost:11434",
413
  timeout: float = 60.0,
414
+ fallback: Optional[FakeVisionClient] = None,
415
  ):
416
  self.model_name = model_name
417
  self.base_url = base_url.rstrip("/")
418
  self.timeout = timeout
419
+ self.fallback = fallback or FakeVisionClient()
420
 
421
  def _post_generate(self, prompt: str, image_b64: str, num_predict: int = 256) -> Dict[str, Any]:
422
  payload = {
 
436
  with urllib.request.urlopen(request, timeout=self.timeout) as response:
437
  return json.loads(response.read().decode("utf-8"))
438
 
439
+ def extract_witness(self, image_path: Optional[str]) -> VisionWitness:
440
+ if not image_path:
441
+ return VisionWitness()
442
+ try:
443
+ with open(image_path, "rb") as image_file:
444
+ image_b64 = base64.b64encode(image_file.read()).decode("ascii")
445
+ response = self._post_generate(visual_witness_prompt(), image_b64, num_predict=320)
446
+ except (OSError, TimeoutError, urllib.error.URLError, json.JSONDecodeError):
447
+ return self.fallback.extract_witness(image_path)
448
+
449
+ text = str(response.get("response", ""))
450
+ parsed = _extract_json_object(text)
451
+ if parsed and _has_witness_report_fields(parsed) and not _has_legacy_flat_clue_fields(parsed):
452
+ return _vision_witness_from_parsed(parsed)
453
+ if not parsed:
454
+ return _simple_witness_from_text(text)
455
+ return VisionWitness()
456
+
457
  def extract_clues(self, image_path: Optional[str]) -> List[str]:
458
  if not image_path:
459
  return []
460
  try:
461
  with open(image_path, "rb") as image_file:
462
  image_b64 = base64.b64encode(image_file.read()).decode("ascii")
463
+ response = self._post_generate(visual_witness_prompt(), image_b64, num_predict=320)
464
  except (OSError, TimeoutError, urllib.error.URLError, json.JSONDecodeError):
465
+ return self.fallback.extract_clues(image_path)
466
 
467
  text = str(response.get("response", ""))
468
+ clues = _visual_clues_from_model_text(text)
469
+ if clues:
470
+ return clues
471
+
472
+ try:
473
+ response = self._post_generate(visual_clue_prompt(), image_b64)
474
+ except (OSError, TimeoutError, urllib.error.URLError, json.JSONDecodeError):
475
+ return self.fallback.extract_clues(image_path)
476
+
477
+ clues = _visual_clues_from_model_text(str(response.get("response", "")))
478
+ return clues or self.fallback.extract_clues(image_path)
479
 
480
 
481
  class MiniCPMVisionClient:
 
631
  except (KeyError, TypeError, ValueError):
632
  return self.fallback.generate_pact(prompt)
633
 
634
+ def generate_today_tip(self, prompt: str) -> TodayTipCard:
635
+ parsed = self._generate_json(
636
+ prompt,
637
+ (
638
+ '{"dream_summary":"string","main_question":"string","dream_anchors":["string"],'
639
+ '"followup_questions":["string"],"user_answers":["string"],"interpretation":"string",'
640
+ '"today_tip":"string","tiny_action":"string","caring_note":"string","safety_note":"string"}'
641
+ ),
642
+ max_tokens=780,
643
+ )
644
+ if not parsed:
645
+ return self.fallback.generate_today_tip(prompt)
646
+ try:
647
+ return _today_tip_from_parsed(parsed)
648
+ except (KeyError, TypeError, ValueError):
649
+ return self.fallback.generate_today_tip(prompt)
650
+ def generate_brief(self, prompt: str) -> DreamBrief:
651
+ parsed = self._generate_json(
652
+ prompt,
653
+ (
654
+ '{"anchors":["string"],"emotional_hypothesis":"string",'
655
+ '"today_bridge":"string","visual_evidence":["string"],'
656
+ '"safety_flags":["string"],"language":"en"}'
657
+ ),
658
+ max_tokens=520,
659
+ )
660
+ if not parsed:
661
+ return self.fallback.generate_brief(prompt)
662
+ try:
663
+ return _dream_brief_from_parsed(parsed)
664
+ except (TypeError, ValueError):
665
+ return self.fallback.generate_brief(prompt)
666
+
667
+ def generate_pact_draft(self, prompt: str) -> PactCard:
668
+ return self.generate_pact(prompt)
669
+
670
+ def critique_pact(self, prompt: str) -> PactCritique:
671
+ parsed = self._generate_json(
672
+ prompt,
673
+ '{"passes":true,"issues":["string"],"rewrite_instruction":"string"}',
674
+ max_tokens=360,
675
+ )
676
+ if not parsed:
677
+ return self.fallback.critique_pact(prompt)
678
+ try:
679
+ return _pact_critique_from_parsed(parsed)
680
+ except (TypeError, ValueError):
681
+ return self.fallback.critique_pact(prompt)
682
+
683
+ def rewrite_pact(self, prompt: str) -> PactCard:
684
+ return self.generate_pact(prompt)
685
+
686
 
687
  class HostedMiniCPMVisionClient:
688
  def __init__(
 
701
  self.max_tokens = max(64, min(int(max_tokens), 800))
702
  self.fallback = fallback or FakeVisionClient()
703
 
704
+ def _post_image(self, image_path: str, prompt: Optional[str] = None) -> Optional[Dict[str, Any]]:
705
  if not self.endpoint:
706
  return None
707
  try:
 
710
  except OSError:
711
  return None
712
  payload = {
713
+ "prompt": prompt or visual_clue_prompt(),
714
  "image": image_b64,
715
  "images": [image_b64],
716
  "temperature": self.temperature,
 
731
  except (OSError, TimeoutError, urllib.error.URLError, json.JSONDecodeError):
732
  return None
733
 
734
+ def extract_witness(self, image_path: Optional[str]) -> VisionWitness:
735
+ if not image_path:
736
+ return VisionWitness()
737
+ try:
738
+ payload = self._post_image(image_path, visual_witness_prompt())
739
+ except TypeError:
740
+ payload = self._post_image(image_path)
741
+ if not payload:
742
+ return self.fallback.extract_witness(image_path)
743
+ text = _hosted_text_from_response(payload)
744
+ parsed = _extract_json_object(text)
745
+ if parsed and _has_witness_report_fields(parsed) and not _has_legacy_flat_clue_fields(parsed):
746
+ return _vision_witness_from_parsed(parsed)
747
+ if not parsed:
748
+ return _simple_witness_from_text(text)
749
+ return VisionWitness()
750
+
751
  def extract_clues(self, image_path: Optional[str]) -> List[str]:
752
  if not image_path:
753
  return []
754
+ try:
755
+ payload = self._post_image(image_path, visual_witness_prompt())
756
+ except TypeError:
757
+ payload = self._post_image(image_path)
758
  if not payload:
759
  return self.fallback.extract_clues(image_path)
 
 
 
 
 
 
 
760
  text = _hosted_text_from_response(payload)
761
+ clues = _visual_clues_from_model_text(text)
762
+ if clues:
763
+ return clues
764
+
765
+ try:
766
+ payload = self._post_image(image_path, visual_clue_prompt())
767
+ except TypeError:
768
+ payload = self._post_image(image_path)
769
+ if not payload:
770
+ return self.fallback.extract_clues(image_path)
771
+ clues = _visual_clues_from_model_text(_hosted_text_from_response(payload))
772
+ return clues or self.fallback.extract_clues(image_path)
773
 
774
 
775
  def _hosted_transcript_from_response(payload: Dict[str, Any]) -> str:
dream_customs/pipeline.py CHANGED
@@ -2,10 +2,21 @@ import re
2
  from datetime import date
3
  from typing import Dict, List, Optional, Tuple
4
 
5
- from dream_customs.prompts import followup_question_prompt, negotiation_prompt, pact_prompt, pact_revision_prompt
6
- from dream_customs.render import render_pact_card
 
 
 
 
 
 
 
 
 
 
 
7
  from dream_customs.safety import needs_escalation, safety_note
8
- from dream_customs.schema import CustomsSession, DreamIntake, EvidenceItem, PactCard, TimelineEvent
9
 
10
 
11
  def build_intake(
@@ -13,6 +24,7 @@ def build_intake(
13
  voice_transcript: str = "",
14
  visual_clues: Optional[List[str]] = None,
15
  mood: str = "",
 
16
  recurring_symbols: Optional[List[str]] = None,
17
  uncertainty: str = "",
18
  user_context: str = "",
@@ -22,6 +34,7 @@ def build_intake(
22
  voice_transcript=voice_transcript,
23
  visual_clues=visual_clues or [],
24
  mood=mood,
 
25
  recurring_symbols=recurring_symbols or [],
26
  uncertainty=uncertainty,
27
  user_context=user_context,
@@ -49,7 +62,9 @@ _ANCHOR_STOPWORDS = {
49
  "about",
50
  "after",
51
  "again",
 
52
  "asked",
 
53
  "before",
54
  "behind",
55
  "being",
@@ -71,10 +86,29 @@ _ANCHOR_STOPWORDS = {
71
  "promise",
72
  "through",
73
  "today",
 
74
  "window",
75
  "with",
76
  }
77
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
78
 
79
  def _dedupe_preserve_order(items: List[str]) -> List[str]:
80
  seen = set()
@@ -88,15 +122,22 @@ def _dedupe_preserve_order(items: List[str]) -> List[str]:
88
 
89
 
90
  def _extract_dream_anchors(intake: DreamIntake) -> List[str]:
91
- text = " ".join(
92
  [
93
  intake.dream_text,
94
  intake.voice_transcript,
95
  " ".join(intake.visual_clues),
96
  " ".join(intake.recurring_symbols),
97
  ]
98
- ).lower()
 
99
  candidates: List[str] = []
 
 
 
 
 
 
100
  pair_pattern = re.compile(
101
  r"\b([a-z][a-z'-]+)\s+("
102
  r"paper|papers|promise|promises|window|windows|suitcase|suitcases|"
@@ -116,12 +157,12 @@ def _extract_dream_anchors(intake: DreamIntake) -> List[str]:
116
  candidates.extend(match.group(1) for match in noun_pattern.finditer(text))
117
  candidates.extend(clue.lower() for clue in intake.visual_clues if clue.strip())
118
 
119
- return _dedupe_preserve_order(candidates)[:3]
120
 
121
 
122
  def _primary_anchor(intake: DreamIntake) -> str:
123
  anchors = _extract_dream_anchors(intake)
124
- return anchors[0] if anchors else "night visitor"
125
 
126
 
127
  def _secondary_anchor(intake: DreamIntake) -> str:
@@ -133,6 +174,50 @@ def _title_anchor(text: str) -> str:
133
  return " ".join(part.capitalize() for part in text.split())
134
 
135
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
136
  def _text_uses_anchor(text: str, anchors: List[str]) -> bool:
137
  clean = (text or "").lower()
138
  return any(anchor in clean for anchor in anchors)
@@ -274,14 +359,14 @@ def _grounded_question(intake: DreamIntake, question: str) -> str:
274
  primary = _primary_anchor(intake)
275
  secondary = _secondary_anchor(intake)
276
  return (
277
- f"When you picture the {primary} and the {secondary}, what is one real-life promise "
278
- "or task you want to make easier today?"
279
  )
280
 
281
 
282
  def _grounded_followup_question(intake: DreamIntake) -> str:
283
  primary = _primary_anchor(intake)
284
- return f"If the {primary} could hand you one smaller first step for today, what would that step be?"
285
 
286
 
287
  def _polish_card_for_daily_use(card: PactCard, intake: DreamIntake, answers: str) -> PactCard:
@@ -326,6 +411,23 @@ def _polish_card_for_daily_use(card: PactCard, intake: DreamIntake, answers: str
326
  return polished
327
 
328
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
329
  def intake_from_modalities(
330
  dream_text: str,
331
  image_path: Optional[str],
@@ -338,12 +440,85 @@ def intake_from_modalities(
338
  return build_intake(
339
  dream_text=dream_text or "",
340
  voice_transcript=asr_client.transcribe(audio_path),
341
- visual_clues=vision_client.extract_clues(image_path),
342
  mood=mood or "",
343
  user_context=user_context,
344
  )
345
 
346
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
347
  def generate_negotiation(intake: DreamIntake, text_client) -> Dict:
348
  prompt = negotiation_prompt(intake)
349
  return text_client.generate_negotiation(prompt)
@@ -360,13 +535,57 @@ def generate_pact(intake: DreamIntake, answers: str, text_client) -> Tuple[PactC
360
  return card, render_pact_card(card)
361
 
362
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
363
  def create_session() -> CustomsSession:
364
  return CustomsSession(
365
  events=[
366
  TimelineEvent(
367
  role="system",
368
- title="Dream Customs desk opened",
369
- body="Start with any fragment: a sentence, a sketch, a voice note, or just the mood left behind.",
370
  status="ready",
371
  )
372
  ]
@@ -438,7 +657,7 @@ def add_evidence(
438
  clues: List[str] = []
439
  error = ""
440
  try:
441
- clues = vision_client.extract_clues(image_path) if vision_client else []
442
  except Exception:
443
  clues = []
444
  error = "Image clue extraction failed. Text-only path remains available."
@@ -497,14 +716,15 @@ def add_evidence(
497
  if not added_items:
498
  next_session.phase = "error"
499
  next_session.events.append(
500
- _event("error", "No material added", "Add text, image, or voice before asking the clerk.", status="empty")
501
  )
502
  return next_session
503
 
504
  next_session.evidence_items.extend(added_items)
505
- next_session.phase = "declaring"
 
506
  summary = "\n".join(f"{item.label}: {item.content or item.error}" for item in added_items)
507
- next_session.events.append(_event("user", "Material added", summary, status="filed"))
508
  _record_safety(next_session)
509
  return next_session
510
 
@@ -514,7 +734,7 @@ def ask_questions(session: CustomsSession, text_client, force_another: bool = Fa
514
  if not next_session.intake.merged_text():
515
  next_session.phase = "error"
516
  next_session.events.append(
517
- _event("error", "Customs has no declaration yet", "Add one dream fragment before asking a question.", status="empty")
518
  )
519
  return next_session
520
 
@@ -527,7 +747,7 @@ def ask_questions(session: CustomsSession, text_client, force_another: bool = Fa
527
  questions = [question for question in negotiation.get("questions", []) if question]
528
  fresh_questions = [question for question in questions if question not in next_session.question_history]
529
  if force_another and not fresh_questions:
530
- fresh_questions = ["If today only needs one smaller first step, what should that step be?"]
531
  if not fresh_questions:
532
  fresh_questions = questions[:3]
533
  if fresh_questions:
@@ -543,11 +763,17 @@ def ask_questions(session: CustomsSession, text_client, force_another: bool = Fa
543
  fresh_questions = deduped_questions
544
 
545
  next_session.question_history.extend(fresh_questions[:3])
546
- next_session.phase = "negotiating"
 
 
 
 
 
 
547
  next_session.events.append(
548
  _event(
549
- "customs",
550
- "Customs question filed" if len(fresh_questions) == 1 else "Customs questions filed",
551
  "\n".join(fresh_questions[:3]),
552
  meta=str(negotiation.get("visitor_name", "")),
553
  status="question",
@@ -565,18 +791,52 @@ def answer_question(session: CustomsSession, answer: str) -> CustomsSession:
565
  )
566
  return next_session
567
  next_session.answer_history.append(answer.strip())
568
- next_session.phase = "negotiating"
569
- next_session.events.append(_event("user", "Answer filed", answer.strip(), status="answered"))
 
570
  _record_safety(next_session)
571
  return next_session
572
 
573
 
574
  def skip_question(session: CustomsSession) -> CustomsSession:
575
  next_session = session.model_copy(deep=True)
576
- skip_text = "The user chose to skip this question."
577
  next_session.answer_history.append(skip_text)
578
- next_session.phase = "negotiating"
579
- next_session.events.append(_event("user", "Question skipped", skip_text, status="skipped"))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
580
  return next_session
581
 
582
 
@@ -590,7 +850,10 @@ def draft_pact(session: CustomsSession, text_client) -> CustomsSession:
590
  return next_session
591
 
592
  answers = next_session.answers_text() or "The user has not answered yet; infer a gentle pact from the declaration."
593
- card, _html = generate_pact(next_session.intake, answers, text_client)
 
 
 
594
  next_session.draft_pact = card
595
  next_session.phase = "drafting"
596
  next_session.events.append(
 
2
  from datetime import date
3
  from typing import Dict, List, Optional, Tuple
4
 
5
+ from dream_customs.prompts import (
6
+ dream_brief_prompt,
7
+ dream_qa_state_prompt,
8
+ followup_question_prompt,
9
+ negotiation_prompt,
10
+ pact_critique_prompt,
11
+ pact_draft_prompt,
12
+ pact_prompt,
13
+ pact_revision_prompt,
14
+ pact_rewrite_prompt,
15
+ today_tip_prompt,
16
+ )
17
+ from dream_customs.render import render_pact_card, render_today_tip_card
18
  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(
 
24
  voice_transcript: str = "",
25
  visual_clues: Optional[List[str]] = None,
26
  mood: str = "",
27
+ main_question: str = "",
28
  recurring_symbols: Optional[List[str]] = None,
29
  uncertainty: str = "",
30
  user_context: str = "",
 
34
  voice_transcript=voice_transcript,
35
  visual_clues=visual_clues or [],
36
  mood=mood,
37
+ main_question=main_question,
38
  recurring_symbols=recurring_symbols or [],
39
  uncertainty=uncertainty,
40
  user_context=user_context,
 
62
  "about",
63
  "after",
64
  "again",
65
+ "an",
66
  "asked",
67
+ "a",
68
  "before",
69
  "behind",
70
  "being",
 
86
  "promise",
87
  "through",
88
  "today",
89
+ "the",
90
  "window",
91
  "with",
92
  }
93
 
94
+ _ZH_ANCHOR_MARKERS = [
95
+ "电梯按钮",
96
+ "融化的按钮",
97
+ "按钮融化",
98
+ "数字 14",
99
+ "数字14",
100
+ "老楼",
101
+ "桥",
102
+ "水",
103
+ "被追赶",
104
+ "找不到路",
105
+ "呼救",
106
+ "蓝色楼道",
107
+ "电梯",
108
+ "按钮",
109
+ "楼层",
110
+ ]
111
+
112
 
113
  def _dedupe_preserve_order(items: List[str]) -> List[str]:
114
  seen = set()
 
122
 
123
 
124
  def _extract_dream_anchors(intake: DreamIntake) -> List[str]:
125
+ raw_text = " ".join(
126
  [
127
  intake.dream_text,
128
  intake.voice_transcript,
129
  " ".join(intake.visual_clues),
130
  " ".join(intake.recurring_symbols),
131
  ]
132
+ )
133
+ text = raw_text.lower()
134
  candidates: List[str] = []
135
+ for marker in _ZH_ANCHOR_MARKERS:
136
+ if marker in raw_text:
137
+ candidates.append(marker)
138
+ for number in re.findall(r"\b\d{1,3}\b", raw_text):
139
+ if number == "14":
140
+ candidates.append("数字 14")
141
  pair_pattern = re.compile(
142
  r"\b([a-z][a-z'-]+)\s+("
143
  r"paper|papers|promise|promises|window|windows|suitcase|suitcases|"
 
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())
159
 
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:
 
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
+
210
+ def _anchor_in_text(text: str, anchors: List[str]) -> bool:
211
+ clean = (text or "").lower()
212
+ for anchor in anchors:
213
+ item = anchor.lower().strip()
214
+ if item and item in clean:
215
+ return True
216
+ if any(marker in item and marker in clean for marker in ["电梯", "按钮", "14", "桥", "水", "elevator", "button"]):
217
+ return True
218
+ return False
219
+
220
+
221
  def _text_uses_anchor(text: str, anchors: List[str]) -> bool:
222
  clean = (text or "").lower()
223
  return any(anchor in clean for anchor in anchors)
 
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
 
372
  def _polish_card_for_daily_use(card: PactCard, intake: DreamIntake, answers: str) -> PactCard:
 
411
  return polished
412
 
413
 
414
+ def _extract_visual_clues(vision_client, image_path: Optional[str]) -> List[str]:
415
+ if not image_path:
416
+ return []
417
+ try:
418
+ if hasattr(vision_client, "extract_witness"):
419
+ witness = vision_client.extract_witness(image_path)
420
+ clues = witness.to_visual_clues()
421
+ if clues:
422
+ return clues
423
+ except Exception:
424
+ pass
425
+ try:
426
+ return vision_client.extract_clues(image_path)
427
+ except Exception:
428
+ return []
429
+
430
+
431
  def intake_from_modalities(
432
  dream_text: str,
433
  image_path: Optional[str],
 
440
  return build_intake(
441
  dream_text=dream_text or "",
442
  voice_transcript=asr_client.transcribe(audio_path),
443
+ visual_clues=_extract_visual_clues(vision_client, image_path),
444
  mood=mood or "",
445
  user_context=user_context,
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 [],
457
+ current_step="ask" if questions else "record",
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)
495
+ else:
496
+ legacy, _html = generate_pact(intake, answers, text_client)
497
+ card = TodayTipCard(
498
+ dream_summary=qa_state.dream_summary,
499
+ main_question=qa_state.main_question,
500
+ dream_anchors=qa_state.dream_anchors,
501
+ followup_questions=qa_state.followup_questions,
502
+ user_answers=qa_state.user_answers,
503
+ interpretation=legacy.alliance_reading,
504
+ today_tip=legacy.practical_suggestion,
505
+ tiny_action=legacy.weird_task,
506
+ caring_note=legacy.bedtime_release,
507
+ safety_note=legacy.safety_note,
508
+ )
509
+ except Exception:
510
+ card = TodayTipCard(
511
+ dream_summary=qa_state.dream_summary,
512
+ main_question=qa_state.main_question,
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:
523
  prompt = negotiation_prompt(intake)
524
  return text_client.generate_negotiation(prompt)
 
535
  return card, render_pact_card(card)
536
 
537
 
538
+ def _clean_repeated_articles(text: str) -> str:
539
+ clean = re.sub(r"\bthe\s+an\s+", "an ", text, flags=re.IGNORECASE)
540
+ clean = re.sub(r"\bthe\s+the\s+", "the ", clean, flags=re.IGNORECASE)
541
+ clean = re.sub(r"\ban\s+an\s+", "an ", clean, flags=re.IGNORECASE)
542
+ clean = re.sub(r"\ba\s+a\s+", "a ", clean, flags=re.IGNORECASE)
543
+ return re.sub(r"\s+", " ", clean).strip()
544
+
545
+
546
+ def _clean_card_grammar(card: PactCard) -> PactCard:
547
+ cleaned = card.model_copy(deep=True)
548
+ cleaned.visitor_name = _clean_repeated_articles(cleaned.visitor_name)
549
+ cleaned.risk_level = _clean_repeated_articles(cleaned.risk_level)
550
+ cleaned.alliance_reading = _clean_repeated_articles(cleaned.alliance_reading)
551
+ cleaned.practical_suggestion = _clean_repeated_articles(cleaned.practical_suggestion)
552
+ cleaned.weird_task = _clean_repeated_articles(cleaned.weird_task)
553
+ cleaned.bedtime_release = _clean_repeated_articles(cleaned.bedtime_release)
554
+ cleaned.contraband = [_clean_repeated_articles(item) for item in cleaned.contraband]
555
+ return cleaned
556
+
557
+
558
+ def generate_model_led_pact(intake: DreamIntake, answers: str, text_client) -> Tuple[PactCard, str]:
559
+ brief = text_client.generate_brief(dream_brief_prompt(intake))
560
+ card = text_client.generate_pact_draft(pact_draft_prompt(brief, answers))
561
+ critique = text_client.critique_pact(pact_critique_prompt(brief, card))
562
+ if not critique.passes and critique.rewrite_instruction.strip():
563
+ card = text_client.rewrite_pact(pact_rewrite_prompt(brief, card, critique))
564
+ card = _clean_card_grammar(card)
565
+ card = _polish_card_for_daily_use(card, intake, answers)
566
+ merged = intake.merged_text() + "\n" + answers
567
+ if needs_escalation(merged):
568
+ card.safety_note = safety_note()
569
+ else:
570
+ card.safety_note = ""
571
+ card = _stamp_card_for_today(card)
572
+ return card, render_pact_card(card)
573
+
574
+
575
+ def _supports_model_led_pact(text_client) -> bool:
576
+ return all(
577
+ callable(getattr(text_client, name, None))
578
+ for name in ("generate_brief", "generate_pact_draft", "critique_pact", "rewrite_pact")
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
  ]
 
657
  clues: List[str] = []
658
  error = ""
659
  try:
660
+ clues = _extract_visual_clues(vision_client, image_path) if vision_client else []
661
  except Exception:
662
  clues = []
663
  error = "Image clue extraction failed. Text-only path remains available."
 
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
 
 
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
 
 
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:
 
763
  fresh_questions = deduped_questions
764
 
765
  next_session.question_history.extend(fresh_questions[:3])
766
+ next_session.phase = "ask"
767
+ next_session.qa_state = build_qa_state(
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",
 
791
  )
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
825
+ next_session.qa_state.current_step = "tip"
826
+ next_session.draft_tip = card
827
+ next_session.sealed_tip = card
828
+ next_session.draft_pact = None
829
+ next_session.sealed_pact = None
830
+ next_session.phase = "tip"
831
+ next_session.events.append(
832
+ _event(
833
+ "tip",
834
+ "今日小 Tips 已生成",
835
+ f"{card.interpretation}\n{card.today_tip}",
836
+ status="tip",
837
+ )
838
+ )
839
+ _record_safety(next_session)
840
  return next_session
841
 
842
 
 
850
  return next_session
851
 
852
  answers = next_session.answers_text() or "The user has not answered yet; infer a gentle pact from the declaration."
853
+ if _supports_model_led_pact(text_client):
854
+ card, _html = generate_model_led_pact(next_session.intake, answers, text_client)
855
+ else:
856
+ card, _html = generate_pact(next_session.intake, answers, text_client)
857
  next_session.draft_pact = card
858
  next_session.phase = "drafting"
859
  next_session.events.append(
dream_customs/prompts.py CHANGED
@@ -1,9 +1,151 @@
1
- from dream_customs.schema import DreamIntake
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
 
3
 
4
  def visual_clue_prompt() -> str:
5
  return (
6
- "You are the witness clerk at Dream Customs. Extract concise visual clues "
7
  "from this dream sketch, note, screenshot, or photo. Return JSON with keys: "
8
  "objects, places, visible_text, colors, mood_cues, uncertain_details. "
9
  "Do not diagnose the user."
@@ -12,33 +154,31 @@ def visual_clue_prompt() -> str:
12
 
13
  def negotiation_prompt(intake: DreamIntake) -> str:
14
  return f"""
15
- You are the Dream Customs diplomat. The user is not asking for diagnosis.
16
- Treat the dream as a strange visitor that can form a small pact with the user.
17
- The tone should be gentle, plain, and specific. Do not make medical claims.
18
- Ask questions that an ordinary person can understand without knowing the app lore.
19
- Prefer questions about today's mood, one small real-life concern, or one safe action.
20
- Do not ask vague symbolic questions such as what a stamp wants to release.
21
  Ground every question in a concrete detail from the intake when possible, such as an object,
22
  place, action, color, or phrase the user actually provided.
23
- Do not invent a human name unless the user mentioned a person.
24
 
25
  Dream intake:
26
  {intake.merged_text()}
27
 
28
  Return JSON with:
29
- - visitor_name: short vivid name
30
  - questions: 2 or 3 gentle, specific, easy-to-understand questions
31
- - tone_note: one sentence explaining the visitor without certainty
32
  """.strip()
33
 
34
 
35
  def followup_question_prompt(intake: DreamIntake, question_history: list[str], answer_history: list[str]) -> str:
36
  return f"""
37
- You are the Dream Customs diplomat. Ask one more gentle customs question.
38
  Do not diagnose. Do not repeat previous questions.
39
- The question must be plain and useful: ask what the user wants to make easier today,
40
- or whether there is one realistic thing they want help starting.
41
- Do not use unclear metaphors about stamps, release, fate, symbols, or hidden meanings.
42
  Reuse one concrete dream detail from the intake so the user can feel the question belongs
43
  to this dream rather than to a generic reflection form.
44
 
 
1
+ import json
2
+
3
+ from dream_customs.schema import DreamBrief, DreamIntake, DreamQAState, PactCard, PactCritique
4
+
5
+
6
+ def _json_block(value) -> str:
7
+ return json.dumps(value.model_dump(mode="json"), ensure_ascii=False, indent=2)
8
+
9
+
10
+ def visual_witness_prompt() -> str:
11
+ return (
12
+ "You are MiniCPM-V-4.6 acting as the visual witness for Dream QA. "
13
+ "Describe only what is visible in the dream sketch, note, screenshot, or photo. "
14
+ "Return strict JSON with keys: scene_summary, objects, visible_text, "
15
+ "spatial_relations, mood_cues, uncertain_details, surprising_detail. "
16
+ "Use short concrete observations. Mark uncertainty instead of guessing. "
17
+ "Do not diagnose the user."
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()}
30
+
31
+ Return strict JSON with:
32
+ - dream_summary: short summary of the user's dream
33
+ - main_question: the doubt the user most wants to understand
34
+ - dream_anchors: 3 to 5 concrete dream details
35
+ - followup_questions: 1 to 3 gentle questions
36
+ - current_step: exactly "ask"
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.
44
+ Use non-certain language such as "也许", "可以把它当作", "maybe", or "for today, try".
45
+ 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)}
52
+
53
+ Return strict JSON with:
54
+ dream_summary, main_question, dream_anchors, followup_questions, user_answers,
55
+ interpretation, today_tip, tiny_action, caring_note, safety_note.
56
+ """.strip()
57
+
58
+
59
+ def dream_brief_prompt(intake: DreamIntake) -> str:
60
+ return f"""
61
+ You are the Dream Customs briefing clerk. Create an English demo brief for the pact writer.
62
+ Do not diagnose the user. Do not claim the dream has one fixed meaning.
63
+ Extract concrete anchors from the user's own dream text, voice transcript, mood, and visual clues.
64
+ Reuse visual evidence when present instead of treating the image as decoration.
65
+ Keep the brief specific enough that a later card cannot fall back to generic wellness advice.
66
+
67
+ Dream intake:
68
+ {intake.merged_text()}
69
+
70
+ Return strict JSON with:
71
+ - anchors: 3 to 5 concrete dream details, objects, places, actions, or visible text
72
+ - emotional_hypothesis: one gentle maybe-statement, not a diagnosis
73
+ - today_bridge: one realistic bridge from the dream to today
74
+ - visual_evidence: concrete visual clues to preserve, or an empty list
75
+ - safety_flags: only severe distress or safety concerns, otherwise an empty list
76
+ - language: exactly "en"
77
+ """.strip()
78
+
79
+
80
+ def pact_draft_prompt(brief: DreamBrief, answers: str) -> str:
81
+ return f"""
82
+ You are the Dream Customs pact writer. Write natural English for a public English demo.
83
+ Use the DreamBrief as the source of truth, not a template fallback.
84
+ Use at least two anchors from the brief across the card.
85
+ Use visual_evidence if present, especially visible text, objects, or spatial details.
86
+ Do not use template phrases, generic wellness filler, diagnosis, frightening certainty, or mystical absolutes.
87
+ Keep practical_suggestion useful for today and keep weird_task harmless, playful, and doable in 5 minutes.
88
+
89
+ DreamBrief:
90
+ {_json_block(brief)}
91
+
92
+ User answers:
93
+ {answers or "No answers yet."}
94
+
95
+ Return strict JSON with PactCard fields:
96
+ visitor_name, permit_id, contraband, risk_level, alliance_reading,
97
+ practical_suggestion, weird_task, bedtime_release, safety_note.
98
+ """.strip()
99
+
100
+
101
+ def pact_critique_prompt(brief: DreamBrief, card: PactCard) -> str:
102
+ return f"""
103
+ You are the Dream Customs demo quality reviewer. Check this drafted card against the brief.
104
+ Flag screenshot regressions and English quality problems before the card reaches the public demo.
105
+ Check repeated articles such as "the an" and "the the", awkward grammar, template fallback,
106
+ invented details, diagnosis, frightening certainty, generic wellness advice, missing anchors,
107
+ and whether the card sounds like natural English.
108
+ Do not add new dream facts. Judge only against the DreamBrief and the card.
109
+
110
+ DreamBrief:
111
+ {_json_block(brief)}
112
+
113
+ Draft PactCard:
114
+ {_json_block(card)}
115
+
116
+ Return strict JSON with:
117
+ - passes: boolean
118
+ - issues: list of short issue labels
119
+ - rewrite_instruction: one actionable instruction for a rewrite, or an empty string if passes is true
120
+ """.strip()
121
+
122
+
123
+ def pact_rewrite_prompt(brief: DreamBrief, card: PactCard, critique: PactCritique) -> str:
124
+ return f"""
125
+ You are the Dream Customs pact rewriter. Follow the critique instruction exactly.
126
+ Rewrite without repeated articles, awkward grammar, invented details, diagnosis, or frightening certainty.
127
+ Preserve the DreamBrief anchors and safe non-diagnostic tone.
128
+ Do not add objects, places, people, or events that are not already in the DreamBrief or Current PactCard.
129
+ Write natural English for the public English demo.
130
+
131
+ DreamBrief:
132
+ {_json_block(brief)}
133
+
134
+ Current PactCard:
135
+ {_json_block(card)}
136
+
137
+ Critique:
138
+ {_json_block(critique)}
139
+
140
+ Return strict JSON with PactCard fields:
141
+ visitor_name, permit_id, contraband, risk_level, alliance_reading,
142
+ practical_suggestion, weird_task, bedtime_release, safety_note.
143
+ """.strip()
144
 
145
 
146
  def visual_clue_prompt() -> str:
147
  return (
148
+ "You are the image witness for Dream QA. Extract concise visual clues "
149
  "from this dream sketch, note, screenshot, or photo. Return JSON with keys: "
150
  "objects, places, visible_text, colors, mood_cues, uncertain_details. "
151
  "Do not diagnose the user."
 
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.
159
+ The tone should be warm, plain, and specific. Do not make medical claims.
160
+ Ask questions that an ordinary person can understand without knowing any app lore.
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.
179
+ The question must be plain and useful: ask what the user wants to understand, what feeling was strongest,
180
+ 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
 
dream_customs/render.py CHANGED
@@ -1,6 +1,6 @@
1
  from html import escape
2
 
3
- from dream_customs.schema import CustomsSession, EvidenceItem, PactCard, TimelineEvent
4
 
5
 
6
  def _nl2br(text: str) -> str:
@@ -10,6 +10,10 @@ def _nl2br(text: str) -> str:
10
  def _phase_label(phase: str) -> str:
11
  labels = {
12
  "empty": "Empty desk",
 
 
 
 
13
  "declaring": "Declare",
14
  "negotiating": "Negotiate",
15
  "drafting": "Draft",
@@ -19,6 +23,63 @@ def _phase_label(phase: str) -> str:
19
  return labels.get(phase, phase.title())
20
 
21
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22
  def _evidence_chip(item: EvidenceItem) -> str:
23
  return (
24
  f"<span class='dc-evidence-chip is-{escape(item.status)}'>"
@@ -31,10 +92,10 @@ def render_status_bar(session: CustomsSession, text_backend: str = "demo", visio
31
  return f"""
32
  <header class="dc-statusbar">
33
  <div class="dc-brand-lockup">
34
- <span class="dc-brand-mark">DC</span>
35
  <div>
36
- <h1>Dream Customs</h1>
37
- <p>Turn last night's strange visitor into one gentle pact for the day: a safe life tip, a small weird task, and a bedtime release.</p>
38
  </div>
39
  </div>
40
  <div class="dc-system-status">
@@ -65,8 +126,8 @@ def render_timeline(session: CustomsSession) -> str:
65
  events = [
66
  TimelineEvent(
67
  role="system",
68
- title="The night desk is open",
69
- body="Start with one fragment. The clerk can work from text, image, voice, or only the mood.",
70
  status="ready",
71
  )
72
  ]
@@ -87,12 +148,12 @@ def render_timeline(session: CustomsSession) -> str:
87
  )
88
  )
89
  return f"""
90
- <section class="dc-timeline-shell" aria-label="Dream Customs timeline">
91
  <div class="dc-timeline-head">
92
  <div>
93
- <span class="dc-section-kicker">Customs timeline</span>
94
- <h2>Story so far</h2>
95
- <p>Dream fragments, clerk questions, and pact drafts stay together so the ritual feels easy to follow.</p>
96
  </div>
97
  <span>{session.evidence_count()} filed</span>
98
  </div>
@@ -105,14 +166,16 @@ def render_timeline(session: CustomsSession) -> str:
105
 
106
 
107
  def render_pact_inspector(session: CustomsSession) -> str:
 
 
108
  card = session.sealed_pact or session.draft_pact
109
  state = "sealed" if session.sealed_pact else "draft" if session.draft_pact else "waiting"
110
  if not card:
111
  return f"""
112
- <aside class="dc-inspector is-waiting" aria-label="Pact inspector">
113
- <div class="dc-inspector-kicker">Pact inspector</div>
114
- <h2>No pact drafted yet</h2>
115
- <p>Your alliance card will appear here after the clerk has enough dream material. Text-only is enough to begin.</p>
116
  <dl>
117
  <div><dt>Evidence</dt><dd>{session.evidence_count()} filed</dd></div>
118
  <div><dt>Phase</dt><dd>{escape(_phase_label(session.phase))}</dd></div>
@@ -128,8 +191,8 @@ def render_pact_inspector(session: CustomsSession) -> str:
128
  else ""
129
  )
130
  return f"""
131
- <aside class="dc-inspector is-{state}" aria-label="Pact inspector">
132
- <div class="dc-inspector-kicker">Pact inspector</div>
133
  <div class="dc-permit-row">
134
  <span>{escape(card.permit_id)}</span>
135
  <span>{escape(state.title())}</span>
 
1
  from html import escape
2
 
3
+ from dream_customs.schema import CustomsSession, EvidenceItem, PactCard, TimelineEvent, TodayTipCard
4
 
5
 
6
  def _nl2br(text: str) -> str:
 
10
  def _phase_label(phase: str) -> str:
11
  labels = {
12
  "empty": "Empty desk",
13
+ "record": "Record",
14
+ "ask": "Ask",
15
+ "interpret": "Interpret",
16
+ "tip": "Today Tip",
17
  "declaring": "Declare",
18
  "negotiating": "Negotiate",
19
  "drafting": "Draft",
 
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
+ )
35
+ caring_note = (
36
+ f"<div class='dqa-care-note'>♡ {escape(card.caring_note)}</div>"
37
+ if card.caring_note
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}
76
+ {qa_history}
77
+ {safety}
78
+ {caring_note}
79
+ </section>
80
+ """.strip()
81
+
82
+
83
  def _evidence_chip(item: EvidenceItem) -> str:
84
  return (
85
  f"<span class='dc-evidence-chip is-{escape(item.status)}'>"
 
92
  return f"""
93
  <header class="dc-statusbar">
94
  <div class="dc-brand-lockup">
95
+ <span class="dc-brand-mark"></span>
96
  <div>
97
+ <h1>梦境问答台</h1>
98
+ <p>记录梦境,回答或跳过温和追问,得到一个引用梦境细节的今日小 Tips。</p>
99
  </div>
100
  </div>
101
  <div class="dc-system-status">
 
126
  events = [
127
  TimelineEvent(
128
  role="system",
129
+ title="梦境问答台已打开",
130
+ body="先写下一个梦境片段。文字、图片、语音都会进入同一个 intake。",
131
  status="ready",
132
  )
133
  ]
 
148
  )
149
  )
150
  return f"""
151
+ <section class="dc-timeline-shell" aria-label="Dream QA timeline">
152
  <div class="dc-timeline-head">
153
  <div>
154
+ <span class="dc-section-kicker">梦境问答流程</span>
155
+ <h2>目前记录</h2>
156
+ <p>梦境片段、追问、回答和解读草稿会按时间线留在这里。</p>
157
  </div>
158
  <span>{session.evidence_count()} filed</span>
159
  </div>
 
166
 
167
 
168
  def render_pact_inspector(session: CustomsSession) -> str:
169
+ if session.sealed_tip or session.draft_tip:
170
+ return render_today_tip_card(session.sealed_tip or session.draft_tip)
171
  card = session.sealed_pact or session.draft_pact
172
  state = "sealed" if session.sealed_pact else "draft" if session.draft_pact else "waiting"
173
  if not card:
174
  return f"""
175
+ <aside class="dc-inspector is-waiting" aria-label="Today Tip preview">
176
+ <div class="dc-inspector-kicker">解读草稿</div>
177
+ <h2>还没有生成今日小 Tips</h2>
178
+ <p>记录梦境并回答或跳过追问后,解读草稿和今日小 Tips 会出现在这里。Text-only 已足够开始。</p>
179
  <dl>
180
  <div><dt>Evidence</dt><dd>{session.evidence_count()} filed</dd></div>
181
  <div><dt>Phase</dt><dd>{escape(_phase_label(session.phase))}</dd></div>
 
191
  else ""
192
  )
193
  return f"""
194
+ <aside class="dc-inspector is-{state}" aria-label="Today Tip preview">
195
+ <div class="dc-inspector-kicker">解读草稿</div>
196
  <div class="dc-permit-row">
197
  <span>{escape(card.permit_id)}</span>
198
  <span>{escape(state.title())}</span>
dream_customs/safety.py CHANGED
@@ -24,7 +24,7 @@ def needs_escalation(text: str) -> bool:
24
 
25
  def safety_note() -> str:
26
  return (
27
- "This dream sounds heavier than a playful customs ritual should handle. "
28
  "If you feel unsafe, cannot sleep for many nights, or worry you may hurt "
29
  "yourself or someone else, please reach out to a trusted person or professional support now."
30
  )
 
24
 
25
  def safety_note() -> str:
26
  return (
27
+ "This dream sounds heavier than a playful reflection tool should handle. "
28
  "If you feel unsafe, cannot sleep for many nights, or worry you may hurt "
29
  "yourself or someone else, please reach out to a trusted person or professional support now."
30
  )
dream_customs/schema.py CHANGED
@@ -3,11 +3,12 @@ from typing import Literal, Optional
3
  from pydantic import BaseModel, Field
4
 
5
 
6
- class DreamIntake(BaseModel):
7
  dream_text: str = ""
8
  voice_transcript: str = ""
9
  visual_clues: list[str] = Field(default_factory=list)
10
  mood: str = ""
 
11
  recurring_symbols: list[str] = Field(default_factory=list)
12
  uncertainty: str = ""
13
  user_context: str = ""
@@ -18,6 +19,7 @@ class DreamIntake(BaseModel):
18
  self.voice_transcript.strip(),
19
  "Visual clues: " + ", ".join(self.visual_clues) if self.visual_clues else "",
20
  "Mood: " + self.mood.strip() if self.mood else "",
 
21
  "Recurring symbols: " + ", ".join(self.recurring_symbols) if self.recurring_symbols else "",
22
  "Uncertainty: " + self.uncertainty.strip() if self.uncertainty else "",
23
  "Context: " + self.user_context.strip() if self.user_context else "",
@@ -25,6 +27,134 @@ class DreamIntake(BaseModel):
25
  return "\n".join(part for part in parts if part)
26
 
27
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
28
  class PactCard(BaseModel):
29
  visitor_name: str
30
  permit_id: str
@@ -53,10 +183,21 @@ class PactCard(BaseModel):
53
  return "\n".join(lines)
54
 
55
 
56
- SessionPhase = Literal["empty", "declaring", "negotiating", "drafting", "sealed", "error"]
 
 
 
 
 
 
 
 
 
 
 
57
  EvidenceType = Literal["text", "image", "audio", "mood"]
58
  EvidenceStatus = Literal["queued", "extracting", "extracted", "failed", "selected"]
59
- TimelineRole = Literal["system", "user", "customs", "pact", "error"]
60
 
61
 
62
  class EvidenceItem(BaseModel):
@@ -78,10 +219,13 @@ class TimelineEvent(BaseModel):
78
 
79
  class CustomsSession(BaseModel):
80
  phase: SessionPhase = "empty"
81
- intake: DreamIntake = Field(default_factory=DreamIntake)
 
82
  evidence_items: list[EvidenceItem] = Field(default_factory=list)
83
  question_history: list[str] = Field(default_factory=list)
84
  answer_history: list[str] = Field(default_factory=list)
 
 
85
  draft_pact: Optional[PactCard] = None
86
  sealed_pact: Optional[PactCard] = None
87
  safety_flags: list[str] = Field(default_factory=list)
@@ -92,3 +236,7 @@ class CustomsSession(BaseModel):
92
 
93
  def evidence_count(self) -> int:
94
  return len([item for item in self.evidence_items if item.status != "failed"])
 
 
 
 
 
3
  from pydantic import BaseModel, Field
4
 
5
 
6
+ class DreamQuestionIntake(BaseModel):
7
  dream_text: str = ""
8
  voice_transcript: str = ""
9
  visual_clues: list[str] = Field(default_factory=list)
10
  mood: str = ""
11
+ main_question: str = ""
12
  recurring_symbols: list[str] = Field(default_factory=list)
13
  uncertainty: str = ""
14
  user_context: str = ""
 
19
  self.voice_transcript.strip(),
20
  "Visual clues: " + ", ".join(self.visual_clues) if self.visual_clues else "",
21
  "Mood: " + self.mood.strip() if self.mood else "",
22
+ "Main question: " + self.main_question.strip() if self.main_question else "",
23
  "Recurring symbols: " + ", ".join(self.recurring_symbols) if self.recurring_symbols else "",
24
  "Uncertainty: " + self.uncertainty.strip() if self.uncertainty else "",
25
  "Context: " + self.user_context.strip() if self.user_context else "",
 
27
  return "\n".join(part for part in parts if part)
28
 
29
 
30
+ class DreamIntake(DreamQuestionIntake):
31
+ """Backward-compatible name for older integration code."""
32
+
33
+
34
+ class DreamQAState(BaseModel):
35
+ dream_summary: str = ""
36
+ main_question: str = ""
37
+ dream_anchors: list[str] = Field(default_factory=list)
38
+ followup_questions: list[str] = Field(default_factory=list)
39
+ user_answers: list[str] = Field(default_factory=list)
40
+ current_step: Literal["record", "ask", "interpret", "tip"] = "record"
41
+
42
+ @classmethod
43
+ def from_intake(
44
+ cls,
45
+ intake: DreamQuestionIntake,
46
+ dream_summary: str = "",
47
+ dream_anchors: Optional[list[str]] = None,
48
+ followup_questions: Optional[list[str]] = None,
49
+ ) -> "DreamQAState":
50
+ return cls(
51
+ dream_summary=dream_summary,
52
+ main_question=intake.main_question,
53
+ dream_anchors=dream_anchors or [],
54
+ followup_questions=followup_questions or [],
55
+ current_step="ask" if followup_questions else "record",
56
+ )
57
+
58
+
59
+ class TodayTipCard(BaseModel):
60
+ dream_summary: str
61
+ main_question: str = ""
62
+ dream_anchors: list[str] = Field(default_factory=list)
63
+ followup_questions: list[str] = Field(default_factory=list)
64
+ user_answers: list[str] = Field(default_factory=list)
65
+ interpretation: str
66
+ today_tip: str
67
+ tiny_action: str = ""
68
+ caring_note: str = ""
69
+ safety_note: str = ""
70
+
71
+ def _combined_public_text(self) -> str:
72
+ return "\n".join(
73
+ [
74
+ self.dream_summary,
75
+ self.main_question,
76
+ self.interpretation,
77
+ self.today_tip,
78
+ self.tiny_action,
79
+ self.caring_note,
80
+ ]
81
+ ).lower()
82
+
83
+ def references_dream_anchor(self) -> bool:
84
+ text = self._combined_public_text()
85
+ for anchor in self.dream_anchors:
86
+ clean = anchor.strip().lower()
87
+ if not clean:
88
+ continue
89
+ if clean in text:
90
+ return True
91
+ tokens = [part for part in clean.replace(":", " ").replace(":", " ").split() if len(part) >= 2]
92
+ if any(token in text for token in tokens):
93
+ return True
94
+ for marker in ("电梯", "按钮", "楼", "桥", "水", "14", "melted", "elevator", "button", "floor"):
95
+ if marker in clean and marker in text:
96
+ return True
97
+ return False
98
+
99
+ def to_plain_text(self) -> str:
100
+ lines = [
101
+ "今日小 Tips",
102
+ f"梦境摘要: {self.dream_summary}",
103
+ f"想理解的问题: {self.main_question}",
104
+ f"梦境线索: {', '.join(self.dream_anchors)}",
105
+ f"解读草稿: {self.interpretation}",
106
+ f"今日小 Tips: {self.today_tip}",
107
+ ]
108
+ if self.tiny_action:
109
+ lines.append(f"没试过的小事: {self.tiny_action}")
110
+ if self.caring_note:
111
+ lines.append(f"关心一句: {self.caring_note}")
112
+ if self.safety_note:
113
+ lines.append(f"安全提示: {self.safety_note}")
114
+ return "\n".join(lines)
115
+
116
+
117
+ class VisionWitness(BaseModel):
118
+ scene_summary: str = ""
119
+ objects: list[str] = Field(default_factory=list)
120
+ visible_text: list[str] = Field(default_factory=list)
121
+ spatial_relations: list[str] = Field(default_factory=list)
122
+ mood_cues: list[str] = Field(default_factory=list)
123
+ uncertain_details: list[str] = Field(default_factory=list)
124
+ surprising_detail: str = ""
125
+
126
+ def to_visual_clues(self) -> list[str]:
127
+ clues: list[str] = []
128
+ if self.scene_summary.strip():
129
+ clues.append(f"Scene: {self.scene_summary.strip()}")
130
+ for label, values in [
131
+ ("Object", self.objects),
132
+ ("Visible text", self.visible_text),
133
+ ("Spatial relation", self.spatial_relations),
134
+ ("Mood cue", self.mood_cues),
135
+ ("Uncertain detail", self.uncertain_details),
136
+ ]:
137
+ clues.extend(f"{label}: {value.strip()}" for value in values if value.strip())
138
+ if self.surprising_detail.strip():
139
+ clues.append(f"Surprising detail: {self.surprising_detail.strip()}")
140
+ return clues[:12]
141
+
142
+
143
+ class DreamBrief(BaseModel):
144
+ anchors: list[str] = Field(default_factory=list)
145
+ emotional_hypothesis: str = ""
146
+ today_bridge: str = ""
147
+ visual_evidence: list[str] = Field(default_factory=list)
148
+ safety_flags: list[str] = Field(default_factory=list)
149
+ language: str = "en"
150
+
151
+
152
+ class PactCritique(BaseModel):
153
+ passes: bool = True
154
+ issues: list[str] = Field(default_factory=list)
155
+ rewrite_instruction: str = ""
156
+
157
+
158
  class PactCard(BaseModel):
159
  visitor_name: str
160
  permit_id: str
 
183
  return "\n".join(lines)
184
 
185
 
186
+ SessionPhase = Literal[
187
+ "empty",
188
+ "record",
189
+ "ask",
190
+ "interpret",
191
+ "tip",
192
+ "declaring",
193
+ "negotiating",
194
+ "drafting",
195
+ "sealed",
196
+ "error",
197
+ ]
198
  EvidenceType = Literal["text", "image", "audio", "mood"]
199
  EvidenceStatus = Literal["queued", "extracting", "extracted", "failed", "selected"]
200
+ TimelineRole = Literal["system", "user", "assistant", "interpretation", "tip", "customs", "pact", "error"]
201
 
202
 
203
  class EvidenceItem(BaseModel):
 
219
 
220
  class CustomsSession(BaseModel):
221
  phase: SessionPhase = "empty"
222
+ intake: DreamQuestionIntake = Field(default_factory=DreamQuestionIntake)
223
+ qa_state: DreamQAState = Field(default_factory=DreamQAState)
224
  evidence_items: list[EvidenceItem] = Field(default_factory=list)
225
  question_history: list[str] = Field(default_factory=list)
226
  answer_history: list[str] = Field(default_factory=list)
227
+ draft_tip: Optional[TodayTipCard] = None
228
+ sealed_tip: Optional[TodayTipCard] = None
229
  draft_pact: Optional[PactCard] = None
230
  sealed_pact: Optional[PactCard] = None
231
  safety_flags: list[str] = Field(default_factory=list)
 
236
 
237
  def evidence_count(self) -> int:
238
  return len([item for item in self.evidence_items if item.status != "failed"])
239
+
240
+ def model_dump(self, *args, **kwargs):
241
+ kwargs.setdefault("exclude_none", True)
242
+ return super().model_dump(*args, **kwargs)
dream_customs/ui/actions.py CHANGED
@@ -9,12 +9,13 @@ from dream_customs.pipeline import (
9
  answer_question,
10
  ask_questions,
11
  create_session,
12
- draft_pact,
13
  revise_pact,
14
  seal_pact,
15
  skip_question,
16
  )
17
- from dream_customs.schema import CustomsSession, PactCard
 
18
 
19
 
20
  def _state_json(session: CustomsSession) -> str:
@@ -34,73 +35,19 @@ def _trim_to_one_visible_question(session: CustomsSession, previous_count: int)
34
  visible_question = next_session.question_history[previous_count]
35
  next_session.question_history = next_session.question_history[:previous_count] + [visible_question]
36
  for event in reversed(next_session.events):
37
- if event.role == "customs" and event.status == "question":
38
- event.title = "Customs question filed"
39
  event.body = visible_question
40
  break
41
  return next_session
42
 
43
 
44
- def _card_plain_text(card: PactCard) -> str:
45
- contraband = ", ".join(card.contraband)
46
- parts = [
47
- "Today's Clearance Pass",
48
- f"Visitor: {card.visitor_name}",
49
- f"Permit ID: {card.permit_id}",
50
- f"Emotional contraband: {contraband}",
51
- f"Risk level: {card.risk_level}",
52
- f"Alliance reading: {card.alliance_reading}",
53
- f"Life tip for today: {card.practical_suggestion}",
54
- f"5-minute odd task: {card.weird_task}",
55
- f"Bedtime release: {card.bedtime_release}",
56
- ]
57
- if card.safety_note:
58
- parts.append(f"Support note: {card.safety_note}")
59
- return "\n".join(parts)
60
-
61
-
62
- def _render_today_pass(card: PactCard) -> str:
63
- contraband = "".join(f"<li>{escape(item)}</li>" for item in card.contraband)
64
- safety = (
65
- "<section class='dc-pass-safety'>"
66
- "<strong>Support note, if needed</strong>"
67
- f"<p>{escape(card.safety_note)}</p>"
68
- "</section>"
69
- if card.safety_note
70
- else ""
71
- )
72
- return f"""
73
- <article class="dc-pass-card">
74
- <div class="dc-pass-topline">
75
- <span>Today's Clearance Pass</span>
76
- <span>{escape(card.permit_id)}</span>
77
- </div>
78
- <h2>{escape(card.visitor_name)}</h2>
79
- <p class="dc-pass-risk">{escape(card.risk_level)}</p>
80
- <section>
81
- <h3>What it may be carrying</h3>
82
- <p>{escape(card.alliance_reading)}</p>
83
- </section>
84
- <section>
85
- <h3>Life tip for today</h3>
86
- <p>{escape(card.practical_suggestion)}</p>
87
- </section>
88
- <section>
89
- <h3>5-minute odd task</h3>
90
- <p>{escape(card.weird_task)}</p>
91
- </section>
92
- <section>
93
- <h3>Emotional contraband</h3>
94
- <ul>{contraband}</ul>
95
- </section>
96
- <section>
97
- <h3>Bedtime release</h3>
98
- <p>{escape(card.bedtime_release)}</p>
99
- </section>
100
- {safety}
101
- <div class="dc-pass-seal">SEALED / CLEARED</div>
102
- </article>
103
- """.strip()
104
 
105
 
106
  def _questions(session: CustomsSession) -> List[str]:
@@ -108,15 +55,15 @@ def _questions(session: CustomsSession) -> List[str]:
108
 
109
 
110
  def _view_payload(session: CustomsSession, text_backend: str, vision_backend: str, **settings) -> Dict[str, Any]:
111
- card = session.sealed_pact or session.draft_pact
112
  error = _latest_error(session)
113
- status = "error" if error else "card" if session.sealed_pact else "question" if session.question_history else "declaration"
114
  return {
115
  "status": status,
116
  "phase": session.phase,
117
  "question": _questions(session)[0] if _questions(session) else "",
118
  "questions": _questions(session),
119
- "card_title": "Today's Clearance Pass" if card else "",
120
  "card_text": _card_plain_text(card) if card else "",
121
  "card_html": _render_today_pass(card) if card else "",
122
  "error": error,
@@ -135,12 +82,12 @@ def _view(session: CustomsSession, text_backend: str, vision_backend: str, **set
135
 
136
  def _notice_for_status(status: str, error: str = "") -> str:
137
  if status == "error":
138
- return error or "Dream Customs has not received a fragment yet."
139
- if status == "question":
140
- return "Optional check-in: add one grounded detail, or skip straight to the pass."
141
- if status == "card":
142
- return "Today's pass is sealed. Treat it as a gentle action cue, not a diagnosis."
143
- return "Write a sentence, a few lines, or paste a dream fragment. Text always works."
144
 
145
 
146
  def initial_mobile_state(
@@ -208,13 +155,10 @@ def revise_card_action(
208
  **settings,
209
  ) -> Tuple[str, str]:
210
  session = _session_from_state(state)
211
- if session.sealed_pact and not session.draft_pact:
212
- session.draft_pact = session.sealed_pact
213
  text_client, _vision_client, _asr_client = _clients(text_backend, vision_backend, **settings)
214
- session = revise_pact(session, revision_request or "", text_client)
215
- if session.phase == "error":
216
- return _view(session, text_backend, vision_backend, **settings)
217
- session = seal_pact(session)
218
  return _view(session, text_backend, vision_backend, **settings)
219
 
220
 
@@ -228,7 +172,5 @@ def reset_mobile_action(
228
 
229
  def _seal_view(session: CustomsSession, text_backend: str, vision_backend: str, **settings) -> Tuple[str, str]:
230
  text_client, _vision_client, _asr_client = _clients(text_backend, vision_backend, **settings)
231
- session = draft_pact(session, text_client)
232
- if session.phase != "error":
233
- session = seal_pact(session)
234
  return _view(session, text_backend, vision_backend, **settings)
 
9
  answer_question,
10
  ask_questions,
11
  create_session,
12
+ finish_today_tip,
13
  revise_pact,
14
  seal_pact,
15
  skip_question,
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:
 
35
  visible_question = next_session.question_history[previous_count]
36
  next_session.question_history = next_session.question_history[:previous_count] + [visible_question]
37
  for event in reversed(next_session.events):
38
+ if event.role in {"assistant", "customs"} and event.status == "question":
39
+ event.title = "梦境助手追问"
40
  event.body = visible_question
41
  break
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]:
 
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,
 
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(
 
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
 
 
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)
dream_customs/ui/app.py CHANGED
@@ -87,7 +87,7 @@ VOICE_JS = r"""
87
  button.addEventListener("click", async () => {
88
  const Recognition = window.SpeechRecognition || window.webkitSpeechRecognition;
89
  if (!Recognition) {
90
- setStatus("This browser cannot transcribe speech directly yet. You can still type the dream fragment.", "error");
91
  textarea.focus();
92
  return;
93
  }
@@ -98,7 +98,7 @@ VOICE_JS = r"""
98
  stream.getTracks().forEach((track) => track.stop());
99
  }
100
  } catch (error) {
101
- setStatus("Microphone permission was not granted. Allow recording in the browser, then try again.", "error");
102
  return;
103
  }
104
 
@@ -111,7 +111,7 @@ VOICE_JS = r"""
111
  let latestTranscript = "";
112
 
113
  recognition.onstart = () => {
114
- setStatus("Listening. Speak the dream fragment when you are ready.", "listening");
115
  };
116
 
117
  recognition.onresult = (event) => {
@@ -120,23 +120,23 @@ VOICE_JS = r"""
120
  .join("")
121
  .trim();
122
  if (latestTranscript) {
123
- setStatus(`Recording: ${latestTranscript}`, "listening");
124
  }
125
  };
126
 
127
  recognition.onerror = (event) => {
128
  const message = event.error === "not-allowed"
129
- ? "Microphone permission was denied. Allow recording, then try again."
130
- : "I did not catch that clearly. Tap the mic once more if you want to retry.";
131
  setStatus(message, "error");
132
  };
133
 
134
  recognition.onend = () => {
135
  if (latestTranscript) {
136
  appendTranscript(latestTranscript);
137
- setStatus("Added to the dream fragment.", "done");
138
  } else if (button.dataset.mode === "listening") {
139
- setStatus("No speech detected. Tap the mic once more if you want to retry.", "idle");
140
  }
141
  };
142
 
@@ -155,7 +155,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": "The interface state could not be read. Please start a new declaration."}
159
 
160
 
161
  def _notice_html(view: dict) -> str:
@@ -167,17 +167,17 @@ def _notice_html(view: dict) -> str:
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>Optional question</span>{question}</p>"
171
  if question
172
  else ""
173
  )
174
  return f"""
175
  <div class="dc-question-card">
176
- <span class="dc-question-kicker">Optional check-in</span>
177
- <h2>Anything worth adding?</h2>
178
- <p>If it helps, name one real-world thing you want to make easier today, or describe how the dream left your body feeling.</p>
179
  {optional_question}
180
- <p class="dc-question-note">This step is optional. You can skip it and create the pass now.</p>
181
  </div>
182
  """.strip()
183
 
@@ -192,9 +192,9 @@ def _updates(state: str, view_json: str):
192
  _question_markdown(view),
193
  view.get("card_html", ""),
194
  view.get("card_text", ""),
195
- gr.update(visible=status in {"declaration", "error"}),
196
- gr.update(visible=status == "question"),
197
- gr.update(visible=status == "card"),
198
  json.dumps(view.get("debug", {}), ensure_ascii=False, indent=2),
199
  )
200
 
@@ -300,7 +300,7 @@ 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="Dream Customs") as demo:
304
  session_state = gr.State(initial_state)
305
  view_state = gr.State(initial_view)
306
 
@@ -311,24 +311,19 @@ def build_demo() -> gr.Blocks:
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 class="dc-passport-icon" aria-hidden="true">
315
- <span class="dc-cloud-dot one"></span>
316
- <span class="dc-cloud-dot two"></span>
317
- <span class="dc-cloud-dot three"></span>
318
- </div>
319
  <div>
320
- <p class="dc-brand-kicker">DREAM CUSTOMS</p>
321
  <h1>{APP_TITLE}</h1>
322
- <p class="dc-brand-subtitle">A gentle dream declaration desk</p>
323
  </div>
324
  </div>
325
- <div class="dc-clearance-badge" aria-hidden="true">
326
- <span>Dream Clearance</span>
327
- <strong>DC-2026-0606</strong>
328
- <small>Status: Ready</small>
329
- </div>
 
 
330
  </div>
331
- <p class="dc-hero-copy">{APP_SUBTITLE}</p>
332
  </header>
333
  """.strip()
334
  )
@@ -342,12 +337,12 @@ def build_demo() -> gr.Blocks:
342
  """
343
  <div class="dc-section-title">
344
  <span class="dc-title-icon">1</span>
345
- <strong>Describe your dream</strong>
346
  </div>
347
  """.strip()
348
  )
349
  dream_text = gr.Textbox(
350
- label="Dream declaration",
351
  placeholder=DREAM_PLACEHOLDER,
352
  lines=12,
353
  value="",
@@ -356,47 +351,47 @@ def build_demo() -> gr.Blocks:
356
  gr.HTML(
357
  """
358
  <div class="dc-mic-control">
359
- <button type="button" class="dc-mic-button" aria-label="Tap the microphone to dictate">
360
  <span class="dc-mic-glyph" aria-hidden="true"></span>
361
  </button>
362
- <div class="dc-mic-status" aria-live="polite">Tap the microphone to dictate</div>
363
  </div>
364
  """.strip()
365
  )
366
  audio_input = gr.State(None)
367
  gr.HTML(
368
  """
369
- <p class="dc-field-tip">Tips: include people, places, emotions, colors, and anything that stood out.</p>
370
  """.strip()
371
  )
372
  with gr.Row(elem_classes=["dc-submit-row"]):
373
- example_button = gr.Button("Try the sample →", variant="secondary")
374
- submit_button = gr.Button("Issue today's pass →", variant="primary")
375
  gr.HTML(f"<p class='dc-processing-note'>{escape(PROCESSING_NOTE)}</p>")
376
- with gr.Accordion("Additional material", open=False, elem_classes=["dc-attachment-drawer"]):
377
- image_input = gr.Image(label="Image clue", type="filepath", height=160)
378
 
379
  with gr.Group(visible=False, elem_classes=["dc-stage", "dc-question"]) as question_group:
380
  question_markdown = gr.HTML(_question_markdown(initial))
381
  answer_text = gr.Textbox(
382
- label="Your optional note",
383
  placeholder=ANSWER_PLACEHOLDER,
384
  lines=4,
385
  value="",
386
  )
387
  with gr.Row(elem_classes=["dc-question-actions"]):
388
- answer_button = gr.Button("Use this note", variant="primary")
389
- skip_button = gr.Button("Skip and create pass", variant="secondary")
390
 
391
  with gr.Group(visible=False, elem_classes=["dc-stage", "dc-card"]) as card_group:
392
  card_html = gr.HTML("")
393
  with gr.Row(elem_classes=["dc-actions"]):
394
- gentle_button = gr.Button("Softer", variant="secondary")
395
- weird_button = gr.Button("Stranger", variant="secondary")
396
- copy_button = gr.Button("Copy text", variant="secondary")
397
- reset_button = gr.Button("New declaration", variant="secondary")
398
  card_text = gr.Textbox(
399
- label="Copy-ready text",
400
  value="",
401
  lines=8,
402
  show_copy_button=True,
@@ -408,17 +403,17 @@ def build_demo() -> gr.Blocks:
408
  """
409
  <div class="dc-section-title">
410
  <span class="dc-title-icon">2</span>
411
- <strong>After waking feeling</strong>
412
  </div>
413
  """.strip()
414
  )
415
- mood = gr.Dropdown(label="After-waking feeling", choices=MOOD_OPTIONS, value=DEFAULT_MOOD)
416
  gr.HTML(
417
  """
418
  <div class="dc-side-stamp">
419
- <span>Dream Customs</span>
420
- <strong>Calm clearance</strong>
421
- <small>Gentle declarations</small>
422
  </div>
423
  """.strip()
424
  )
@@ -427,7 +422,7 @@ def build_demo() -> gr.Blocks:
427
  """
428
  <div class="dc-dev-help">
429
  <strong>For debugging only. Most people can leave this alone.</strong>
430
- <span>Auto mode uses the backend configured for this Space, then safely falls back to demo data when no endpoint is available.</span>
431
  </div>
432
  """.strip()
433
  )
 
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
  stream.getTracks().forEach((track) => track.stop());
99
  }
100
  } catch (error) {
101
+ setStatus("没有获得麦克风权限。允许浏览器录音后可以再试一次。", "error");
102
  return;
103
  }
104
 
 
111
  let latestTranscript = "";
112
 
113
  recognition.onstart = () => {
114
+ setStatus("正在听。准备好后说出梦境片段。", "listening");
115
  };
116
 
117
  recognition.onresult = (event) => {
 
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
  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:
 
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
 
 
192
  _question_markdown(view),
193
  view.get("card_html", ""),
194
  view.get("card_text", ""),
195
+ gr.update(visible=status in {"record", "error"}),
196
+ gr.update(visible=status == "ask"),
197
+ gr.update(visible=status == "tip"),
198
  json.dumps(view.get("debug", {}), ensure_ascii=False, indent=2),
199
  )
200
 
 
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
 
 
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
  )
 
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="",
 
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,
 
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
  )
 
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
  )
dream_customs/ui/copy.py CHANGED
@@ -1,15 +1,15 @@
1
- APP_TITLE = "Dream Customs"
2
- APP_SUBTITLE = "Turn last night's strange fragment into one gentle clearance pass for today."
3
  PROCESSING_NOTE = (
4
- "When you file a dream, the clerk reads the fragment and drafts a pass. "
5
- "Model-backed routes may take a few seconds; text-only fallback stays ready."
6
  )
7
 
8
- DREAM_PLACEHOLDER = "For example: I kept missing an elevator, and the buttons melted like wax."
9
- ANSWER_PLACEHOLDER = "Add one sentence if it helps, or leave this blank and skip ahead."
10
 
11
- EXAMPLE_DREAM = "I kept missing an elevator. The buttons melted like wax, and the floor number froze at 14."
12
- EXAMPLE_MOOD = "Uneasy"
13
 
14
- DEFAULT_MOOD = "Calm"
15
- MOOD_OPTIONS = ["Foggy", "Uneasy", "Curious", "Tired", "Calm"]
 
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 = ["一般", "焦虑", "迷糊", "好奇", "疲惫", "有点好笑"]
dream_customs/ui/styles.py CHANGED
@@ -1021,3 +1021,489 @@ a.built-with[href*="gradio.app"] {
1021
  }
1022
  }
1023
  """
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1021
  }
1022
  }
1023
  """
1024
+
1025
+ CSS += """
1026
+ :root {
1027
+ --dqa-mist: #f3f8f9;
1028
+ --dqa-paper: #fffdf8;
1029
+ --dqa-ink: #1e2a30;
1030
+ --dqa-muted: #6f7d86;
1031
+ --dqa-sage: #4f8a58;
1032
+ --dqa-sage-deep: #3f7446;
1033
+ --dqa-sage-soft: #e9f3e7;
1034
+ --dqa-amber: #f0b53d;
1035
+ --dqa-coral: #ef8b70;
1036
+ --dqa-line: #d9e2dc;
1037
+ --dqa-shadow: 0 18px 42px rgba(45, 56, 42, 0.12);
1038
+ }
1039
+
1040
+ html,
1041
+ body,
1042
+ .gradio-container {
1043
+ background:
1044
+ linear-gradient(180deg, rgba(243, 248, 249, 0.92), rgba(255, 253, 248, 0.98)),
1045
+ var(--dqa-mist) !important;
1046
+ color: var(--dqa-ink) !important;
1047
+ font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif !important;
1048
+ }
1049
+
1050
+ .dc-shell {
1051
+ margin: 0 auto 36px;
1052
+ max-width: 1440px;
1053
+ padding: 0 clamp(12px, 2.5vw, 28px);
1054
+ }
1055
+
1056
+ .dc-hero {
1057
+ background:
1058
+ linear-gradient(180deg, rgba(243, 249, 251, 0.78), rgba(255, 253, 248, 0.9)),
1059
+ url("https://images.unsplash.com/photo-1494438639946-1ebd1d20bf85?auto=format&fit=crop&w=1600&q=80") center 58% / cover;
1060
+ border: 0;
1061
+ border-radius: 0;
1062
+ box-shadow: none;
1063
+ color: var(--dqa-ink);
1064
+ min-height: clamp(250px, 34vw, 430px);
1065
+ padding: clamp(22px, 4vw, 42px) clamp(18px, 5vw, 70px);
1066
+ }
1067
+
1068
+ .dc-hero::after,
1069
+ .dc-stage::before,
1070
+ .dc-pass-card::after {
1071
+ display: none !important;
1072
+ }
1073
+
1074
+ .dc-hero-top {
1075
+ align-items: start;
1076
+ display: grid;
1077
+ grid-template-columns: 44px minmax(0, 1fr) 58px;
1078
+ }
1079
+
1080
+ .dc-menu-mark span {
1081
+ background: var(--dqa-ink);
1082
+ height: 4px;
1083
+ width: 36px;
1084
+ }
1085
+
1086
+ .dc-brand-lockup {
1087
+ justify-content: center;
1088
+ text-align: center;
1089
+ }
1090
+
1091
+ .dc-hero h1 {
1092
+ color: #121a1f !important;
1093
+ font-family: Inter, ui-sans-serif, system-ui, sans-serif !important;
1094
+ font-size: clamp(2rem, 5.2vw, 3.6rem);
1095
+ font-weight: 780;
1096
+ line-height: 1.05;
1097
+ margin: 0;
1098
+ }
1099
+
1100
+ .dc-brand-subtitle {
1101
+ color: #728495;
1102
+ font-size: clamp(1rem, 2.2vw, 1.55rem);
1103
+ font-weight: 450;
1104
+ margin: 8px 0 0;
1105
+ text-transform: none;
1106
+ }
1107
+
1108
+ .dc-sun-mark {
1109
+ color: var(--dqa-coral);
1110
+ font-size: clamp(2.4rem, 4vw, 4rem);
1111
+ line-height: 1;
1112
+ text-align: right;
1113
+ }
1114
+
1115
+ .dc-stepper {
1116
+ align-items: start;
1117
+ display: grid;
1118
+ gap: 0;
1119
+ grid-template-columns: repeat(4, minmax(0, 1fr));
1120
+ margin: clamp(28px, 5vw, 58px) auto 0;
1121
+ max-width: 920px;
1122
+ position: relative;
1123
+ }
1124
+
1125
+ .dc-stepper::before {
1126
+ background: #9cadb8;
1127
+ content: "";
1128
+ height: 3px;
1129
+ left: 12%;
1130
+ position: absolute;
1131
+ right: 12%;
1132
+ top: 25px;
1133
+ }
1134
+
1135
+ .dc-stepper span {
1136
+ color: #667989;
1137
+ display: grid;
1138
+ font-size: clamp(1rem, 2.3vw, 1.45rem);
1139
+ gap: 10px;
1140
+ justify-items: center;
1141
+ position: relative;
1142
+ z-index: 1;
1143
+ }
1144
+
1145
+ .dc-stepper strong {
1146
+ align-items: center;
1147
+ background: rgba(242, 247, 249, 0.86);
1148
+ border: 2px solid #fff;
1149
+ border-radius: 999px;
1150
+ box-shadow: 0 5px 16px rgba(50, 73, 82, 0.12);
1151
+ color: #263847;
1152
+ display: inline-flex;
1153
+ font-size: clamp(1.1rem, 2.4vw, 1.6rem);
1154
+ height: clamp(48px, 7vw, 70px);
1155
+ justify-content: center;
1156
+ width: clamp(48px, 7vw, 70px);
1157
+ }
1158
+
1159
+ .dc-stepper .is-active {
1160
+ color: var(--dqa-sage-deep);
1161
+ }
1162
+
1163
+ .dc-stepper .is-active strong {
1164
+ background: var(--dqa-sage);
1165
+ color: #fff;
1166
+ }
1167
+
1168
+ .dc-workspace-grid {
1169
+ display: grid !important;
1170
+ gap: 18px !important;
1171
+ grid-template-columns: minmax(0, 1fr) minmax(280px, 330px);
1172
+ margin-top: 0;
1173
+ }
1174
+
1175
+ .dc-stage,
1176
+ .dc-side-panel,
1177
+ .dc-dev {
1178
+ background: rgba(255, 253, 248, 0.94) !important;
1179
+ border: 1px solid var(--dqa-line) !important;
1180
+ border-radius: 12px !important;
1181
+ box-shadow: var(--dqa-shadow);
1182
+ }
1183
+
1184
+ .dc-stage {
1185
+ padding: clamp(18px, 3vw, 30px);
1186
+ }
1187
+
1188
+ .dc-composer {
1189
+ background: rgba(255, 255, 255, 0.72) !important;
1190
+ border: 1px solid var(--dqa-line) !important;
1191
+ border-radius: 14px !important;
1192
+ box-shadow: none;
1193
+ min-height: 420px;
1194
+ }
1195
+
1196
+ .dc-section-title {
1197
+ color: var(--dqa-ink);
1198
+ font-family: Inter, ui-sans-serif, system-ui, sans-serif;
1199
+ text-transform: none;
1200
+ }
1201
+
1202
+ .dc-title-icon {
1203
+ background: var(--dqa-sage);
1204
+ border: 0;
1205
+ color: #fff;
1206
+ font-size: 1rem;
1207
+ height: 42px;
1208
+ width: 42px;
1209
+ }
1210
+
1211
+ .dc-dream-text textarea {
1212
+ border-radius: 12px !important;
1213
+ font-size: 1.08rem !important;
1214
+ line-height: 1.7 !important;
1215
+ min-height: 270px !important;
1216
+ padding: 18px 18px 70px !important;
1217
+ }
1218
+
1219
+ .dc-field-tip,
1220
+ .dc-processing-note,
1221
+ .dc-question-note {
1222
+ color: var(--dqa-muted);
1223
+ }
1224
+
1225
+ .dc-processing-note::before {
1226
+ content: "";
1227
+ }
1228
+
1229
+ .dc-submit-row {
1230
+ gap: 16px !important;
1231
+ grid-template-columns: minmax(0, 0.75fr) minmax(0, 1.25fr);
1232
+ }
1233
+
1234
+ .dc-stage .primary button,
1235
+ .dc-stage button.primary,
1236
+ button.primary {
1237
+ background: linear-gradient(180deg, #5b995f, var(--dqa-sage-deep)) !important;
1238
+ border: 1px solid var(--dqa-sage-deep) !important;
1239
+ border-radius: 12px !important;
1240
+ box-shadow: 0 12px 24px rgba(63, 116, 70, 0.22);
1241
+ color: #fff !important;
1242
+ font-family: Inter, ui-sans-serif, system-ui, sans-serif !important;
1243
+ }
1244
+
1245
+ .dc-stage .secondary button,
1246
+ .dc-stage button.secondary,
1247
+ button.secondary {
1248
+ background: #fff !important;
1249
+ border: 1px solid var(--dqa-line) !important;
1250
+ border-radius: 12px !important;
1251
+ color: var(--dqa-sage-deep) !important;
1252
+ font-family: Inter, ui-sans-serif, system-ui, sans-serif !important;
1253
+ }
1254
+
1255
+ .dc-question-card {
1256
+ background: linear-gradient(180deg, #fffaf0, #fff6df);
1257
+ border: 1px solid #ead8aa;
1258
+ border-radius: 14px;
1259
+ }
1260
+
1261
+ .dc-question-card h2,
1262
+ .dc-question h2,
1263
+ .dc-card h2 {
1264
+ color: var(--dqa-ink) !important;
1265
+ font-family: Inter, ui-sans-serif, system-ui, sans-serif !important;
1266
+ font-size: clamp(1.55rem, 3vw, 2.25rem);
1267
+ }
1268
+
1269
+ .dc-question-kicker {
1270
+ color: var(--dqa-sage-deep);
1271
+ text-transform: none;
1272
+ }
1273
+
1274
+ .dc-question-original {
1275
+ border-left: 4px solid var(--dqa-amber);
1276
+ }
1277
+
1278
+ .dc-notice {
1279
+ background: rgba(233, 243, 231, 0.9);
1280
+ border: 1px solid rgba(79, 138, 88, 0.18);
1281
+ border-radius: 10px;
1282
+ color: var(--dqa-sage-deep);
1283
+ }
1284
+
1285
+ .dc-side-stamp {
1286
+ background: linear-gradient(180deg, #fff9e8, #fffdf8);
1287
+ border: 1px solid #ead8aa;
1288
+ border-radius: 12px;
1289
+ color: var(--dqa-ink);
1290
+ height: auto;
1291
+ line-height: 1.6;
1292
+ padding: 18px;
1293
+ transform: none;
1294
+ width: auto;
1295
+ }
1296
+
1297
+ .dc-side-stamp span,
1298
+ .dc-side-stamp small {
1299
+ color: var(--dqa-muted);
1300
+ text-transform: none;
1301
+ }
1302
+
1303
+ .dc-side-stamp strong {
1304
+ color: var(--dqa-ink);
1305
+ font-family: Inter, ui-sans-serif, system-ui, sans-serif;
1306
+ font-size: 1rem;
1307
+ }
1308
+
1309
+ .dqa-tip-page {
1310
+ background:
1311
+ linear-gradient(180deg, rgba(255, 253, 248, 0.96), rgba(255, 250, 240, 0.96));
1312
+ border-radius: 18px;
1313
+ color: var(--dqa-ink);
1314
+ display: grid;
1315
+ gap: 18px;
1316
+ line-height: 1.7;
1317
+ margin: 0 auto;
1318
+ max-width: 900px;
1319
+ }
1320
+
1321
+ .dqa-tip-hero {
1322
+ background:
1323
+ linear-gradient(90deg, rgba(255, 253, 248, 0.3), rgba(255, 253, 248, 0.92)),
1324
+ url("https://images.unsplash.com/photo-1494438639946-1ebd1d20bf85?auto=format&fit=crop&w=1200&q=80") center / cover;
1325
+ border-radius: 16px 16px 0 0;
1326
+ min-height: 190px;
1327
+ padding: 34px 42px;
1328
+ }
1329
+
1330
+ .dqa-sun {
1331
+ color: var(--dqa-amber);
1332
+ display: inline-block;
1333
+ font-size: 2rem;
1334
+ margin-right: 10px;
1335
+ }
1336
+
1337
+ .dqa-tip-hero h2 {
1338
+ color: var(--dqa-ink) !important;
1339
+ display: inline-block;
1340
+ font-size: clamp(2rem, 4vw, 3rem);
1341
+ margin: 0;
1342
+ }
1343
+
1344
+ .dqa-tip-hero p {
1345
+ color: var(--dqa-muted);
1346
+ font-size: 1.2rem;
1347
+ margin: 18px 0 0;
1348
+ }
1349
+
1350
+ .dqa-result-card {
1351
+ align-items: start;
1352
+ background: rgba(255, 255, 255, 0.9);
1353
+ border: 1px solid var(--dqa-line);
1354
+ border-radius: 18px;
1355
+ box-shadow: var(--dqa-shadow);
1356
+ display: grid;
1357
+ gap: 18px;
1358
+ grid-template-columns: 76px minmax(0, 1fr);
1359
+ margin: 0 42px;
1360
+ padding: 28px;
1361
+ }
1362
+
1363
+ .dqa-card-icon {
1364
+ align-items: center;
1365
+ background: var(--dqa-sage-soft);
1366
+ border-radius: 999px;
1367
+ color: var(--dqa-sage-deep);
1368
+ display: inline-flex;
1369
+ font-size: 2rem;
1370
+ height: 64px;
1371
+ justify-content: center;
1372
+ width: 64px;
1373
+ }
1374
+
1375
+ .dqa-result-card h3 {
1376
+ color: var(--dqa-ink) !important;
1377
+ font-size: clamp(1.5rem, 3vw, 2.15rem);
1378
+ line-height: 1.2;
1379
+ margin: 0 0 12px;
1380
+ }
1381
+
1382
+ .dqa-result-card p {
1383
+ color: var(--dqa-ink) !important;
1384
+ font-size: 1.08rem;
1385
+ margin: 0;
1386
+ }
1387
+
1388
+ .dqa-anchor-strip,
1389
+ .dqa-tip-highlight {
1390
+ background: linear-gradient(90deg, #edf5df, #fff7d9);
1391
+ border-radius: 12px;
1392
+ color: var(--dqa-sage-deep);
1393
+ font-weight: 680;
1394
+ margin-top: 18px;
1395
+ padding: 10px 14px;
1396
+ }
1397
+
1398
+ .dqa-safety-note,
1399
+ .dqa-care-note,
1400
+ .dqa-qa-history {
1401
+ background: #fff7df;
1402
+ border: 1px solid #efdfb7;
1403
+ border-radius: 12px;
1404
+ color: #745b2e;
1405
+ margin: 0 42px 0;
1406
+ padding: 14px 18px;
1407
+ }
1408
+
1409
+ .dqa-care-note {
1410
+ background: transparent;
1411
+ border: 0;
1412
+ color: var(--dqa-muted);
1413
+ margin-bottom: 28px;
1414
+ text-align: center;
1415
+ }
1416
+
1417
+ .dc-actions {
1418
+ grid-template-columns: repeat(4, minmax(0, 1fr));
1419
+ }
1420
+
1421
+ @media (max-width: 900px) {
1422
+ .dc-shell {
1423
+ padding: 0;
1424
+ }
1425
+
1426
+ .dc-hero {
1427
+ min-height: 52vh;
1428
+ padding: 24px 24px 30px;
1429
+ }
1430
+
1431
+ .dc-workspace-grid,
1432
+ .dc-submit-row,
1433
+ .dc-question-actions,
1434
+ .dc-actions {
1435
+ display: grid !important;
1436
+ grid-template-columns: 1fr !important;
1437
+ }
1438
+
1439
+ .dc-workspace-grid {
1440
+ padding: 0 16px 22px;
1441
+ }
1442
+
1443
+ .dc-side-panel {
1444
+ position: static;
1445
+ }
1446
+
1447
+ .dc-stepper {
1448
+ margin-top: 36px;
1449
+ }
1450
+
1451
+ .dc-stepper::before {
1452
+ left: 10%;
1453
+ right: 10%;
1454
+ top: 22px;
1455
+ }
1456
+
1457
+ .dc-stepper strong {
1458
+ height: 48px;
1459
+ width: 48px;
1460
+ }
1461
+
1462
+ .dc-stepper span {
1463
+ font-size: 1rem;
1464
+ }
1465
+
1466
+ .dc-stage {
1467
+ border-radius: 22px !important;
1468
+ margin-top: -18px;
1469
+ }
1470
+
1471
+ .dc-composer {
1472
+ min-height: auto;
1473
+ }
1474
+
1475
+ .dc-mic-control {
1476
+ bottom: 78px;
1477
+ right: 28px;
1478
+ }
1479
+
1480
+ .dqa-tip-page {
1481
+ border-radius: 0;
1482
+ }
1483
+
1484
+ .dqa-tip-hero {
1485
+ border-radius: 0 0 18px 18px;
1486
+ min-height: 180px;
1487
+ padding: 30px 28px;
1488
+ }
1489
+
1490
+ .dqa-result-card {
1491
+ grid-template-columns: 54px minmax(0, 1fr);
1492
+ margin: 0 18px;
1493
+ padding: 22px;
1494
+ }
1495
+
1496
+ .dqa-card-icon {
1497
+ font-size: 1.45rem;
1498
+ height: 48px;
1499
+ width: 48px;
1500
+ }
1501
+
1502
+ .dqa-safety-note,
1503
+ .dqa-care-note,
1504
+ .dqa-qa-history {
1505
+ margin-left: 18px;
1506
+ margin-right: 18px;
1507
+ }
1508
+ }
1509
+ """
scripts/evaluate_demo_quality.py ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ from pathlib import Path
3
+ from typing import Iterable
4
+
5
+
6
+ def evaluate_text(text: str, required_terms: Iterable[str], banned_terms: Iterable[str]) -> dict:
7
+ lowered = text.lower()
8
+ issues: list[str] = []
9
+ for term in required_terms:
10
+ if term.lower() not in lowered:
11
+ issues.append(f"missing required term: {term}")
12
+ for term in banned_terms:
13
+ if term.lower() in lowered:
14
+ issues.append(f"banned term: {term}")
15
+ if any(marker in lowered for marker in ["you have depression", "this means you will", "certainly predicts"]):
16
+ issues.append("unsafe certainty or diagnosis")
17
+ return {"passes": not issues, "issues": issues}
18
+
19
+
20
+ def load_cases(path: str = "tests/fixtures/demo_eval_cases.json") -> list[dict]:
21
+ return json.loads(Path(path).read_text(encoding="utf-8"))
22
+
23
+
24
+ def case_source_text(case: dict) -> str:
25
+ return " ".join(
26
+ part
27
+ for part in [
28
+ str(case.get("dream_text", "")),
29
+ str(case.get("voice_transcript", "")),
30
+ " ".join(str(clue) for clue in case.get("visual_clues", [])),
31
+ ]
32
+ if part.strip()
33
+ )
34
+
35
+
36
+ def evaluate_case(case: dict) -> dict:
37
+ result = evaluate_text(
38
+ case_source_text(case),
39
+ required_terms=case.get("required_terms", []),
40
+ banned_terms=case.get("banned_terms", []),
41
+ )
42
+ return {"id": case.get("id", ""), **result}
43
+
44
+
45
+ def evaluate_cases(cases: list[dict]) -> dict:
46
+ results = [evaluate_case(case) for case in cases]
47
+ failures = [result for result in results if not result["passes"]]
48
+ return {
49
+ "case_count": len(cases),
50
+ "ids": [case["id"] for case in cases],
51
+ "failures": failures,
52
+ "passes": not failures,
53
+ }
54
+
55
+
56
+ def main() -> int:
57
+ cases = load_cases()
58
+ report = evaluate_cases(cases)
59
+ print(json.dumps(report, indent=2))
60
+ return 0 if report["passes"] else 1
61
+
62
+
63
+ if __name__ == "__main__":
64
+ raise SystemExit(main())
tests/fixtures/demo_eval_cases.json ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [
2
+ {
3
+ "id": "elevator_wax_floor14",
4
+ "dream_text": "I kept missing an elevator. The buttons melted like wax, and the floor number froze at 14.",
5
+ "visual_clues": ["Visible text: 14", "Object: melted elevator button"],
6
+ "mood": "Uneasy",
7
+ "required_terms": ["elevator", "14"],
8
+ "banned_terms": ["the an", "the the", "lever", "diagnosis"]
9
+ },
10
+ {
11
+ "id": "customs_wet_paper",
12
+ "dream_text": "I was at a customs window carrying a suitcase full of wet paper. The clerk asked me to declare every unfinished promise before sunrise.",
13
+ "visual_clues": [],
14
+ "mood": "Foggy",
15
+ "required_terms": ["customs", "paper"],
16
+ "banned_terms": ["hydrate", "fruit", "prophecy", "diagnosis"]
17
+ },
18
+ {
19
+ "id": "exam_without_pencil",
20
+ "dream_text": "I arrived at an exam with no pencil, but everyone else was already writing.",
21
+ "visual_clues": [],
22
+ "mood": "Restless",
23
+ "required_terms": ["exam", "pencil"],
24
+ "banned_terms": ["medical", "fate", "the an"]
25
+ },
26
+ {
27
+ "id": "locked_green_room",
28
+ "dream_text": "I found a small green room behind my kitchen, but the door kept locking itself.",
29
+ "visual_clues": ["Color: green", "Object: small door"],
30
+ "mood": "Curious",
31
+ "required_terms": ["green", "door"],
32
+ "banned_terms": ["diagnosis", "warning sign", "the the"]
33
+ },
34
+ {
35
+ "id": "train_arrives_silent",
36
+ "dream_text": "A silent train arrived with my name written on a ticket, but I could not read the destination.",
37
+ "visual_clues": ["Visible text: user name on ticket"],
38
+ "mood": "Uneasy",
39
+ "required_terms": ["train", "ticket"],
40
+ "banned_terms": ["destiny", "prophecy", "the an"]
41
+ },
42
+ {
43
+ "id": "phone_full_of_water",
44
+ "dream_text": "My phone was full of water, but it kept ringing from inside a drawer.",
45
+ "visual_clues": [],
46
+ "mood": "Tired",
47
+ "required_terms": ["phone", "water"],
48
+ "banned_terms": ["hydration", "diagnosis", "the the"]
49
+ },
50
+ {
51
+ "id": "missing_shoes_stairwell",
52
+ "dream_text": "I was in a stairwell without shoes, holding a map that only showed blank squares.",
53
+ "visual_clues": ["Object: blank map", "Place: stairwell"],
54
+ "mood": "Foggy",
55
+ "required_terms": ["stairwell", "map"],
56
+ "banned_terms": ["fate", "medical", "the an"]
57
+ },
58
+ {
59
+ "id": "bedside_note_rain",
60
+ "dream_text": "A note on my bedside table said 'bring the rain inside', and the window was already open.",
61
+ "visual_clues": ["Visible text: bring the rain inside", "Object: open window"],
62
+ "mood": "Calm",
63
+ "required_terms": ["rain", "window"],
64
+ "banned_terms": ["diagnosis", "prophecy", "the the"]
65
+ },
66
+ {
67
+ "id": "voice_fragment_buttons",
68
+ "dream_text": "",
69
+ "voice_transcript": "The hallway was blue, and the elevator buttons were warm.",
70
+ "visual_clues": ["Color: blue"],
71
+ "mood": "Uneasy",
72
+ "required_terms": ["hallway", "buttons"],
73
+ "banned_terms": ["the an", "the the", "diagnosis"]
74
+ },
75
+ {
76
+ "id": "abstract_sketch_moon_key",
77
+ "dream_text": "I drew the dream because I only remember a moon-shaped key and a red square.",
78
+ "visual_clues": ["Object: moon-shaped key", "Color: red square"],
79
+ "mood": "Curious",
80
+ "required_terms": ["key", "red"],
81
+ "banned_terms": ["medical", "fate", "the an"]
82
+ }
83
+ ]
tests/test_app_logic.py CHANGED
@@ -23,24 +23,24 @@ def test_run_customs_once_generates_demo_outputs():
23
  )
24
  debug = json.loads(debug_json)
25
  assert "Visitor:" in negotiation
26
- assert "Dream visitor:" in pact_text
27
- assert "Today's Pact" in html
28
  assert debug["status"] == "ok"
29
  assert debug["intake"]["dream_text"] == "I dreamed of a late elevator."
30
 
31
 
32
  def test_run_customs_once_requires_one_modality():
33
  negotiation, pact_text, html, debug_json = run_customs_once(dream_text="")
34
- assert negotiation == "No declaration received."
35
- assert "Please add text" in pact_text
36
  assert html == ""
37
  assert json.loads(debug_json) == {"status": "empty"}
38
 
39
 
40
- def test_workbench_actions_progress_to_sealed_pact():
41
  state, _status, timeline, inspector, sealed_html, debug_json, _notice = initial_workbench_state()
42
- assert "Customs timeline" in timeline
43
- assert "No pact drafted yet" in inspector
44
  assert sealed_html == ""
45
  assert json.loads(debug_json)["status"] == "empty"
46
 
@@ -50,16 +50,12 @@ def test_workbench_actions_progress_to_sealed_pact():
50
  mood="curious",
51
  )
52
  debug = json.loads(debug_json)
53
- assert debug["status"] == "negotiating"
54
  assert debug["session"]["question_history"]
55
 
56
- state, _status, _timeline, inspector, _sealed_html, debug_json, _notice = draft_pact_action(state)
57
- assert json.loads(debug_json)["status"] == "drafting"
58
- assert "Pact inspector" in inspector
59
-
60
  state, _status, _timeline, _inspector, sealed_html, debug_json, _notice = seal_pact_action(state)
61
- assert json.loads(debug_json)["status"] == "sealed"
62
- assert "Today's Pact" in sealed_html
63
 
64
 
65
  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 "今日小 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
 
31
 
32
  def test_run_customs_once_requires_one_modality():
33
  negotiation, pact_text, html, debug_json = run_customs_once(dream_text="")
34
+ assert negotiation == "还没有收到梦境记录。"
35
+ assert "请先添加文字" in pact_text
36
  assert html == ""
37
  assert json.loads(debug_json) == {"status": "empty"}
38
 
39
 
40
+ def test_workbench_actions_progress_to_today_tip():
41
  state, _status, timeline, inspector, sealed_html, debug_json, _notice = initial_workbench_state()
42
+ assert "梦境问答流程" in timeline
43
+ assert "还没有生成今日小 Tips" in inspector
44
  assert sealed_html == ""
45
  assert json.loads(debug_json)["status"] == "empty"
46
 
 
50
  mood="curious",
51
  )
52
  debug = json.loads(debug_json)
53
+ assert debug["status"] == "ask"
54
  assert debug["session"]["question_history"]
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():
tests/test_demo_quality_eval.py ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from scripts.evaluate_demo_quality import evaluate_cases, evaluate_text, load_cases
2
+
3
+
4
+ def test_evaluate_text_passes_grounded_english_card():
5
+ text = (
6
+ "The Elevator Stuck at 14. The melted buttons and frozen floor 14 can stand for "
7
+ "one stalled beginning. Choose one task and write the next button-sized action."
8
+ )
9
+
10
+ result = evaluate_text(text, required_terms=["elevator", "14"], banned_terms=["the an", "the the"])
11
+
12
+ assert result["passes"]
13
+ assert result["issues"] == []
14
+
15
+
16
+ def test_evaluate_text_fails_repeated_articles_and_missing_anchor():
17
+ text = "Pick one real task that feels like the an elevator."
18
+
19
+ result = evaluate_text(text, required_terms=["floor 14"], banned_terms=["the an"])
20
+
21
+ assert not result["passes"]
22
+ assert "banned term: the an" in result["issues"]
23
+ assert "missing required term: floor 14" in result["issues"]
24
+
25
+
26
+ def test_load_cases_returns_ten_demo_eval_cases():
27
+ cases = load_cases()
28
+
29
+ assert len(cases) == 10
30
+ assert {case["id"] for case in cases} >= {"elevator_wax_floor14"}
31
+
32
+
33
+ def test_evaluate_cases_fails_when_case_required_terms_are_missing():
34
+ report = evaluate_cases(
35
+ [
36
+ {
37
+ "id": "bad_case",
38
+ "dream_text": "I saw a door.",
39
+ "visual_clues": [],
40
+ "mood": "Uneasy",
41
+ "required_terms": ["elevator"],
42
+ "banned_terms": [],
43
+ }
44
+ ]
45
+ )
46
+
47
+ assert not report["passes"]
48
+ assert report["failures"][0]["id"] == "bad_case"
49
+ assert "missing required term: elevator" in report["failures"][0]["issues"]
tests/test_dream_qa_refactor.py ADDED
@@ -0,0 +1,122 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+
3
+ from dream_customs.models import FakeTextClient
4
+ from dream_customs.pipeline import (
5
+ add_evidence,
6
+ answer_question,
7
+ ask_questions,
8
+ create_session,
9
+ finish_today_tip,
10
+ generate_today_tip,
11
+ )
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():
19
+ intake = DreamQuestionIntake(
20
+ dream_text="我梦到老楼里的电梯按钮融化了。",
21
+ main_question="这是不是和我最近拖延有关?",
22
+ )
23
+ state = DreamQAState.from_intake(
24
+ intake,
25
+ dream_summary="你梦见老楼里的电梯按钮融化。",
26
+ dream_anchors=["老楼", "电梯按钮融化"],
27
+ followup_questions=["梦里最强烈的是焦虑、荒诞,还是卡住?"],
28
+ )
29
+ card = TodayTipCard(
30
+ dream_summary=state.dream_summary,
31
+ main_question=state.main_question,
32
+ dream_anchors=state.dream_anchors,
33
+ followup_questions=state.followup_questions,
34
+ user_answers=["更像是卡住。"],
35
+ interpretation="也许这个梦把还没开始就担心来不及的感觉,演成了融化的电梯按钮。",
36
+ today_tip="今天先把一件事缩小成一个按钮大小的第一步。",
37
+ )
38
+
39
+ assert card.references_dream_anchor()
40
+ assert card.safety_note == ""
41
+ assert "permit_id" not in card.model_dump()
42
+ assert "contraband" not in card.model_dump()
43
+
44
+
45
+ def test_pipeline_progresses_record_ask_interpret_tip_without_pact_language():
46
+ session = add_evidence(
47
+ create_session(),
48
+ dream_text="我梦到老楼里的电梯按钮融化了,数字停在 14。",
49
+ mood="焦虑但有点滑稽",
50
+ )
51
+ assert session.phase == "record"
52
+
53
+ session = ask_questions(session, FakeTextClient())
54
+ assert session.phase == "ask"
55
+ assert session.question_history
56
+
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"
64
+ assert session.sealed_tip is not None
65
+ dumped = json.dumps(session.model_dump(mode="json"), ensure_ascii=False).lower()
66
+ assert "contraband" not in dumped
67
+ assert "sealed_pact" not in dumped
68
+
69
+
70
+ def test_render_today_tip_card_matches_new_public_contract():
71
+ card = TodayTipCard(
72
+ dream_summary="你梦见老楼里的电梯按钮融化,数字停在 14。",
73
+ main_question="它是否和最近拖延有关?",
74
+ dream_anchors=["电梯按钮融化", "数字 14"],
75
+ followup_questions=["梦里最强烈的感受是什么?"],
76
+ user_answers=["还没开始就觉得迟到。"],
77
+ interpretation="也许这个梦不是预言,而是在提醒你把开始这件事变小。",
78
+ today_tip="今天先按下一个很小的“电梯按钮”:只打开任务,不要求完成。",
79
+ tiny_action="用 5 分钟写下今天要停靠的一层。",
80
+ caring_note="你不需要一下子抵达所有楼层。",
81
+ )
82
+
83
+ html = render_today_tip_card(card)
84
+
85
+ assert "今日小 Tips" in html
86
+ assert "电梯按钮" in html
87
+ assert "诊断" in html
88
+ assert "permit" not in html.lower()
89
+ assert "contraband" not in html.lower()
90
+ assert "sealed" not in html.lower()
91
+
92
+
93
+ def test_mobile_action_payload_uses_today_tip_not_clearance_pass():
94
+ state, view_json = submit_dream_action(
95
+ dream_text="我梦到电梯按钮融化,楼层数字停在 14。",
96
+ mood="焦虑",
97
+ text_backend="demo",
98
+ vision_backend="demo",
99
+ )
100
+ view = json.loads(view_json)
101
+
102
+ assert view["status"] == "ask"
103
+ assert view["question"]
104
+
105
+ _state, view_json = skip_to_card_action(state, text_backend="demo", vision_backend="demo")
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
tests/test_model_led_pact.py ADDED
@@ -0,0 +1,199 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from dream_customs.models import FakeTextClient
2
+ from dream_customs.pipeline import build_intake, generate_model_led_pact
3
+ from dream_customs.prompts import (
4
+ dream_brief_prompt,
5
+ pact_critique_prompt,
6
+ pact_draft_prompt,
7
+ pact_rewrite_prompt,
8
+ )
9
+ from dream_customs.schema import DreamBrief, PactCard, PactCritique
10
+
11
+
12
+ def test_dream_brief_prompt_requires_english_demo_brief():
13
+ intake = build_intake(
14
+ dream_text="I kept missing an elevator. The buttons melted like wax, and floor 14 froze.",
15
+ visual_clues=["Visible text: 14", "Object: melted button"],
16
+ mood="Uneasy",
17
+ )
18
+
19
+ prompt = dream_brief_prompt(intake)
20
+
21
+ assert "English demo" in prompt
22
+ assert "anchors" in prompt
23
+ assert "visual_evidence" in prompt
24
+ assert "Do not diagnose" in prompt
25
+ assert "emotional_hypothesis" in prompt
26
+ assert "today_bridge" in prompt
27
+ assert "safety_flags" in prompt
28
+ assert "language" in prompt
29
+ assert '"en"' in prompt
30
+ assert "Return strict JSON" in prompt
31
+
32
+
33
+ def test_pact_draft_prompt_uses_brief_not_template_fallback():
34
+ brief = DreamBrief(
35
+ anchors=["elevator", "melted wax", "floor 14"],
36
+ emotional_hypothesis="The dream may be protecting a fear of getting stuck.",
37
+ today_bridge="Choose one stalled task and name the next small movement.",
38
+ visual_evidence=["Visible text: 14"],
39
+ )
40
+
41
+ prompt = pact_draft_prompt(brief, "I want to stop freezing before a task.")
42
+
43
+ assert "Write natural English" in prompt
44
+ assert "Use at least two anchors" in prompt
45
+ assert "Do not use template phrases" in prompt
46
+ assert "Use visual_evidence if present" in prompt
47
+ assert "elevator" in prompt
48
+ assert "visitor_name" in prompt
49
+ assert "permit_id" in prompt
50
+ assert "contraband" in prompt
51
+ assert "risk_level" in prompt
52
+ assert "alliance_reading" in prompt
53
+ assert "practical_suggestion" in prompt
54
+ assert "weird_task" in prompt
55
+ assert "bedtime_release" in prompt
56
+ assert "safety_note" in prompt
57
+
58
+
59
+ def test_pact_critique_prompt_checks_screenshot_regression():
60
+ brief = DreamBrief(
61
+ anchors=["elevator", "melted wax", "floor 14"],
62
+ emotional_hypothesis="The dream may be protecting a fear of getting stuck.",
63
+ today_bridge="Choose one stalled task and name the next small movement.",
64
+ visual_evidence=["Visible text: 14"],
65
+ )
66
+ card = PactCard(
67
+ visitor_name="The Elevator",
68
+ permit_id="DREAM20260608-014",
69
+ contraband=["stuck feeling"],
70
+ risk_level="low",
71
+ alliance_reading="The an elevator is trying to keep you from rushing.",
72
+ practical_suggestion="Take the the next small step.",
73
+ weird_task="Tap an imaginary floor 14 button.",
74
+ bedtime_release="The elevator can rest outside my room tonight.",
75
+ )
76
+
77
+ prompt = pact_critique_prompt(brief, card)
78
+
79
+ assert "the an" in prompt
80
+ assert "the the" in prompt
81
+ assert "invented details" in prompt
82
+ assert "natural English" in prompt
83
+ assert "passes" in prompt
84
+ assert "issues" in prompt
85
+ assert "rewrite_instruction" in prompt
86
+ assert "diagnosis" in prompt
87
+ assert "frightening certainty" in prompt
88
+ assert "generic wellness" in prompt
89
+ assert "missing anchors" in prompt
90
+
91
+
92
+ def test_pact_rewrite_prompt_uses_critique_instruction():
93
+ brief = DreamBrief(
94
+ anchors=["elevator", "melted wax", "floor 14"],
95
+ emotional_hypothesis="The dream may be protecting a fear of getting stuck.",
96
+ today_bridge="Choose one stalled task and name the next small movement.",
97
+ visual_evidence=["Visible text: 14"],
98
+ )
99
+ card = PactCard(
100
+ visitor_name="The Elevator",
101
+ permit_id="DREAM20260608-014",
102
+ contraband=["stuck feeling"],
103
+ risk_level="low",
104
+ alliance_reading="The an elevator is trying to keep you from rushing.",
105
+ practical_suggestion="Take the the next small step.",
106
+ weird_task="Tap an imaginary floor 14 button.",
107
+ bedtime_release="The elevator can rest outside my room tonight.",
108
+ )
109
+ critique = PactCritique(
110
+ passes=False,
111
+ issues=["repeated article"],
112
+ rewrite_instruction="Rewrite without repeated articles while preserving floor 14.",
113
+ )
114
+
115
+ prompt = pact_rewrite_prompt(brief, card, critique)
116
+
117
+ assert "Rewrite without repeated articles" in prompt
118
+ assert "Return strict JSON" in prompt
119
+ assert "floor 14" in prompt
120
+ assert "visitor_name" in prompt
121
+ assert "permit_id" in prompt
122
+ assert "contraband" in prompt
123
+ assert "risk_level" in prompt
124
+ assert "alliance_reading" in prompt
125
+ assert "practical_suggestion" in prompt
126
+ assert "weird_task" in prompt
127
+ assert "bedtime_release" in prompt
128
+ assert "safety_note" in prompt
129
+ assert "safe non-diagnostic" in prompt
130
+ assert "natural English" in prompt
131
+ assert "Do not add objects" in prompt
132
+
133
+
134
+ def test_fake_text_client_supports_model_led_methods():
135
+ client = FakeTextClient()
136
+
137
+ brief = client.generate_brief("brief prompt")
138
+ card = client.generate_pact_draft("draft prompt")
139
+ critique = client.critique_pact("critique prompt")
140
+
141
+ assert brief.language == "en"
142
+ assert brief.anchors
143
+ assert card.visitor_name
144
+ assert critique.passes
145
+
146
+
147
+ def test_model_led_pact_rewrites_screenshot_elevator_regression():
148
+ class CriticTextClient(FakeTextClient):
149
+ def generate_pact_draft(self, prompt):
150
+ return PactCard(
151
+ visitor_name="An Elevator",
152
+ permit_id="DREAM20260608-015",
153
+ contraband=["melted buttons", "froze at 14"],
154
+ risk_level="medium: handle gently, without treating it as a warning sign",
155
+ alliance_reading="I am safe, but the wax is sticky and the floor is cold.",
156
+ practical_suggestion="Pick one real task that feels like the an elevator.",
157
+ weird_task="I will try to push the floor button with my hand instead of the lever.",
158
+ bedtime_release="Tonight, the an elevator and the the button are logged.",
159
+ )
160
+
161
+ def critique_pact(self, prompt):
162
+ return PactCritique(
163
+ passes=False,
164
+ issues=["repeated article", "invented lever"],
165
+ rewrite_instruction="Rewrite without repeated articles or the invented lever.",
166
+ )
167
+
168
+ def rewrite_pact(self, prompt):
169
+ return PactCard(
170
+ visitor_name="The Elevator Stuck at 14",
171
+ permit_id="DREAM20260608-015",
172
+ contraband=["melted buttons", "floor 14", "the urge to freeze before starting"],
173
+ risk_level="medium: handle gently, without treating it as a warning sign",
174
+ alliance_reading=(
175
+ "The elevator, wax-soft buttons, and frozen 14 can be treated as a small scene "
176
+ "about getting stuck before the first move."
177
+ ),
178
+ practical_suggestion="Choose one stalled task and write only the next button-sized action.",
179
+ weird_task="Draw floor 14 on a sticky note, tap it once, and spend five minutes on the first step.",
180
+ bedtime_release=(
181
+ "Tonight, the elevator can stay on floor 14 while tomorrow's first button waits quietly."
182
+ ),
183
+ )
184
+
185
+ intake = build_intake(
186
+ dream_text="I kept missing an elevator. The buttons melted like wax, and the floor number froze at 14.",
187
+ visual_clues=["Visible text: 14", "Object: melted elevator button"],
188
+ mood="Uneasy",
189
+ )
190
+
191
+ card, html = generate_model_led_pact(intake, "", CriticTextClient())
192
+ joined = card.to_plain_text().lower()
193
+
194
+ assert "the an" not in joined
195
+ assert "the the" not in joined
196
+ assert "lever" not in joined
197
+ assert "floor 14" in joined
198
+ assert "melted" in joined or "wax" in joined
199
+ assert "Today's Clearance Pass" in html or "Today's Pact" in html
tests/test_ollama_models.py CHANGED
@@ -1,4 +1,11 @@
1
- from dream_customs.models import HostedMiniCPMTextClient, OllamaTextClient, OllamaVisionClient, _extract_json_object
 
 
 
 
 
 
 
2
 
3
 
4
  class StubOllamaTextClient(OllamaTextClient):
@@ -80,6 +87,42 @@ def test_ollama_vision_client_parses_visual_clues(tmp_path):
80
  assert clues == ["passport stamp", "customs desk", "blue", "foggy"]
81
 
82
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
83
  def test_hosted_text_client_parses_common_response_shape():
84
  client = StubHostedTextClient(
85
  {
@@ -98,3 +141,104 @@ def test_hosted_text_client_parses_common_response_shape():
98
  negotiation = client.generate_negotiation("dream")
99
  assert negotiation["visitor_name"] == "Gate 14"
100
  assert negotiation["questions"] == ["What does it ask?"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from dream_customs.models import (
2
+ HostedMiniCPMTextClient,
3
+ HostedMiniCPMVisionClient,
4
+ OllamaTextClient,
5
+ OllamaVisionClient,
6
+ _extract_json_object,
7
+ )
8
+ from dream_customs.schema import DreamBrief, PactCritique, VisionWitness
9
 
10
 
11
  class StubOllamaTextClient(OllamaTextClient):
 
87
  assert clues == ["passport stamp", "customs desk", "blue", "foggy"]
88
 
89
 
90
+ def test_ollama_vision_client_does_not_treat_legacy_flat_json_as_witness(tmp_path):
91
+ image_path = tmp_path / "dream.png"
92
+ image_path.write_bytes(b"not a real png but enough for adapter encoding")
93
+ client = StubOllamaVisionClient(
94
+ '{"objects":["passport stamp"],"places":["customs desk"],"colors":["blue"],"mood_cues":["foggy"]}'
95
+ )
96
+
97
+ witness = client.extract_witness(str(image_path))
98
+
99
+ assert witness.to_visual_clues() == []
100
+
101
+
102
+ def test_ollama_vision_client_preserves_flat_text_clues(tmp_path):
103
+ image_path = tmp_path / "dream.png"
104
+ image_path.write_bytes(b"not a real png but enough for adapter encoding")
105
+ client = StubOllamaVisionClient("passport stamp, customs desk, blue")
106
+
107
+ clues = client.extract_clues(str(image_path))
108
+
109
+ assert clues == ["passport stamp", "customs desk", "blue"]
110
+
111
+
112
+ def test_ollama_vision_client_accepts_partial_witness_json(tmp_path):
113
+ image_path = tmp_path / "dream.png"
114
+ image_path.write_bytes(b"not a real png but enough for adapter encoding")
115
+ client = StubOllamaVisionClient(
116
+ '{"objects":["elevator button"],"visible_text":["14"],"mood_cues":["cold"]}'
117
+ )
118
+
119
+ clues = client.extract_witness(str(image_path)).to_visual_clues()
120
+
121
+ assert "Object: elevator button" in clues
122
+ assert "Visible text: 14" in clues
123
+ assert "Mood cue: cold" in clues
124
+
125
+
126
  def test_hosted_text_client_parses_common_response_shape():
127
  client = StubHostedTextClient(
128
  {
 
141
  negotiation = client.generate_negotiation("dream")
142
  assert negotiation["visitor_name"] == "Gate 14"
143
  assert negotiation["questions"] == ["What does it ask?"]
144
+
145
+
146
+ def test_hosted_text_client_parses_model_led_brief():
147
+ class StubHostedBriefClient(HostedMiniCPMTextClient):
148
+ def _post_json(self, prompt, max_tokens=700):
149
+ return {
150
+ "response": (
151
+ '{"anchors":["elevator","melted wax","floor 14"],'
152
+ '"emotional_hypothesis":"The dream may be protecting a stuck feeling.",'
153
+ '"today_bridge":"Name one next movement.",'
154
+ '"visual_evidence":["Visible text: 14"],'
155
+ '"safety_flags":[],"language":"en"}'
156
+ )
157
+ }
158
+
159
+ brief = StubHostedBriefClient(endpoint="https://example.test").generate_brief("prompt")
160
+
161
+ assert isinstance(brief, DreamBrief)
162
+ assert brief.anchors == ["elevator", "melted wax", "floor 14"]
163
+ assert brief.language == "en"
164
+
165
+
166
+ def test_hosted_text_client_parses_pact_critique():
167
+ class StubHostedCritiqueClient(HostedMiniCPMTextClient):
168
+ def _post_json(self, prompt, max_tokens=700):
169
+ return {
170
+ "response": (
171
+ '{"passes":false,'
172
+ '"issues":["repeated article","missing floor 14"],'
173
+ '"rewrite_instruction":"Rewrite with elevator, wax, and floor 14."}'
174
+ )
175
+ }
176
+
177
+ critique = StubHostedCritiqueClient(endpoint="https://example.test").critique_pact("prompt")
178
+
179
+ assert isinstance(critique, PactCritique)
180
+ assert not critique.passes
181
+ assert critique.issues == ["repeated article", "missing floor 14"]
182
+ assert "floor 14" in critique.rewrite_instruction
183
+
184
+
185
+ def test_hosted_vision_client_parses_witness_report():
186
+ class StubHostedVisionWitnessClient(HostedMiniCPMVisionClient):
187
+ def _post_image(self, image_path):
188
+ return {
189
+ "response": (
190
+ '{"scene_summary":"A blue hallway ends at a stuck elevator.",'
191
+ '"objects":["elevator button","melted wax"],'
192
+ '"visible_text":["14"],'
193
+ '"spatial_relations":["button below frozen floor number"],'
194
+ '"mood_cues":["stuck","cold"],'
195
+ '"uncertain_details":["whether the wax is real"],'
196
+ '"surprising_detail":"The button looks melted rather than broken."}'
197
+ )
198
+ }
199
+
200
+ witness = StubHostedVisionWitnessClient(endpoint="https://example.test").extract_witness("demo.png")
201
+
202
+ assert isinstance(witness, VisionWitness)
203
+ assert witness.scene_summary == "A blue hallway ends at a stuck elevator."
204
+ assert "Visible text: 14" in witness.to_visual_clues()
205
+
206
+
207
+ def test_hosted_vision_client_preserves_flat_text_clues():
208
+ class StubHostedFlatTextVisionClient(HostedMiniCPMVisionClient):
209
+ def _post_image(self, image_path):
210
+ return {"response": "passport stamp, customs desk, blue"}
211
+
212
+ clues = StubHostedFlatTextVisionClient(endpoint="https://example.test").extract_clues("demo.png")
213
+
214
+ assert clues == ["passport stamp", "customs desk", "blue"]
215
+
216
+
217
+ def test_hosted_vision_client_does_not_treat_legacy_flat_json_as_witness():
218
+ class StubHostedLegacyFlatJsonVisionClient(HostedMiniCPMVisionClient):
219
+ def _post_image(self, image_path):
220
+ return {
221
+ "response": (
222
+ '{"objects":["passport stamp"],"places":["customs desk"],'
223
+ '"colors":["blue"],"mood_cues":["foggy"]}'
224
+ )
225
+ }
226
+
227
+ client = StubHostedLegacyFlatJsonVisionClient(endpoint="https://example.test")
228
+
229
+ assert client.extract_witness("demo.png").to_visual_clues() == []
230
+ assert client.extract_clues("demo.png") == ["passport stamp", "customs desk", "blue", "foggy"]
231
+
232
+
233
+ def test_hosted_vision_client_accepts_partial_witness_json():
234
+ class StubHostedPartialWitnessClient(HostedMiniCPMVisionClient):
235
+ def _post_image(self, image_path):
236
+ return {
237
+ "response": '{"objects":["elevator button"],"visible_text":["14"],"mood_cues":["cold"]}'
238
+ }
239
+
240
+ clues = StubHostedPartialWitnessClient(endpoint="https://example.test").extract_witness("demo.png").to_visual_clues()
241
+
242
+ assert "Object: elevator button" in clues
243
+ assert "Visible text: 14" in clues
244
+ assert "Mood cue: cold" in clues
tests/test_pipeline.py CHANGED
@@ -1,5 +1,7 @@
1
  from datetime import date
2
 
 
 
3
  from dream_customs.models import FakeASRClient, FakeTextClient, FakeVisionClient
4
  from dream_customs.pipeline import (
5
  add_evidence,
@@ -17,7 +19,7 @@ from dream_customs.pipeline import (
17
  skip_question,
18
  )
19
  from dream_customs.prompts import pact_prompt
20
- from dream_customs.schema import PactCard
21
 
22
 
23
  def test_dated_permit_id_uses_runtime_date_and_preserves_serial():
@@ -106,7 +108,7 @@ def test_generate_pact_polishes_unclear_model_output_into_daily_tip():
106
  intake = build_intake(dream_text="我梦见电梯按钮融化,数字停在 14。", mood="焦虑")
107
  card, html = generate_pact(intake, "", UnclearTextClient())
108
 
109
- assert card.visitor_name == "Night Visitor"
110
  assert "drink water" in card.practical_suggestion
111
  assert "电梯运行" not in card.practical_suggestion
112
  assert card.safety_note == ""
@@ -187,9 +189,9 @@ def test_add_evidence_updates_session_with_text_image_audio_and_mood():
187
  vision_client=FakeVisionClient(),
188
  asr_client=FakeASRClient(),
189
  )
190
- assert session.phase == "declaring"
191
  assert "elevator" in session.intake.dream_text
192
- assert "blue hallway" in session.intake.visual_clues
193
  assert "The buttons melted" in session.intake.voice_transcript
194
  assert session.intake.mood == "anxious"
195
  assert {item.type for item in session.evidence_items} == {"text", "image", "audio", "mood"}
@@ -204,7 +206,7 @@ def test_ask_answer_skip_draft_revise_and_seal_actions():
204
  asr_client=FakeASRClient(),
205
  )
206
  session = ask_questions(session, FakeTextClient())
207
- assert session.phase == "negotiating"
208
  assert len(session.question_history) == 3
209
 
210
  session = answer_question(session, "I want one small start.")
@@ -214,7 +216,7 @@ def test_ask_answer_skip_draft_revise_and_seal_actions():
214
  assert len(session.question_history) == 4
215
 
216
  session = skip_question(session)
217
- assert "skip" in session.answer_history[-1].lower()
218
 
219
  session = draft_pact(session, FakeTextClient())
220
  assert session.phase == "drafting"
@@ -230,6 +232,73 @@ def test_ask_answer_skip_draft_revise_and_seal_actions():
230
  assert session.sealed_pact == session.draft_pact
231
 
232
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
233
  def test_image_audio_failures_keep_text_path_alive():
234
  class FailingVision:
235
  def extract_clues(self, image_path):
@@ -253,3 +322,51 @@ def test_image_audio_failures_keep_text_path_alive():
253
  assert session.draft_pact is not None
254
  assert any(item.status == "failed" for item in session.evidence_items)
255
  assert "Text still works." in session.intake.dream_text
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  from datetime import date
2
 
3
+ import pytest
4
+
5
  from dream_customs.models import FakeASRClient, FakeTextClient, FakeVisionClient
6
  from dream_customs.pipeline import (
7
  add_evidence,
 
19
  skip_question,
20
  )
21
  from dream_customs.prompts import pact_prompt
22
+ from dream_customs.schema import PactCard, VisionWitness
23
 
24
 
25
  def test_dated_permit_id_uses_runtime_date_and_preserves_serial():
 
108
  intake = build_intake(dream_text="我梦见电梯按钮融化,数字停在 14。", mood="焦虑")
109
  card, html = generate_pact(intake, "", UnclearTextClient())
110
 
111
+ assert card.visitor_name
112
  assert "drink water" in card.practical_suggestion
113
  assert "电梯运行" not in card.practical_suggestion
114
  assert card.safety_note == ""
 
189
  vision_client=FakeVisionClient(),
190
  asr_client=FakeASRClient(),
191
  )
192
+ assert session.phase == "record"
193
  assert "elevator" in session.intake.dream_text
194
+ assert any("blue hallway" in clue for clue in session.intake.visual_clues)
195
  assert "The buttons melted" in session.intake.voice_transcript
196
  assert session.intake.mood == "anxious"
197
  assert {item.type for item in session.evidence_items} == {"text", "image", "audio", "mood"}
 
206
  asr_client=FakeASRClient(),
207
  )
208
  session = ask_questions(session, FakeTextClient())
209
+ assert session.phase == "ask"
210
  assert len(session.question_history) == 3
211
 
212
  session = answer_question(session, "I want one small start.")
 
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"
 
232
  assert session.sealed_pact == session.draft_pact
233
 
234
 
235
+ def test_draft_pact_falls_back_to_legacy_generate_pact_client():
236
+ class LegacyPactOnlyClient:
237
+ def generate_pact(self, prompt):
238
+ return PactCard(
239
+ visitor_name="Legacy Elevator",
240
+ permit_id="DC-014",
241
+ contraband=["melted buttons"],
242
+ risk_level="medium: handle gently, without treating it as a warning sign",
243
+ alliance_reading="The elevator can be treated as a request for one smaller move.",
244
+ practical_suggestion="Write one next step before touching the bigger task.",
245
+ weird_task="Draw a tiny elevator button and press it once.",
246
+ bedtime_release="Tonight, the elevator can wait quietly outside the room.",
247
+ )
248
+
249
+ session = add_evidence(
250
+ create_session(),
251
+ dream_text="I missed an elevator and the buttons melted.",
252
+ mood="Uneasy",
253
+ vision_client=FakeVisionClient(),
254
+ asr_client=FakeASRClient(),
255
+ )
256
+
257
+ session = draft_pact(session, LegacyPactOnlyClient())
258
+
259
+ assert session.phase == "drafting"
260
+ assert session.draft_pact is not None
261
+ assert session.draft_pact.visitor_name == "Legacy Elevator"
262
+
263
+
264
+ def test_draft_pact_exposes_internal_model_led_attribute_errors():
265
+ class BrokenModelLedClient:
266
+ def generate_brief(self, prompt):
267
+ raise AttributeError("internal model-led bug")
268
+
269
+ def generate_pact_draft(self, prompt):
270
+ raise AssertionError("generate_pact_draft should not run")
271
+
272
+ def critique_pact(self, prompt):
273
+ raise AssertionError("critique_pact should not run")
274
+
275
+ def rewrite_pact(self, prompt):
276
+ raise AssertionError("rewrite_pact should not run")
277
+
278
+ def generate_pact(self, prompt):
279
+ return PactCard(
280
+ visitor_name="Hidden Legacy Fallback",
281
+ permit_id="DC-999",
282
+ contraband=["hidden bug"],
283
+ risk_level="medium: handle gently, without treating it as a warning sign",
284
+ alliance_reading="This should not be generated when model-led internals fail.",
285
+ practical_suggestion="This fallback should stay unreachable.",
286
+ weird_task="This fallback should stay unreachable.",
287
+ bedtime_release="This fallback should stay unreachable.",
288
+ )
289
+
290
+ session = add_evidence(
291
+ create_session(),
292
+ dream_text="I missed an elevator and the buttons melted.",
293
+ mood="Uneasy",
294
+ vision_client=FakeVisionClient(),
295
+ asr_client=FakeASRClient(),
296
+ )
297
+
298
+ with pytest.raises(AttributeError, match="internal model-led bug"):
299
+ draft_pact(session, BrokenModelLedClient())
300
+
301
+
302
  def test_image_audio_failures_keep_text_path_alive():
303
  class FailingVision:
304
  def extract_clues(self, image_path):
 
322
  assert session.draft_pact is not None
323
  assert any(item.status == "failed" for item in session.evidence_items)
324
  assert "Text still works." in session.intake.dream_text
325
+
326
+
327
+ def test_add_evidence_prefers_vision_witness_report():
328
+ class WitnessPreferredVision:
329
+ def extract_witness(self, image_path):
330
+ return VisionWitness(
331
+ scene_summary="Witness scene",
332
+ visible_text=["14"],
333
+ surprising_detail="Witness detail",
334
+ )
335
+
336
+ def extract_clues(self, image_path):
337
+ return ["flat clue should not win"]
338
+
339
+ session = add_evidence(
340
+ create_session(),
341
+ dream_text="I saw an elevator.",
342
+ image_path="demo.png",
343
+ mood="Uneasy",
344
+ vision_client=WitnessPreferredVision(),
345
+ asr_client=FakeASRClient(),
346
+ )
347
+
348
+ assert "Scene: Witness scene" in session.intake.visual_clues
349
+ assert "Visible text: 14" in session.intake.visual_clues
350
+ assert "Surprising detail: Witness detail" in session.intake.visual_clues
351
+ assert "flat clue should not win" not in session.intake.visual_clues
352
+
353
+
354
+ def test_witness_failure_keeps_text_path_alive():
355
+ class BrokenWitnessVision:
356
+ def extract_witness(self, image_path):
357
+ raise RuntimeError("vision offline")
358
+
359
+ def extract_clues(self, image_path):
360
+ return ["fallback clue"]
361
+
362
+ intake = intake_from_modalities(
363
+ dream_text="Text still works.",
364
+ image_path="demo.png",
365
+ audio_path=None,
366
+ mood="Foggy",
367
+ vision_client=BrokenWitnessVision(),
368
+ asr_client=FakeASRClient(),
369
+ )
370
+
371
+ assert "Text still works." in intake.merged_text()
372
+ assert "fallback clue" in intake.merged_text()
tests/test_render.py CHANGED
@@ -61,9 +61,9 @@ def test_render_timeline_and_inspector_show_session_state():
61
  session = add_evidence(create_session(), dream_text="A blue gate opened.", mood="foggy")
62
  timeline = render_timeline(session)
63
  inspector = render_pact_inspector(session)
64
- assert "Customs timeline" in timeline
65
  assert "Dream note" in timeline
66
- assert "No pact drafted yet" in inspector
67
  assert "2 filed" in inspector
68
 
69
 
 
61
  session = add_evidence(create_session(), dream_text="A blue gate opened.", mood="foggy")
62
  timeline = render_timeline(session)
63
  inspector = render_pact_inspector(session)
64
+ assert "梦境问答流程" in timeline
65
  assert "Dream note" in timeline
66
+ assert "还没有生成今日小 Tips" in inspector
67
  assert "2 filed" in inspector
68
 
69
 
tests/test_schema.py CHANGED
@@ -52,3 +52,78 @@ def test_evidence_item_tracks_failure_state():
52
  assert item.type == "image"
53
  assert item.status == "failed"
54
  assert "No clues" in item.error
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
52
  assert item.type == "image"
53
  assert item.status == "failed"
54
  assert "No clues" in item.error
55
+
56
+
57
+ from dream_customs.schema import DreamBrief, PactCritique, VisionWitness
58
+
59
+
60
+ def test_vision_witness_flattens_report_into_demo_clues():
61
+ witness = VisionWitness(
62
+ scene_summary="A hand-drawn elevator panel is stuck on floor 14.",
63
+ objects=["elevator button", "wax"],
64
+ visible_text=["14"],
65
+ spatial_relations=["button below the frozen number"],
66
+ mood_cues=["stuck", "cold"],
67
+ uncertain_details=["whether the floor is a basement"],
68
+ surprising_detail="The buttons look melted rather than broken.",
69
+ )
70
+
71
+ clues = witness.to_visual_clues()
72
+
73
+ assert clues[0] == "Scene: A hand-drawn elevator panel is stuck on floor 14."
74
+ assert "Object: elevator button" in clues
75
+ assert "Visible text: 14" in clues
76
+ assert "Spatial relation: button below the frozen number" in clues
77
+ assert "Mood cue: stuck" in clues
78
+ assert "Uncertain detail: whether the floor is a basement" in clues
79
+ assert "Surprising detail: The buttons look melted rather than broken." in clues
80
+
81
+
82
+ def test_vision_witness_limits_visual_clues_for_demo():
83
+ witness = VisionWitness(
84
+ scene_summary="A long witness report.",
85
+ objects=[f"object {index}" for index in range(6)],
86
+ visible_text=[f"text {index}" for index in range(4)],
87
+ spatial_relations=["near the door", "under the clock"],
88
+ mood_cues=["bright", "cold"],
89
+ uncertain_details=["which room it is"],
90
+ surprising_detail="A tiny moon is taped to the wall.",
91
+ )
92
+
93
+ assert len(witness.to_visual_clues()) == 12
94
+
95
+
96
+ def test_dream_brief_carries_evidence_and_demo_language():
97
+ brief = DreamBrief(
98
+ anchors=["elevator", "melted wax", "floor 14"],
99
+ emotional_hypothesis="The dream may be protecting a fear of getting stuck.",
100
+ today_bridge="Choose one stalled task and name the next small movement.",
101
+ visual_evidence=["Visible text: 14"],
102
+ safety_flags=[],
103
+ language="en",
104
+ )
105
+
106
+ assert brief.language == "en"
107
+ assert "floor 14" in brief.anchors
108
+ assert brief.visual_evidence == ["Visible text: 14"]
109
+
110
+
111
+ def test_dream_brief_defaults_to_demo_language_and_empty_lists():
112
+ brief = DreamBrief()
113
+
114
+ assert brief.language == "en"
115
+ assert brief.anchors == []
116
+ assert brief.visual_evidence == []
117
+ assert brief.safety_flags == []
118
+
119
+
120
+ def test_pact_critique_flags_template_and_grammar_failures():
121
+ critique = PactCritique(
122
+ passes=False,
123
+ issues=["repeated article", "template fallback"],
124
+ rewrite_instruction="Rewrite in natural English using elevator, wax, and floor 14.",
125
+ )
126
+
127
+ assert not critique.passes
128
+ assert "repeated article" in critique.issues
129
+ assert "natural English" in critique.rewrite_instruction
tests/test_ui_actions.py CHANGED
@@ -25,8 +25,10 @@ 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 "clerk" in lowered
29
- assert "model" in lowered
 
 
30
  assert "token" not in lowered
31
  assert "endpoint" not in lowered
32
  assert "debug" not in lowered
@@ -64,7 +66,7 @@ def test_zerogpu_probe_is_importable_without_local_gpu():
64
  assert zerogpu_startup_probe() == {"status": "ok", "purpose": "zerogpu-startup-detection"}
65
 
66
 
67
- def test_mobile_mvp_submit_then_skip_auto_seals_pact():
68
  state, view_json = submit_dream_action(
69
  dream_text="I dreamed of a late elevator.",
70
  mood="Uneasy",
@@ -73,7 +75,7 @@ def test_mobile_mvp_submit_then_skip_auto_seals_pact():
73
  )
74
  view = json.loads(view_json)
75
 
76
- assert view["status"] == "question"
77
  assert view["question"]
78
  assert len(view["questions"]) == 1
79
  assert "DC-DEMO-014" not in view["card_text"]
@@ -81,15 +83,15 @@ def test_mobile_mvp_submit_then_skip_auto_seals_pact():
81
  state, view_json = skip_to_card_action(state)
82
  view = json.loads(view_json)
83
 
84
- assert view["status"] == "card"
85
- assert view["phase"] == "sealed"
86
- assert "Today's Clearance Pass" in view["card_title"]
87
- assert f"DREAM{date.today():%Y%m%d}-014" in view["card_text"]
88
  assert "DC-DEMO-014" not in view["card_text"]
89
- assert "Late Elevator" in view["card_html"]
90
 
91
 
92
- def test_mobile_mvp_answer_to_card_auto_seals_pact():
93
  state, _view_json = submit_dream_action(
94
  dream_text="I dreamed the elevator buttons melted and the elevator never came.",
95
  mood="Foggy",
@@ -105,6 +107,6 @@ def test_mobile_mvp_answer_to_card_auto_seals_pact():
105
  )
106
  view = json.loads(view_json)
107
 
108
- assert view["status"] == "card"
109
- assert view["phase"] == "sealed"
110
  assert "It may be asking me to slow down." in view["debug"]["session"]["answer_history"]
 
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
34
  assert "debug" not in lowered
 
66
  assert zerogpu_startup_probe() == {"status": "ok", "purpose": "zerogpu-startup-detection"}
67
 
68
 
69
+ def test_mobile_mvp_submit_then_skip_generates_today_tip():
70
  state, view_json = submit_dream_action(
71
  dream_text="I dreamed of a late elevator.",
72
  mood="Uneasy",
 
75
  )
76
  view = json.loads(view_json)
77
 
78
+ assert view["status"] == "ask"
79
  assert view["question"]
80
  assert len(view["questions"]) == 1
81
  assert "DC-DEMO-014" not in view["card_text"]
 
83
  state, view_json = skip_to_card_action(state)
84
  view = json.loads(view_json)
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
 
94
+ def test_mobile_mvp_answer_to_card_generates_today_tip():
95
  state, _view_json = submit_dream_action(
96
  dream_text="I dreamed the elevator buttons melted and the elevator never came.",
97
  mood="Foggy",
 
107
  )
108
  view = json.loads(view_json)
109
 
110
+ assert view["status"] == "tip"
111
+ assert view["phase"] == "tip"
112
  assert "It may be asking me to slow down." in view["debug"]["session"]["answer_history"]
tests/test_vision_witness.py ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from dream_customs.models import FakeASRClient, FakeVisionClient
2
+ from dream_customs.pipeline import intake_from_modalities
3
+ from dream_customs.prompts import visual_witness_prompt
4
+
5
+
6
+ def test_visual_witness_prompt_requests_structured_report():
7
+ prompt = visual_witness_prompt()
8
+
9
+ assert "MiniCPM-V-4.6" in prompt
10
+ assert "scene_summary" in prompt
11
+ assert "objects" in prompt
12
+ assert "visible_text" in prompt
13
+ assert "spatial_relations" in prompt
14
+ assert "mood_cues" in prompt
15
+ assert "uncertain_details" in prompt
16
+ assert "surprising_detail" in prompt
17
+ assert "strict JSON" in prompt
18
+ assert "Do not diagnose" in prompt
19
+
20
+
21
+ def test_fake_vision_client_returns_witness_report():
22
+ witness = FakeVisionClient().extract_witness("demo.png")
23
+
24
+ assert witness.scene_summary
25
+ assert "blue hallway" in " ".join(witness.to_visual_clues()).lower()
26
+
27
+
28
+ def test_intake_from_modalities_uses_vision_witness_clues():
29
+ intake = intake_from_modalities(
30
+ dream_text="I was waiting for an elevator.",
31
+ image_path="demo.png",
32
+ audio_path=None,
33
+ mood="Uneasy",
34
+ vision_client=FakeVisionClient(),
35
+ asr_client=FakeASRClient(),
36
+ )
37
+
38
+ merged = intake.merged_text().lower()
39
+
40
+ assert "scene:" in merged
41
+ assert "visible text: 14" in merged
42
+ assert "surprising detail" in merged