Deploy Dream Customs dream QA refactor
#16
by ADJCJH - opened
- .gitattributes +6 -0
- AGENTS.md +42 -24
- DESIGN.md +94 -90
- PRODUCT.md +13 -10
- README.md +36 -13
- design-qa.md +17 -14
- docs/design/2026-06-05-uiux-v2-brief.md +2 -0
- docs/goal-prompts/2026-06-08-model-led-pact-v2.md +23 -0
- docs/handoff.md +80 -61
- docs/prd.md +63 -57
- docs/prototypes/2026-06-08-dream-qa-desktop-workbench.png +3 -0
- docs/prototypes/2026-06-08-dream-qa-mobile-flow.png +3 -0
- docs/prototypes/2026-06-08-dream-qa-tips-card.png +3 -0
- docs/smoke/2026-06-08-dream-qa-refactor-smoke.md +90 -0
- docs/smoke/2026-06-08-model-led-pact-v2-smoke.md +60 -0
- docs/spec.md +110 -76
- docs/superpowers/plans/2026-06-08-dream-qa-refactor-doc-plan.md +202 -0
- docs/superpowers/plans/2026-06-08-dream-qa-refactor.md +195 -0
- docs/superpowers/plans/2026-06-08-model-led-pact-v2.md +1357 -0
- dream_customs/app_logic.py +16 -15
- dream_customs/models.py +334 -28
- dream_customs/pipeline.py +291 -28
- dream_customs/prompts.py +155 -15
- dream_customs/render.py +79 -16
- dream_customs/safety.py +1 -1
- dream_customs/schema.py +152 -4
- dream_customs/ui/actions.py +24 -82
- dream_customs/ui/app.py +49 -54
- dream_customs/ui/copy.py +10 -10
- dream_customs/ui/styles.py +486 -0
- scripts/evaluate_demo_quality.py +64 -0
- tests/fixtures/demo_eval_cases.json +83 -0
- tests/test_app_logic.py +10 -14
- tests/test_demo_quality_eval.py +49 -0
- tests/test_dream_qa_refactor.py +122 -0
- tests/test_model_led_pact.py +199 -0
- tests/test_ollama_models.py +145 -1
- tests/test_pipeline.py +123 -6
- tests/test_render.py +2 -2
- tests/test_schema.py +75 -0
- tests/test_ui_actions.py +14 -12
- 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
|
| 2 |
|
| 3 |
## 语言规则
|
| 4 |
|
|
@@ -7,9 +7,10 @@
|
|
| 7 |
|
| 8 |
## 项目定位
|
| 9 |
|
| 10 |
-
- 当前项目是 Build Small Hackathon 的 `Dream
|
| 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 |
-
- 所有输入统一进入
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
- 输出必须包含:
|
| 38 |
-
-
|
| 39 |
-
-
|
| 40 |
-
-
|
| 41 |
-
-
|
| 42 |
-
-
|
| 43 |
-
-
|
| 44 |
-
-
|
| 45 |
-
-
|
| 46 |
-
-
|
|
|
|
| 47 |
|
| 48 |
## 安全规则
|
| 49 |
|
| 50 |
- 不做心理疾病诊断。
|
| 51 |
- 不声称梦境有唯一含义。
|
| 52 |
- 不输出恐吓性、宿命论、神秘绝对化内容。
|
|
|
|
| 53 |
- 普通输出使用“也许”“可以把它当作”“今天先试试”这类非确定措辞。
|
| 54 |
- 如果用户表达自伤、伤人、长期严重失眠、无法正常生活或极强痛苦,必须显示寻求可信任的人或专业支持的提示。
|
| 55 |
|
|
@@ -57,11 +67,15 @@
|
|
| 57 |
|
| 58 |
实现前先阅读:
|
| 59 |
|
| 60 |
-
1. `docs/
|
| 61 |
-
2. `docs/
|
| 62 |
-
3. `docs/
|
| 63 |
-
4. `
|
| 64 |
-
5. `
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
-
|
|
|
|
| 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
|
| 3 |
-
description: A
|
| 4 |
colors:
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
accent-
|
| 14 |
-
|
| 15 |
-
|
| 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: "
|
| 22 |
fontWeight: 760
|
| 23 |
-
lineHeight: 1.
|
| 24 |
letterSpacing: "0"
|
| 25 |
headline:
|
| 26 |
fontFamily: "Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif"
|
| 27 |
-
fontSize: "1.
|
| 28 |
fontWeight: 720
|
| 29 |
-
lineHeight: 1.
|
| 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: "
|
| 48 |
-
lg: "
|
| 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.
|
| 60 |
rounded: "{rounded.md}"
|
| 61 |
padding: "12px 16px"
|
| 62 |
-
button-
|
| 63 |
-
backgroundColor: "{colors.
|
| 64 |
-
textColor: "{colors.
|
| 65 |
rounded: "{rounded.md}"
|
| 66 |
padding: "12px 16px"
|
| 67 |
input-composer:
|
| 68 |
-
backgroundColor: "{colors.surface-
|
| 69 |
-
textColor: "{colors.
|
| 70 |
rounded: "{rounded.lg}"
|
| 71 |
padding: "14px 16px"
|
| 72 |
---
|
| 73 |
|
| 74 |
-
# Design System: Dream
|
| 75 |
|
| 76 |
## 1. Overview
|
| 77 |
|
| 78 |
-
**Creative North Star: "The Morning
|
| 79 |
|
| 80 |
-
Dream
|
| 81 |
|
| 82 |
-
|
| 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 |
-
-
|
| 89 |
-
- Conversation timeline for
|
| 90 |
-
-
|
| 91 |
-
-
|
| 92 |
-
-
|
| 93 |
|
| 94 |
## 2. Colors
|
| 95 |
|
| 96 |
-
The palette is
|
| 97 |
|
| 98 |
### Primary
|
| 99 |
|
| 100 |
-
- **Sage
|
| 101 |
-
- **Deep Sage
|
| 102 |
|
| 103 |
### Secondary
|
| 104 |
|
| 105 |
-
- **
|
| 106 |
-
- **
|
| 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 |
-
- **
|
| 112 |
-
- **
|
| 113 |
-
- **
|
| 114 |
-
- **
|
| 115 |
-
- **
|
| 116 |
-
- **Muted Ink** (`oklch(0.760 0.018 78)`): secondary labels that still remain readable.
|
| 117 |
|
| 118 |
### Named Rules
|
| 119 |
|
| 120 |
-
**The
|
| 121 |
|
| 122 |
-
**The No
|
| 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 |
-
**
|
| 132 |
|
| 133 |
### Hierarchy
|
| 134 |
|
| 135 |
-
- **Display** (760,
|
| 136 |
-
- **Headline** (720, 1.
|
| 137 |
-
- **Title** (680, 1rem, 1.3): timeline item headers, buttons,
|
| 138 |
-
- **Body** (450, 1rem, 1.55): dream text, questions,
|
| 139 |
-
- **Label** (650, 0.8125rem, 1.25):
|
| 140 |
|
| 141 |
### Named Rules
|
| 142 |
|
| 143 |
-
**The
|
| 144 |
|
| 145 |
## 4. Elevation
|
| 146 |
|
| 147 |
-
Depth is
|
| 148 |
|
| 149 |
### Shadow Vocabulary
|
| 150 |
|
| 151 |
-
- **composer-focus** (`0 0 0 1px
|
| 152 |
-
- **
|
| 153 |
-
- **
|
| 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:**
|
| 164 |
-
- **Primary:** Deep
|
| 165 |
-
- **
|
| 166 |
-
- **
|
| 167 |
-
- **
|
| 168 |
|
| 169 |
### Chips
|
| 170 |
|
| 171 |
- **Style:** small rounded pills with icon, label, and state dot.
|
| 172 |
-
- **
|
|
|
|
| 173 |
|
| 174 |
### Cards / Containers
|
| 175 |
|
| 176 |
-
- **Corner Style:**
|
| 177 |
-
- **Background:**
|
| 178 |
-
- **Shadow Strategy:** flat by default, lifted only when active or
|
| 179 |
-
- **Border:** full
|
| 180 |
- **Internal Padding:** 16px mobile, 24px desktop.
|
| 181 |
|
| 182 |
### Inputs / Fields
|
| 183 |
|
| 184 |
-
- **Style:**
|
| 185 |
-
- **Focus:**
|
| 186 |
- **Error / Disabled:** inline message below composer, disabled actions stay readable and explain why.
|
| 187 |
|
| 188 |
### Navigation
|
| 189 |
|
| 190 |
-
- **Style:**
|
| 191 |
-
- **Mobile:**
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 192 |
|
| 193 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 194 |
|
| 195 |
-
The
|
| 196 |
|
| 197 |
## 6. Do's and Don'ts
|
| 198 |
|
| 199 |
### Do:
|
| 200 |
|
| 201 |
-
- **Do** make the
|
| 202 |
-
- **Do**
|
| 203 |
- **Do** keep every phase actionable with one clear next step.
|
| 204 |
-
- **Do**
|
| 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
|
| 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**
|
| 216 |
-
- **Don't**
|
| 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
|
| 10 |
|
| 11 |
## Product Purpose
|
| 12 |
|
| 13 |
-
|
| 14 |
|
| 15 |
-
Success means the user receives something more useful than a one-shot interpretation: a
|
| 16 |
|
| 17 |
## Brand Personality
|
| 18 |
|
| 19 |
Gentle, strange, lucid.
|
| 20 |
|
| 21 |
-
The voice is
|
| 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
|
|
|
|
| 28 |
- Do not bury the main action below many controls.
|
| 29 |
-
- Do not
|
|
|
|
| 30 |
|
| 31 |
## Design Principles
|
| 32 |
|
| 33 |
-
1. The AI is a
|
| 34 |
-
2. The user keeps agency:
|
| 35 |
3. The interface should be dream-colored but product-trustworthy.
|
| 36 |
-
4. Every output should lead to
|
| 37 |
-
5. The
|
|
|
|
| 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:
|
| 5 |
-
colorTo:
|
| 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:
|
| 13 |
models:
|
| 14 |
- openbmb/MiniCPM5-1B
|
| 15 |
- openbmb/MiniCPM-V-4.6
|
|
@@ -20,22 +20,45 @@ tags:
|
|
| 20 |
- dream-journal
|
| 21 |
---
|
| 22 |
|
| 23 |
-
# Dream
|
| 24 |
|
| 25 |
-
A Build Small Hackathon Gradio app that helps users
|
|
|
|
|
|
|
| 26 |
|
| 27 |
## Concept
|
| 28 |
|
| 29 |
-
Dream
|
|
|
|
|
|
|
| 30 |
|
| 31 |
## Models
|
| 32 |
|
| 33 |
- `openbmb/MiniCPM-V-4.6` for image/sketch/note understanding.
|
| 34 |
-
- `openbmb/MiniCPM5-1B` for dream
|
| 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
|
| 93 |
PY
|
| 94 |
```
|
| 95 |
|
|
@@ -116,12 +139,12 @@ python -m pytest -q
|
|
| 116 |
|
| 117 |
## Deployment Smoke Status
|
| 118 |
|
| 119 |
-
|
| 120 |
|
| 121 |
-
|
| 122 |
|
| 123 |
-
|
| 124 |
|
| 125 |
## Safety
|
| 126 |
|
| 127 |
-
This is not a therapy or diagnosis product. It gives playful reflection, small
|
|
|
|
| 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
|
| 2 |
|
| 3 |
-
Date: 2026-06-
|
| 4 |
|
| 5 |
-
Reference:
|
| 6 |
|
| 7 |
## Checks
|
| 8 |
|
| 9 |
-
- First screen
|
| 10 |
-
-
|
| 11 |
-
-
|
| 12 |
-
-
|
| 13 |
-
-
|
| 14 |
-
-
|
| 15 |
-
-
|
| 16 |
-
-
|
|
|
|
|
|
|
| 17 |
|
| 18 |
## Notes
|
| 19 |
|
| 20 |
-
-
|
| 21 |
-
-
|
|
|
|
| 22 |
- The UI keeps safety copy non-diagnostic.
|
| 23 |
|
| 24 |
-
|
|
|
|
| 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
|
| 2 |
|
| 3 |
-
Last updated: 2026-06-
|
| 4 |
|
| 5 |
-
## Current
|
| 6 |
|
| 7 |
-
The project
|
| 8 |
|
| 9 |
-
|
| 10 |
|
| 11 |
-
|
| 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 |
-
|
| 18 |
|
| 19 |
-
|
| 20 |
|
| 21 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
|
| 23 |
-
|
| 24 |
-
- `docs/dream-customs-concept/assets/multimodal-intake.svg`
|
| 25 |
-
- `docs/dream-customs-concept/assets/alliance-card.svg`
|
| 26 |
|
| 27 |
-
|
| 28 |
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
|
| 37 |
## Product Decision
|
| 38 |
|
| 39 |
-
Build
|
| 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,
|
| 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 `
|
| 71 |
-
5. MiniCPM5-1B
|
| 72 |
-
6.
|
| 73 |
-
7.
|
| 74 |
-
8.
|
|
|
|
|
|
|
| 75 |
|
| 76 |
## Required Output Fields
|
| 77 |
|
| 78 |
-
- `
|
| 79 |
-
- `
|
| 80 |
-
- `
|
| 81 |
-
- `
|
| 82 |
-
- `
|
| 83 |
-
- `
|
| 84 |
-
- `
|
| 85 |
-
- `
|
|
|
|
| 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
|
| 93 |
|
| 94 |
-
The
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 116 |
|
| 117 |
## Implementation Priorities
|
| 118 |
|
| 119 |
-
1.
|
| 120 |
-
2.
|
| 121 |
-
3.
|
| 122 |
-
4.
|
| 123 |
-
5.
|
| 124 |
-
6.
|
|
|
|
| 125 |
|
| 126 |
## Repository Status
|
| 127 |
|
| 128 |
-
This directory is
|
| 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
|
| 135 |
|
| 136 |
## Modal Backend Route
|
| 137 |
|
| 138 |
-
The intended production-quality route
|
| 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
|
| 150 |
|
| 151 |
## Open Questions For User
|
| 152 |
|
| 153 |
-
These do not block
|
| 154 |
|
| 155 |
-
- Should the
|
| 156 |
-
-
|
| 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
|
| 2 |
|
| 3 |
-
Last updated: 2026-06-
|
| 4 |
|
| 5 |
## 1. Executive Summary
|
| 6 |
|
| 7 |
### Problem Statement
|
| 8 |
|
| 9 |
-
Users who wake up from
|
| 10 |
|
| 11 |
### Proposed Solution
|
| 12 |
|
| 13 |
-
Dream
|
|
|
|
|
|
|
| 14 |
|
| 15 |
### Success Criteria
|
| 16 |
|
| 17 |
-
- A user can complete the core
|
| 18 |
-
- At least
|
| 19 |
-
-
|
| 20 |
-
-
|
|
|
|
|
|
|
| 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
|
| 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
|
| 42 |
|
| 43 |
-
As a vivid dreamer, I want to type my dream so that
|
| 44 |
|
| 45 |
Acceptance criteria:
|
| 46 |
|
| 47 |
- User can submit text-only input.
|
| 48 |
-
- App creates a valid
|
| 49 |
-
-
|
|
|
|
|
|
|
| 50 |
|
| 51 |
-
#### Story 2: Image Dream
|
| 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
|
| 59 |
-
- Visual clues are included in the final
|
| 60 |
- If image extraction fails, text-only flow still works.
|
| 61 |
|
| 62 |
-
#### Story 3: Voice Dream
|
| 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
|
| 71 |
- If ASR fails, user receives a concise fallback message and can type manually.
|
| 72 |
|
| 73 |
-
#### Story 4:
|
| 74 |
|
| 75 |
-
As a user, I want the AI to ask me a few questions so that the final
|
| 76 |
|
| 77 |
Acceptance criteria:
|
| 78 |
|
| 79 |
-
- App generates
|
| 80 |
-
- User can answer all, answer some, or
|
| 81 |
-
- Skip path still generates a final
|
|
|
|
| 82 |
|
| 83 |
-
#### Story 5: Today
|
| 84 |
|
| 85 |
-
As a user, I want a
|
| 86 |
|
| 87 |
Acceptance criteria:
|
| 88 |
|
| 89 |
-
-
|
| 90 |
-
-
|
| 91 |
-
-
|
| 92 |
-
-
|
| 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,
|
| 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
|
| 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
|
|
|
|
| 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 |
-
-
|
| 138 |
-
-
|
| 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 |
-
-
|
|
|
|
| 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 |
-
->
|
| 158 |
-
-> MiniCPM5-1B
|
| 159 |
-
->
|
| 160 |
-
->
|
| 161 |
-
->
|
|
|
|
|
|
|
| 162 |
```
|
| 163 |
|
| 164 |
### Core Components
|
| 165 |
|
| 166 |
-
- `app.py`: Gradio
|
| 167 |
-
- `dream_customs/schema.py`:
|
| 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
|
| 173 |
-
- `
|
|
|
|
| 174 |
|
| 175 |
### Integration Points
|
| 176 |
|
| 177 |
- Hugging Face Space runtime.
|
| 178 |
-
- Hugging Face model downloads or
|
| 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
|
| 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
|
| 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 |
-
-
|
| 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
|
docs/prototypes/2026-06-08-dream-qa-mobile-flow.png
ADDED
|
Git LFS Details
|
docs/prototypes/2026-06-08-dream-qa-tips-card.png
ADDED
|
Git LFS Details
|
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
|
| 2 |
|
| 3 |
-
Last updated: 2026-06-
|
| 4 |
|
| 5 |
## 1. One-Line Concept
|
| 6 |
|
| 7 |
-
梦境
|
| 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
|
| 16 |
-
- Secondary story: Backyard AI, because the concept comes from a real sleep problem: frequent vivid dreams and
|
| 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
|
| 22 |
|
| 23 |
## 4. Product Principles
|
| 24 |
|
| 25 |
1. Do not diagnose.
|
| 26 |
2. Do not frighten the user.
|
| 27 |
-
3.
|
| 28 |
-
4.
|
| 29 |
-
5.
|
| 30 |
-
6.
|
|
|
|
| 31 |
|
| 32 |
## 5. User Inputs
|
| 33 |
|
| 34 |
-
All inputs are normalized into one
|
| 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.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 91 |
-
- Do not provide psychological
|
| 92 |
|
| 93 |
### MiniCPM5-1B
|
| 94 |
|
| 95 |
-
Role: dream
|
| 96 |
|
| 97 |
Responsibilities:
|
| 98 |
|
| 99 |
- Merge dream text, voice transcript, and visual clues.
|
| 100 |
-
-
|
| 101 |
-
-
|
| 102 |
-
-
|
|
|
|
|
|
|
| 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 `
|
| 112 |
-
6. MiniCPM5-1B
|
| 113 |
-
7.
|
| 114 |
-
8.
|
| 115 |
-
9.
|
|
|
|
|
|
|
| 116 |
|
| 117 |
## 9. Output Contract
|
| 118 |
|
| 119 |
-
The final
|
| 120 |
|
| 121 |
-
- Dream
|
| 122 |
-
-
|
| 123 |
-
-
|
| 124 |
-
-
|
| 125 |
-
-
|
| 126 |
-
-
|
| 127 |
-
- One 5-minute weird task.
|
| 128 |
-
- One bedtime release phrase.
|
| 129 |
- Safety note if the input indicates severe distress.
|
| 130 |
|
| 131 |
-
|
| 132 |
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 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:
|
| 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 |
-
- "
|
| 161 |
|
| 162 |
-
### Screen 2:
|
| 163 |
|
| 164 |
Content:
|
| 165 |
|
| 166 |
-
-
|
| 167 |
-
-
|
| 168 |
-
-
|
|
|
|
| 169 |
|
| 170 |
-
### Screen 3:
|
| 171 |
|
| 172 |
Content:
|
| 173 |
|
| 174 |
-
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 175 |
- Copy text button.
|
| 176 |
-
-
|
| 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
|
| 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
|
| 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 |
-
-
|
| 201 |
-
-
|
|
|
|
| 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
|
| 218 |
|
| 219 |
2. Which track should the submission emphasize?
|
| 220 |
- Recommended answer: Thousand Token Wood first, Backyard AI second.
|
| 221 |
|
| 222 |
-
3.
|
| 223 |
-
- Recommended answer:
|
| 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 == "
|
| 222 |
-
return "<div class='dc-inline-notice is-sealed'>
|
| 223 |
if session.phase == "drafting":
|
| 224 |
-
return "<div class='dc-inline-notice'>
|
| 225 |
-
if session.phase
|
| 226 |
-
return "<div class='dc-inline-notice'>
|
| 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 =
|
| 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 |
-
|
| 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 |
-
"
|
| 401 |
-
"
|
| 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 "
|
| 417 |
-
card
|
|
|
|
| 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 |
-
"
|
| 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": "
|
| 31 |
"questions": [
|
| 32 |
-
"
|
| 33 |
-
"
|
| 34 |
-
"
|
| 35 |
],
|
| 36 |
-
"tone_note": "
|
| 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(
|
| 237 |
except (OSError, TimeoutError, urllib.error.URLError, json.JSONDecodeError):
|
| 238 |
-
return
|
| 239 |
|
| 240 |
text = str(response.get("response", ""))
|
| 241 |
-
|
| 242 |
-
if
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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 =
|
| 466 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 6 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 92 |
[
|
| 93 |
intake.dream_text,
|
| 94 |
intake.voice_transcript,
|
| 95 |
" ".join(intake.visual_clues),
|
| 96 |
" ".join(intake.recurring_symbols),
|
| 97 |
]
|
| 98 |
-
)
|
|
|
|
| 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)[:
|
| 120 |
|
| 121 |
|
| 122 |
def _primary_anchor(intake: DreamIntake) -> str:
|
| 123 |
anchors = _extract_dream_anchors(intake)
|
| 124 |
-
return anchors[0] if anchors else "
|
| 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"
|
| 278 |
-
"
|
| 279 |
)
|
| 280 |
|
| 281 |
|
| 282 |
def _grounded_followup_question(intake: DreamIntake) -> str:
|
| 283 |
primary = _primary_anchor(intake)
|
| 284 |
-
return f"
|
| 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=
|
| 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="
|
| 369 |
-
body="
|
| 370 |
status="ready",
|
| 371 |
)
|
| 372 |
]
|
|
@@ -438,7 +657,7 @@ def add_evidence(
|
|
| 438 |
clues: List[str] = []
|
| 439 |
error = ""
|
| 440 |
try:
|
| 441 |
-
clues =
|
| 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", "
|
| 501 |
)
|
| 502 |
return next_session
|
| 503 |
|
| 504 |
next_session.evidence_items.extend(added_items)
|
| 505 |
-
next_session.phase = "
|
|
|
|
| 506 |
summary = "\n".join(f"{item.label}: {item.content or item.error}" for item in added_items)
|
| 507 |
-
next_session.events.append(_event("user", "
|
| 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", "
|
| 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 = ["
|
| 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 = "
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 547 |
next_session.events.append(
|
| 548 |
_event(
|
| 549 |
-
"
|
| 550 |
-
"
|
| 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 = "
|
| 569 |
-
next_session.
|
|
|
|
| 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 = "
|
| 577 |
next_session.answer_history.append(skip_text)
|
| 578 |
-
next_session.phase = "
|
| 579 |
-
next_session.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
|
| 4 |
def visual_clue_prompt() -> str:
|
| 5 |
return (
|
| 6 |
-
"You are the witness
|
| 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
|
| 16 |
-
|
| 17 |
-
The tone should be
|
| 18 |
-
Ask questions that an ordinary person can understand without knowing
|
| 19 |
-
Prefer questions about
|
| 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
|
| 30 |
- questions: 2 or 3 gentle, specific, easy-to-understand questions
|
| 31 |
-
- tone_note: one sentence explaining the
|
| 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
|
| 38 |
Do not diagnose. Do not repeat previous questions.
|
| 39 |
-
The question must be plain and useful: ask what the user wants to
|
| 40 |
-
or whether
|
| 41 |
-
Do not use unclear metaphors about
|
| 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">
|
| 35 |
<div>
|
| 36 |
-
<h1>
|
| 37 |
-
<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="
|
| 69 |
-
body="
|
| 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
|
| 91 |
<div class="dc-timeline-head">
|
| 92 |
<div>
|
| 93 |
-
<span class="dc-section-kicker">
|
| 94 |
-
<h2>
|
| 95 |
-
<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="
|
| 113 |
-
<div class="dc-inspector-kicker">
|
| 114 |
-
<h2>
|
| 115 |
-
<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="
|
| 132 |
-
<div class="dc-inspector-kicker">
|
| 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
|
| 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
|
| 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[
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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:
|
|
|
|
| 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 |
-
|
| 13 |
revise_pact,
|
| 14 |
seal_pact,
|
| 15 |
skip_question,
|
| 16 |
)
|
| 17 |
-
from dream_customs.
|
|
|
|
| 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
|
| 38 |
-
event.title = "
|
| 39 |
event.body = visible_question
|
| 40 |
break
|
| 41 |
return next_session
|
| 42 |
|
| 43 |
|
| 44 |
-
def _card_plain_text(card:
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 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.
|
| 112 |
error = _latest_error(session)
|
| 113 |
-
status = "error" if error else "
|
| 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": "
|
| 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 "
|
| 139 |
-
if status == "
|
| 140 |
-
return "
|
| 141 |
-
if status == "
|
| 142 |
-
return "
|
| 143 |
-
return "
|
| 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.
|
| 212 |
-
session.
|
| 213 |
text_client, _vision_client, _asr_client = _clients(text_backend, vision_backend, **settings)
|
| 214 |
-
session =
|
| 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 =
|
| 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("
|
| 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("
|
| 102 |
return;
|
| 103 |
}
|
| 104 |
|
|
@@ -111,7 +111,7 @@ VOICE_JS = r"""
|
|
| 111 |
let latestTranscript = "";
|
| 112 |
|
| 113 |
recognition.onstart = () => {
|
| 114 |
-
setStatus("
|
| 115 |
};
|
| 116 |
|
| 117 |
recognition.onresult = (event) => {
|
|
@@ -120,23 +120,23 @@ VOICE_JS = r"""
|
|
| 120 |
.join("")
|
| 121 |
.trim();
|
| 122 |
if (latestTranscript) {
|
| 123 |
-
setStatus(`
|
| 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("
|
| 138 |
} else if (button.dataset.mode === "listening") {
|
| 139 |
-
setStatus("
|
| 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": "
|
| 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>
|
| 171 |
if question
|
| 172 |
else ""
|
| 173 |
)
|
| 174 |
return f"""
|
| 175 |
<div class="dc-question-card">
|
| 176 |
-
<span class="dc-question-kicker">
|
| 177 |
-
<h2>
|
| 178 |
-
<p>
|
| 179 |
{optional_question}
|
| 180 |
-
<p class="dc-question-note">
|
| 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 {"
|
| 196 |
-
gr.update(visible=status == "
|
| 197 |
-
gr.update(visible=status == "
|
| 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="
|
| 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">
|
| 323 |
</div>
|
| 324 |
</div>
|
| 325 |
-
<div class="dc-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
</
|
|
|
|
|
|
|
| 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>
|
| 346 |
</div>
|
| 347 |
""".strip()
|
| 348 |
)
|
| 349 |
dream_text = gr.Textbox(
|
| 350 |
-
label="
|
| 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="
|
| 360 |
<span class="dc-mic-glyph" aria-hidden="true"></span>
|
| 361 |
</button>
|
| 362 |
-
<div class="dc-mic-status" aria-live="polite">
|
| 363 |
</div>
|
| 364 |
""".strip()
|
| 365 |
)
|
| 366 |
audio_input = gr.State(None)
|
| 367 |
gr.HTML(
|
| 368 |
"""
|
| 369 |
-
<p class="dc-field-tip">
|
| 370 |
""".strip()
|
| 371 |
)
|
| 372 |
with gr.Row(elem_classes=["dc-submit-row"]):
|
| 373 |
-
example_button = gr.Button("
|
| 374 |
-
submit_button = gr.Button("
|
| 375 |
gr.HTML(f"<p class='dc-processing-note'>{escape(PROCESSING_NOTE)}</p>")
|
| 376 |
-
with gr.Accordion("
|
| 377 |
-
image_input = gr.Image(label="
|
| 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="
|
| 383 |
placeholder=ANSWER_PLACEHOLDER,
|
| 384 |
lines=4,
|
| 385 |
value="",
|
| 386 |
)
|
| 387 |
with gr.Row(elem_classes=["dc-question-actions"]):
|
| 388 |
-
answer_button = gr.Button("
|
| 389 |
-
skip_button = gr.Button("
|
| 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("
|
| 395 |
-
weird_button = gr.Button("
|
| 396 |
-
copy_button = gr.Button("
|
| 397 |
-
reset_button = gr.Button("
|
| 398 |
card_text = gr.Textbox(
|
| 399 |
-
label="
|
| 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>
|
| 412 |
</div>
|
| 413 |
""".strip()
|
| 414 |
)
|
| 415 |
-
mood = gr.Dropdown(label="
|
| 416 |
gr.HTML(
|
| 417 |
"""
|
| 418 |
<div class="dc-side-stamp">
|
| 419 |
-
<span>
|
| 420 |
-
<strong>
|
| 421 |
-
<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 |
-
|
| 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 = "
|
| 2 |
-
APP_SUBTITLE = "
|
| 3 |
PROCESSING_NOTE = (
|
| 4 |
-
"
|
| 5 |
-
"
|
| 6 |
)
|
| 7 |
|
| 8 |
-
DREAM_PLACEHOLDER = "
|
| 9 |
-
ANSWER_PLACEHOLDER = "
|
| 10 |
|
| 11 |
-
EXAMPLE_DREAM = "
|
| 12 |
-
EXAMPLE_MOOD = "
|
| 13 |
|
| 14 |
-
DEFAULT_MOOD = "
|
| 15 |
-
MOOD_OPTIONS = ["
|
|
|
|
| 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 "
|
| 27 |
-
assert "
|
| 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 "
|
| 36 |
assert html == ""
|
| 37 |
assert json.loads(debug_json) == {"status": "empty"}
|
| 38 |
|
| 39 |
|
| 40 |
-
def
|
| 41 |
state, _status, timeline, inspector, sealed_html, debug_json, _notice = initial_workbench_state()
|
| 42 |
-
assert "
|
| 43 |
-
assert "
|
| 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"] == "
|
| 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"] == "
|
| 62 |
-
assert "
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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 == "
|
| 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 == "
|
| 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 "
|
| 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 "
|
| 65 |
assert "Dream note" in timeline
|
| 66 |
-
assert "
|
| 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 "
|
| 29 |
-
assert "
|
|
|
|
|
|
|
| 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
|
| 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"] == "
|
| 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"] == "
|
| 85 |
-
assert view["phase"] == "
|
| 86 |
-
assert "
|
| 87 |
-
assert
|
| 88 |
assert "DC-DEMO-014" not in view["card_text"]
|
| 89 |
-
assert "
|
| 90 |
|
| 91 |
|
| 92 |
-
def
|
| 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"] == "
|
| 109 |
-
assert view["phase"] == "
|
| 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
|