Deploy Dream Customs app

#1
.gitignore ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .venv/
2
+ __pycache__/
3
+ .pytest_cache/
4
+ *.pyc
5
+ .DS_Store
6
+ .superpowers/
7
+ tmp/
8
+ outputs/
9
+ *.wav
10
+ *.mp3
11
+ *.m4a
AGENTS.md ADDED
@@ -0,0 +1,98 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # AGENTS.md - Dream Customs / 梦境海关
2
+
3
+ ## 语言规则
4
+
5
+ - 默认使用中文回复用户。
6
+ - 面向评审/README/Space 展示的文字可以中英双语,但不要牺牲中文表达的清晰度。
7
+
8
+ ## 项目定位
9
+
10
+ - 当前项目是 Build Small Hackathon 的 `Dream Customs / 梦境海关`。
11
+ - 核心定位:帮用户和昨晚的梦结盟,把不安转成第二天的小建议,把怪梦转成一件有趣的小事。
12
+ - 这不是心理诊断、医疗建议或治疗工具。
13
+
14
+ ## 竞赛约束
15
+
16
+ - 目标交付:Gradio app on Hugging Face Space。
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
+ - 首版不要实现语音输出,避免扩大部署风险。
30
+
31
+ ## 产品边界
32
+
33
+ - 必须支持文字输入。
34
+ - 应支持图片输入,用 MiniCPM-V-4.6 提取视觉线索。
35
+ - 应支持语音输入,通过小 ASR 适配器转成文本。
36
+ - 所有输入统一进入 `DreamIntake`,不要做成三套割裂产品。
37
+ - 输出必须包含:
38
+ - dream visitor / 梦境来访者
39
+ - permit id / 入境编号
40
+ - contraband / 携带情绪违禁品
41
+ - risk level / 风险等级
42
+ - alliance reading / 结盟解读
43
+ - practical suggestion / 今日认真建议
44
+ - weird task / 5 分钟怪趣任务
45
+ - bedtime release / 睡前放行仪式
46
+ - safety note / 必要时的安全提示
47
+
48
+ ## 安全规则
49
+
50
+ - 不做心理疾病诊断。
51
+ - 不声称梦境有唯一含义。
52
+ - 不输出恐吓性、宿命论、神秘绝对化内容。
53
+ - 普通输出使用“也许”“可以把它当作”“今天先试试”这类非确定措辞。
54
+ - 如果用户表达自伤、伤人、长期严重失眠、无法正常生活或极强痛苦,必须显示寻求可信任的人或专业支持的提示。
55
+
56
+ ## 文档优先级
57
+
58
+ 实现前先阅读:
59
+
60
+ 1. `docs/spec.md`
61
+ 2. `docs/prd.md`
62
+ 3. `docs/handoff.md`
63
+ 4. `docs/superpowers/plans/2026-06-05-dream-customs-mvp.md`
64
+ 5. `docs/dream-customs-concept/index.html`
65
+
66
+ ## 开发流程
67
+
68
+ - 修改或实验前先确认工作目录是 `/Users/junhaocheng/working-dir/ai-competitions/build-small-hackthon`。
69
+ - 当前目录可能位于父级 `vlarepo` git root 下;不要误推到 VLA remote。
70
+ - 如果需要正式提交,先确认是否已初始化/切换到 Dream Customs 专用仓库。
71
+ - 在仓库归属未确认前,可以本地写文件,但不要强行 `git add` / `git commit` / `git push` 到父级 VLA 仓库。
72
+ - 如果建立新功能分支,使用 `feature/xxx` 命名。
73
+
74
+ ## 实现原则
75
+
76
+ - 先用 fake clients 打通 schema、safety、render、pipeline 和 Gradio UI。
77
+ - 核心测试通过后再接真实 MiniCPM 模型。
78
+ - 所有模型输出都必须经过 schema 校验。
79
+ - JSON 解析失败时必须有修复或降级路径。
80
+ - Text-only path 必须永远可用,作为 demo fallback。
81
+ - Gradio 页面应优先移动端可用,因为用户可能在手机上记录梦。
82
+
83
+ ## 验收命令
84
+
85
+ 基础验收:
86
+
87
+ ```bash
88
+ python -m pytest -q
89
+ python app.py
90
+ ```
91
+
92
+ 人工验收:
93
+
94
+ - 输入一段文字梦境,能生成盟约卡。
95
+ - 上传一张草图或便签,视觉线索进入 `DreamIntake`。
96
+ - 上传/录制语音后,有转写或清晰降级提示。
97
+ - 输出不包含诊断、恐吓或医疗化建议。
98
+ - 页面在手机宽度下可读。
README.md CHANGED
@@ -4,12 +4,71 @@ emoji: ⚡
4
  colorFrom: blue
5
  colorTo: pink
6
  sdk: gradio
7
- sdk_version: 6.16.0
8
- python_version: '3.13'
9
  app_file: app.py
10
  pinned: false
11
  license: mit
12
- short_description: A playful, non-diagnostic dream customs clerk that turns dre
 
 
 
 
 
 
 
 
13
  ---
14
 
15
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4
  colorFrom: blue
5
  colorTo: pink
6
  sdk: gradio
7
+ sdk_version: 4.44.1
8
+ python_version: "3.10"
9
  app_file: app.py
10
  pinned: false
11
  license: mit
12
+ short_description: Turn dream declarations into a playful next-day pact.
13
+ models:
14
+ - openbmb/MiniCPM5-1B
15
+ - openbmb/MiniCPM-V-4.6
16
+ tags:
17
+ - gradio
18
+ - minicpm
19
+ - build-small-hackathon
20
+ - dream-journal
21
  ---
22
 
23
+ # Dream Customs / 梦境海关
24
+
25
+ A Build Small Hackathon Gradio app that helps users form a playful alliance with last night's dream.
26
+
27
+ ## Concept
28
+
29
+ Dream Customs accepts dream declarations by text, image, or voice. It turns the dream into a gentle "customs negotiation" and returns a Today's Pact card: one practical suggestion, one weird 5-minute task, and one bedtime release phrase.
30
+
31
+ ## Models
32
+
33
+ - `openbmb/MiniCPM-V-4.6` for image/sketch/note understanding.
34
+ - `openbmb/MiniCPM5-1B` for dream negotiation and pact generation.
35
+ - A small ASR adapter may be used only for voice transcription.
36
+ - The app defaults to a stable demo backend so the local Gradio flow always works.
37
+ - Optional Ollama adapters are included for local MiniCPM testing.
38
+
39
+ ## Run
40
+
41
+ ```bash
42
+ python3 -m venv .venv
43
+ source .venv/bin/activate
44
+ python -m pip install -r requirements.txt
45
+ python app.py
46
+ ```
47
+
48
+ Open `http://127.0.0.1:7860`.
49
+
50
+ ## Optional Ollama Models
51
+
52
+ ```bash
53
+ ollama pull hf.co/openbmb/MiniCPM5-1B-GGUF:Q8_0
54
+ ollama pull openbmb/minicpm-v4.6
55
+ ```
56
+
57
+ Then switch the UI engine controls from `demo` to `ollama`.
58
+
59
+ Local smoke notes from this Mac mini:
60
+
61
+ - Memory/size is fine: 16 GB RAM handled the local model downloads.
62
+ - `hf.co/openbmb/MiniCPM5-1B-GGUF:Q8_0` loads in Ollama, but current output was malformed for JSON prompts.
63
+ - `openbmb/minicpm-v4.6` pulled successfully, but current Ollama runner returned `unable to load model`.
64
+ - Because of that, the MVP keeps Ollama optional and falls back to deterministic demo behavior.
65
+
66
+ ## Test
67
+
68
+ ```bash
69
+ python -m pytest -q
70
+ ```
71
+
72
+ ## Safety
73
+
74
+ This is not a therapy or diagnosis product. It gives playful reflection, small actions, and escalation copy for severe distress.
app.py ADDED
@@ -0,0 +1,133 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import gradio as gr
2
+ from gradio_client import utils as gradio_client_utils
3
+
4
+ from dream_customs.app_logic import run_customs_once
5
+
6
+
7
+ _ORIGINAL_SCHEMA_TO_TYPE = gradio_client_utils._json_schema_to_python_type
8
+
9
+
10
+ def _json_schema_to_python_type(schema, defs):
11
+ # Gradio 4.44 can pass JSON Schema booleans here on newer dependency sets.
12
+ if isinstance(schema, bool):
13
+ return "Any" if schema else "None"
14
+ return _ORIGINAL_SCHEMA_TO_TYPE(schema, defs)
15
+
16
+
17
+ gradio_client_utils._json_schema_to_python_type = _json_schema_to_python_type
18
+
19
+
20
+ CSS = """
21
+ body { background: #f6f1e7; }
22
+ .gradio-container {
23
+ color: #17272f;
24
+ font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
25
+ }
26
+ .dc-shell {
27
+ max-width: 1180px;
28
+ margin: 0 auto;
29
+ }
30
+ .dc-title h1 {
31
+ font-size: clamp(30px, 5vw, 56px);
32
+ line-height: 1;
33
+ margin-bottom: 4px;
34
+ }
35
+ .dc-title p {
36
+ color: #49626b;
37
+ font-size: 16px;
38
+ margin-top: 0;
39
+ }
40
+ .dc-stamp button {
41
+ background: #17313b !important;
42
+ border: 0 !important;
43
+ color: #fff8ed !important;
44
+ font-weight: 800 !important;
45
+ }
46
+ textarea, input, select {
47
+ font-size: 16px !important;
48
+ }
49
+ @media (max-width: 760px) {
50
+ .dc-title h1 { font-size: 34px; }
51
+ .dc-title p { font-size: 14px; }
52
+ }
53
+ """
54
+
55
+
56
+ def build_demo() -> gr.Blocks:
57
+ with gr.Blocks(css=CSS, title="Dream Customs") as demo:
58
+ with gr.Column(elem_classes=["dc-shell"]):
59
+ gr.Markdown(
60
+ """
61
+ # Dream Customs / 梦境海关
62
+ 夜间来访者入境申报处
63
+ """,
64
+ elem_classes=["dc-title"],
65
+ )
66
+ with gr.Row():
67
+ with gr.Column(scale=5):
68
+ dream_text = gr.Textbox(
69
+ label="Dream declaration",
70
+ lines=8,
71
+ value="我梦见一部迟到的电梯,按钮都融化了,我一直到不了 14 楼。",
72
+ )
73
+ with gr.Row():
74
+ image_input = gr.Image(label="Image evidence", type="filepath")
75
+ audio_input = gr.Audio(label="Voice evidence", type="filepath")
76
+ mood = gr.Dropdown(
77
+ label="Current weather",
78
+ choices=["foggy", "anxious", "curious", "tired", "restless", "calm"],
79
+ value="foggy",
80
+ )
81
+ answers = gr.Textbox(
82
+ label="Answers to the clerk",
83
+ lines=4,
84
+ value="我想和它结盟,但今天只想完成一件很小的事。",
85
+ )
86
+ with gr.Row():
87
+ text_backend = gr.Radio(
88
+ label="Text engine",
89
+ choices=["demo", "ollama"],
90
+ value="demo",
91
+ )
92
+ vision_backend = gr.Radio(
93
+ label="Vision engine",
94
+ choices=["demo", "ollama"],
95
+ value="demo",
96
+ )
97
+ submit = gr.Button("Stamp clearance", elem_classes=["dc-stamp"])
98
+
99
+ with gr.Column(scale=6):
100
+ negotiation = gr.Textbox(label="Customs questions", lines=7)
101
+ pact_text = gr.Textbox(label="Pact manifest", lines=12)
102
+ pact_html = gr.HTML(label="Today's pact card")
103
+ debug_json = gr.Code(label="Debug manifest", language="json")
104
+
105
+ submit.click(
106
+ run_customs_once,
107
+ inputs=[dream_text, image_input, audio_input, mood, answers, text_backend, vision_backend],
108
+ outputs=[negotiation, pact_text, pact_html, debug_json],
109
+ )
110
+
111
+ gr.Examples(
112
+ examples=[
113
+ [
114
+ "梦见一间便利店漂在海上,收银员让我用旧日历付款。",
115
+ "curious",
116
+ "我想知道它到底在保护什么。",
117
+ ],
118
+ [
119
+ "I found a tiny border checkpoint inside my pillow. The officer stamped my hand with blue ink.",
120
+ "restless",
121
+ "I want a small action that makes tomorrow less loud.",
122
+ ],
123
+ ],
124
+ inputs=[dream_text, mood, answers],
125
+ )
126
+ return demo
127
+
128
+
129
+ demo = build_demo()
130
+
131
+
132
+ if __name__ == "__main__":
133
+ demo.launch(server_name="127.0.0.1", server_port=7860, show_api=False, show_error=True)
docs/dream-customs-concept/assets/alliance-card.svg ADDED
docs/dream-customs-concept/assets/dream-passport.svg ADDED
docs/dream-customs-concept/assets/multimodal-intake.svg ADDED
docs/dream-customs-concept/index.html ADDED
@@ -0,0 +1,480 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>梦境海关 / Dream Customs Concept</title>
7
+ <style>
8
+ :root {
9
+ --ink: #102c3a;
10
+ --ink-soft: #36545c;
11
+ --paper: #fff8ea;
12
+ --mist: #d8edf0;
13
+ --mint: #c9dfd2;
14
+ --coral: #e85d4f;
15
+ --amber: #f2a65a;
16
+ --line: rgba(16, 44, 58, 0.18);
17
+ --shadow: 0 24px 70px rgba(16, 44, 58, 0.18);
18
+ }
19
+
20
+ * { box-sizing: border-box; }
21
+
22
+ body {
23
+ margin: 0;
24
+ color: var(--ink);
25
+ background:
26
+ radial-gradient(circle at 9% 12%, rgba(232, 93, 79, 0.18), transparent 28%),
27
+ radial-gradient(circle at 88% 4%, rgba(216, 237, 240, 0.95), transparent 24%),
28
+ linear-gradient(135deg, #f6ead8 0%, #f8f1e4 46%, #d7ece5 100%);
29
+ font-family: "Iowan Old Style", "Songti SC", "Noto Serif CJK SC", Georgia, serif;
30
+ letter-spacing: 0;
31
+ line-height: 1.62;
32
+ }
33
+
34
+ .page {
35
+ width: min(1180px, calc(100% - 40px));
36
+ margin: 0 auto;
37
+ padding: 28px 0 60px;
38
+ }
39
+
40
+ .stampbar {
41
+ display: flex;
42
+ justify-content: space-between;
43
+ gap: 14px;
44
+ align-items: center;
45
+ color: var(--ink-soft);
46
+ font-size: 14px;
47
+ margin-bottom: 22px;
48
+ }
49
+
50
+ .stampbar span {
51
+ border: 1px solid var(--line);
52
+ border-radius: 999px;
53
+ padding: 8px 12px;
54
+ background: rgba(255, 248, 234, 0.72);
55
+ }
56
+
57
+ .hero {
58
+ min-height: 74vh;
59
+ display: grid;
60
+ grid-template-columns: 1.02fr 0.98fr;
61
+ gap: 34px;
62
+ align-items: center;
63
+ padding: 34px 0 42px;
64
+ }
65
+
66
+ .kicker {
67
+ color: var(--coral);
68
+ font-weight: 800;
69
+ text-transform: uppercase;
70
+ font-family: "Avenir Next Condensed", "Gill Sans", sans-serif;
71
+ letter-spacing: 0;
72
+ margin-bottom: 16px;
73
+ }
74
+
75
+ h1, h2, h3 {
76
+ margin: 0;
77
+ line-height: 1.06;
78
+ letter-spacing: 0;
79
+ }
80
+
81
+ h1 {
82
+ max-width: 760px;
83
+ font-size: clamp(54px, 8vw, 118px);
84
+ font-weight: 900;
85
+ }
86
+
87
+ h2 {
88
+ font-size: clamp(31px, 4vw, 58px);
89
+ margin-bottom: 22px;
90
+ }
91
+
92
+ h3 {
93
+ font-size: 23px;
94
+ margin-bottom: 10px;
95
+ }
96
+
97
+ .lead {
98
+ max-width: 660px;
99
+ margin: 24px 0 26px;
100
+ font-size: 22px;
101
+ color: var(--ink-soft);
102
+ }
103
+
104
+ .pillrow {
105
+ display: flex;
106
+ flex-wrap: wrap;
107
+ gap: 10px;
108
+ margin-top: 28px;
109
+ }
110
+
111
+ .pill {
112
+ padding: 9px 13px;
113
+ border: 1px solid var(--line);
114
+ border-radius: 999px;
115
+ background: rgba(255, 248, 234, 0.72);
116
+ font-size: 14px;
117
+ font-weight: 800;
118
+ font-family: "Avenir Next", "Gill Sans", sans-serif;
119
+ }
120
+
121
+ .hero-visual {
122
+ position: relative;
123
+ }
124
+
125
+ .hero-visual img,
126
+ .image-card img {
127
+ display: block;
128
+ width: 100%;
129
+ height: auto;
130
+ border-radius: 8px;
131
+ box-shadow: var(--shadow);
132
+ }
133
+
134
+ .section {
135
+ padding: 70px 0 24px;
136
+ }
137
+
138
+ .band {
139
+ border-top: 1px solid var(--line);
140
+ padding-top: 46px;
141
+ }
142
+
143
+ .grid {
144
+ display: grid;
145
+ grid-template-columns: repeat(3, minmax(0, 1fr));
146
+ gap: 18px;
147
+ }
148
+
149
+ .two {
150
+ display: grid;
151
+ grid-template-columns: 1fr 1fr;
152
+ gap: 24px;
153
+ align-items: start;
154
+ }
155
+
156
+ .card {
157
+ background: rgba(255, 248, 234, 0.78);
158
+ border: 1px solid var(--line);
159
+ border-radius: 8px;
160
+ padding: 24px;
161
+ box-shadow: 0 10px 36px rgba(16, 44, 58, 0.08);
162
+ }
163
+
164
+ .card strong,
165
+ .flow-step strong {
166
+ color: var(--coral);
167
+ }
168
+
169
+ .flow {
170
+ display: grid;
171
+ grid-template-columns: repeat(4, minmax(0, 1fr));
172
+ gap: 14px;
173
+ }
174
+
175
+ .flow-step {
176
+ min-height: 210px;
177
+ background: var(--ink);
178
+ color: var(--paper);
179
+ border-radius: 8px;
180
+ padding: 22px;
181
+ position: relative;
182
+ overflow: hidden;
183
+ }
184
+
185
+ .flow-step::after {
186
+ content: attr(data-no);
187
+ position: absolute;
188
+ right: 14px;
189
+ bottom: -16px;
190
+ font-size: 104px;
191
+ line-height: 1;
192
+ color: rgba(255, 248, 234, 0.12);
193
+ font-weight: 900;
194
+ font-family: Georgia, serif;
195
+ }
196
+
197
+ .flow-step:nth-child(2) { background: #2e6870; }
198
+ .flow-step:nth-child(3) { background: #7c6b45; }
199
+ .flow-step:nth-child(4) { background: #b64c43; }
200
+
201
+ .spec {
202
+ background: rgba(16, 44, 58, 0.92);
203
+ color: var(--paper);
204
+ border-radius: 8px;
205
+ padding: 24px;
206
+ font-family: "SF Mono", Menlo, Consolas, monospace;
207
+ font-size: 14px;
208
+ overflow-x: auto;
209
+ line-height: 1.5;
210
+ }
211
+
212
+ .output-sample {
213
+ background: var(--paper);
214
+ border: 3px solid var(--ink);
215
+ border-radius: 8px;
216
+ padding: 26px;
217
+ box-shadow: 12px 12px 0 rgba(16, 44, 58, 0.14);
218
+ }
219
+
220
+ .sample-head {
221
+ display: flex;
222
+ justify-content: space-between;
223
+ gap: 12px;
224
+ border-bottom: 2px dashed var(--line);
225
+ padding-bottom: 12px;
226
+ margin-bottom: 18px;
227
+ font-weight: 900;
228
+ }
229
+
230
+ .seal {
231
+ display: inline-block;
232
+ border: 3px solid var(--coral);
233
+ color: var(--coral);
234
+ border-radius: 999px;
235
+ padding: 8px 14px;
236
+ transform: rotate(-4deg);
237
+ font-weight: 900;
238
+ margin-top: 12px;
239
+ }
240
+
241
+ .note {
242
+ color: var(--ink-soft);
243
+ font-size: 15px;
244
+ margin-top: 14px;
245
+ }
246
+
247
+ .image-card {
248
+ margin: 26px 0;
249
+ }
250
+
251
+ .footer {
252
+ border-top: 1px solid var(--line);
253
+ margin-top: 70px;
254
+ padding-top: 22px;
255
+ display: flex;
256
+ justify-content: space-between;
257
+ gap: 16px;
258
+ color: var(--ink-soft);
259
+ font-size: 14px;
260
+ }
261
+
262
+ .deerflow {
263
+ color: var(--ink-soft);
264
+ opacity: 0.72;
265
+ text-decoration: none;
266
+ border-bottom: 1px solid rgba(16, 44, 58, 0.24);
267
+ }
268
+
269
+ @media (max-width: 860px) {
270
+ .page { width: min(100% - 26px, 1180px); }
271
+ .hero,
272
+ .two,
273
+ .grid,
274
+ .flow {
275
+ grid-template-columns: 1fr;
276
+ }
277
+ .hero { min-height: auto; padding-top: 18px; }
278
+ .stampbar,
279
+ .footer {
280
+ flex-direction: column;
281
+ align-items: flex-start;
282
+ }
283
+ .lead { font-size: 19px; }
284
+ }
285
+ </style>
286
+ </head>
287
+ <body>
288
+ <main class="page">
289
+ <div class="stampbar">
290
+ <span>Build Small Hackathon concept note</span>
291
+ <span>MiniCPM-V-4.6 + MiniCPM5-1B</span>
292
+ </div>
293
+
294
+ <section class="hero">
295
+ <div>
296
+ <div class="kicker">Dream Customs / 梦境海关</div>
297
+ <h1>和昨晚的梦结盟</h1>
298
+ <p class="lead">
299
+ 一个三模态小模型应用:你用语音、图片或文字申报梦境。梦境海关把不安转成第二天的小建议,
300
+ 把怪梦转成一件 5 分钟能完成的有趣小事。
301
+ </p>
302
+ <div class="pillrow">
303
+ <span class="pill">语音输入</span>
304
+ <span class="pill">图片/草图输入</span>
305
+ <span class="pill">文字输入</span>
306
+ <span class="pill">今日盟约卡</span>
307
+ <span class="pill">非诊断式睡眠陪伴</span>
308
+ </div>
309
+ </div>
310
+ <div class="hero-visual">
311
+ <img src="assets/dream-passport.svg" alt="梦境入境许可证插图">
312
+ </div>
313
+ </section>
314
+
315
+ <section class="section band">
316
+ <h2>产品定位</h2>
317
+ <div class="two">
318
+ <div class="card">
319
+ <h3>它不是梦境诊断器</h3>
320
+ <p>
321
+ 梦境海关不告诉用户“你有什么心理问题”,也不把梦解释成病症。它把梦当成一个来访者:
322
+ 也许荒诞,也许吵闹,但可以谈判、安置、盖章放行。
323
+ </p>
324
+ </div>
325
+ <div class="card">
326
+ <h3>它是第二天的小同盟</h3>
327
+ <p>
328
+ 输出重点不是解释过去,而是给明天一个轻量动作:一条认真建议、一个怪趣任务、
329
+ 一句睡前放行仪式。用户睡不好时,也能获得一点温柔的秩序感。
330
+ </p>
331
+ </div>
332
+ </div>
333
+ </section>
334
+
335
+ <section class="section">
336
+ <h2>三模态申报入口</h2>
337
+ <div class="image-card">
338
+ <img src="assets/multimodal-intake.svg" alt="语音、图片、文字输入汇入 Dream Intake JSON 的流程图">
339
+ </div>
340
+ <div class="grid">
341
+ <div class="card">
342
+ <h3>语音</h3>
343
+ <p>
344
+ 睡醒后直接说梦,先由很小的 ASR 适配器转写成文本。它只负责转写,不参与梦境理解,
345
+ 保证 MiniCPM 仍是体验核心。
346
+ </p>
347
+ </div>
348
+ <div class="card">
349
+ <h3>图片</h3>
350
+ <p>
351
+ 上传梦境草图、床边便签、截图或随手拍。MiniCPM-V-4.6 读取视觉线索:
352
+ 人、地点、物件、颜色、文字和情绪暗示。
353
+ </p>
354
+ </div>
355
+ <div class="card">
356
+ <h3>文字</h3>
357
+ <p>
358
+ 最稳定的主入口。用户直接写梦境内容、醒来后的感觉、反复出现的符号,
359
+ MiniCPM5-1B 进入结盟谈判。
360
+ </p>
361
+ </div>
362
+ </div>
363
+ </section>
364
+
365
+ <section class="section">
366
+ <h2>MVP 体验流</h2>
367
+ <div class="flow">
368
+ <div class="flow-step" data-no="1">
369
+ <h3>申报</h3>
370
+ <p>用户用语音、图片或文字提交梦境。系统合并成统一的 Dream Intake JSON。</p>
371
+ </div>
372
+ <div class="flow-step" data-no="2">
373
+ <h3>来访者画像</h3>
374
+ <p>MiniCPM5-1B 把梦境命名为一个来访者,例如“迟到的电梯”。</p>
375
+ </div>
376
+ <div class="flow-step" data-no="3">
377
+ <h3>结盟谈判</h3>
378
+ <p>海关官提出 2-3 个温柔古怪的问题,帮助用户把梦转成可行动线索。</p>
379
+ </div>
380
+ <div class="flow-step" data-no="4">
381
+ <h3>今日盟约</h3>
382
+ <p>生成一张可截图分享的盟约卡:认真建议、怪趣任务、睡前放行。</p>
383
+ </div>
384
+ </div>
385
+ </section>
386
+
387
+ <section class="section">
388
+ <h2>模型分工</h2>
389
+ <div class="two">
390
+ <div class="card">
391
+ <h3>MiniCPM-V-4.6:梦境目击证人</h3>
392
+ <p>
393
+ 负责多模态感知:草图、便签、截图、照片中的视觉元素抽取。输出结构化线索,
394
+ 不承担最终建议,避免视觉模型被迫做长文本人格化写作。
395
+ </p>
396
+ </div>
397
+ <div class="card">
398
+ <h3>MiniCPM5-1B:梦境外交官</h3>
399
+ <p>
400
+ 负责核心体验:梦境来访者命名、谈判问题、今日建议、怪趣任务、睡前放行仪式。
401
+ 1B 模型足够轻,和赛题“小而巧”的精神贴合。
402
+ </p>
403
+ </div>
404
+ </div>
405
+ </section>
406
+
407
+ <section class="section">
408
+ <h2>Dream Intake JSON</h2>
409
+ <pre class="spec">{
410
+ "dream_text": "我梦见自己一直赶不上电梯,楼层按钮会融化。",
411
+ "visual_clues": ["歪斜电梯门", "蓝色楼道", "融化的数字 14"],
412
+ "mood": "焦虑但有点滑稽",
413
+ "recurring_symbols": ["电梯", "迟到", "融化的按钮"],
414
+ "uncertainty": "梦的后半段记不清",
415
+ "user_context": "最近晚上常做梦,睡醒后想获得第二天建议"
416
+ }</pre>
417
+ </section>
418
+
419
+ <section class="section">
420
+ <h2>输出样例</h2>
421
+ <div class="two">
422
+ <div class="output-sample">
423
+ <div class="sample-head">
424
+ <span>梦境入境许可证</span>
425
+ <span>DC-06-05-014</span>
426
+ </div>
427
+ <p><strong>来访者:</strong>迟到的电梯</p>
428
+ <p><strong>携带物:</strong>未申报的焦虑、融化的按钮、一小袋没来得及开始的事。</p>
429
+ <p><strong>准入决定:</strong>准许入境,但必须在今天被安置成一个小动作。</p>
430
+ <p><strong>今日建议:</strong>提前 10 分钟开始一件最小的任务,不追求完成,只追求启动。</p>
431
+ <p><strong>怪趣任务:</strong>给电梯写一句道歉信:“抱歉总让你替我背迟到的锅。”</p>
432
+ <p><strong>睡前放行:</strong>“今日电梯已停靠,未完成事项明日再报关。”</p>
433
+ <span class="seal">SEALED</span>
434
+ </div>
435
+ <div class="image-card">
436
+ <img src="assets/alliance-card.svg" alt="今日盟约卡插图">
437
+ </div>
438
+ </div>
439
+ </section>
440
+
441
+ <section class="section">
442
+ <h2>安全边界</h2>
443
+ <div class="grid">
444
+ <div class="card">
445
+ <h3>不诊断</h3>
446
+ <p>不输出心理疾病判断,不解释成创伤证据,不制造恐惧。</p>
447
+ </div>
448
+ <div class="card">
449
+ <h3>轻建议</h3>
450
+ <p>只给生活层面的温和建议和微行动,避免医疗化、绝对化和命令式措辞。</p>
451
+ </div>
452
+ <div class="card">
453
+ <h3>升级提示</h3>
454
+ <p>如果用户表达长期严重失眠、自伤念头或强烈痛苦,提示寻求专业帮助。</p>
455
+ </div>
456
+ </div>
457
+ </section>
458
+
459
+ <section class="section">
460
+ <h2>Demo 叙事</h2>
461
+ <div class="card">
462
+ <p>
463
+ 视频可以从真实场景开始:晚上睡不好,早上醒来还记得一个怪梦。用户对着麦克风说几句,
464
+ 又拍一张床边便签。系统把它转成梦境申报表,盖章签发今日盟约卡。
465
+ 结尾展示那个 5 分钟怪趣任务真的被执行了。
466
+ </p>
467
+ </div>
468
+ <p class="note">
469
+ 比赛定位:更偏 Thousand Token Wood,因为它是一个 AI 承重的怪异互动体验;
470
+ 同时也能讲 Backyard AI,因为它来自真实睡眠困扰。
471
+ </p>
472
+ </section>
473
+
474
+ <footer class="footer">
475
+ <span>Dream Customs concept draft · Build Small Hackathon</span>
476
+ <a class="deerflow" href="https://deerflow.tech" target="_blank" rel="noreferrer">Created By Deerflow</a>
477
+ </footer>
478
+ </main>
479
+ </body>
480
+ </html>
docs/handoff.md ADDED
@@ -0,0 +1,131 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Dream Customs / 梦境海关 Handoff
2
+
3
+ Last updated: 2026-06-05
4
+
5
+ ## Current State
6
+
7
+ The project is in concept/spec stage. A visual HTML concept note already exists at:
8
+
9
+ - `docs/dream-customs-concept/index.html`
10
+
11
+ Supporting images:
12
+
13
+ - `docs/dream-customs-concept/assets/dream-passport.svg`
14
+ - `docs/dream-customs-concept/assets/multimodal-intake.svg`
15
+ - `docs/dream-customs-concept/assets/alliance-card.svg`
16
+
17
+ This handoff adds project context documents for implementation:
18
+
19
+ - `docs/spec.md`
20
+ - `docs/prd.md`
21
+ - `docs/handoff.md`
22
+ - `docs/superpowers/plans/2026-06-05-dream-customs-mvp.md`
23
+ - `AGENTS.md`
24
+
25
+ ## Product Decision
26
+
27
+ Build Dream Customs as a "dream alliance" app, not a dream diagnosis app.
28
+
29
+ Final positioning:
30
+
31
+ > 梦境海关帮你和昨晚的梦结盟:把不安转成明天的小建议,把怪梦转成一件有趣的小事。
32
+
33
+ ## Model Decision
34
+
35
+ Use:
36
+
37
+ - `openbmb/MiniCPM-V-4.6` for image/sketch/note understanding.
38
+ - `openbmb/MiniCPM5-1B` for text reasoning, negotiation, and final output.
39
+
40
+ Do not broaden to arbitrary small models unless MiniCPM paths fail.
41
+
42
+ Voice input is allowed, but current MiniCPM pair does not directly transcribe raw audio. Use a small ASR adapter only for transcription. Keep dream understanding and final generation inside MiniCPM.
43
+
44
+ ## Contest Constraints
45
+
46
+ - Build Small Hackathon.
47
+ - Build window: 2026-06-05 to 2026-06-15.
48
+ - App should ship as Gradio on Hugging Face Space.
49
+ - Total model parameters must be <= 32B.
50
+ - Strongest track: An Adventure in Thousand Token Wood.
51
+ - Secondary narrative: Backyard AI due to real sleep/dream use case.
52
+
53
+ ## MVP User Flow
54
+
55
+ 1. User submits dream using text, image, voice, or a combination.
56
+ 2. Audio is transcribed by a small adapter.
57
+ 3. Image is converted into visual clues by MiniCPM-V-4.6.
58
+ 4. App builds a `DreamIntake` object.
59
+ 5. MiniCPM5-1B generates dream visitor and 2-3 negotiation questions.
60
+ 6. User answers or skips.
61
+ 7. MiniCPM5-1B generates final "Today's Pact" card.
62
+ 8. Gradio renders a screenshot-friendly HTML card.
63
+
64
+ ## Required Output Fields
65
+
66
+ - `visitor_name`
67
+ - `permit_id`
68
+ - `contraband`
69
+ - `risk_level`
70
+ - `alliance_reading`
71
+ - `practical_suggestion`
72
+ - `weird_task`
73
+ - `bedtime_release`
74
+ - `safety_note`
75
+
76
+ ## Safety Boundary
77
+
78
+ 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.
79
+
80
+ ## Recommended First Implementation
81
+
82
+ Create a minimal Python package:
83
+
84
+ ```text
85
+ app.py
86
+ requirements.txt
87
+ README.md
88
+ dream_customs/
89
+ __init__.py
90
+ schema.py
91
+ prompts.py
92
+ safety.py
93
+ render.py
94
+ pipeline.py
95
+ models.py
96
+ tests/
97
+ test_schema.py
98
+ test_safety.py
99
+ test_render.py
100
+ test_pipeline.py
101
+ ```
102
+
103
+ Start with mocked model clients and deterministic examples. Add real model loading only after schema, safety, rendering, and pipeline tests pass.
104
+
105
+ ## Implementation Priorities
106
+
107
+ 1. Text-only flow.
108
+ 2. Card rendering.
109
+ 3. Safety layer.
110
+ 4. Image clue extraction.
111
+ 5. Voice transcription adapter.
112
+ 6. Demo examples.
113
+ 7. Space packaging.
114
+
115
+ ## Known Repository Issue
116
+
117
+ The current directory is under git root `/Users/junhaocheng`, whose remote is `adjcjh777/vlarepo.git`. This is likely not the intended Build Small repository. Also, `.git/info/exclude` currently ignores paths beneath this workspace root.
118
+
119
+ Do not push these files to the VLA remote by accident. Before implementation, either:
120
+
121
+ 1. initialize a dedicated repo in `/Users/junhaocheng/working-dir/ai-competitions/build-small-hackthon`, or
122
+ 2. move this context into the intended Build Small repository/Space repo.
123
+
124
+ ## Open Questions For User
125
+
126
+ These do not block initial scaffolding:
127
+
128
+ - Should the demo language be Chinese-only or bilingual?
129
+ - Which Hugging Face Space name should be used?
130
+ - Should we reuse any previous `build-small-relics` assets, or keep Dream Customs separate?
131
+ - Which ASR adapter is acceptable for the hackathon submission?
docs/prd.md ADDED
@@ -0,0 +1,229 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Dream Customs / 梦境海关 PRD
2
+
3
+ Last updated: 2026-06-05
4
+
5
+ ## 1. Executive Summary
6
+
7
+ ### Problem Statement
8
+
9
+ Users who wake up from frequent or vivid dreams may feel unsettled, tired, or mentally cluttered. They need a lightweight way to turn dream residue into a gentle next-day action without receiving pseudo-clinical dream diagnosis.
10
+
11
+ ### Proposed Solution
12
+
13
+ Dream Customs is a Gradio app that accepts voice, image, and text dream declarations, normalizes them into a structured dream intake, and uses MiniCPM models to generate a playful "dream alliance" card with practical advice, a weird 5-minute task, and a bedtime release phrase.
14
+
15
+ ### Success Criteria
16
+
17
+ - A user can complete the core flow from input to final card in <= 90 seconds on the hosted demo.
18
+ - At least 90% of demo runs produce a parseable final output object matching the required schema.
19
+ - At least 5 real users can understand the output without extra explanation and rate it >= 4/5 for delight.
20
+ - At least 3 real users report that the "today's pact" feels actionable.
21
+ - The app runs with total model parameters <= 32B and is packaged for Gradio/Hugging Face Space.
22
+
23
+ ## 2. User Experience & Functionality
24
+
25
+ ### User Personas
26
+
27
+ 1. Primary persona: vivid dreamer.
28
+ - Recently sleeps poorly or wakes with strong dream fragments.
29
+ - Wants a small morning ritual, not therapy.
30
+
31
+ 2. Secondary persona: playful AI explorer.
32
+ - Wants a strange AI experience worth showing a friend.
33
+ - Cares about creativity and shareable outputs.
34
+
35
+ 3. Demo viewer / judge.
36
+ - Needs to understand the interaction in under one minute.
37
+ - Looks for clear small-model fit and Gradio polish.
38
+
39
+ ### User Stories
40
+
41
+ #### Story 1: Text Dream Declaration
42
+
43
+ As a vivid dreamer, I want to type my dream so that I can get a next-day pact even if I have no image or audio.
44
+
45
+ Acceptance criteria:
46
+
47
+ - User can submit text-only input.
48
+ - App creates a valid `DreamIntake` object.
49
+ - Final card includes dream visitor, practical suggestion, weird task, and bedtime release phrase.
50
+
51
+ #### Story 2: Image Dream Declaration
52
+
53
+ As a user who remembers images better than words, I want to upload a sketch or bedside note so that the system can use visual dream clues.
54
+
55
+ Acceptance criteria:
56
+
57
+ - User can upload one image.
58
+ - MiniCPM-V-4.6 extracts at least 3 visual clues when visible content exists.
59
+ - Visual clues are included in the final `DreamIntake`.
60
+ - If image extraction fails, text-only flow still works.
61
+
62
+ #### Story 3: Voice Dream Declaration
63
+
64
+ As a half-awake user, I want to speak my dream so that I do not need to type immediately after waking.
65
+
66
+ Acceptance criteria:
67
+
68
+ - User can record or upload an audio clip.
69
+ - ASR adapter returns a transcript.
70
+ - Transcript is visible/editable before final submission or transparently included in the declaration.
71
+ - If ASR fails, user receives a concise fallback message and can type manually.
72
+
73
+ #### Story 4: Alliance Negotiation
74
+
75
+ As a user, I want the AI to ask me a few questions so that the final advice feels connected to my dream.
76
+
77
+ Acceptance criteria:
78
+
79
+ - App generates 2-3 questions.
80
+ - User can answer all, answer some, or skip.
81
+ - Skip path still generates a final card.
82
+
83
+ #### Story 5: Today's Pact Card
84
+
85
+ As a user, I want a visually clear output card so that I can screenshot or share the result.
86
+
87
+ Acceptance criteria:
88
+
89
+ - Card includes all required output fields.
90
+ - Card is readable on mobile and desktop.
91
+ - Card avoids medical or diagnostic framing.
92
+ - Card includes a short safety note only when needed.
93
+
94
+ ### Non-Goals
95
+
96
+ - Medical diagnosis or therapy.
97
+ - Sleep-stage tracking.
98
+ - Long-term journaling database.
99
+ - Account creation.
100
+ - Voice output in MVP.
101
+ - Social feed or leaderboard.
102
+ - Fine-tuning during the hackathon MVP.
103
+
104
+ ## 3. AI System Requirements
105
+
106
+ ### Tool Requirements
107
+
108
+ - MiniCPM-V-4.6 for image-to-text visual clue extraction.
109
+ - MiniCPM5-1B for text reasoning, persona, structured output, and alliance card generation.
110
+ - A small ASR adapter for voice-to-text. This adapter is not the core AI experience.
111
+ - Gradio for UI.
112
+ - Optional HTML/CSS card renderer.
113
+
114
+ ### Prompt Requirements
115
+
116
+ Prompts must:
117
+
118
+ - Ask for JSON-compatible output.
119
+ - Keep the tone playful, gentle, and non-clinical.
120
+ - Avoid strong claims about dream meaning.
121
+ - Include a safety boundary for severe distress.
122
+ - Keep final advice small enough to execute in 5-10 minutes.
123
+
124
+ ### Evaluation Strategy
125
+
126
+ Create an eval set with at least 12 dream inputs:
127
+
128
+ - 4 text-only dreams.
129
+ - 3 image-assisted dreams.
130
+ - 3 voice-transcript dreams.
131
+ - 2 distress-edge cases.
132
+
133
+ Measure:
134
+
135
+ - Schema validity.
136
+ - Tone safety.
137
+ - Practicality of advice.
138
+ - Weird-task delight.
139
+ - Ability to recover from missing modality.
140
+
141
+ Pass thresholds:
142
+
143
+ - Schema validity: >= 90%.
144
+ - No diagnostic language in ordinary cases: 100%.
145
+ - Safety note appears in distress-edge cases: >= 90%.
146
+ - Human delight rating on 5 demo cases: average >= 4/5.
147
+
148
+ ## 4. Technical Specifications
149
+
150
+ ### Architecture Overview
151
+
152
+ ```text
153
+ Gradio inputs
154
+ -> text input
155
+ -> image upload -> MiniCPM-V-4.6 visual clue extractor
156
+ -> audio upload/record -> ASR adapter
157
+ -> DreamIntake builder
158
+ -> MiniCPM5-1B customs officer prompt
159
+ -> negotiation questions
160
+ -> final MiniCPM5-1B pact prompt
161
+ -> HTML card renderer
162
+ ```
163
+
164
+ ### Core Components
165
+
166
+ - `app.py`: Gradio UI composition and event wiring.
167
+ - `dream_customs/schema.py`: dataclasses or Pydantic models for inputs and outputs.
168
+ - `dream_customs/models.py`: model/client loading and inference wrappers.
169
+ - `dream_customs/prompts.py`: prompt templates.
170
+ - `dream_customs/pipeline.py`: orchestration logic.
171
+ - `dream_customs/safety.py`: lightweight safety checks and copy.
172
+ - `dream_customs/render.py`: HTML card rendering.
173
+ - `tests/`: schema, prompt, safety, and pipeline unit tests with mocked model outputs.
174
+
175
+ ### Integration Points
176
+
177
+ - Hugging Face Space runtime.
178
+ - Hugging Face model downloads or inference endpoints.
179
+ - Gradio audio, image, and text components.
180
+ - Optional model cache in Space environment.
181
+
182
+ ### Security & Privacy
183
+
184
+ - Do not persist user dreams by default.
185
+ - Do not log raw dream text or uploaded images in production demo logs.
186
+ - Provide visible copy: "This is a playful reflection tool, not medical advice."
187
+ - Do not require account login.
188
+
189
+ ## 5. Risks & Roadmap
190
+
191
+ ### Phased Rollout
192
+
193
+ #### MVP
194
+
195
+ - Text, image, and voice input.
196
+ - Dream intake normalization.
197
+ - MiniCPM-V-4.6 image clue extraction.
198
+ - MiniCPM5-1B negotiation and final card.
199
+ - HTML output card.
200
+ - Safety boundary.
201
+
202
+ #### v1.1
203
+
204
+ - More polished card export.
205
+ - Better example gallery.
206
+ - Optional bilingual output.
207
+ - More robust eval harness.
208
+
209
+ #### v2.0
210
+
211
+ - Voice output through VoxCPM2.
212
+ - Local dream history stored only in browser/session.
213
+ - Multiple customs officer styles.
214
+
215
+ ### Technical Risks
216
+
217
+ - MiniCPM-V-4.6 may be heavy for Space hardware if loaded locally.
218
+ - MiniCPM5-1B output may fail strict JSON without repair.
219
+ - ASR adapter may complicate dependencies.
220
+ - Audio input on mobile browsers may behave inconsistently.
221
+ - Safety classification may be too weak if only prompt-based.
222
+
223
+ ### Mitigations
224
+
225
+ - Keep text-only demo path always functional.
226
+ - Add JSON repair and schema validation.
227
+ - Use examples with fixed sample media for demo recording.
228
+ - Make ASR optional and fallback to manual transcript.
229
+ - Keep image extraction output concise to reduce latency.
docs/spec.md ADDED
@@ -0,0 +1,236 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Dream Customs / 梦境海关 Product Spec
2
+
3
+ Last updated: 2026-06-05
4
+
5
+ ## 1. One-Line Concept
6
+
7
+ 梦境海关帮用户和昨晚的梦结盟:用户用语音、图片或文字申报梦境,系统把不安转成第二天的小建议,把怪梦转成一件 5 分钟能完成的有趣小事。
8
+
9
+ ## 2. Contest Fit
10
+
11
+ - Event: Build Small Hackathon.
12
+ - Build window: 2026-06-05 to 2026-06-15.
13
+ - Deployment target: Gradio app on Hugging Face Space.
14
+ - Model constraint: total model parameters must be <= 32B.
15
+ - Primary track fit: An Adventure in Thousand Token Wood, because the AI is the load-bearing delight of the experience.
16
+ - Secondary story: Backyard AI, because the concept comes from a real sleep problem: frequent vivid dreams and poor sleep.
17
+ - Model family constraint: prioritize MiniCPM models before considering any unrelated small model.
18
+
19
+ ## 3. Core User Problem
20
+
21
+ The user wakes up after vivid or unsettling dreams and wants a quick, non-clinical way to make sense of the dream before the day begins. Existing dream tools often feel either too mystical, too diagnostic, or too generic. This product should feel like a playful ritual that gives emotional order without pretending to be therapy.
22
+
23
+ ## 4. Product Principles
24
+
25
+ 1. Do not diagnose.
26
+ 2. Do not frighten the user.
27
+ 3. Convert dream residue into a small next-day action.
28
+ 4. Keep the output weird enough to show a friend.
29
+ 5. Make the AI the main experience, not a helper hidden behind a form.
30
+ 6. Keep the MVP stable enough for Gradio Space and demo-video recording.
31
+
32
+ ## 5. User Inputs
33
+
34
+ All inputs are normalized into one `DreamIntake` object so the app does not become three separate products.
35
+
36
+ ### 5.1 Text Input
37
+
38
+ The user writes:
39
+
40
+ - Dream narrative.
41
+ - Wake-up mood.
42
+ - Repeated symbols.
43
+ - Any second-day concern or wish.
44
+
45
+ ### 5.2 Image Input
46
+
47
+ The user uploads:
48
+
49
+ - A dream sketch.
50
+ - A bedside note.
51
+ - A screenshot or photo related to the dream.
52
+ - A quick doodle drawn after waking.
53
+
54
+ MiniCPM-V-4.6 extracts visual clues and returns structured text.
55
+
56
+ ### 5.3 Voice Input
57
+
58
+ The user speaks the dream immediately after waking.
59
+
60
+ Implementation note: MiniCPM-V-4.6 and MiniCPM5-1B are not raw audio ASR models. Voice input needs a small transcription adapter. The adapter only converts audio to text; dream understanding remains MiniCPM-driven.
61
+
62
+ ## 6. Dream Intake Schema
63
+
64
+ ```json
65
+ {
66
+ "dream_text": "我梦见自己一直赶不上电梯,楼层按钮会融化。",
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
79
+
80
+ Role: dream witness.
81
+
82
+ Responsibilities:
83
+
84
+ - Read sketches, notes, screenshots, and photos.
85
+ - Extract objects, locations, visible text, colors, spatial relationships, and emotional cues.
86
+ - Return concise JSON-compatible visual clues.
87
+
88
+ Non-responsibilities:
89
+
90
+ - Do not generate the final alliance card.
91
+ - Do not provide psychological interpretation.
92
+
93
+ ### MiniCPM5-1B
94
+
95
+ Role: dream diplomat.
96
+
97
+ Responsibilities:
98
+
99
+ - Merge dream text, voice transcript, and visual clues.
100
+ - Name the dream visitor.
101
+ - Ask 2-3 alliance negotiation questions.
102
+ - Generate the final alliance card.
103
+ - Keep tone playful, gentle, and non-clinical.
104
+
105
+ ## 8. MVP User Flow
106
+
107
+ 1. User opens Gradio app.
108
+ 2. User submits any combination of text, image, and voice.
109
+ 3. App transcribes voice if present.
110
+ 4. App calls MiniCPM-V-4.6 if image is present.
111
+ 5. App builds `DreamIntake`.
112
+ 6. MiniCPM5-1B generates a dream visitor profile and 2-3 follow-up questions.
113
+ 7. User answers questions or clicks "skip / refuse cooperation".
114
+ 8. MiniCPM5-1B generates a final alliance card.
115
+ 9. App renders the card as a shareable visual block and plain text.
116
+
117
+ ## 9. Output Contract
118
+
119
+ The final card must include:
120
+
121
+ - Dream visitor name.
122
+ - Entry permit number.
123
+ - Carried emotional contraband.
124
+ - Risk level, phrased playfully.
125
+ - Alliance reading: what the dream may be trying to protect.
126
+ - One practical next-day suggestion.
127
+ - One 5-minute weird task.
128
+ - One bedtime release phrase.
129
+ - Safety note if the input indicates severe distress.
130
+
131
+ Example:
132
+
133
+ ```json
134
+ {
135
+ "visitor_name": "迟到的电梯",
136
+ "permit_id": "DC-0605-014",
137
+ "contraband": ["未申报的焦虑", "融化的按钮", "一小袋没来得及开始的事"],
138
+ "risk_level": "橙色:需要被安置,但不需要被害怕",
139
+ "alliance_reading": "这个梦像是在提醒你,今天不要把启动一件事和完成一件事混成同一个压力。",
140
+ "practical_suggestion": "提前 10 分钟开始一件最小的任务,只要求打开它。",
141
+ "weird_task": "给电梯写一句道歉信:抱歉总让你替我背迟到的锅。",
142
+ "bedtime_release": "今日电梯已停靠,未完成事项明日再报关。",
143
+ "safety_note": ""
144
+ }
145
+ ```
146
+
147
+ ## 10. UX Structure
148
+
149
+ ### Screen 1: Dream Declaration Desk
150
+
151
+ Inputs:
152
+
153
+ - Textbox for dream notes.
154
+ - Image upload for dream sketch or bedside note.
155
+ - Audio input for spoken dream.
156
+ - Optional mood selector.
157
+
158
+ Primary action:
159
+
160
+ - "Submit to Customs"
161
+
162
+ ### Screen 2: Alliance Negotiation
163
+
164
+ Content:
165
+
166
+ - Dream visitor name.
167
+ - 2-3 questions from the customs officer.
168
+ - Buttons: answer, skip, refuse cooperation.
169
+
170
+ ### Screen 3: Today's Pact Card
171
+
172
+ Content:
173
+
174
+ - Card-style result.
175
+ - Copy text button.
176
+ - Download/share image target if feasible.
177
+ - "Release this dream for tonight" reset action.
178
+
179
+ ## 11. Safety Boundaries
180
+
181
+ The app must:
182
+
183
+ - Avoid diagnoses, medical claims, or certainty about dream meaning.
184
+ - Avoid telling users that a dream proves trauma, illness, or danger.
185
+ - Use language such as "may be", "could be treated as", and "for today, try".
186
+ - If the user mentions self-harm, intent to harm others, severe insomnia, panic, or inability to function, show a supportive escalation note.
187
+
188
+ Escalation copy:
189
+
190
+ > This dream sounds heavier than a playful customs ritual should handle. If you feel unsafe, unable to sleep for many nights, or worried you may hurt yourself or someone else, please reach out to a trusted person or professional support now.
191
+
192
+ ## 12. Recommended MVP Scope
193
+
194
+ Included:
195
+
196
+ - Gradio app.
197
+ - Text input.
198
+ - Image input through MiniCPM-V-4.6.
199
+ - Voice input through small ASR adapter.
200
+ - MiniCPM5-1B prompt flow.
201
+ - Result card in HTML.
202
+ - Example mode for demo stability.
203
+ - Basic safety classifier prompt or rule layer.
204
+
205
+ Excluded from MVP:
206
+
207
+ - Voice output.
208
+ - Long-term account system.
209
+ - Medical sleep tracking.
210
+ - Dream history database.
211
+ - Personalized therapy plans.
212
+ - Multi-user social feed.
213
+
214
+ ## 13. Grill Questions And Recommended Answers
215
+
216
+ 1. Is this a therapy tool?
217
+ - Recommended answer: no. It is a playful reflection and next-day ritual tool.
218
+
219
+ 2. Which track should the submission emphasize?
220
+ - Recommended answer: Thousand Token Wood first, Backyard AI second.
221
+
222
+ 3. Should voice be core?
223
+ - Recommended answer: voice input yes, voice output no for MVP.
224
+
225
+ 4. Should the image input be mandatory?
226
+ - Recommended answer: no. Image is a strong MiniCPM-V demo path but text alone must work.
227
+
228
+ 5. What is the winning demo moment?
229
+ - Recommended answer: user speaks a dream, uploads a quick sketch, and receives a strange but useful "Today's Pact" card.
230
+
231
+ ## 14. Open Questions
232
+
233
+ - Which ASR adapter should be used on Hugging Face Space for the MVP?
234
+ - Should the first demo be in Chinese, English, or bilingual?
235
+ - Do we want to submit under the same team/repo as the earlier Ordinary Relics MVP, or create a new Space?
236
+ - Should the visual card support image download in MVP or only screenshot-friendly HTML?
docs/superpowers/plans/2026-06-05-dream-customs-mvp.md ADDED
@@ -0,0 +1,812 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Dream Customs MVP Implementation Plan
2
+
3
+ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
+
5
+ **Goal:** Build a Gradio MVP for Dream Customs that accepts text, image, and voice dream declarations and returns a playful, safe "Today's Pact" card powered primarily by MiniCPM5-1B plus MiniCPM-V-4.6.
6
+
7
+ **Architecture:** The app normalizes all modalities into a `DreamIntake` schema, then runs a two-stage pipeline: dream visitor negotiation and final pact generation. The MVP starts with mocked model clients and deterministic tests, then swaps in MiniCPM inference wrappers after core behavior is stable.
8
+
9
+ **Tech Stack:** Python, Gradio, pytest, Pydantic or dataclasses, Hugging Face Transformers or compatible inference endpoints, MiniCPM-V-4.6, MiniCPM5-1B, small ASR adapter.
10
+
11
+ ---
12
+
13
+ ## Execution Status 2026-06-05
14
+
15
+ - [x] Project scaffold, README, package marker, pytest path hook.
16
+ - [x] `DreamIntake` and `PactCard` schemas.
17
+ - [x] Safety layer with escalation copy for severe distress.
18
+ - [x] Prompt templates for visual clue extraction, negotiation, and pact generation.
19
+ - [x] HTML pact card renderer.
20
+ - [x] Fake model clients and deterministic pipeline.
21
+ - [x] Ollama adapters for MiniCPM5 text and MiniCPM-V vision, kept optional.
22
+ - [x] Gradio app with text, image, voice inputs and backend selectors.
23
+ - [x] Local smoke checks: pytest, app import, `get_api_info`, HTTP 200, `/config`, `/api/predict`.
24
+ - [x] Local Ollama pull: `hf.co/openbmb/MiniCPM5-1B-GGUF:Q8_0` and `openbmb/minicpm-v4.6`.
25
+ - [ ] Submission packaging, demo video script, and final material checklist.
26
+
27
+ Implementation note: detailed steps below are the original TDD plan. The final code keeps the demo backend as default because local Ollama smoke tests showed MiniCPM5 GGUF returning malformed output and MiniCPM-V failing to load in the current Ollama runner.
28
+
29
+ ---
30
+
31
+ ## File Structure
32
+
33
+ - Create `app.py`: Gradio UI and event handlers.
34
+ - Create `requirements.txt`: Space dependencies.
35
+ - Create `README.md`: project overview, model constraints, running instructions.
36
+ - Create `dream_customs/__init__.py`: package marker.
37
+ - Create `dream_customs/schema.py`: input and output data models.
38
+ - Create `dream_customs/prompts.py`: prompt templates and formatting helpers.
39
+ - Create `dream_customs/safety.py`: safety checks and escalation copy.
40
+ - Create `dream_customs/render.py`: HTML card rendering.
41
+ - Create `dream_customs/models.py`: model wrapper interfaces, fake clients, and later real model adapters.
42
+ - Create `dream_customs/pipeline.py`: orchestration from inputs to final card.
43
+ - Create `tests/test_schema.py`: schema tests.
44
+ - Create `tests/test_safety.py`: safety tests.
45
+ - Create `tests/test_render.py`: render tests.
46
+ - Create `tests/test_pipeline.py`: pipeline tests with fake clients.
47
+
48
+ ## Task 1: Project Scaffold
49
+
50
+ **Files:**
51
+ - Create: `requirements.txt`
52
+ - Create: `README.md`
53
+ - Create: `dream_customs/__init__.py`
54
+ - Create: `tests/conftest.py`
55
+
56
+ - [ ] **Step 1: Create dependency file**
57
+
58
+ Write `requirements.txt`:
59
+
60
+ ```text
61
+ gradio>=5.0
62
+ pydantic>=2.7
63
+ pytest>=8.0
64
+ transformers>=4.56
65
+ torch
66
+ pillow
67
+ soundfile
68
+ ```
69
+
70
+ - [ ] **Step 2: Create README**
71
+
72
+ Write `README.md`:
73
+
74
+ ````markdown
75
+ # Dream Customs / 梦境海关
76
+
77
+ A Build Small Hackathon Gradio app that helps users form a playful alliance with last night's dream.
78
+
79
+ ## Models
80
+
81
+ - MiniCPM-V-4.6 for image/sketch understanding.
82
+ - MiniCPM5-1B for dream negotiation and pact generation.
83
+ - A small ASR adapter may be used only for voice transcription.
84
+
85
+ ## Run
86
+
87
+ ```bash
88
+ python -m venv .venv
89
+ source .venv/bin/activate
90
+ pip install -r requirements.txt
91
+ python app.py
92
+ ```
93
+
94
+ ## Safety
95
+
96
+ This is not a therapy or diagnosis product. It gives playful reflection, small actions, and escalation copy for severe distress.
97
+ ````
98
+
99
+ - [ ] **Step 3: Create package marker**
100
+
101
+ Write `dream_customs/__init__.py`:
102
+
103
+ ```python
104
+ """Dream Customs package."""
105
+ ```
106
+
107
+ - [ ] **Step 4: Create pytest config hook**
108
+
109
+ Write `tests/conftest.py`:
110
+
111
+ ```python
112
+ import sys
113
+ from pathlib import Path
114
+
115
+ ROOT = Path(__file__).resolve().parents[1]
116
+ if str(ROOT) not in sys.path:
117
+ sys.path.insert(0, str(ROOT))
118
+ ```
119
+
120
+ - [ ] **Step 5: Run scaffold check**
121
+
122
+ Run:
123
+
124
+ ```bash
125
+ python -m pytest -q
126
+ ```
127
+
128
+ Expected:
129
+
130
+ ```text
131
+ no tests ran
132
+ ```
133
+
134
+ - [ ] **Step 6: Commit scaffold**
135
+
136
+ ```bash
137
+ git add requirements.txt README.md dream_customs/__init__.py tests/conftest.py
138
+ git commit -m "feat: scaffold dream customs app"
139
+ ```
140
+
141
+ ## Task 2: Data Schemas
142
+
143
+ **Files:**
144
+ - Create: `dream_customs/schema.py`
145
+ - Create: `tests/test_schema.py`
146
+
147
+ - [ ] **Step 1: Write failing schema tests**
148
+
149
+ Write `tests/test_schema.py`:
150
+
151
+ ```python
152
+ from dream_customs.schema import DreamIntake, PactCard
153
+
154
+
155
+ def test_dream_intake_defaults_lists():
156
+ intake = DreamIntake(dream_text="I missed an elevator.")
157
+ assert intake.dream_text == "I missed an elevator."
158
+ assert intake.visual_clues == []
159
+ assert intake.recurring_symbols == []
160
+
161
+
162
+ def test_pact_card_requires_core_fields():
163
+ card = PactCard(
164
+ visitor_name="Late Elevator",
165
+ permit_id="DC-0001",
166
+ contraband=["unfiled anxiety"],
167
+ risk_level="orange",
168
+ alliance_reading="The dream asks for a smaller start.",
169
+ practical_suggestion="Open one task ten minutes early.",
170
+ weird_task="Write the elevator an apology note.",
171
+ bedtime_release="Today the elevator has docked.",
172
+ )
173
+ assert card.safety_note == ""
174
+ assert "Late Elevator" in card.to_plain_text()
175
+ ```
176
+
177
+ - [ ] **Step 2: Run test to verify failure**
178
+
179
+ Run:
180
+
181
+ ```bash
182
+ python -m pytest tests/test_schema.py -q
183
+ ```
184
+
185
+ Expected: FAIL with `ModuleNotFoundError` or missing model classes.
186
+
187
+ - [ ] **Step 3: Implement schemas**
188
+
189
+ Write `dream_customs/schema.py`:
190
+
191
+ ```python
192
+ from pydantic import BaseModel, Field
193
+
194
+
195
+ class DreamIntake(BaseModel):
196
+ dream_text: str = ""
197
+ voice_transcript: str = ""
198
+ visual_clues: list[str] = Field(default_factory=list)
199
+ mood: str = ""
200
+ recurring_symbols: list[str] = Field(default_factory=list)
201
+ uncertainty: str = ""
202
+ user_context: str = ""
203
+
204
+ def merged_text(self) -> str:
205
+ parts = [
206
+ self.dream_text.strip(),
207
+ self.voice_transcript.strip(),
208
+ "Visual clues: " + ", ".join(self.visual_clues) if self.visual_clues else "",
209
+ "Mood: " + self.mood.strip() if self.mood else "",
210
+ "Recurring symbols: " + ", ".join(self.recurring_symbols) if self.recurring_symbols else "",
211
+ "Uncertainty: " + self.uncertainty.strip() if self.uncertainty else "",
212
+ "Context: " + self.user_context.strip() if self.user_context else "",
213
+ ]
214
+ return "\n".join(part for part in parts if part)
215
+
216
+
217
+ class PactCard(BaseModel):
218
+ visitor_name: str
219
+ permit_id: str
220
+ contraband: list[str]
221
+ risk_level: str
222
+ alliance_reading: str
223
+ practical_suggestion: str
224
+ weird_task: str
225
+ bedtime_release: str
226
+ safety_note: str = ""
227
+
228
+ def to_plain_text(self) -> str:
229
+ contraband = ", ".join(self.contraband)
230
+ lines = [
231
+ f"Dream visitor: {self.visitor_name}",
232
+ f"Permit: {self.permit_id}",
233
+ f"Contraband: {contraband}",
234
+ f"Risk level: {self.risk_level}",
235
+ f"Alliance reading: {self.alliance_reading}",
236
+ f"Today's suggestion: {self.practical_suggestion}",
237
+ f"Weird task: {self.weird_task}",
238
+ f"Bedtime release: {self.bedtime_release}",
239
+ ]
240
+ if self.safety_note:
241
+ lines.append(f"Safety note: {self.safety_note}")
242
+ return "\n".join(lines)
243
+ ```
244
+
245
+ - [ ] **Step 4: Run schema tests**
246
+
247
+ Run:
248
+
249
+ ```bash
250
+ python -m pytest tests/test_schema.py -q
251
+ ```
252
+
253
+ Expected: PASS.
254
+
255
+ - [ ] **Step 5: Commit schemas**
256
+
257
+ ```bash
258
+ git add dream_customs/schema.py tests/test_schema.py
259
+ git commit -m "feat: add dream customs schemas"
260
+ ```
261
+
262
+ ## Task 3: Safety Layer
263
+
264
+ **Files:**
265
+ - Create: `dream_customs/safety.py`
266
+ - Create: `tests/test_safety.py`
267
+
268
+ - [ ] **Step 1: Write failing safety tests**
269
+
270
+ Write `tests/test_safety.py`:
271
+
272
+ ```python
273
+ from dream_customs.safety import needs_escalation, safety_note
274
+
275
+
276
+ def test_ordinary_dream_does_not_escalate():
277
+ assert not needs_escalation("I dreamed about a strange elevator.")
278
+
279
+
280
+ def test_self_harm_text_escalates():
281
+ assert needs_escalation("I might hurt myself if I cannot sleep.")
282
+
283
+
284
+ def test_safety_note_mentions_professional_support():
285
+ assert "professional support" in safety_note().lower()
286
+ ```
287
+
288
+ - [ ] **Step 2: Run test to verify failure**
289
+
290
+ Run:
291
+
292
+ ```bash
293
+ python -m pytest tests/test_safety.py -q
294
+ ```
295
+
296
+ Expected: FAIL with missing module or functions.
297
+
298
+ - [ ] **Step 3: Implement safety functions**
299
+
300
+ Write `dream_customs/safety.py`:
301
+
302
+ ```python
303
+ ESCALATION_TERMS = (
304
+ "hurt myself",
305
+ "kill myself",
306
+ "suicide",
307
+ "self-harm",
308
+ "hurt someone",
309
+ "many nights",
310
+ "cannot function",
311
+ "panic attack",
312
+ "想伤害自己",
313
+ "自杀",
314
+ "伤害别人",
315
+ "很多天睡不着",
316
+ "无法正常生活",
317
+ )
318
+
319
+
320
+ def needs_escalation(text: str) -> bool:
321
+ lowered = text.lower()
322
+ return any(term in lowered for term in ESCALATION_TERMS)
323
+
324
+
325
+ def safety_note() -> str:
326
+ return (
327
+ "This dream sounds heavier than a playful customs ritual should handle. "
328
+ "If you feel unsafe, cannot sleep for many nights, or worry you may hurt "
329
+ "yourself or someone else, please reach out to a trusted person or professional support now."
330
+ )
331
+ ```
332
+
333
+ - [ ] **Step 4: Run safety tests**
334
+
335
+ Run:
336
+
337
+ ```bash
338
+ python -m pytest tests/test_safety.py -q
339
+ ```
340
+
341
+ Expected: PASS.
342
+
343
+ - [ ] **Step 5: Commit safety layer**
344
+
345
+ ```bash
346
+ git add dream_customs/safety.py tests/test_safety.py
347
+ git commit -m "feat: add dream customs safety layer"
348
+ ```
349
+
350
+ ## Task 4: Prompt Templates
351
+
352
+ **Files:**
353
+ - Create: `dream_customs/prompts.py`
354
+
355
+ - [ ] **Step 1: Create prompt module**
356
+
357
+ Write `dream_customs/prompts.py`:
358
+
359
+ ```python
360
+ from dream_customs.schema import DreamIntake
361
+
362
+
363
+ def visual_clue_prompt() -> str:
364
+ return (
365
+ "You are the witness clerk at Dream Customs. Extract concise visual clues "
366
+ "from this dream sketch, note, screenshot, or photo. Return JSON with keys: "
367
+ "objects, places, visible_text, colors, mood_cues, uncertain_details. "
368
+ "Do not diagnose the user."
369
+ )
370
+
371
+
372
+ def negotiation_prompt(intake: DreamIntake) -> str:
373
+ return f"""
374
+ You are the Dream Customs diplomat. The user is not asking for diagnosis.
375
+ Treat the dream as a strange visitor that can form a small pact with the user.
376
+
377
+ Dream intake:
378
+ {intake.merged_text()}
379
+
380
+ Return JSON with:
381
+ - visitor_name: short vivid name
382
+ - questions: 2 or 3 gentle, specific, slightly weird questions
383
+ - tone_note: one sentence explaining the visitor without certainty
384
+ """.strip()
385
+
386
+
387
+ def pact_prompt(intake: DreamIntake, answers: str) -> str:
388
+ return f"""
389
+ You are the Dream Customs diplomat. Generate a final Today's Pact card.
390
+ Do not diagnose. Do not claim the dream has one certain meaning.
391
+ Give one practical next-day suggestion and one weird task doable in 5 minutes.
392
+
393
+ Dream intake:
394
+ {intake.merged_text()}
395
+
396
+ User answers:
397
+ {answers}
398
+
399
+ Return strict JSON with:
400
+ visitor_name, permit_id, contraband, risk_level, alliance_reading,
401
+ practical_suggestion, weird_task, bedtime_release, safety_note.
402
+ """.strip()
403
+ ```
404
+
405
+ - [ ] **Step 2: Commit prompts**
406
+
407
+ ```bash
408
+ git add dream_customs/prompts.py
409
+ git commit -m "feat: add dream customs prompts"
410
+ ```
411
+
412
+ ## Task 5: HTML Renderer
413
+
414
+ **Files:**
415
+ - Create: `dream_customs/render.py`
416
+ - Create: `tests/test_render.py`
417
+
418
+ - [ ] **Step 1: Write failing render tests**
419
+
420
+ Write `tests/test_render.py`:
421
+
422
+ ```python
423
+ from dream_customs.render import render_pact_card
424
+ from dream_customs.schema import PactCard
425
+
426
+
427
+ def test_render_pact_card_contains_core_fields():
428
+ html = render_pact_card(
429
+ PactCard(
430
+ visitor_name="Late Elevator",
431
+ permit_id="DC-1",
432
+ contraband=["unfiled anxiety"],
433
+ risk_level="orange",
434
+ alliance_reading="The dream asks for a smaller start.",
435
+ practical_suggestion="Open one task ten minutes early.",
436
+ weird_task="Write the elevator an apology note.",
437
+ bedtime_release="Today the elevator has docked.",
438
+ )
439
+ )
440
+ assert "Late Elevator" in html
441
+ assert "Today's Pact" in html
442
+ assert "<script" not in html.lower()
443
+ ```
444
+
445
+ - [ ] **Step 2: Run test to verify failure**
446
+
447
+ Run:
448
+
449
+ ```bash
450
+ python -m pytest tests/test_render.py -q
451
+ ```
452
+
453
+ Expected: FAIL with missing renderer.
454
+
455
+ - [ ] **Step 3: Implement renderer**
456
+
457
+ Write `dream_customs/render.py`:
458
+
459
+ ```python
460
+ from html import escape
461
+
462
+ from dream_customs.schema import PactCard
463
+
464
+
465
+ def render_pact_card(card: PactCard) -> str:
466
+ contraband = "".join(f"<li>{escape(item)}</li>" for item in card.contraband)
467
+ safety = f"<p class='safety'>{escape(card.safety_note)}</p>" if card.safety_note else ""
468
+ return f"""
469
+ <section class="pact-card">
470
+ <div class="pact-header">
471
+ <span>Dream Customs</span>
472
+ <span>{escape(card.permit_id)}</span>
473
+ </div>
474
+ <h2>Today's Pact</h2>
475
+ <h3>{escape(card.visitor_name)}</h3>
476
+ <p><strong>Risk:</strong> {escape(card.risk_level)}</p>
477
+ <p><strong>Alliance:</strong> {escape(card.alliance_reading)}</p>
478
+ <p><strong>Suggestion:</strong> {escape(card.practical_suggestion)}</p>
479
+ <p><strong>Weird task:</strong> {escape(card.weird_task)}</p>
480
+ <p><strong>Bedtime release:</strong> {escape(card.bedtime_release)}</p>
481
+ <div><strong>Contraband</strong><ul>{contraband}</ul></div>
482
+ {safety}
483
+ </section>
484
+ """.strip()
485
+ ```
486
+
487
+ - [ ] **Step 4: Run render tests**
488
+
489
+ Run:
490
+
491
+ ```bash
492
+ python -m pytest tests/test_render.py -q
493
+ ```
494
+
495
+ Expected: PASS.
496
+
497
+ - [ ] **Step 5: Commit renderer**
498
+
499
+ ```bash
500
+ git add dream_customs/render.py tests/test_render.py
501
+ git commit -m "feat: render dream pact card"
502
+ ```
503
+
504
+ ## Task 6: Fake Model Clients And Pipeline
505
+
506
+ **Files:**
507
+ - Create: `dream_customs/models.py`
508
+ - Create: `dream_customs/pipeline.py`
509
+ - Create: `tests/test_pipeline.py`
510
+
511
+ - [ ] **Step 1: Write failing pipeline tests**
512
+
513
+ Write `tests/test_pipeline.py`:
514
+
515
+ ```python
516
+ from dream_customs.models import FakeTextClient, FakeVisionClient
517
+ from dream_customs.pipeline import build_intake, generate_pact
518
+
519
+
520
+ def test_build_intake_merges_modalities():
521
+ intake = build_intake(
522
+ dream_text="I missed an elevator.",
523
+ voice_transcript="The buttons melted.",
524
+ visual_clues=["blue hallway"],
525
+ mood="anxious",
526
+ )
527
+ assert "elevator" in intake.merged_text()
528
+ assert "blue hallway" in intake.merged_text()
529
+
530
+
531
+ def test_generate_pact_returns_card_and_html():
532
+ intake = build_intake(dream_text="I missed an elevator.", mood="anxious")
533
+ card, html = generate_pact(intake, "I want a small start.", FakeTextClient())
534
+ assert card.visitor_name
535
+ assert "Today's Pact" in html
536
+ ```
537
+
538
+ - [ ] **Step 2: Run test to verify failure**
539
+
540
+ Run:
541
+
542
+ ```bash
543
+ python -m pytest tests/test_pipeline.py -q
544
+ ```
545
+
546
+ Expected: FAIL with missing modules.
547
+
548
+ - [ ] **Step 3: Implement fake model clients**
549
+
550
+ Write `dream_customs/models.py`:
551
+
552
+ ```python
553
+ from dream_customs.schema import PactCard
554
+
555
+
556
+ class FakeVisionClient:
557
+ def extract_clues(self, image_path: str | None) -> list[str]:
558
+ if not image_path:
559
+ return []
560
+ return ["blue hallway", "melted elevator buttons", "number 14"]
561
+
562
+
563
+ class FakeTextClient:
564
+ def generate_pact(self, prompt: str) -> PactCard:
565
+ return PactCard(
566
+ visitor_name="Late Elevator",
567
+ permit_id="DC-DEMO-014",
568
+ contraband=["unfiled anxiety", "melted buttons"],
569
+ risk_level="orange: needs placement, not fear",
570
+ alliance_reading="This visitor asks you to separate starting from finishing.",
571
+ practical_suggestion="Open one small task ten minutes early.",
572
+ weird_task="Write the elevator a one-sentence apology note.",
573
+ bedtime_release="Today the elevator has docked; unfinished floors report tomorrow.",
574
+ )
575
+ ```
576
+
577
+ - [ ] **Step 4: Implement pipeline**
578
+
579
+ Write `dream_customs/pipeline.py`:
580
+
581
+ ```python
582
+ from dream_customs.prompts import pact_prompt
583
+ from dream_customs.render import render_pact_card
584
+ from dream_customs.safety import needs_escalation, safety_note
585
+ from dream_customs.schema import DreamIntake, PactCard
586
+
587
+
588
+ def build_intake(
589
+ dream_text: str = "",
590
+ voice_transcript: str = "",
591
+ visual_clues: list[str] | None = None,
592
+ mood: str = "",
593
+ recurring_symbols: list[str] | None = None,
594
+ uncertainty: str = "",
595
+ user_context: str = "",
596
+ ) -> DreamIntake:
597
+ return DreamIntake(
598
+ dream_text=dream_text,
599
+ voice_transcript=voice_transcript,
600
+ visual_clues=visual_clues or [],
601
+ mood=mood,
602
+ recurring_symbols=recurring_symbols or [],
603
+ uncertainty=uncertainty,
604
+ user_context=user_context,
605
+ )
606
+
607
+
608
+ def generate_pact(intake: DreamIntake, answers: str, text_client) -> tuple[PactCard, str]:
609
+ prompt = pact_prompt(intake, answers)
610
+ card = text_client.generate_pact(prompt)
611
+ merged = intake.merged_text() + "\n" + answers
612
+ if needs_escalation(merged):
613
+ card.safety_note = safety_note()
614
+ return card, render_pact_card(card)
615
+ ```
616
+
617
+ - [ ] **Step 5: Run pipeline tests**
618
+
619
+ Run:
620
+
621
+ ```bash
622
+ python -m pytest tests/test_pipeline.py -q
623
+ ```
624
+
625
+ Expected: PASS.
626
+
627
+ - [ ] **Step 6: Commit pipeline**
628
+
629
+ ```bash
630
+ git add dream_customs/models.py dream_customs/pipeline.py tests/test_pipeline.py
631
+ git commit -m "feat: add dream customs pipeline"
632
+ ```
633
+
634
+ ## Task 7: Gradio App
635
+
636
+ **Files:**
637
+ - Create: `app.py`
638
+
639
+ - [ ] **Step 1: Implement Gradio UI with fake clients**
640
+
641
+ Write `app.py`:
642
+
643
+ ```python
644
+ import gradio as gr
645
+
646
+ from dream_customs.models import FakeTextClient, FakeVisionClient
647
+ from dream_customs.pipeline import build_intake, generate_pact
648
+
649
+
650
+ vision_client = FakeVisionClient()
651
+ text_client = FakeTextClient()
652
+
653
+
654
+ def run_customs(dream_text, image, audio, mood):
655
+ visual_clues = vision_client.extract_clues(image)
656
+ voice_transcript = ""
657
+ if audio:
658
+ voice_transcript = "Audio received. ASR adapter will transcribe this in the next implementation pass."
659
+ intake = build_intake(
660
+ dream_text=dream_text or "",
661
+ voice_transcript=voice_transcript,
662
+ visual_clues=visual_clues,
663
+ mood=mood or "",
664
+ user_context="User wants a gentle next-day suggestion after vivid dreams.",
665
+ )
666
+ card, html = generate_pact(intake, "User accepted a small pact.", text_client)
667
+ return intake.model_dump(), card.to_plain_text(), html
668
+
669
+
670
+ with gr.Blocks(title="Dream Customs") as demo:
671
+ gr.Markdown("# Dream Customs / 梦境海关")
672
+ gr.Markdown("Declare last night's dream by text, image, or voice. Get a playful pact for today.")
673
+ with gr.Row():
674
+ dream_text = gr.Textbox(label="Dream text", lines=6)
675
+ image = gr.Image(label="Dream sketch or bedside note", type="filepath")
676
+ audio = gr.Audio(label="Voice note", type="filepath")
677
+ mood = gr.Textbox(label="Wake-up mood", placeholder="anxious, amused, foggy...")
678
+ submit = gr.Button("Submit to Customs")
679
+ intake_json = gr.JSON(label="Dream Intake")
680
+ plain = gr.Textbox(label="Plain text result", lines=10)
681
+ card = gr.HTML(label="Today's Pact Card")
682
+ submit.click(run_customs, [dream_text, image, audio, mood], [intake_json, plain, card])
683
+
684
+
685
+ if __name__ == "__main__":
686
+ demo.launch()
687
+ ```
688
+
689
+ - [ ] **Step 2: Run app smoke test**
690
+
691
+ Run:
692
+
693
+ ```bash
694
+ python app.py
695
+ ```
696
+
697
+ Expected: local Gradio URL appears and the app loads.
698
+
699
+ - [ ] **Step 3: Commit Gradio app**
700
+
701
+ ```bash
702
+ git add app.py
703
+ git commit -m "feat: add dream customs gradio app"
704
+ ```
705
+
706
+ ## Task 8: Real Model Adapter Pass
707
+
708
+ **Files:**
709
+ - Modify: `dream_customs/models.py`
710
+ - Modify: `requirements.txt`
711
+ - Add tests only if wrappers can be mocked without downloading models.
712
+
713
+ - [ ] **Step 1: Add interface classes without loading models at import time**
714
+
715
+ Modify `dream_customs/models.py` to add lazy classes:
716
+
717
+ ```python
718
+ class MiniCPMVisionClient:
719
+ def __init__(self, model_name: str = "openbmb/MiniCPM-V-4.6"):
720
+ self.model_name = model_name
721
+ self._pipe = None
722
+
723
+ def _load(self):
724
+ if self._pipe is None:
725
+ from transformers import pipeline
726
+ self._pipe = pipeline("image-text-to-text", model=self.model_name)
727
+ return self._pipe
728
+
729
+ def extract_clues(self, image_path: str | None) -> list[str]:
730
+ if not image_path:
731
+ return []
732
+ pipe = self._load()
733
+ messages = [{
734
+ "role": "user",
735
+ "content": [
736
+ {"type": "image", "path": image_path},
737
+ {"type": "text", "text": "Extract concise dream-like visual clues as a comma-separated list. Do not diagnose."},
738
+ ],
739
+ }]
740
+ result = pipe(text=messages)
741
+ text = str(result)
742
+ return [part.strip() for part in text.replace("\n", ",").split(",") if part.strip()][:8]
743
+ ```
744
+
745
+ - [ ] **Step 2: Keep fake clients as default for tests**
746
+
747
+ Do not make real models load during pytest. The Gradio app may keep fake clients until model runtime is validated.
748
+
749
+ - [ ] **Step 3: Commit adapters**
750
+
751
+ ```bash
752
+ git add dream_customs/models.py requirements.txt
753
+ git commit -m "feat: add minicpm model adapter skeleton"
754
+ ```
755
+
756
+ ## Task 9: Space Packaging And Final Checks
757
+
758
+ **Files:**
759
+ - Modify: `README.md`
760
+ - Create: `.gitignore`
761
+
762
+ - [ ] **Step 1: Add `.gitignore`**
763
+
764
+ Write `.gitignore`:
765
+
766
+ ```text
767
+ .venv/
768
+ __pycache__/
769
+ .pytest_cache/
770
+ *.pyc
771
+ .DS_Store
772
+ .superpowers/
773
+ ```
774
+
775
+ - [ ] **Step 2: Run all tests**
776
+
777
+ Run:
778
+
779
+ ```bash
780
+ python -m pytest -q
781
+ ```
782
+
783
+ Expected: all tests pass.
784
+
785
+ - [ ] **Step 3: Run app**
786
+
787
+ Run:
788
+
789
+ ```bash
790
+ python app.py
791
+ ```
792
+
793
+ Expected: app launches; submit sample dream; final card renders.
794
+
795
+ - [ ] **Step 4: Commit final packaging**
796
+
797
+ ```bash
798
+ git add .gitignore README.md
799
+ git commit -m "chore: prepare dream customs space packaging"
800
+ ```
801
+
802
+ ## Self-Review
803
+
804
+ - Spec coverage: text, image, voice, DreamIntake, MiniCPM roles, safety, card output, and Gradio are covered.
805
+ - Placeholder scan: no planned step uses unresolved placeholder tokens as an implementation substitute.
806
+ - Type consistency: `DreamIntake`, `PactCard`, `FakeTextClient`, and `FakeVisionClient` are defined before use.
807
+
808
+ ## Execution Choice
809
+
810
+ Plan complete and saved to `docs/superpowers/plans/2026-06-05-dream-customs-mvp.md`.
811
+
812
+ Recommended execution option: Subagent-Driven. Use one fresh worker per task, review between tasks, and keep commits small.
dream_customs/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Dream Customs package."""
dream_customs/app_logic.py ADDED
@@ -0,0 +1,102 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import os
3
+ from typing import Any, Tuple
4
+
5
+ from dream_customs.models import (
6
+ FakeASRClient,
7
+ FakeTextClient,
8
+ FakeVisionClient,
9
+ OllamaTextClient,
10
+ OllamaVisionClient,
11
+ )
12
+ from dream_customs.pipeline import generate_negotiation, generate_pact, intake_from_modalities
13
+
14
+
15
+ DEFAULT_TEXT_MODEL = "hf.co/openbmb/MiniCPM5-1B-GGUF:Q8_0"
16
+ DEFAULT_VISION_MODEL = "openbmb/minicpm-v4.6"
17
+
18
+
19
+ def _file_path(value: Any) -> str:
20
+ if value is None:
21
+ return ""
22
+ if isinstance(value, str):
23
+ return value
24
+ if isinstance(value, dict):
25
+ return str(value.get("path") or value.get("name") or "")
26
+ if isinstance(value, (list, tuple)) and value:
27
+ return _file_path(value[0])
28
+ return ""
29
+
30
+
31
+ def _clients(text_backend: str, vision_backend: str):
32
+ text_backend = (text_backend or "demo").lower()
33
+ vision_backend = (vision_backend or "demo").lower()
34
+ if text_backend == "ollama":
35
+ text_client = OllamaTextClient(
36
+ model_name=os.getenv("DREAM_CUSTOMS_TEXT_MODEL", DEFAULT_TEXT_MODEL),
37
+ base_url=os.getenv("DREAM_CUSTOMS_OLLAMA_URL", "http://localhost:11434"),
38
+ )
39
+ else:
40
+ text_client = FakeTextClient()
41
+
42
+ if vision_backend == "ollama":
43
+ vision_client = OllamaVisionClient(
44
+ model_name=os.getenv("DREAM_CUSTOMS_VISION_MODEL", DEFAULT_VISION_MODEL),
45
+ base_url=os.getenv("DREAM_CUSTOMS_OLLAMA_URL", "http://localhost:11434"),
46
+ )
47
+ else:
48
+ vision_client = FakeVisionClient()
49
+
50
+ return text_client, vision_client, FakeASRClient()
51
+
52
+
53
+ def run_customs_once(
54
+ dream_text: str,
55
+ image_value: Any = None,
56
+ audio_value: Any = None,
57
+ mood: str = "",
58
+ answers: str = "",
59
+ text_backend: str = "demo",
60
+ vision_backend: str = "demo",
61
+ ) -> Tuple[str, str, str, str]:
62
+ image_path = _file_path(image_value)
63
+ audio_path = _file_path(audio_value)
64
+ if not any([dream_text and dream_text.strip(), image_path, audio_path]):
65
+ return (
66
+ "No declaration received.",
67
+ "Please add text, an image, or a voice note before requesting clearance.",
68
+ "",
69
+ json.dumps({"status": "empty"}, ensure_ascii=False, indent=2),
70
+ )
71
+
72
+ text_client, vision_client, asr_client = _clients(text_backend, vision_backend)
73
+ intake = intake_from_modalities(
74
+ dream_text=dream_text or "",
75
+ image_path=image_path or None,
76
+ audio_path=audio_path or None,
77
+ mood=mood or "",
78
+ vision_client=vision_client,
79
+ asr_client=asr_client,
80
+ )
81
+ negotiation = generate_negotiation(intake, text_client)
82
+ answer_text = answers or "The user has not answered yet; infer a gentle pact from the declaration."
83
+ card, html = generate_pact(intake, answer_text, text_client)
84
+ questions = "\n".join(f"{index}. {question}" for index, question in enumerate(negotiation["questions"], start=1))
85
+ negotiation_text = "\n".join(
86
+ part
87
+ for part in [
88
+ f"Visitor: {negotiation['visitor_name']}",
89
+ questions,
90
+ negotiation.get("tone_note", ""),
91
+ ]
92
+ if part
93
+ )
94
+ debug = {
95
+ "status": "ok",
96
+ "text_backend": text_backend,
97
+ "vision_backend": vision_backend,
98
+ "intake": intake.model_dump(),
99
+ "negotiation": negotiation,
100
+ "pact": card.model_dump(),
101
+ }
102
+ return negotiation_text, card.to_plain_text(), html, json.dumps(debug, ensure_ascii=False, indent=2)
dream_customs/models.py ADDED
@@ -0,0 +1,302 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import base64
2
+ import json
3
+ import re
4
+ import urllib.error
5
+ import urllib.request
6
+ from typing import Any, Dict, List, Optional
7
+
8
+ from dream_customs.prompts import visual_clue_prompt
9
+ from dream_customs.schema import PactCard
10
+
11
+
12
+ def _contains_cjk(text: str) -> bool:
13
+ return bool(re.search(r"[\u4e00-\u9fff]", text))
14
+
15
+
16
+ class FakeVisionClient:
17
+ def extract_clues(self, image_path: Optional[str]) -> List[str]:
18
+ if not image_path:
19
+ return []
20
+ return ["blue hallway", "melted elevator buttons", "number 14"]
21
+
22
+
23
+ class FakeASRClient:
24
+ def transcribe(self, audio_path: Optional[str]) -> str:
25
+ if not audio_path:
26
+ return ""
27
+ return "The buttons melted and I could not catch the elevator."
28
+
29
+
30
+ class FakeTextClient:
31
+ def generate_negotiation(self, prompt: str) -> Dict[str, Any]:
32
+ if _contains_cjk(prompt):
33
+ return {
34
+ "visitor_name": "迟到的电梯",
35
+ "questions": [
36
+ "这部电梯是在阻止你,还是在替你争取一点慢下来的时间?",
37
+ "如果它今天只允许你带走一件东西,你会选择哪件未完成的小事?",
38
+ "你愿意用一个 5 分钟动作和它交换放行章吗?",
39
+ ],
40
+ "tone_note": "这个来访者也许不是敌人,而是在提醒你把开始和完成分开。",
41
+ }
42
+ return {
43
+ "visitor_name": "Late Elevator",
44
+ "questions": [
45
+ "Was the elevator blocking you, or buying you a slower morning?",
46
+ "If it lets you carry one unfinished thing into today, which one is smallest?",
47
+ "Would you trade it a five-minute action for a release stamp?",
48
+ ],
49
+ "tone_note": "This visitor may be asking you to separate starting from finishing.",
50
+ }
51
+
52
+ def generate_pact(self, prompt: str) -> PactCard:
53
+ if _contains_cjk(prompt):
54
+ return PactCard(
55
+ visitor_name="迟到的电梯",
56
+ permit_id="DC-DEMO-014",
57
+ contraband=["未申报的焦虑", "融化的按钮", "一小袋没来得及开始的事"],
58
+ risk_level="橙色:需要被安置,但不需要被害怕",
59
+ alliance_reading="这个梦也许在提醒你,今天先把启动一件事和完成一件事分开。",
60
+ practical_suggestion="提前 10 分钟打开一个最小任务,只要求开始,不要求完成。",
61
+ weird_task="给电梯写一句道歉信:抱歉总让你替我背迟到的锅。",
62
+ bedtime_release="今日电梯已停靠,未完成事项明日再报关。",
63
+ )
64
+ return PactCard(
65
+ visitor_name="Late Elevator",
66
+ permit_id="DC-DEMO-014",
67
+ contraband=["unfiled anxiety", "melted buttons", "one pouch of unstarted tasks"],
68
+ risk_level="orange: needs placement, not fear",
69
+ alliance_reading="This visitor asks you to separate starting from finishing.",
70
+ practical_suggestion="Open one small task ten minutes early. You only need to start it.",
71
+ weird_task="Write the elevator a one-sentence apology note.",
72
+ bedtime_release="Today the elevator has docked; unfinished floors report tomorrow.",
73
+ )
74
+
75
+
76
+ def _strip_markdown_and_thinking(text: str) -> str:
77
+ cleaned = text.strip()
78
+ cleaned = re.sub(r"<think>.*?</think>", "", cleaned, flags=re.DOTALL).strip()
79
+ cleaned = cleaned.replace("<think>", "").replace("</think>", "").strip()
80
+ if cleaned.startswith("```"):
81
+ cleaned = re.sub(r"^```(?:json)?", "", cleaned.strip(), flags=re.IGNORECASE).strip()
82
+ cleaned = re.sub(r"```$", "", cleaned).strip()
83
+ return cleaned
84
+
85
+
86
+ def _extract_json_object(text: str) -> Optional[Dict[str, Any]]:
87
+ cleaned = _strip_markdown_and_thinking(text)
88
+ try:
89
+ parsed = json.loads(cleaned)
90
+ return parsed if isinstance(parsed, dict) else None
91
+ except json.JSONDecodeError:
92
+ pass
93
+
94
+ start = cleaned.find("{")
95
+ if start < 0:
96
+ return None
97
+
98
+ depth = 0
99
+ in_string = False
100
+ escaped = False
101
+ for index, char in enumerate(cleaned[start:], start=start):
102
+ if in_string:
103
+ if escaped:
104
+ escaped = False
105
+ elif char == "\\":
106
+ escaped = True
107
+ elif char == '"':
108
+ in_string = False
109
+ continue
110
+ if char == '"':
111
+ in_string = True
112
+ elif char == "{":
113
+ depth += 1
114
+ elif char == "}":
115
+ depth -= 1
116
+ if depth == 0:
117
+ try:
118
+ parsed = json.loads(cleaned[start : index + 1])
119
+ except json.JSONDecodeError:
120
+ return None
121
+ return parsed if isinstance(parsed, dict) else None
122
+ return None
123
+
124
+
125
+ def _as_string_list(value: Any) -> List[str]:
126
+ if isinstance(value, list):
127
+ return [str(item).strip() for item in value if str(item).strip()]
128
+ if isinstance(value, str) and value.strip():
129
+ return [part.strip() for part in re.split(r"[,,\n]", value) if part.strip()]
130
+ return []
131
+
132
+
133
+ class OllamaTextClient:
134
+ def __init__(
135
+ self,
136
+ model_name: str = "hf.co/openbmb/MiniCPM5-1B-GGUF:Q8_0",
137
+ base_url: str = "http://localhost:11434",
138
+ timeout: float = 45.0,
139
+ fallback: Optional[FakeTextClient] = None,
140
+ ):
141
+ self.model_name = model_name
142
+ self.base_url = base_url.rstrip("/")
143
+ self.timeout = timeout
144
+ self.fallback = fallback or FakeTextClient()
145
+
146
+ def _post_generate(self, prompt: str, num_predict: int = 512) -> Dict[str, Any]:
147
+ payload = {
148
+ "model": self.model_name,
149
+ "prompt": prompt,
150
+ "stream": False,
151
+ "options": {"temperature": 0.2, "num_predict": num_predict},
152
+ }
153
+ data = json.dumps(payload).encode("utf-8")
154
+ request = urllib.request.Request(
155
+ f"{self.base_url}/api/generate",
156
+ data=data,
157
+ headers={"Content-Type": "application/json"},
158
+ method="POST",
159
+ )
160
+ with urllib.request.urlopen(request, timeout=self.timeout) as response:
161
+ return json.loads(response.read().decode("utf-8"))
162
+
163
+ def _generate_json(self, prompt: str, schema_hint: str, num_predict: int = 512) -> Optional[Dict[str, Any]]:
164
+ strict_prompt = (
165
+ f"{prompt}\n\n"
166
+ "Return only a single valid JSON object. No markdown, no code fences, no hidden reasoning.\n"
167
+ f"Required schema: {schema_hint}"
168
+ )
169
+ try:
170
+ response = self._post_generate(strict_prompt, num_predict=num_predict)
171
+ except (OSError, TimeoutError, urllib.error.URLError, json.JSONDecodeError):
172
+ return None
173
+ return _extract_json_object(str(response.get("response", "")))
174
+
175
+ def generate_negotiation(self, prompt: str) -> Dict[str, Any]:
176
+ parsed = self._generate_json(
177
+ prompt,
178
+ '{"visitor_name":"string","questions":["string","string"],"tone_note":"string"}',
179
+ num_predict=320,
180
+ )
181
+ if not parsed:
182
+ return self.fallback.generate_negotiation(prompt)
183
+ questions = _as_string_list(parsed.get("questions"))
184
+ if not parsed.get("visitor_name") or not questions:
185
+ return self.fallback.generate_negotiation(prompt)
186
+ return {
187
+ "visitor_name": str(parsed.get("visitor_name", "")).strip(),
188
+ "questions": questions[:3],
189
+ "tone_note": str(parsed.get("tone_note", "")).strip(),
190
+ }
191
+
192
+ def generate_pact(self, prompt: str) -> PactCard:
193
+ parsed = self._generate_json(
194
+ prompt,
195
+ (
196
+ '{"visitor_name":"string","permit_id":"string","contraband":["string"],'
197
+ '"risk_level":"string","alliance_reading":"string","practical_suggestion":"string",'
198
+ '"weird_task":"string","bedtime_release":"string","safety_note":"string"}'
199
+ ),
200
+ num_predict=700,
201
+ )
202
+ if not parsed:
203
+ return self.fallback.generate_pact(prompt)
204
+ try:
205
+ return PactCard(
206
+ visitor_name=str(parsed["visitor_name"]).strip(),
207
+ permit_id=str(parsed["permit_id"]).strip(),
208
+ contraband=_as_string_list(parsed["contraband"]) or ["unfiled dream fragment"],
209
+ risk_level=str(parsed["risk_level"]).strip(),
210
+ alliance_reading=str(parsed["alliance_reading"]).strip(),
211
+ practical_suggestion=str(parsed["practical_suggestion"]).strip(),
212
+ weird_task=str(parsed["weird_task"]).strip(),
213
+ bedtime_release=str(parsed["bedtime_release"]).strip(),
214
+ safety_note=str(parsed.get("safety_note", "")).strip(),
215
+ )
216
+ except (KeyError, TypeError, ValueError):
217
+ return self.fallback.generate_pact(prompt)
218
+
219
+
220
+ class OllamaVisionClient:
221
+ def __init__(
222
+ self,
223
+ model_name: str = "openbmb/minicpm-v4.6",
224
+ base_url: str = "http://localhost:11434",
225
+ timeout: float = 60.0,
226
+ ):
227
+ self.model_name = model_name
228
+ self.base_url = base_url.rstrip("/")
229
+ self.timeout = timeout
230
+
231
+ def _post_generate(self, prompt: str, image_b64: str, num_predict: int = 256) -> Dict[str, Any]:
232
+ payload = {
233
+ "model": self.model_name,
234
+ "prompt": prompt,
235
+ "images": [image_b64],
236
+ "stream": False,
237
+ "options": {"temperature": 0.1, "num_predict": num_predict},
238
+ }
239
+ data = json.dumps(payload).encode("utf-8")
240
+ request = urllib.request.Request(
241
+ f"{self.base_url}/api/generate",
242
+ data=data,
243
+ headers={"Content-Type": "application/json"},
244
+ method="POST",
245
+ )
246
+ with urllib.request.urlopen(request, timeout=self.timeout) as response:
247
+ return json.loads(response.read().decode("utf-8"))
248
+
249
+ def extract_clues(self, image_path: Optional[str]) -> List[str]:
250
+ if not image_path:
251
+ return []
252
+ try:
253
+ with open(image_path, "rb") as image_file:
254
+ image_b64 = base64.b64encode(image_file.read()).decode("ascii")
255
+ response = self._post_generate(visual_clue_prompt(), image_b64)
256
+ except (OSError, TimeoutError, urllib.error.URLError, json.JSONDecodeError):
257
+ return []
258
+
259
+ text = str(response.get("response", ""))
260
+ parsed = _extract_json_object(text)
261
+ if parsed:
262
+ clues: List[str] = []
263
+ for key in ("objects", "places", "visible_text", "colors", "mood_cues", "uncertain_details"):
264
+ clues.extend(_as_string_list(parsed.get(key)))
265
+ return clues[:8]
266
+ return [part.strip() for part in re.split(r"[,,\n]", _strip_markdown_and_thinking(text)) if part.strip()][:8]
267
+
268
+
269
+ class MiniCPMVisionClient:
270
+ def __init__(self, model_name: str = "openbmb/MiniCPM-V-4.6"):
271
+ self.model_name = model_name
272
+ self._pipe = None
273
+
274
+ def _load(self):
275
+ if self._pipe is None:
276
+ from transformers import pipeline
277
+
278
+ self._pipe = pipeline("image-text-to-text", model=self.model_name)
279
+ return self._pipe
280
+
281
+ def extract_clues(self, image_path: Optional[str]) -> List[str]:
282
+ if not image_path:
283
+ return []
284
+ pipe = self._load()
285
+ messages = [
286
+ {
287
+ "role": "user",
288
+ "content": [
289
+ {"type": "image", "path": image_path},
290
+ {
291
+ "type": "text",
292
+ "text": (
293
+ "Extract concise dream-like visual clues from this image. "
294
+ "Return a comma-separated list. Do not diagnose."
295
+ ),
296
+ },
297
+ ],
298
+ }
299
+ ]
300
+ result = pipe(text=messages)
301
+ text = str(result)
302
+ return [part.strip() for part in text.replace("\n", ",").split(",") if part.strip()][:8]
dream_customs/pipeline.py ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Dict, List, Optional, Tuple
2
+
3
+ from dream_customs.prompts import negotiation_prompt, pact_prompt
4
+ from dream_customs.render import render_pact_card
5
+ from dream_customs.safety import needs_escalation, safety_note
6
+ from dream_customs.schema import DreamIntake, PactCard
7
+
8
+
9
+ def build_intake(
10
+ dream_text: str = "",
11
+ voice_transcript: str = "",
12
+ visual_clues: Optional[List[str]] = None,
13
+ mood: str = "",
14
+ recurring_symbols: Optional[List[str]] = None,
15
+ uncertainty: str = "",
16
+ user_context: str = "",
17
+ ) -> DreamIntake:
18
+ return DreamIntake(
19
+ dream_text=dream_text,
20
+ voice_transcript=voice_transcript,
21
+ visual_clues=visual_clues or [],
22
+ mood=mood,
23
+ recurring_symbols=recurring_symbols or [],
24
+ uncertainty=uncertainty,
25
+ user_context=user_context,
26
+ )
27
+
28
+
29
+ def intake_from_modalities(
30
+ dream_text: str,
31
+ image_path: Optional[str],
32
+ audio_path: Optional[str],
33
+ mood: str,
34
+ vision_client,
35
+ asr_client,
36
+ user_context: str = "User wants a gentle next-day suggestion after vivid dreams.",
37
+ ) -> DreamIntake:
38
+ return build_intake(
39
+ dream_text=dream_text or "",
40
+ voice_transcript=asr_client.transcribe(audio_path),
41
+ visual_clues=vision_client.extract_clues(image_path),
42
+ mood=mood or "",
43
+ user_context=user_context,
44
+ )
45
+
46
+
47
+ def generate_negotiation(intake: DreamIntake, text_client) -> Dict:
48
+ prompt = negotiation_prompt(intake)
49
+ return text_client.generate_negotiation(prompt)
50
+
51
+
52
+ def generate_pact(intake: DreamIntake, answers: str, text_client) -> Tuple[PactCard, str]:
53
+ prompt = pact_prompt(intake, answers)
54
+ card = text_client.generate_pact(prompt)
55
+ merged = intake.merged_text() + "\n" + answers
56
+ if needs_escalation(merged):
57
+ card.safety_note = safety_note()
58
+ return card, render_pact_card(card)
dream_customs/prompts.py ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from dream_customs.schema import DreamIntake
2
+
3
+
4
+ def visual_clue_prompt() -> str:
5
+ return (
6
+ "You are the witness clerk at Dream Customs. Extract concise visual clues "
7
+ "from this dream sketch, note, screenshot, or photo. Return JSON with keys: "
8
+ "objects, places, visible_text, colors, mood_cues, uncertain_details. "
9
+ "Do not diagnose the user."
10
+ )
11
+
12
+
13
+ def negotiation_prompt(intake: DreamIntake) -> str:
14
+ return f"""
15
+ You are the Dream Customs diplomat. The user is not asking for diagnosis.
16
+ Treat the dream as a strange visitor that can form a small pact with the user.
17
+ The tone should be gentle, playful, and specific. Do not make medical claims.
18
+
19
+ Dream intake:
20
+ {intake.merged_text()}
21
+
22
+ Return JSON with:
23
+ - visitor_name: short vivid name
24
+ - questions: 2 or 3 gentle, specific, slightly weird questions
25
+ - tone_note: one sentence explaining the visitor without certainty
26
+ """.strip()
27
+
28
+
29
+ def pact_prompt(intake: DreamIntake, answers: str) -> str:
30
+ return f"""
31
+ You are the Dream Customs diplomat. Generate a final Today's Pact card.
32
+ Do not diagnose. Do not claim the dream has one certain meaning.
33
+ Give one practical next-day suggestion and one weird task doable in 5 minutes.
34
+ Use warm, non-clinical language. If the user wrote in Chinese, answer in Chinese.
35
+
36
+ Dream intake:
37
+ {intake.merged_text()}
38
+
39
+ User answers:
40
+ {answers}
41
+
42
+ Return strict JSON with:
43
+ visitor_name, permit_id, contraband, risk_level, alliance_reading,
44
+ practical_suggestion, weird_task, bedtime_release, safety_note.
45
+ """.strip()
dream_customs/render.py ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from html import escape
2
+
3
+ from dream_customs.schema import PactCard
4
+
5
+
6
+ def render_pact_card(card: PactCard) -> str:
7
+ contraband = "".join(f"<li>{escape(item)}</li>" for item in card.contraband)
8
+ safety = f"<p class='dc-safety'>{escape(card.safety_note)}</p>" if card.safety_note else ""
9
+ return f"""
10
+ <style>
11
+ .pact-card {{
12
+ color: #102c3a;
13
+ background: linear-gradient(135deg, #fff8ea 0%, #e8f4ef 100%);
14
+ border: 3px solid #102c3a;
15
+ border-radius: 10px;
16
+ box-shadow: 10px 10px 0 rgba(16, 44, 58, 0.14);
17
+ font-family: Georgia, 'Songti SC', serif;
18
+ line-height: 1.55;
19
+ margin: 8px 0;
20
+ max-width: 860px;
21
+ padding: 24px;
22
+ }}
23
+ .pact-header {{
24
+ align-items: center;
25
+ border-bottom: 2px dashed rgba(16, 44, 58, 0.25);
26
+ display: flex;
27
+ font-weight: 800;
28
+ justify-content: space-between;
29
+ margin-bottom: 16px;
30
+ padding-bottom: 10px;
31
+ }}
32
+ .pact-card h2 {{
33
+ font-size: 32px;
34
+ line-height: 1.1;
35
+ margin: 0 0 4px;
36
+ }}
37
+ .pact-card h3 {{
38
+ color: #e85d4f;
39
+ font-size: 24px;
40
+ margin: 0 0 18px;
41
+ }}
42
+ .dc-label {{
43
+ color: #36545c;
44
+ font-weight: 800;
45
+ }}
46
+ .dc-seal {{
47
+ border: 3px solid #e85d4f;
48
+ border-radius: 999px;
49
+ color: #e85d4f;
50
+ display: inline-block;
51
+ font-weight: 900;
52
+ margin-top: 12px;
53
+ padding: 8px 14px;
54
+ transform: rotate(-4deg);
55
+ }}
56
+ .dc-safety {{
57
+ background: rgba(232, 93, 79, 0.11);
58
+ border-left: 4px solid #e85d4f;
59
+ padding: 10px 12px;
60
+ }}
61
+ </style>
62
+ <section class="pact-card">
63
+ <div class="pact-header">
64
+ <span>Dream Customs</span>
65
+ <span>{escape(card.permit_id)}</span>
66
+ </div>
67
+ <h2>Today's Pact</h2>
68
+ <h3>{escape(card.visitor_name)}</h3>
69
+ <p><span class="dc-label">Risk:</span> {escape(card.risk_level)}</p>
70
+ <p><span class="dc-label">Alliance:</span> {escape(card.alliance_reading)}</p>
71
+ <p><span class="dc-label">Suggestion:</span> {escape(card.practical_suggestion)}</p>
72
+ <p><span class="dc-label">Weird task:</span> {escape(card.weird_task)}</p>
73
+ <p><span class="dc-label">Bedtime release:</span> {escape(card.bedtime_release)}</p>
74
+ <div><span class="dc-label">Contraband:</span><ul>{contraband}</ul></div>
75
+ {safety}
76
+ <span class="dc-seal">SEALED</span>
77
+ </section>
78
+ """.strip()
dream_customs/safety.py ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ESCALATION_TERMS = (
2
+ "hurt myself",
3
+ "kill myself",
4
+ "suicide",
5
+ "self-harm",
6
+ "hurt someone",
7
+ "many nights",
8
+ "cannot function",
9
+ "panic attack",
10
+ "想伤害自己",
11
+ "自杀",
12
+ "自残",
13
+ "伤害别人",
14
+ "很多天睡不着",
15
+ "无法正常生活",
16
+ "无法生活",
17
+ )
18
+
19
+
20
+ def needs_escalation(text: str) -> bool:
21
+ lowered = text.lower()
22
+ return any(term in lowered for term in ESCALATION_TERMS)
23
+
24
+
25
+ def safety_note() -> str:
26
+ return (
27
+ "This dream sounds heavier than a playful customs ritual should handle. "
28
+ "If you feel unsafe, cannot sleep for many nights, or worry you may hurt "
29
+ "yourself or someone else, please reach out to a trusted person or professional support now."
30
+ )
dream_customs/schema.py ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pydantic import BaseModel, Field
2
+
3
+
4
+ class DreamIntake(BaseModel):
5
+ dream_text: str = ""
6
+ voice_transcript: str = ""
7
+ visual_clues: list[str] = Field(default_factory=list)
8
+ mood: str = ""
9
+ recurring_symbols: list[str] = Field(default_factory=list)
10
+ uncertainty: str = ""
11
+ user_context: str = ""
12
+
13
+ def merged_text(self) -> str:
14
+ parts = [
15
+ self.dream_text.strip(),
16
+ self.voice_transcript.strip(),
17
+ "Visual clues: " + ", ".join(self.visual_clues) if self.visual_clues else "",
18
+ "Mood: " + self.mood.strip() if self.mood else "",
19
+ "Recurring symbols: " + ", ".join(self.recurring_symbols) if self.recurring_symbols else "",
20
+ "Uncertainty: " + self.uncertainty.strip() if self.uncertainty else "",
21
+ "Context: " + self.user_context.strip() if self.user_context else "",
22
+ ]
23
+ return "\n".join(part for part in parts if part)
24
+
25
+
26
+ class PactCard(BaseModel):
27
+ visitor_name: str
28
+ permit_id: str
29
+ contraband: list[str]
30
+ risk_level: str
31
+ alliance_reading: str
32
+ practical_suggestion: str
33
+ weird_task: str
34
+ bedtime_release: str
35
+ safety_note: str = ""
36
+
37
+ def to_plain_text(self) -> str:
38
+ contraband = ", ".join(self.contraband)
39
+ lines = [
40
+ f"Dream visitor: {self.visitor_name}",
41
+ f"Permit: {self.permit_id}",
42
+ f"Contraband: {contraband}",
43
+ f"Risk level: {self.risk_level}",
44
+ f"Alliance reading: {self.alliance_reading}",
45
+ f"Today's suggestion: {self.practical_suggestion}",
46
+ f"Weird task: {self.weird_task}",
47
+ f"Bedtime release: {self.bedtime_release}",
48
+ ]
49
+ if self.safety_note:
50
+ lines.append(f"Safety note: {self.safety_note}")
51
+ return "\n".join(lines)
requirements.txt ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ gradio>=4.44,<5.0
2
+ huggingface_hub<1.0
3
+ pydantic>=2.7
4
+ pytest>=8.0
5
+ pillow
6
+ soundfile
7
+
8
+ # Optional direct Hugging Face runtime for MiniCPM-V.
9
+ # The default local demo uses Ollama adapters or the stable demo backend.
10
+ # transformers>=4.56
11
+ # torch
tests/conftest.py ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ import sys
2
+ from pathlib import Path
3
+
4
+ ROOT = Path(__file__).resolve().parents[1]
5
+ if str(ROOT) not in sys.path:
6
+ sys.path.insert(0, str(ROOT))
tests/test_app_logic.py ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+
3
+ from dream_customs.app_logic import run_customs_once
4
+
5
+
6
+ def test_run_customs_once_generates_demo_outputs():
7
+ negotiation, pact_text, html, debug_json = run_customs_once(
8
+ dream_text="我梦见迟到的电梯。",
9
+ mood="foggy",
10
+ answers="我想先完成一件小事。",
11
+ )
12
+ debug = json.loads(debug_json)
13
+ assert "Visitor:" in negotiation
14
+ assert "Dream visitor:" in pact_text
15
+ assert "Today's Pact" in html
16
+ assert debug["status"] == "ok"
17
+ assert debug["intake"]["dream_text"] == "我梦见迟到的电梯。"
18
+
19
+
20
+ def test_run_customs_once_requires_one_modality():
21
+ negotiation, pact_text, html, debug_json = run_customs_once(dream_text="")
22
+ assert negotiation == "No declaration received."
23
+ assert "Please add text" in pact_text
24
+ assert html == ""
25
+ assert json.loads(debug_json) == {"status": "empty"}
tests/test_ollama_models.py ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from dream_customs.models import OllamaTextClient, OllamaVisionClient, _extract_json_object
2
+
3
+
4
+ class StubOllamaTextClient(OllamaTextClient):
5
+ def __init__(self, response_text: str):
6
+ super().__init__()
7
+ self.response_text = response_text
8
+
9
+ def _post_generate(self, prompt: str, num_predict: int = 512):
10
+ return {"response": self.response_text}
11
+
12
+
13
+ class StubOllamaVisionClient(OllamaVisionClient):
14
+ def __init__(self, response_text: str):
15
+ super().__init__()
16
+ self.response_text = response_text
17
+
18
+ def _post_generate(self, prompt: str, image_b64: str, num_predict: int = 256):
19
+ return {"response": self.response_text}
20
+
21
+
22
+ def test_extract_json_object_handles_code_fence():
23
+ parsed = _extract_json_object('```json\n{"visitor_name": "Gate 14"}\n```')
24
+ assert parsed == {"visitor_name": "Gate 14"}
25
+
26
+
27
+ def test_ollama_text_client_parses_negotiation_json():
28
+ client = StubOllamaTextClient(
29
+ '{"visitor_name":"迟到的电梯","questions":["它要什么?","你要什么?"],"tone_note":"温和一点"}'
30
+ )
31
+ negotiation = client.generate_negotiation("梦见电梯")
32
+ assert negotiation["visitor_name"] == "迟到的电梯"
33
+ assert negotiation["questions"] == ["它要什么?", "你要什么?"]
34
+
35
+
36
+ def test_ollama_text_client_falls_back_on_empty_json():
37
+ client = StubOllamaTextClient("{}")
38
+ negotiation = client.generate_negotiation("梦见电梯")
39
+ assert negotiation["visitor_name"]
40
+ assert len(negotiation["questions"]) == 3
41
+
42
+
43
+ def test_ollama_text_client_parses_pact_card():
44
+ client = StubOllamaTextClient(
45
+ """
46
+ {
47
+ "visitor_name":"迟到的电梯",
48
+ "permit_id":"DC-42",
49
+ "contraband":["未申报的焦虑"],
50
+ "risk_level":"yellow",
51
+ "alliance_reading":"先开始,不急着完成。",
52
+ "practical_suggestion":"打开一个 5 分钟任务。",
53
+ "weird_task":"给电梯盖章。",
54
+ "bedtime_release":"今日放行。",
55
+ "safety_note":""
56
+ }
57
+ """
58
+ )
59
+ card = client.generate_pact("梦见电梯")
60
+ assert card.permit_id == "DC-42"
61
+ assert card.contraband == ["未申报的焦虑"]
62
+
63
+
64
+ def test_ollama_vision_client_parses_visual_clues(tmp_path):
65
+ image_path = tmp_path / "dream.png"
66
+ image_path.write_bytes(b"not a real png but enough for adapter encoding")
67
+ client = StubOllamaVisionClient(
68
+ '{"objects":["passport stamp"],"places":["customs desk"],"colors":["blue"],"mood_cues":["foggy"]}'
69
+ )
70
+ clues = client.extract_clues(str(image_path))
71
+ assert clues == ["passport stamp", "customs desk", "blue", "foggy"]
tests/test_pipeline.py ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from dream_customs.models import FakeASRClient, FakeTextClient, FakeVisionClient
2
+ from dream_customs.pipeline import build_intake, generate_negotiation, generate_pact, intake_from_modalities
3
+
4
+
5
+ def test_build_intake_merges_modalities():
6
+ intake = build_intake(
7
+ dream_text="I missed an elevator.",
8
+ voice_transcript="The buttons melted.",
9
+ visual_clues=["blue hallway"],
10
+ mood="anxious",
11
+ )
12
+ assert "elevator" in intake.merged_text()
13
+ assert "blue hallway" in intake.merged_text()
14
+
15
+
16
+ def test_intake_from_modalities_uses_adapters():
17
+ intake = intake_from_modalities(
18
+ dream_text="",
19
+ image_path="demo.png",
20
+ audio_path="demo.wav",
21
+ mood="foggy",
22
+ vision_client=FakeVisionClient(),
23
+ asr_client=FakeASRClient(),
24
+ )
25
+ merged = intake.merged_text()
26
+ assert "The buttons melted" in merged
27
+ assert "blue hallway" in merged
28
+ assert "Mood: foggy" in merged
29
+
30
+
31
+ def test_generate_negotiation_returns_questions():
32
+ intake = build_intake(dream_text="I missed an elevator.", mood="anxious")
33
+ negotiation = generate_negotiation(intake, FakeTextClient())
34
+ assert negotiation["visitor_name"]
35
+ assert len(negotiation["questions"]) == 3
36
+
37
+
38
+ def test_generate_pact_returns_card_and_html():
39
+ intake = build_intake(dream_text="I missed an elevator.", mood="anxious")
40
+ card, html = generate_pact(intake, "I want a small start.", FakeTextClient())
41
+ assert card.visitor_name
42
+ assert "Today's Pact" in html
43
+
44
+
45
+ def test_generate_pact_adds_safety_note_for_distress():
46
+ intake = build_intake(dream_text="I might hurt myself if I cannot sleep.")
47
+ card, _html = generate_pact(intake, "", FakeTextClient())
48
+ assert card.safety_note
tests/test_render.py ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from dream_customs.render import render_pact_card
2
+ from dream_customs.schema import PactCard
3
+
4
+
5
+ def test_render_pact_card_contains_core_fields():
6
+ html = render_pact_card(
7
+ PactCard(
8
+ visitor_name="Late Elevator",
9
+ permit_id="DC-1",
10
+ contraband=["unfiled anxiety"],
11
+ risk_level="orange",
12
+ alliance_reading="The dream asks for a smaller start.",
13
+ practical_suggestion="Open one task ten minutes early.",
14
+ weird_task="Write the elevator an apology note.",
15
+ bedtime_release="Today the elevator has docked.",
16
+ )
17
+ )
18
+ assert "Late Elevator" in html
19
+ assert "Today&apos;s Pact" in html or "Today's Pact" in html
20
+ assert "<script" not in html.lower()
21
+
22
+
23
+ def test_render_pact_card_escapes_user_like_content():
24
+ html = render_pact_card(
25
+ PactCard(
26
+ visitor_name="<bad>",
27
+ permit_id="DC-2",
28
+ contraband=["<script>alert(1)</script>"],
29
+ risk_level="green",
30
+ alliance_reading="Maybe this visitor wants a smaller morning.",
31
+ practical_suggestion="Drink water.",
32
+ weird_task="Salute the kettle.",
33
+ bedtime_release="The kettle is released.",
34
+ )
35
+ )
36
+ assert "<bad>" not in html
37
+ assert "<script" not in html.lower()
tests/test_safety.py ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from dream_customs.safety import needs_escalation, safety_note
2
+
3
+
4
+ def test_ordinary_dream_does_not_escalate():
5
+ assert not needs_escalation("I dreamed about a strange elevator.")
6
+
7
+
8
+ def test_self_harm_text_escalates():
9
+ assert needs_escalation("I might hurt myself if I cannot sleep.")
10
+
11
+
12
+ def test_chinese_severe_insomnia_escalates():
13
+ assert needs_escalation("我已经很多天睡不着,感觉无法正常生活。")
14
+
15
+
16
+ def test_safety_note_mentions_professional_support():
17
+ assert "professional support" in safety_note().lower()
tests/test_schema.py ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from dream_customs.schema import DreamIntake, PactCard
2
+
3
+
4
+ def test_dream_intake_defaults_lists():
5
+ intake = DreamIntake(dream_text="I missed an elevator.")
6
+ assert intake.dream_text == "I missed an elevator."
7
+ assert intake.visual_clues == []
8
+ assert intake.recurring_symbols == []
9
+
10
+
11
+ def test_dream_intake_merged_text_includes_modalities():
12
+ intake = DreamIntake(
13
+ dream_text="I missed an elevator.",
14
+ voice_transcript="The buttons melted.",
15
+ visual_clues=["blue hallway"],
16
+ mood="anxious",
17
+ recurring_symbols=["elevator"],
18
+ user_context="I slept badly.",
19
+ )
20
+ merged = intake.merged_text()
21
+ assert "I missed an elevator." in merged
22
+ assert "The buttons melted." in merged
23
+ assert "blue hallway" in merged
24
+ assert "Mood: anxious" in merged
25
+
26
+
27
+ def test_pact_card_requires_core_fields():
28
+ card = PactCard(
29
+ visitor_name="Late Elevator",
30
+ permit_id="DC-0001",
31
+ contraband=["unfiled anxiety"],
32
+ risk_level="orange",
33
+ alliance_reading="The dream asks for a smaller start.",
34
+ practical_suggestion="Open one task ten minutes early.",
35
+ weird_task="Write the elevator an apology note.",
36
+ bedtime_release="Today the elevator has docked.",
37
+ )
38
+ assert card.safety_note == ""
39
+ assert "Late Elevator" in card.to_plain_text()