Deploy Dream Customs workbench UI

#5
.gitattributes CHANGED
@@ -33,3 +33,6 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ docs/design/assets/dream-customs-probe-morning-console.png filter=lfs diff=lfs merge=lfs -text
37
+ docs/design/assets/dream-customs-probe-night-border.png filter=lfs diff=lfs merge=lfs -text
38
+ docs/design/assets/dream-customs-probe-night-desk.png filter=lfs diff=lfs merge=lfs -text
.impeccable/design.json ADDED
@@ -0,0 +1,175 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "schemaVersion": 2,
3
+ "generatedAt": "2026-06-05T13:25:00+08:00",
4
+ "title": "Design System: Dream Customs",
5
+ "extensions": {
6
+ "colorMeta": {
7
+ "bg-night": {
8
+ "role": "neutral",
9
+ "displayName": "Night Background",
10
+ "canonical": "oklch(0.145 0.030 238)",
11
+ "tonalRamp": [
12
+ "oklch(0.120 0.030 238)",
13
+ "oklch(0.180 0.030 238)",
14
+ "oklch(0.260 0.030 238)",
15
+ "oklch(0.360 0.030 238)",
16
+ "oklch(0.500 0.030 238)",
17
+ "oklch(0.660 0.030 238)",
18
+ "oklch(0.820 0.030 238)",
19
+ "oklch(0.940 0.030 238)"
20
+ ]
21
+ },
22
+ "primary-cobalt": {
23
+ "role": "primary",
24
+ "displayName": "Cobalt Gate",
25
+ "canonical": "oklch(0.690 0.105 232)",
26
+ "tonalRamp": [
27
+ "oklch(0.180 0.105 232)",
28
+ "oklch(0.280 0.105 232)",
29
+ "oklch(0.400 0.105 232)",
30
+ "oklch(0.520 0.105 232)",
31
+ "oklch(0.640 0.105 232)",
32
+ "oklch(0.760 0.105 232)",
33
+ "oklch(0.860 0.080 232)",
34
+ "oklch(0.940 0.050 232)"
35
+ ]
36
+ },
37
+ "accent-coral-stamp": {
38
+ "role": "accent",
39
+ "displayName": "Coral Stamp Ink",
40
+ "canonical": "oklch(0.705 0.160 28)",
41
+ "tonalRamp": [
42
+ "oklch(0.200 0.130 28)",
43
+ "oklch(0.320 0.145 28)",
44
+ "oklch(0.440 0.155 28)",
45
+ "oklch(0.560 0.160 28)",
46
+ "oklch(0.680 0.160 28)",
47
+ "oklch(0.800 0.120 28)",
48
+ "oklch(0.900 0.070 28)",
49
+ "oklch(0.960 0.035 28)"
50
+ ]
51
+ }
52
+ },
53
+ "typographyMeta": {
54
+ "display": {
55
+ "displayName": "Display",
56
+ "purpose": "App title and sealed pact headline only."
57
+ },
58
+ "body": {
59
+ "displayName": "Body",
60
+ "purpose": "Dream text, questions, answers, and pact explanations."
61
+ }
62
+ },
63
+ "shadows": [
64
+ {
65
+ "name": "composer-focus",
66
+ "value": "0 0 0 1px oklch(0.690 0.105 232), 0 12px 32px rgba(0, 0, 0, 0.28)",
67
+ "purpose": "Focused bottom composer."
68
+ },
69
+ {
70
+ "name": "pact-lift",
71
+ "value": "0 10px 28px rgba(0, 0, 0, 0.24)",
72
+ "purpose": "Sealed pact card."
73
+ }
74
+ ],
75
+ "motion": [
76
+ {
77
+ "name": "state-shift",
78
+ "value": "180ms cubic-bezier(0.25, 1, 0.5, 1)",
79
+ "purpose": "Buttons, chips, composer focus, and inspector state changes."
80
+ }
81
+ ],
82
+ "breakpoints": [
83
+ {
84
+ "name": "mobile",
85
+ "value": "760px"
86
+ },
87
+ {
88
+ "name": "desktop",
89
+ "value": "1040px"
90
+ }
91
+ ]
92
+ },
93
+ "components": [
94
+ {
95
+ "name": "Primary Button",
96
+ "kind": "button",
97
+ "refersTo": "button-primary",
98
+ "description": "Default action for continuing the customs flow.",
99
+ "html": "<button class=\"ds-btn-primary\">Continue negotiation</button>",
100
+ "css": ".ds-btn-primary { background: oklch(0.510 0.115 234); color: oklch(0.965 0.010 235); border: 0; border-radius: 10px; padding: 12px 16px; font: 680 14px/1.2 Inter, system-ui, sans-serif; transition: background 180ms cubic-bezier(0.25, 1, 0.5, 1), transform 180ms cubic-bezier(0.25, 1, 0.5, 1); } .ds-btn-primary:hover { background: oklch(0.590 0.120 232); transform: translateY(-1px); } .ds-btn-primary:focus-visible { outline: 2px solid oklch(0.820 0.110 185); outline-offset: 2px; }"
101
+ },
102
+ {
103
+ "name": "Seal Button",
104
+ "kind": "button",
105
+ "refersTo": "button-accent",
106
+ "description": "Final action that seals today's pact.",
107
+ "html": "<button class=\"ds-btn-seal\">Seal today's pact</button>",
108
+ "css": ".ds-btn-seal { background: oklch(0.705 0.160 28); color: oklch(0.080 0.010 250); border: 0; border-radius: 10px; padding: 12px 16px; font: 760 14px/1.2 Inter, system-ui, sans-serif; transition: box-shadow 180ms cubic-bezier(0.25, 1, 0.5, 1), transform 180ms cubic-bezier(0.25, 1, 0.5, 1); } .ds-btn-seal:hover { box-shadow: 0 0 20px rgba(255, 107, 84, 0.24); transform: translateY(-1px); } .ds-btn-seal:focus-visible { outline: 2px solid oklch(0.965 0.010 235); outline-offset: 2px; }"
109
+ },
110
+ {
111
+ "name": "Evidence Chip",
112
+ "kind": "chip",
113
+ "refersTo": "evidence-chip",
114
+ "description": "Shows a queued or extracted dream evidence item.",
115
+ "html": "<span class=\"ds-evidence-chip\"><span class=\"ds-evidence-dot\"></span>Image extracted</span>",
116
+ "css": ".ds-evidence-chip { display: inline-flex; align-items: center; gap: 8px; min-height: 30px; border: 1px solid oklch(0.430 0.055 235); border-radius: 999px; background: oklch(0.205 0.035 238); color: oklch(0.965 0.010 235); padding: 5px 10px; font: 650 13px/1.2 Inter, system-ui, sans-serif; } .ds-evidence-dot { width: 8px; height: 8px; border-radius: 999px; background: oklch(0.820 0.110 185); box-shadow: 0 0 12px rgba(96, 220, 205, 0.35); }"
117
+ },
118
+ {
119
+ "name": "Bottom Composer",
120
+ "kind": "input",
121
+ "refersTo": "input-composer",
122
+ "description": "Codex-style dream declaration input with evidence controls.",
123
+ "html": "<div class=\"ds-composer\"><textarea placeholder=\"Declare a dream fragment...\"></textarea><div class=\"ds-composer-actions\"><button>Attach image</button><button>Record voice</button><button class=\"ds-send\">Send</button></div></div>",
124
+ "css": ".ds-composer { background: oklch(0.205 0.035 238); color: oklch(0.965 0.010 235); border: 1px solid oklch(0.430 0.055 235); border-radius: 14px; padding: 12px; box-shadow: 0 12px 32px rgba(0, 0, 0, 0.28); } .ds-composer textarea { width: 100%; min-height: 84px; resize: vertical; color: inherit; background: transparent; border: 0; outline: 0; font: 450 16px/1.55 Inter, system-ui, sans-serif; } .ds-composer textarea::placeholder { color: oklch(0.760 0.030 232); } .ds-composer-actions { display: flex; gap: 8px; justify-content: flex-end; flex-wrap: wrap; } .ds-composer button { border: 1px solid oklch(0.430 0.055 235); border-radius: 10px; background: oklch(0.275 0.045 236); color: oklch(0.965 0.010 235); padding: 8px 10px; } .ds-composer .ds-send { background: oklch(0.510 0.115 234); border-color: transparent; }"
125
+ }
126
+ ],
127
+ "narrative": {
128
+ "northStar": "The Night Desk",
129
+ "overview": "Dream Customs should feel like a working customs desk that appears inside a dream at 4:17 a.m. The surface is dark because the user often arrives from bed or after waking, but it is not a generic terminal. Cobalt, aurora cyan, and coral stamp ink carry the strange color. Familiar controls carry trust.",
130
+ "keyCharacteristics": [
131
+ "Bottom composer as the primary input, modeled after Codex app behavior.",
132
+ "Conversation timeline for declarations, questions, and pact drafts.",
133
+ "Evidence tray for text, image, audio, mood, and model status.",
134
+ "Pact inspector that can be drafted, revised, or sealed.",
135
+ "Generated bitmap art as atmospheric header and state art, never as unreadable UI chrome."
136
+ ],
137
+ "rules": [
138
+ {
139
+ "name": "The Stamp Rarity Rule",
140
+ "body": "Coral is used for sealing, warnings, and permit marks only. It must not become a general decoration color.",
141
+ "section": "colors"
142
+ },
143
+ {
144
+ "name": "The No Parchment Rule",
145
+ "body": "Do not return to cream, beige, or faux paper backgrounds. Dream color comes from night, ink, and evidence light.",
146
+ "section": "colors"
147
+ },
148
+ {
149
+ "name": "The Low-Light Readability Rule",
150
+ "body": "Muted text must remain legible on dark surfaces. Never use low-contrast gray for field labels or placeholders.",
151
+ "section": "typography"
152
+ },
153
+ {
154
+ "name": "The State First Rule",
155
+ "body": "Shadows communicate focus, hover, or completion. They are not default decoration.",
156
+ "section": "elevation"
157
+ }
158
+ ],
159
+ "dos": [
160
+ "Do make the bottom composer the most obvious control on mobile and desktop.",
161
+ "Do keep every phase actionable with one clear next step.",
162
+ "Do use generated raster imagery for the dream atmosphere, header state, or empty state.",
163
+ "Do verify dark-mode contrast for body text, labels, placeholders, chips, and disabled controls.",
164
+ "Do preserve a deterministic demo backend while adding a model-backed route."
165
+ ],
166
+ "donts": [
167
+ "Don't ship a plain Gradio two-column form as the primary experience.",
168
+ "Don't use beige parchment, tarot, faux therapy, or generic purple AI visuals.",
169
+ "Don't use one-shot generation as the whole flow.",
170
+ "Don't hide Add material, Continue negotiation, or Seal pact below the fold.",
171
+ "Don't pair a 1px border with a large soft shadow on every card.",
172
+ "Don't use side-stripe borders, gradient text, or decorative glass panels."
173
+ ]
174
+ }
175
+ }
DESIGN.md ADDED
@@ -0,0 +1,210 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ name: Dream Customs
3
+ description: A nocturnal dream customs workbench for turning dream evidence into a next-day pact.
4
+ colors:
5
+ bg-night: "oklch(0.145 0.030 238)"
6
+ bg-void: "oklch(0.080 0.010 250)"
7
+ surface-ink: "oklch(0.205 0.035 238)"
8
+ surface-raised: "oklch(0.275 0.045 236)"
9
+ primary-cobalt: "oklch(0.690 0.105 232)"
10
+ primary-cobalt-deep: "oklch(0.510 0.115 234)"
11
+ accent-coral-stamp: "oklch(0.705 0.160 28)"
12
+ accent-aurora: "oklch(0.820 0.110 185)"
13
+ ink-main: "oklch(0.965 0.010 235)"
14
+ ink-muted: "oklch(0.760 0.030 232)"
15
+ border-dream: "oklch(0.430 0.055 235)"
16
+ warning: "oklch(0.760 0.150 75)"
17
+ typography:
18
+ display:
19
+ fontFamily: "Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif"
20
+ fontSize: "2.25rem"
21
+ fontWeight: 760
22
+ lineHeight: 1.05
23
+ letterSpacing: "-0.02em"
24
+ headline:
25
+ fontFamily: "Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif"
26
+ fontSize: "1.5rem"
27
+ fontWeight: 720
28
+ lineHeight: 1.18
29
+ title:
30
+ fontFamily: "Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif"
31
+ fontSize: "1rem"
32
+ fontWeight: 680
33
+ lineHeight: 1.3
34
+ body:
35
+ fontFamily: "Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif"
36
+ fontSize: "1rem"
37
+ fontWeight: 450
38
+ lineHeight: 1.55
39
+ label:
40
+ fontFamily: "Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif"
41
+ fontSize: "0.8125rem"
42
+ fontWeight: 650
43
+ lineHeight: 1.25
44
+ rounded:
45
+ sm: "6px"
46
+ md: "10px"
47
+ lg: "14px"
48
+ pill: "999px"
49
+ spacing:
50
+ xs: "6px"
51
+ sm: "10px"
52
+ md: "16px"
53
+ lg: "24px"
54
+ xl: "32px"
55
+ components:
56
+ button-primary:
57
+ backgroundColor: "{colors.primary-cobalt-deep}"
58
+ textColor: "{colors.ink-main}"
59
+ rounded: "{rounded.md}"
60
+ padding: "12px 16px"
61
+ button-accent:
62
+ backgroundColor: "{colors.accent-coral-stamp}"
63
+ textColor: "{colors.bg-void}"
64
+ rounded: "{rounded.md}"
65
+ padding: "12px 16px"
66
+ input-composer:
67
+ backgroundColor: "{colors.surface-ink}"
68
+ textColor: "{colors.ink-main}"
69
+ rounded: "{rounded.lg}"
70
+ padding: "14px 16px"
71
+ ---
72
+
73
+ # Design System: Dream Customs
74
+
75
+ ## 1. Overview
76
+
77
+ **Creative North Star: "The Night Desk"**
78
+
79
+ Dream Customs should feel like a working customs desk that appears inside a dream at 4:17 a.m. The surface is dark because the user often arrives from bed or after waking, but it is not a generic terminal. Cobalt, aurora cyan, and coral stamp ink carry the strange color. Familiar controls carry trust.
80
+
81
+ This is product UI first. The user needs to type, upload, record, answer, and decide what happens next. The dream atmosphere belongs in the background wash, pact inspector, stamps, and evidence chips, while controls stay clear and predictable.
82
+
83
+ **Key Characteristics:**
84
+
85
+ - Bottom composer as the primary input, modeled after Codex app behavior.
86
+ - Conversation timeline for declarations, questions, and pact drafts.
87
+ - Evidence tray for text, image, audio, mood, and model status.
88
+ - Pact inspector that can be drafted, revised, or sealed.
89
+ - Generated bitmap art as atmospheric header and state art, never as unreadable UI chrome.
90
+
91
+ ## 2. Colors
92
+
93
+ The palette is nocturnal and atmospheric: neutral dark surfaces, cobalt primary actions, aurora hints, and coral stamp moments.
94
+
95
+ ### Primary
96
+
97
+ - **Cobalt Gate** (`oklch(0.690 0.105 232)`): primary selection, active step, focus accents, and subtle interactive outlines.
98
+ - **Deep Cobalt Gate** (`oklch(0.510 0.115 234)`): filled primary buttons and selected composer controls with near-white text.
99
+
100
+ ### Secondary
101
+
102
+ - **Coral Stamp Ink** (`oklch(0.705 0.160 28)`): final seal action, permit stamp, alert edge for safety notes, and one memorable highlight per screen.
103
+ - **Aurora Evidence Light** (`oklch(0.820 0.110 185)`): image/audio evidence status, successful extraction, and soft dream-glow details.
104
+
105
+ ### Neutral
106
+
107
+ - **Night Background** (`oklch(0.145 0.030 238)`): app body and mobile shell.
108
+ - **Void Rail** (`oklch(0.080 0.010 250)`): app outer edge, header underlay, and screenshot-safe negative space.
109
+ - **Ink Surface** (`oklch(0.205 0.035 238)`): composer, timeline items, and tool panels.
110
+ - **Raised Surface** (`oklch(0.275 0.045 236)`): active inspector panels and output cards.
111
+ - **Main Ink** (`oklch(0.965 0.010 235)`): body text on dark surfaces.
112
+ - **Muted Ink** (`oklch(0.760 0.030 232)`): secondary labels that still remain readable.
113
+
114
+ ### Named Rules
115
+
116
+ **The Stamp Rarity Rule.** Coral is used for sealing, warnings, and permit marks only. It must not become a general decoration color.
117
+
118
+ **The No Parchment Rule.** Do not return to cream, beige, or faux paper backgrounds. Dream color comes from night, ink, and evidence light.
119
+
120
+ ## 3. Typography
121
+
122
+ **Display Font:** Inter with system fallbacks
123
+
124
+ **Body Font:** Inter with system fallbacks
125
+ **Label/Mono Font:** Use system mono only for permit IDs, debug JSON, and model telemetry.
126
+
127
+ **Character:** The interface uses one sans family so the product feels controlled. Personality comes from naming, layout, stamps, and generated imagery rather than decorative fonts.
128
+
129
+ ### Hierarchy
130
+
131
+ - **Display** (760, 2.25rem, 1.05): app title and sealed pact headline only.
132
+ - **Headline** (720, 1.5rem, 1.18): step titles and pact section names.
133
+ - **Title** (680, 1rem, 1.3): timeline item headers, buttons, inspector labels.
134
+ - **Body** (450, 1rem, 1.55): dream text, questions, pact explanations, max 70ch for prose.
135
+ - **Label** (650, 0.8125rem, 1.25): compact field labels, evidence chips, status text.
136
+
137
+ ### Named Rules
138
+
139
+ **The Low-Light Readability Rule.** Muted text must remain legible on dark surfaces. Never use low-contrast gray for field labels or placeholders.
140
+
141
+ ## 4. Elevation
142
+
143
+ Depth is mostly tonal, not shadow-heavy. The app should feel layered like a dark desk under soft light, not like floating cards on a SaaS page. Shadows appear only for the active composer, a hovered evidence item, or a sealed pact.
144
+
145
+ ### Shadow Vocabulary
146
+
147
+ - **composer-focus** (`0 0 0 1px oklch(0.690 0.105 232), 0 12px 32px rgba(0, 0, 0, 0.28)`): focused bottom composer.
148
+ - **pact-lift** (`0 10px 28px rgba(0, 0, 0, 0.24)`): sealed pact card only.
149
+ - **stamp-glow** (`0 0 20px rgba(255, 107, 84, 0.24)`): coral seal hover or success state.
150
+
151
+ ### Named Rules
152
+
153
+ **The State First Rule.** Shadows communicate focus, hover, or completion. They are not default decoration.
154
+
155
+ ## 5. Components
156
+
157
+ ### Buttons
158
+
159
+ - **Shape:** medium rectangle, 10px radius.
160
+ - **Primary:** Deep Cobalt Gate fill, Main Ink text, 12px 16px padding.
161
+ - **Accent:** Coral Stamp Ink for `Seal today's pact` only.
162
+ - **Hover / Focus:** tonal lift plus visible cobalt focus ring.
163
+ - **Secondary:** transparent or Ink Surface fill with full border, never ghost text alone.
164
+
165
+ ### Chips
166
+
167
+ - **Style:** small rounded pills with icon, label, and state dot.
168
+ - **State:** queued, extracted, failed, or selected. Evidence type uses icon and color, not long explanatory text.
169
+
170
+ ### Cards / Containers
171
+
172
+ - **Corner Style:** 10px to 14px. No 32px rounded cards.
173
+ - **Background:** Ink Surface for timeline items, Raised Surface for active inspector.
174
+ - **Shadow Strategy:** flat by default, lifted only when active or sealed.
175
+ - **Border:** full border using Border Dream, not a side stripe.
176
+ - **Internal Padding:** 16px mobile, 24px desktop.
177
+
178
+ ### Inputs / Fields
179
+
180
+ - **Style:** Codex-like bottom composer with large multiline input, attachment controls, model selector menu, and primary action clustered on the right.
181
+ - **Focus:** cobalt ring, slightly raised surface, no layout shift.
182
+ - **Error / Disabled:** inline message below composer, disabled actions stay readable and explain why.
183
+
184
+ ### Navigation
185
+
186
+ - **Style:** compact status rail with current phase: Declare, Inspect, Negotiate, Draft, Seal.
187
+ - **Mobile:** phase rail collapses to segmented steps above the composer.
188
+
189
+ ### Signature Component: Pact Inspector
190
+
191
+ The pact inspector is a live customs file, not just a final HTML card. It shows visitor, permit ID, evidence count, risk, suggested action, weird task, and whether the pact is draft or sealed. It gives the user three choices: add material, ask another question, or seal today's pact.
192
+
193
+ ## 6. Do's and Don'ts
194
+
195
+ ### Do:
196
+
197
+ - **Do** make the bottom composer the most obvious control on mobile and desktop.
198
+ - **Do** keep every phase actionable with one clear next step.
199
+ - **Do** use generated raster imagery for the dream atmosphere, header state, or empty state.
200
+ - **Do** verify dark-mode contrast for body text, labels, placeholders, chips, and disabled controls.
201
+ - **Do** preserve a deterministic demo backend while adding a model-backed route.
202
+
203
+ ### Don't:
204
+
205
+ - **Don't** ship a plain Gradio two-column form as the primary experience.
206
+ - **Don't** use beige parchment, tarot, faux therapy, or generic purple AI visuals.
207
+ - **Don't** use one-shot generation as the whole flow.
208
+ - **Don't** hide `Add material`, `Continue negotiation`, or `Seal pact` below the fold.
209
+ - **Don't** pair a 1px border with a large soft shadow on every card.
210
+ - **Don't** use side-stripe borders, gradient text, or decorative glass panels.
PRODUCT.md ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Product
2
+
3
+ ## Register
4
+
5
+ product
6
+
7
+ ## Users
8
+
9
+ Dream Customs serves people who wake up with vivid, strange, or unsettling dreams and want a small morning ritual before the day takes over. The primary user may still be half asleep, on a phone, and emotionally sticky from the dream. The secondary user is a hackathon judge or playful AI explorer who needs to understand the loop quickly and see why small multimodal models matter.
10
+
11
+ ## Product Purpose
12
+
13
+ Dream Customs helps a user form a playful alliance with last night's dream. It accepts text, image, and voice evidence, turns those fragments into a structured dream declaration, asks a few customs questions, and lets the user choose whether to add more material, continue negotiation, or seal a next-day pact.
14
+
15
+ Success means the user receives something more useful than a one-shot interpretation: a clear visitor, a practical suggestion for today, a weird five-minute task, and a bedtime release phrase. The experience should feel like a small ceremony, not a diagnosis.
16
+
17
+ ## Brand Personality
18
+
19
+ Gentle, strange, lucid.
20
+
21
+ The voice is an overnight border clerk who takes odd evidence seriously without becoming mystical or clinical. It should feel curious and companionable, with enough dream color to be memorable and enough product discipline to be usable on a phone.
22
+
23
+ ## Anti-references
24
+
25
+ - Do not look like a plain Gradio form with two columns and one submit button.
26
+ - Do not look like a beige parchment certificate, generic tarot card, therapy intake form, or purple AI SaaS dashboard.
27
+ - Do not use medical, diagnostic, frightening, or fate-telling language.
28
+ - Do not bury the main action below many controls.
29
+ - Do not end the flow after one answer when the user still wants to add evidence or keep negotiating.
30
+
31
+ ## Design Principles
32
+
33
+ 1. The AI is a clerk in a ritual, not a text generator behind a form.
34
+ 2. The user keeps agency: add evidence, answer, skip, continue, revise, or seal.
35
+ 3. The interface should be dream-colored but product-trustworthy.
36
+ 4. Every output should lead to a tiny next-day action.
37
+ 5. The demo path must survive Space constraints and still have a model-backed route.
38
+
39
+ ## Accessibility & Inclusion
40
+
41
+ Target WCAG AA contrast for body text, controls, and placeholder text. Support mobile-first use, keyboard navigation, visible focus states, reduced motion, and Chinese/English text without layout breakage. Avoid flashing effects, fear-based copy, or overconfident claims about dream meaning. Severe distress, self-harm, harm to others, or inability to function must show a clear support note.
README.md CHANGED
@@ -63,12 +63,61 @@ Local smoke notes from this Mac mini:
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.
 
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
+ ## Optional Hosted MiniCPM Routes
67
+
68
+ Hosted routes are intended for Modal experiments or private Space secrets. Set these in the runtime environment only, then switch the UI route menus from `demo` to `model`:
69
+
70
+ - `DREAM_CUSTOMS_TEXT_ENDPOINT`
71
+ - `DREAM_CUSTOMS_VISION_ENDPOINT`
72
+ - `DREAM_CUSTOMS_HOSTED_TOKEN`
73
+
74
+ Missing endpoints or route failures fall back to deterministic demo behavior. Do not store token values in env files, git, docs, screenshots, logs, or examples.
75
+
76
+ Token-safe text smoke:
77
+
78
+ ```bash
79
+ python - <<'PY'
80
+ import os
81
+ from dream_customs.models import HostedMiniCPMTextClient
82
+
83
+ client = HostedMiniCPMTextClient(
84
+ endpoint=os.environ["DREAM_CUSTOMS_TEXT_ENDPOINT"],
85
+ token=os.getenv("DREAM_CUSTOMS_HOSTED_TOKEN", ""),
86
+ )
87
+ result = client.generate_negotiation("I missed an elevator in a foggy dream.")
88
+ print(result["visitor_name"])
89
+ PY
90
+ ```
91
+
92
+ Token-safe vision smoke:
93
+
94
+ ```bash
95
+ python - <<'PY'
96
+ import os
97
+ from dream_customs.models import HostedMiniCPMVisionClient
98
+
99
+ client = HostedMiniCPMVisionClient(
100
+ endpoint=os.environ["DREAM_CUSTOMS_VISION_ENDPOINT"],
101
+ token=os.getenv("DREAM_CUSTOMS_HOSTED_TOKEN", ""),
102
+ )
103
+ print(client.extract_clues(os.environ["DREAM_CUSTOMS_SMOKE_IMAGE"]))
104
+ PY
105
+ ```
106
+
107
  ## Test
108
 
109
  ```bash
110
  python -m pytest -q
111
  ```
112
 
113
+ ## Deployment Smoke Status
114
+
115
+ 2026-06-05 local V2 verification passed: tests were green and the workbench flow reached a sealed pact through `Send to customs`, `Ask another question`, `Add material`, `Draft pact`, `Revise pact`, and `Seal today's pact`.
116
+
117
+ The public Space may still show the older one-shot UI until the latest branch is pushed to the Hugging Face Space `main` branch. A 2026-06-05 push attempt was rejected by HF authorization, while the already deployed old Space still opened and returned a text-only demo pact through the remote queue.
118
+
119
+ Current smoke details are tracked in `docs/smoke/2026-06-05-space-deployment-smoke.md`.
120
+
121
  ## Safety
122
 
123
  This is not a therapy or diagnosis product. It gives playful reflection, small actions, and escalation copy for severe distress.
app.py CHANGED
@@ -3,7 +3,18 @@ import os
3
  import gradio as gr
4
  from gradio_client import utils as gradio_client_utils
5
 
6
- from dream_customs.app_logic import run_customs_once
 
 
 
 
 
 
 
 
 
 
 
7
 
8
 
9
  _ORIGINAL_SCHEMA_TO_TYPE = gradio_client_utils._json_schema_to_python_type
@@ -20,95 +31,681 @@ gradio_client_utils._json_schema_to_python_type = _json_schema_to_python_type
20
 
21
 
22
  CSS = """
23
- body { background: #f6f1e7; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
24
  .gradio-container {
25
- color: #17272f;
 
 
 
 
26
  font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
 
 
 
 
 
27
  }
28
- .dc-shell {
29
- max-width: 1180px;
 
 
 
 
 
 
30
  margin: 0 auto;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
31
  }
32
- .dc-title h1 {
33
- font-size: clamp(30px, 5vw, 56px);
34
- line-height: 1;
35
- margin-bottom: 4px;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
36
  }
37
- .dc-title p {
38
- color: #49626b;
39
- font-size: 16px;
40
- margin-top: 0;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
41
  }
42
- .dc-stamp button {
43
- background: #17313b !important;
44
- border: 0 !important;
45
- color: #fff8ed !important;
46
- font-weight: 800 !important;
47
  }
48
- textarea, input, select {
49
- font-size: 16px !important;
 
 
50
  }
51
- @media (max-width: 760px) {
52
- .dc-title h1 { font-size: 34px; }
53
- .dc-title p { font-size: 14px; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
54
  }
55
  """
56
 
57
 
 
 
 
 
 
 
 
 
 
 
 
 
58
  def build_demo() -> gr.Blocks:
59
  with gr.Blocks(css=CSS, title="Dream Customs") as demo:
60
- with gr.Column(elem_classes=["dc-shell"]):
61
- gr.Markdown(
62
- """
63
- # Dream Customs / 梦境海关
64
- 夜间来访者入境申报处
65
- """,
66
- elem_classes=["dc-title"],
67
- )
68
- with gr.Row():
69
- with gr.Column(scale=5):
70
- dream_text = gr.Textbox(
71
- label="Dream declaration",
72
- lines=8,
73
- value="我梦见一部迟到的电梯,按钮都融化了,我一直到不了 14 楼。",
74
- )
75
- with gr.Row():
76
- image_input = gr.Image(label="Image evidence", type="filepath")
77
- audio_input = gr.Audio(label="Voice evidence", type="filepath")
 
 
 
78
  mood = gr.Dropdown(
79
- label="Current weather",
80
  choices=["foggy", "anxious", "curious", "tired", "restless", "calm"],
81
  value="foggy",
82
  )
83
- answers = gr.Textbox(
84
- label="Answers to the clerk",
85
- lines=4,
 
86
  value="我想和它结盟,但今天只想完成一件很小的事。",
 
87
  )
88
- with gr.Row():
89
- text_backend = gr.Radio(
90
- label="Text engine",
91
- choices=["demo", "ollama"],
92
- value="demo",
93
- )
94
- vision_backend = gr.Radio(
95
- label="Vision engine",
96
- choices=["demo", "ollama"],
97
- value="demo",
98
- )
99
- submit = gr.Button("Stamp clearance", elem_classes=["dc-stamp"])
100
-
101
- with gr.Column(scale=6):
102
- negotiation = gr.Textbox(label="Customs questions", lines=7)
103
- pact_text = gr.Textbox(label="Pact manifest", lines=12)
104
- pact_html = gr.HTML(label="Today's pact card")
105
- debug_json = gr.Code(label="Debug manifest", language="json")
106
-
107
- submit.click(
108
- run_customs_once,
109
- inputs=[dream_text, image_input, audio_input, mood, answers, text_backend, vision_backend],
110
- outputs=[negotiation, pact_text, pact_html, debug_json],
111
- )
 
 
 
 
 
 
 
 
112
 
113
  gr.Examples(
114
  examples=[
@@ -123,8 +720,67 @@ def build_demo() -> gr.Blocks:
123
  "I want a small action that makes tomorrow less loud.",
124
  ],
125
  ],
126
- inputs=[dream_text, mood, answers],
127
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
128
  return demo
129
 
130
 
@@ -132,8 +788,15 @@ demo = build_demo()
132
 
133
 
134
  if __name__ == "__main__":
 
135
  if os.getenv("SPACE_ID"):
136
- demo.launch(show_api=False, show_error=True)
137
  else:
138
  server_port = int(os.getenv("GRADIO_SERVER_PORT", "7860"))
139
- demo.launch(server_name="127.0.0.1", server_port=server_port, show_api=False, show_error=True)
 
 
 
 
 
 
 
3
  import gradio as gr
4
  from gradio_client import utils as gradio_client_utils
5
 
6
+ from dream_customs.app_logic import (
7
+ add_material_action,
8
+ answer_question_action,
9
+ ask_another_question_action,
10
+ draft_pact_action,
11
+ initial_workbench_state,
12
+ revise_pact_action,
13
+ seal_pact_action,
14
+ skip_question_action,
15
+ start_declaration_action,
16
+ start_new_action,
17
+ )
18
 
19
 
20
  _ORIGINAL_SCHEMA_TO_TYPE = gradio_client_utils._json_schema_to_python_type
 
31
 
32
 
33
  CSS = """
34
+ :root {
35
+ --dc-bg-night: oklch(0.145 0.030 238);
36
+ --dc-bg-void: oklch(0.080 0.010 250);
37
+ --dc-surface-ink: oklch(0.205 0.035 238);
38
+ --dc-surface-raised: oklch(0.275 0.045 236);
39
+ --dc-primary: oklch(0.690 0.105 232);
40
+ --dc-primary-deep: oklch(0.510 0.115 234);
41
+ --dc-coral: oklch(0.705 0.160 28);
42
+ --dc-aurora: oklch(0.820 0.110 185);
43
+ --dc-ink-main: oklch(0.965 0.010 235);
44
+ --dc-ink-muted: oklch(0.760 0.030 232);
45
+ --dc-border: oklch(0.430 0.055 235);
46
+ --dc-warning: oklch(0.760 0.150 75);
47
+ --dc-radius-sm: 6px;
48
+ --dc-radius-md: 10px;
49
+ --dc-radius-lg: 14px;
50
+ }
51
+
52
+ html,
53
+ body,
54
  .gradio-container {
55
+ background:
56
+ radial-gradient(circle at 8% 0%, oklch(0.690 0.105 232 / 0.20), transparent 30rem),
57
+ radial-gradient(circle at 80% 12%, oklch(0.820 0.110 185 / 0.13), transparent 26rem),
58
+ linear-gradient(180deg, var(--dc-bg-void), var(--dc-bg-night) 34%, var(--dc-bg-void));
59
+ color: var(--dc-ink-main);
60
  font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
61
+ font-kerning: normal;
62
+ }
63
+
64
+ .gradio-container {
65
+ max-width: none !important;
66
  }
67
+
68
+ .gradio-container .main,
69
+ .gradio-container .wrap {
70
+ max-width: none !important;
71
+ }
72
+
73
+ .dc-app-shell {
74
+ gap: 16px !important;
75
  margin: 0 auto;
76
+ max-width: 1440px;
77
+ min-height: 100vh;
78
+ padding: 16px;
79
+ }
80
+
81
+ .dc-session-store {
82
+ display: none !important;
83
+ }
84
+
85
+ .dc-statusbar {
86
+ align-items: center;
87
+ background:
88
+ linear-gradient(135deg, oklch(0.205 0.035 238 / 0.96), oklch(0.145 0.030 238 / 0.96)),
89
+ url("/file=docs/design/assets/dream-customs-probe-night-desk.png");
90
+ background-position: center;
91
+ background-size: cover;
92
+ border: 1px solid var(--dc-border);
93
+ border-radius: var(--dc-radius-lg);
94
+ display: grid;
95
+ gap: 16px;
96
+ grid-template-columns: minmax(280px, 1fr) auto minmax(260px, 0.8fr);
97
+ min-height: 108px;
98
+ overflow: hidden;
99
+ padding: 16px;
100
+ position: relative;
101
+ }
102
+
103
+ .dc-statusbar::before {
104
+ background: linear-gradient(90deg, oklch(0.080 0.010 250 / 0.88), oklch(0.145 0.030 238 / 0.72));
105
+ content: "";
106
+ inset: 0;
107
+ position: absolute;
108
+ }
109
+
110
+ .dc-statusbar > * {
111
+ position: relative;
112
+ z-index: 1;
113
+ }
114
+
115
+ .dc-brand-lockup {
116
+ align-items: center;
117
+ display: flex;
118
+ gap: 12px;
119
+ }
120
+
121
+ .dc-brand-mark {
122
+ align-items: center;
123
+ background: var(--dc-primary-deep);
124
+ border: 1px solid var(--dc-primary);
125
+ border-radius: var(--dc-radius-md);
126
+ color: var(--dc-ink-main);
127
+ display: inline-flex;
128
+ flex: 0 0 auto;
129
+ font-size: 0.875rem;
130
+ font-weight: 760;
131
+ height: 44px;
132
+ justify-content: center;
133
+ letter-spacing: 0;
134
+ width: 44px;
135
+ }
136
+
137
+ .dc-brand-lockup h1 {
138
+ color: var(--dc-ink-main);
139
+ font-size: 1.35rem;
140
+ font-weight: 760;
141
+ letter-spacing: 0;
142
+ line-height: 1.15;
143
+ margin: 0;
144
+ text-wrap: balance;
145
+ }
146
+
147
+ .dc-brand-lockup p,
148
+ .dc-timeline-head p {
149
+ color: var(--dc-ink-muted);
150
+ font-size: 0.92rem;
151
+ line-height: 1.45;
152
+ margin: 4px 0 0;
153
+ max-width: 68ch;
154
+ }
155
+
156
+ .dc-phase-rail {
157
+ background: oklch(0.080 0.010 250 / 0.58);
158
+ border: 1px solid oklch(0.430 0.055 235 / 0.72);
159
+ border-radius: 999px;
160
+ display: flex;
161
+ gap: 4px;
162
+ padding: 4px;
163
+ }
164
+
165
+ .dc-phase-rail span,
166
+ .dc-system-status span {
167
+ border-radius: 999px;
168
+ color: var(--dc-ink-muted);
169
+ font-size: 0.78rem;
170
+ font-weight: 680;
171
+ letter-spacing: 0;
172
+ line-height: 1.2;
173
+ padding: 8px 10px;
174
+ white-space: nowrap;
175
+ }
176
+
177
+ .dc-phase-rail .is-active {
178
+ background: var(--dc-primary-deep);
179
+ color: var(--dc-ink-main);
180
+ }
181
+
182
+ .dc-system-status {
183
+ display: flex;
184
+ flex-wrap: wrap;
185
+ gap: 6px;
186
+ justify-content: flex-end;
187
+ }
188
+
189
+ .dc-system-status span {
190
+ background: oklch(0.080 0.010 250 / 0.62);
191
+ border: 1px solid oklch(0.430 0.055 235 / 0.62);
192
+ }
193
+
194
+ .dc-main-grid {
195
+ align-items: stretch !important;
196
+ display: grid !important;
197
+ gap: 16px !important;
198
+ grid-template-columns: minmax(0, 1fr) minmax(320px, 420px);
199
+ }
200
+
201
+ .dc-timeline-column,
202
+ .dc-inspector-column {
203
+ min-width: 0 !important;
204
+ }
205
+
206
+ .dc-timeline-shell,
207
+ .dc-inspector,
208
+ .dc-composer-panel {
209
+ background: oklch(0.205 0.035 238 / 0.96);
210
+ border: 1px solid var(--dc-border);
211
+ border-radius: var(--dc-radius-lg);
212
+ color: var(--dc-ink-main);
213
+ }
214
+
215
+ .dc-timeline-shell {
216
+ min-height: 480px;
217
+ padding: 18px;
218
+ }
219
+
220
+ .dc-timeline-head {
221
+ align-items: start;
222
+ display: flex;
223
+ gap: 16px;
224
+ justify-content: space-between;
225
+ margin-bottom: 14px;
226
+ }
227
+
228
+ .dc-timeline-head h2,
229
+ .dc-inspector h2 {
230
+ color: var(--dc-ink-main);
231
+ font-size: 1.25rem;
232
+ font-weight: 740;
233
+ letter-spacing: 0;
234
+ line-height: 1.2;
235
+ margin: 0;
236
+ text-wrap: balance;
237
+ }
238
+
239
+ .dc-timeline-head > span,
240
+ .dc-inspector-kicker,
241
+ .dc-permit-row span {
242
+ color: var(--dc-aurora);
243
+ font-size: 0.8rem;
244
+ font-weight: 720;
245
+ letter-spacing: 0;
246
+ }
247
+
248
+ .dc-evidence-tray {
249
+ display: flex;
250
+ flex-wrap: wrap;
251
+ gap: 8px;
252
+ margin: 0 0 16px;
253
+ }
254
+
255
+ .dc-evidence-chip {
256
+ align-items: center;
257
+ background: oklch(0.145 0.030 238);
258
+ border: 1px solid oklch(0.430 0.055 235 / 0.84);
259
+ border-radius: 999px;
260
+ color: var(--dc-ink-main);
261
+ display: inline-flex;
262
+ font-size: 0.83rem;
263
+ font-weight: 650;
264
+ gap: 8px;
265
+ line-height: 1.2;
266
+ padding: 8px 10px;
267
+ }
268
+
269
+ .dc-chip-dot {
270
+ background: var(--dc-ink-muted);
271
+ border-radius: 999px;
272
+ display: inline-block;
273
+ height: 7px;
274
+ width: 7px;
275
+ }
276
+
277
+ .dc-evidence-chip.is-extracted .dc-chip-dot,
278
+ .dc-evidence-chip.is-selected .dc-chip-dot {
279
+ background: var(--dc-aurora);
280
+ }
281
+
282
+ .dc-evidence-chip.is-failed {
283
+ border-color: oklch(0.705 0.160 28 / 0.72);
284
+ }
285
+
286
+ .dc-evidence-chip.is-failed .dc-chip-dot {
287
+ background: var(--dc-coral);
288
+ }
289
+
290
+ .dc-timeline-list {
291
+ display: flex;
292
+ flex-direction: column;
293
+ gap: 10px;
294
+ }
295
+
296
+ .dc-timeline-event {
297
+ background: oklch(0.145 0.030 238 / 0.82);
298
+ border: 1px solid oklch(0.430 0.055 235 / 0.72);
299
+ border-radius: var(--dc-radius-md);
300
+ padding: 14px;
301
+ }
302
+
303
+ .dc-timeline-event h3 {
304
+ color: var(--dc-ink-main);
305
+ font-size: 1rem;
306
+ font-weight: 720;
307
+ line-height: 1.25;
308
+ margin: 6px 0 0;
309
+ }
310
+
311
+ .dc-timeline-event p,
312
+ .dc-inspector p,
313
+ .dc-inspector li {
314
+ color: var(--dc-ink-muted);
315
+ font-size: 0.95rem;
316
+ line-height: 1.55;
317
+ margin: 8px 0 0;
318
+ }
319
+
320
+ .dc-event-meta {
321
+ display: flex;
322
+ flex-wrap: wrap;
323
+ gap: 6px;
324
+ }
325
+
326
+ .dc-event-meta span {
327
+ background: oklch(0.275 0.045 236 / 0.88);
328
+ border: 1px solid oklch(0.430 0.055 235 / 0.64);
329
+ border-radius: 999px;
330
+ color: var(--dc-aurora);
331
+ font-size: 0.75rem;
332
+ font-weight: 680;
333
+ padding: 5px 8px;
334
+ }
335
+
336
+ .dc-timeline-event.is-user {
337
+ background: oklch(0.275 0.045 236 / 0.92);
338
+ }
339
+
340
+ .dc-timeline-event.is-pact {
341
+ border-color: oklch(0.705 0.160 28 / 0.72);
342
+ }
343
+
344
+ .dc-timeline-event.is-error {
345
+ background: oklch(0.705 0.160 28 / 0.11);
346
+ border-color: oklch(0.705 0.160 28 / 0.72);
347
+ }
348
+
349
+ .dc-inspector {
350
+ min-height: 480px;
351
+ padding: 18px;
352
+ }
353
+
354
+ .dc-inspector.is-sealed {
355
+ border-color: var(--dc-coral);
356
+ }
357
+
358
+ .dc-inspector-kicker {
359
+ margin-bottom: 10px;
360
+ }
361
+
362
+ .dc-permit-row {
363
+ align-items: center;
364
+ display: flex;
365
+ justify-content: space-between;
366
+ margin-bottom: 12px;
367
  }
368
+
369
+ .dc-inspector dl {
370
+ display: grid;
371
+ gap: 8px;
372
+ grid-template-columns: repeat(3, minmax(0, 1fr));
373
+ margin: 16px 0;
374
+ }
375
+
376
+ .dc-inspector dl div {
377
+ background: oklch(0.145 0.030 238 / 0.82);
378
+ border: 1px solid oklch(0.430 0.055 235 / 0.66);
379
+ border-radius: var(--dc-radius-md);
380
+ padding: 10px;
381
+ }
382
+
383
+ .dc-inspector dt,
384
+ .dc-inspector h3 {
385
+ color: var(--dc-aurora);
386
+ font-size: 0.78rem;
387
+ font-weight: 720;
388
+ letter-spacing: 0;
389
+ margin: 0;
390
+ }
391
+
392
+ .dc-inspector dd {
393
+ color: var(--dc-ink-main);
394
+ font-size: 0.92rem;
395
+ font-weight: 680;
396
+ margin: 4px 0 0;
397
+ }
398
+
399
+ .dc-inspector section {
400
+ border-top: 1px solid oklch(0.430 0.055 235 / 0.6);
401
+ padding: 12px 0;
402
+ }
403
+
404
+ .dc-inspector ul {
405
+ margin: 8px 0 0;
406
+ padding-left: 18px;
407
+ }
408
+
409
+ .dc-support-note,
410
+ .dc-inline-notice {
411
+ background: oklch(0.705 0.160 28 / 0.13);
412
+ border: 1px solid oklch(0.705 0.160 28 / 0.72);
413
+ border-radius: var(--dc-radius-md);
414
+ color: var(--dc-ink-main);
415
+ line-height: 1.45;
416
+ padding: 12px 14px;
417
+ }
418
+
419
+ .dc-inline-notice {
420
+ background: oklch(0.275 0.045 236 / 0.94);
421
+ border-color: oklch(0.430 0.055 235 / 0.76);
422
+ }
423
+
424
+ .dc-inline-notice.is-error {
425
+ background: oklch(0.705 0.160 28 / 0.13);
426
+ border-color: oklch(0.705 0.160 28 / 0.72);
427
+ }
428
+
429
+ .dc-inline-notice.is-sealed {
430
+ border-color: var(--dc-coral);
431
+ }
432
+
433
+ .dc-composer-panel {
434
+ bottom: 12px;
435
+ padding: 14px;
436
+ position: sticky;
437
+ z-index: 5;
438
+ }
439
+
440
+ .dc-composer-panel textarea,
441
+ .dc-composer-panel input,
442
+ .dc-composer-panel select,
443
+ .dc-composer-panel label,
444
+ .dc-composer-panel .wrap,
445
+ .dc-composer-panel .block,
446
+ .dc-composer-panel .input-container,
447
+ .dc-composer-panel .container {
448
+ border-radius: var(--dc-radius-md) !important;
449
+ font-size: 1rem !important;
450
+ }
451
+
452
+ .dc-composer-panel .block,
453
+ .dc-composer-panel .container,
454
+ .dc-composer-panel .input-container,
455
+ .dc-composer-panel .wrap.default {
456
+ background: oklch(0.205 0.035 238) !important;
457
+ border-color: oklch(0.430 0.055 235 / 0.72) !important;
458
+ color: var(--dc-ink-main) !important;
459
+ }
460
+
461
+ .dc-composer-panel .upload-container,
462
+ .dc-composer-panel .image-container,
463
+ .dc-composer-panel .audio-container,
464
+ .dc-composer-panel .upload-container button,
465
+ .dc-composer-panel .audio-container button {
466
+ background: oklch(0.145 0.030 238) !important;
467
+ border-color: oklch(0.430 0.055 235 / 0.72) !important;
468
+ color: var(--dc-ink-muted) !important;
469
+ }
470
+
471
+ .dc-composer-panel .upload-container button,
472
+ .dc-composer-panel .audio-container button {
473
+ border: 1px dashed oklch(0.430 0.055 235 / 0.82) !important;
474
+ }
475
+
476
+ .dc-composer-panel .source-selection button {
477
+ background: oklch(0.080 0.010 250) !important;
478
+ border: 1px solid oklch(0.430 0.055 235 / 0.72) !important;
479
+ color: var(--dc-aurora) !important;
480
+ }
481
+
482
+ .dc-composer-panel svg {
483
+ color: var(--dc-aurora) !important;
484
+ stroke: currentColor !important;
485
+ }
486
+
487
+ .dc-composer-panel label,
488
+ .dc-composer-panel .label-wrap span {
489
+ color: var(--dc-ink-main) !important;
490
+ font-weight: 680 !important;
491
+ }
492
+
493
+ .dc-composer-panel textarea,
494
+ .dc-composer-panel input,
495
+ .dc-composer-panel select {
496
+ background: oklch(0.145 0.030 238) !important;
497
+ color: var(--dc-ink-main) !important;
498
+ }
499
+
500
+ .dc-composer-panel textarea::placeholder,
501
+ .dc-composer-panel input::placeholder {
502
+ color: var(--dc-ink-muted) !important;
503
+ opacity: 1 !important;
504
+ }
505
+
506
+ .dc-composer-panel button {
507
+ border-radius: var(--dc-radius-md) !important;
508
+ font-weight: 720 !important;
509
+ min-height: 44px;
510
+ }
511
+
512
+ .dc-action-row button {
513
+ background: oklch(0.275 0.045 236) !important;
514
+ border: 1px solid oklch(0.430 0.055 235 / 0.82) !important;
515
+ color: var(--dc-ink-main) !important;
516
  }
517
+
518
+ .dc-action-row button:hover {
519
+ border-color: var(--dc-primary) !important;
520
+ }
521
+
522
+ .dc-primary,
523
+ .dc-primary button {
524
+ background: var(--dc-primary-deep) !important;
525
+ border: 1px solid var(--dc-primary) !important;
526
+ color: var(--dc-ink-main) !important;
527
+ }
528
+
529
+ .dc-seal-button,
530
+ .dc-seal-button button {
531
+ background: var(--dc-coral) !important;
532
+ border: 1px solid var(--dc-coral) !important;
533
+ color: var(--dc-bg-void) !important;
534
+ }
535
+
536
+ .dc-action-row {
537
+ align-items: stretch !important;
538
+ display: flex !important;
539
+ flex-wrap: wrap !important;
540
+ gap: 8px !important;
541
+ }
542
+
543
+ .dc-action-row > * {
544
+ min-width: 148px !important;
545
+ }
546
+
547
+ .dc-sealed-output {
548
+ margin-bottom: 16px;
549
  }
550
+
551
+ .dc-diagnostics textarea,
552
+ .dc-diagnostics code {
553
+ font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace !important;
 
554
  }
555
+
556
+ *:focus-visible {
557
+ outline: 2px solid var(--dc-primary) !important;
558
+ outline-offset: 2px !important;
559
  }
560
+
561
+ @media (max-width: 980px) {
562
+ .dc-statusbar {
563
+ grid-template-columns: 1fr;
564
+ }
565
+
566
+ .dc-system-status {
567
+ justify-content: flex-start;
568
+ }
569
+
570
+ .dc-main-grid {
571
+ grid-template-columns: 1fr;
572
+ }
573
+
574
+ .dc-inspector {
575
+ min-height: auto;
576
+ }
577
+ }
578
+
579
+ @media (max-width: 620px) {
580
+ .dc-app-shell {
581
+ padding: 10px;
582
+ }
583
+
584
+ .dc-statusbar,
585
+ .dc-timeline-shell,
586
+ .dc-inspector,
587
+ .dc-composer-panel {
588
+ border-radius: var(--dc-radius-md);
589
+ }
590
+
591
+ .dc-brand-lockup {
592
+ align-items: flex-start;
593
+ }
594
+
595
+ .dc-brand-lockup h1 {
596
+ font-size: 1.1rem;
597
+ }
598
+
599
+ .dc-phase-rail {
600
+ overflow-x: auto;
601
+ }
602
+
603
+ .dc-timeline-head,
604
+ .dc-permit-row {
605
+ align-items: flex-start;
606
+ flex-direction: column;
607
+ }
608
+
609
+ .dc-inspector dl {
610
+ grid-template-columns: 1fr;
611
+ }
612
+
613
+ .dc-action-row > * {
614
+ min-width: 100% !important;
615
+ }
616
+ }
617
+
618
+ @media (prefers-reduced-motion: reduce) {
619
+ *,
620
+ *::before,
621
+ *::after {
622
+ animation-duration: 0.001ms !important;
623
+ scroll-behavior: auto !important;
624
+ transition-duration: 0.001ms !important;
625
+ }
626
  }
627
  """
628
 
629
 
630
+ def _outputs(
631
+ session_state,
632
+ status_html,
633
+ timeline_html,
634
+ inspector_html,
635
+ sealed_html,
636
+ debug_json,
637
+ notice_html,
638
+ ):
639
+ return [session_state, status_html, timeline_html, inspector_html, sealed_html, debug_json, notice_html]
640
+
641
+
642
  def build_demo() -> gr.Blocks:
643
  with gr.Blocks(css=CSS, title="Dream Customs") as demo:
644
+ session_state = gr.Textbox(label="Session manifest", show_label=False, elem_classes=["dc-session-store"])
645
+ with gr.Column(elem_classes=["dc-app-shell"]):
646
+ status_html = gr.HTML()
647
+
648
+ with gr.Row(elem_classes=["dc-main-grid"]):
649
+ with gr.Column(scale=7, elem_classes=["dc-timeline-column"]):
650
+ timeline_html = gr.HTML()
651
+ with gr.Column(scale=4, elem_classes=["dc-inspector-column"]):
652
+ inspector_html = gr.HTML()
653
+
654
+ with gr.Column(elem_classes=["dc-composer-panel"]):
655
+ notice_html = gr.HTML()
656
+ dream_text = gr.Textbox(
657
+ label="Dream material",
658
+ lines=4,
659
+ value="我梦见一部迟到的电梯,按钮都融化了,我一直到不了 14 楼。",
660
+ placeholder="Type the dream, a fragment, or a new piece of material.",
661
+ )
662
+ with gr.Row():
663
+ image_input = gr.Image(label="Attach image", type="filepath", height=150)
664
+ audio_input = gr.Audio(label="Record voice", type="filepath")
665
  mood = gr.Dropdown(
666
+ label="Mood chip",
667
  choices=["foggy", "anxious", "curious", "tired", "restless", "calm"],
668
  value="foggy",
669
  )
670
+ with gr.Row():
671
+ answer_text = gr.Textbox(
672
+ label="Answer to the clerk",
673
+ lines=2,
674
  value="我想和它结盟,但今天只想完成一件很小的事。",
675
+ placeholder="Answer a customs question, or leave blank and skip.",
676
  )
677
+ revision_request = gr.Textbox(
678
+ label="Revision request",
679
+ lines=2,
680
+ value="Make it gentler and more specific for today.",
681
+ placeholder="Try: make it stranger, make it gentler, or make the action smaller.",
682
+ )
683
+ with gr.Row():
684
+ text_backend = gr.Dropdown(
685
+ label="Text route",
686
+ choices=["demo", "model", "ollama"],
687
+ value="demo",
688
+ )
689
+ vision_backend = gr.Dropdown(
690
+ label="Vision route",
691
+ choices=["demo", "model", "ollama"],
692
+ value="demo",
693
+ )
694
+ with gr.Row(elem_classes=["dc-action-row"]):
695
+ send_btn = gr.Button("Send to customs", elem_classes=["dc-primary"])
696
+ add_btn = gr.Button("Add material")
697
+ ask_btn = gr.Button("Ask another question")
698
+ answer_btn = gr.Button("Answer question")
699
+ skip_btn = gr.Button("Skip question")
700
+ draft_btn = gr.Button("Draft pact")
701
+ revise_btn = gr.Button("Revise pact")
702
+ seal_btn = gr.Button("Seal today's pact", elem_classes=["dc-seal-button"])
703
+ new_btn = gr.Button("Start a new declaration")
704
+
705
+ sealed_html = gr.HTML(elem_classes=["dc-sealed-output"])
706
+
707
+ with gr.Accordion("Diagnostics", open=False, elem_classes=["dc-diagnostics"]):
708
+ debug_json = gr.Code(label="Session state", language="json")
709
 
710
  gr.Examples(
711
  examples=[
 
720
  "I want a small action that makes tomorrow less loud.",
721
  ],
722
  ],
723
+ inputs=[dream_text, mood, answer_text],
724
  )
725
+
726
+ outputs = _outputs(session_state, status_html, timeline_html, inspector_html, sealed_html, debug_json, notice_html)
727
+ demo.load(initial_workbench_state, outputs=outputs, api_name=False)
728
+
729
+ send_btn.click(
730
+ start_declaration_action,
731
+ inputs=[session_state, dream_text, image_input, audio_input, mood, text_backend, vision_backend],
732
+ outputs=outputs,
733
+ api_name=False,
734
+ )
735
+ add_btn.click(
736
+ add_material_action,
737
+ inputs=[session_state, dream_text, image_input, audio_input, mood, text_backend, vision_backend],
738
+ outputs=outputs,
739
+ api_name=False,
740
+ )
741
+ ask_btn.click(
742
+ ask_another_question_action,
743
+ inputs=[session_state, text_backend, vision_backend],
744
+ outputs=outputs,
745
+ api_name=False,
746
+ )
747
+ answer_btn.click(
748
+ answer_question_action,
749
+ inputs=[session_state, answer_text, text_backend, vision_backend],
750
+ outputs=outputs,
751
+ api_name=False,
752
+ )
753
+ skip_btn.click(
754
+ skip_question_action,
755
+ inputs=[session_state, text_backend, vision_backend],
756
+ outputs=outputs,
757
+ api_name=False,
758
+ )
759
+ draft_btn.click(
760
+ draft_pact_action,
761
+ inputs=[session_state, text_backend, vision_backend],
762
+ outputs=outputs,
763
+ api_name=False,
764
+ )
765
+ revise_btn.click(
766
+ revise_pact_action,
767
+ inputs=[session_state, revision_request, text_backend, vision_backend],
768
+ outputs=outputs,
769
+ api_name=False,
770
+ )
771
+ seal_btn.click(
772
+ seal_pact_action,
773
+ inputs=[session_state, text_backend, vision_backend],
774
+ outputs=outputs,
775
+ api_name=False,
776
+ )
777
+ new_btn.click(
778
+ start_new_action,
779
+ inputs=[text_backend, vision_backend],
780
+ outputs=outputs,
781
+ api_name=False,
782
+ )
783
+
784
  return demo
785
 
786
 
 
788
 
789
 
790
  if __name__ == "__main__":
791
+ allowed_paths = [os.path.abspath("docs/design/assets")]
792
  if os.getenv("SPACE_ID"):
793
+ demo.launch(show_api=False, show_error=True, allowed_paths=allowed_paths)
794
  else:
795
  server_port = int(os.getenv("GRADIO_SERVER_PORT", "7860"))
796
+ demo.launch(
797
+ server_name="127.0.0.1",
798
+ server_port=server_port,
799
+ show_api=False,
800
+ show_error=True,
801
+ allowed_paths=allowed_paths,
802
+ )
docs/design/2026-06-05-uiux-v2-brief.md ADDED
@@ -0,0 +1,131 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Dream Customs UI/UX V2 Brief
2
+
3
+ Last updated: 2026-06-05
4
+
5
+ ## 1. Feature Summary
6
+
7
+ Redesign Dream Customs from a one-shot Gradio form into a dream customs workbench. The user should be able to declare a dream through text, image, or voice, receive customs questions, add more evidence, continue negotiation, revise the pact draft, or seal the final pact.
8
+
9
+ The target user is tired, on mobile, and still carrying dream residue. The interface must feel vivid and strange, but the controls must be as obvious as a serious product tool.
10
+
11
+ ## 2. Primary User Action
12
+
13
+ The primary action is not "submit once." It is "decide the next customs move": add material, answer, ask another question, revise the pact, or seal today's pact.
14
+
15
+ ## 3. Design Direction
16
+
17
+ Color strategy: committed product UI. The app uses a dark nocturnal base with cobalt as the main action color, aurora cyan for evidence status, and coral as rare stamp ink.
18
+
19
+ Scene sentence: a half-awake user opens the app from bed under dim morning light, facing a night customs desk that turns dream fragments into a small action for today.
20
+
21
+ Anchor references:
22
+
23
+ - Codex app input composer: bottom-first multimodal input and clear action clustering.
24
+ - Linear / Raycast product discipline: dense but calm panels, obvious focus states, no decorative controls.
25
+ - Night train ticket window / customs stamp desk: the physical metaphor for evidence, permit, and final seal.
26
+
27
+ Visual probes generated for this direction:
28
+
29
+ ![Night border probe](assets/dream-customs-probe-night-border.png)
30
+
31
+ ![Night desk probe](assets/dream-customs-probe-night-desk.png)
32
+
33
+ ![Morning console probe](assets/dream-customs-probe-morning-console.png)
34
+
35
+ Chosen lane: `Night desk probe` for layout and interaction, with `Night border probe` for pact stamp atmosphere. `Morning console probe` stays as a later light-mode reference.
36
+
37
+ ## 4. Scope
38
+
39
+ Fidelity: production-ready Gradio app redesign.
40
+
41
+ Breadth: one main app surface plus all states needed for the flow.
42
+
43
+ Interactivity: shipped-quality multi-step Gradio behavior with session state.
44
+
45
+ Time intent: polish until it is good enough for a hackathon demo video and Space public use.
46
+
47
+ ## 5. Layout Strategy
48
+
49
+ Use an app shell instead of a document page.
50
+
51
+ - Top: compact identity, current phase, model status, and safety note entry point.
52
+ - Center: conversation timeline with user declarations, evidence extraction, customs questions, and pact draft events.
53
+ - Bottom: Codex-style composer with text area, image attach, voice record/upload, mood chip, backend/model menu, and primary send button.
54
+ - Side on desktop, collapsible below timeline on mobile: pact inspector with visitor, permit ID, evidence count, risk, suggestion, weird task, and seal state.
55
+ - Output card: only becomes the dominant object after the user chooses to seal.
56
+
57
+ ## 6. Key States
58
+
59
+ - Empty: show a compact dream evidence tray and one sentence telling the user they can start with any fragment.
60
+ - Declaring: composer has text, image, audio, and mood chips, with visible send state.
61
+ - Extracting: evidence chips show queued, extracting, extracted, or failed.
62
+ - Negotiating: customs questions appear one at a time or as a small set; user can answer, skip, or add evidence.
63
+ - Draft pact: inspector shows editable draft actions: revise suggestion, make it stranger, make it gentler, ask another question.
64
+ - Sealed pact: final card locks visually, provides copy text and screenshot-friendly layout.
65
+ - Error: inline recovery, not modal. Keep text-only path available.
66
+ - Safety escalation: supportive note appears above final action and avoids playful framing for severe distress.
67
+ - Reduced motion: no animated gates or delayed reveal. State changes crossfade or swap instantly.
68
+
69
+ ## 7. Interaction Model
70
+
71
+ Core loop:
72
+
73
+ 1. User adds dream evidence through composer.
74
+ 2. App builds or updates `DreamIntake`.
75
+ 3. System asks customs questions.
76
+ 4. User chooses: answer, add material, skip, continue negotiation, or draft pact.
77
+ 5. Draft pact appears in inspector.
78
+ 6. User chooses: revise, add evidence, ask more, or seal.
79
+ 7. Sealed pact renders final card.
80
+
81
+ Longer task behavior should come from real user agency, not artificial delay. Each extra round should incorporate the user's new material or choice into the next prompt.
82
+
83
+ ## 8. Content Requirements
84
+
85
+ Primary labels:
86
+
87
+ - `Declare dream`
88
+ - `Attach image`
89
+ - `Record voice`
90
+ - `Send to customs`
91
+ - `Add material`
92
+ - `Ask another question`
93
+ - `Draft pact`
94
+ - `Revise pact`
95
+ - `Seal today's pact`
96
+ - `Start a new declaration`
97
+
98
+ Output fields remain:
99
+
100
+ - Dream visitor
101
+ - Permit ID
102
+ - Contraband
103
+ - Risk level
104
+ - Alliance reading
105
+ - Practical suggestion
106
+ - Weird task
107
+ - Bedtime release
108
+ - Safety note
109
+
110
+ Image roles:
111
+
112
+ - Atmospheric header or empty-state art: generated raster image.
113
+ - Evidence thumbnails: user upload.
114
+ - Seal and permit marks: CSS/HTML, not sketchy SVG.
115
+
116
+ ## 9. Recommended Impeccable References
117
+
118
+ - `layout.md`: rebuild the app shell, composer, timeline, and inspector.
119
+ - `colorize.md`: apply the nocturnal palette without losing contrast.
120
+ - `harden.md`: error, empty, loading, safety, i18n, and mobile states.
121
+ - `animate.md`: restrained state motion and reduced-motion fallback.
122
+ - `polish.md`: final pass after implementation.
123
+
124
+ ## 10. Open Questions
125
+
126
+ Defaults chosen for implementation:
127
+
128
+ - Default visual lane: Night Desk.
129
+ - Default backend route: keep `demo` for deterministic fallback, add hosted MiniCPM route as optional `model`.
130
+ - Default voice behavior: transcript first, no voice output.
131
+ - Default deployment: Hugging Face Space remains the public demo, Modal can host heavier model experiments if Space hardware is insufficient.
docs/design/assets/dream-customs-probe-morning-console.png ADDED

Git LFS Details

  • SHA256: 68fd84c16931ccf8acf2e1d80995064592ce72eca27c0fa9a41d2ad33137ebb7
  • Pointer size: 132 Bytes
  • Size of remote file: 1.78 MB
docs/design/assets/dream-customs-probe-night-border.png ADDED

Git LFS Details

  • SHA256: 1bdcbffcf9e208287c59fe80d2173058971a74c9087f20923c951e9089af07ab
  • Pointer size: 132 Bytes
  • Size of remote file: 1.68 MB
docs/design/assets/dream-customs-probe-night-desk.png ADDED

Git LFS Details

  • SHA256: 9affd2ae496d2200b9f8fe311f7e50a42c94772126b4798e7ad8db5d255a9d8c
  • Pointer size: 132 Bytes
  • Size of remote file: 1.63 MB
docs/goal-prompts/2026-06-05-dream-customs-uiux-v2.md ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Dream Customs UI/UX V2 Goal Prompt
2
+
3
+ 在 `/Users/junhaocheng/working-dir/ai-competitions/build-small-hackthon` 继续 Dream Customs。先读 `AGENTS.md`、`PRODUCT.md`、`DESIGN.md`、`docs/design/2026-06-05-uiux-v2-brief.md`、`docs/superpowers/plans/2026-06-05-dream-customs-uiux-v2.md`。使用 `$impeccable craft Dream Customs UI/UX V2` 的思路实现,不要再做一页普通 Gradio 表单。
4
+
5
+ 目标:把当前 one-shot app 改成 Codex app 风格的梦境海关工作台。核心布局为顶部状态栏、中心会话时间线、底部 multimodal composer、桌面右侧或移动端下方 pact inspector。用户必须能选择 `Add material`、`Ask another question`、`Draft pact`、`Revise pact`、`Seal today's pact`,而不是一次生成后结束。
6
+
7
+ 设计:采用 `Night Desk` 方向,OKLCH 暗色产品界面,cobalt 主动作、aurora evidence 状态、coral seal 稀有强调。使用 `docs/design/assets/` 里的生成图作为氛围参考或空状态资源。避免 beige parchment、tarot、therapy intake、generic purple AI SaaS、side stripe、gradient text、过圆卡片和低对比灰字。
8
+
9
+ 实现:先用 demo backend 打通 session state、schema、pipeline、UI 和测试,再接 hosted MiniCPM route。保留 text-only fallback。可以使用 Modal credits 做 MiniCPM 测试,HF credits 只在 Space 稳定运行需要时使用。不要把任何 token 写入 repo、日志或文档。
10
+
11
+ 验收:`python -m pytest -q` 通过;本地 `python app.py` 可用;远端 Space queue prediction 可用;手机宽度可读;debug JSON 收进折叠 diagnostics;严重痛苦输入触发安全提示。完成后 commit、push,并更新计划 checkbox。
docs/smoke/2026-06-05-space-deployment-smoke.md ADDED
@@ -0,0 +1,99 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Dream Customs Space Deployment Smoke
2
+
3
+ Date: 2026-06-05
4
+
5
+ ## Scope
6
+
7
+ Goal for this pass: verify the current UI/UX V2 workbench locally, sync it to the Hugging Face Space if authorized, check the public Space with a browser, run a remote text-only queue prediction, and test hosted MiniCPM routes only if runtime secrets are present.
8
+
9
+ ## Local Repo
10
+
11
+ - CWD verified: `/Users/junhaocheng/working-dir/ai-competitions/build-small-hackthon`.
12
+ - Branch verified: `feature/dream-customs-mvp`.
13
+ - `git pull --ff-only`: already up to date.
14
+ - `git remote -v` showed:
15
+ - `origin`: `https://github.com/adjcjh777/dream-customs-build-small.git`
16
+ - `space`: `https://huggingface.co/spaces/build-small-hackathon/dream-customs`
17
+
18
+ ## Local Verification
19
+
20
+ - `.venv/bin/python -m pytest -q`: 31 passed.
21
+ - `python app.py` could not bind `7860` because another Python process was already listening there.
22
+ - Local app started with `GRADIO_SERVER_PORT=7861 .venv/bin/python app.py`.
23
+ - Browser smoke on `http://127.0.0.1:7861/` completed:
24
+ - `Send to customs`
25
+ - `Ask another question`
26
+ - `Add material`
27
+ - `Draft pact`
28
+ - `Revise pact`
29
+ - `Seal today's pact`
30
+ - Final local V2 state showed:
31
+ - phase `Sealed`
32
+ - permit `DC-DEMO-014`
33
+ - 3 filed evidence items
34
+ - 4 asked questions
35
+ - alliance reading
36
+ - today's suggestion
37
+ - 5-minute task
38
+ - contraband list
39
+ - bedtime release
40
+ - `SEALED` pact card
41
+ - Mobile viewport `390x844` remained readable.
42
+ - Diagnostics panel opened and showed sealed demo state. Ordinary-case safety fields were empty, and the persistent header copy still stated that this is playful reflection, not medical advice.
43
+
44
+ ## Hugging Face Space Sync
45
+
46
+ - Space remote `main` was readable.
47
+ - Space `main` was on old app history and did not contain the V2 workbench.
48
+ - A non-force deployment path was attempted by cloning the Space repo to a temporary directory, copying only this repo's tracked files, committing a Space deployment commit, and pushing to `main`.
49
+ - The local temporary deployment commit was created as `3b18ee5 Deploy Dream Customs workbench UI`, but it was not accepted by Hugging Face.
50
+ - `git push origin main` in the temporary Space clone was rejected by HF:
51
+ - `You are not authorized to push to this repo.`
52
+ - Global `huggingface-cli` was not installed, but `.venv/bin/huggingface-cli` and `.venv/bin/hf` were present.
53
+ - `.venv/bin/hf auth whoami` authenticated as `ADJCJH` and showed membership in `build-small-hackathon`.
54
+ - The cached HF token is fine-grained and scoped to the user entity, not the `build-small-hackathon` org Space.
55
+ - A `huggingface_hub.upload_folder(...)` deploy attempt used the cached token, preserved the current Space parent commit, and avoided printing any token value.
56
+ - The API deploy attempt failed with 403 on the Space LFS batch endpoint:
57
+ - `Make sure your token has the correct permissions.`
58
+
59
+ Result: V2 workbench is not deployed to the public Space in this pass.
60
+
61
+ ## Public Space Smoke
62
+
63
+ Checked URLs:
64
+
65
+ - `https://huggingface.co/spaces/build-small-hackathon/dream-customs`
66
+ - `https://build-small-hackathon-dream-customs.hf.space/`
67
+
68
+ Observed result:
69
+
70
+ - Public Space opened successfully.
71
+ - Direct hf.space app rendered the older one-shot UI, not the current V2 workbench.
72
+ - Existing old UI button `Stamp clearance` completed successfully in the browser.
73
+ - Old UI output included `Today's Pact`, permit `DC-DEMO-014`, suggestion, weird task, bedtime release, contraband, and `SEALED`.
74
+ - Mobile viewport `390x844` was readable for the currently deployed old app.
75
+ - Browser console showed one HF subdomain status request returning 400 and one Gradio warning about too many endpoint arguments; no blocker was observed for the old text-only demo output.
76
+
77
+ Remote queue check:
78
+
79
+ - `/config` returned Gradio `4.44.1`.
80
+ - Queued endpoint found: `run_customs_once`, `fn_index=0`.
81
+ - Raw `/queue/join` plus `/queue/data` text-only call completed with `process_completed`.
82
+ - Queue output returned 4 fields and a pact snippet containing `Dream visitor`, `Permit: DC-DEMO-014`, `Today's suggestion`, `Weird task`, and `Bedtime release`.
83
+ - `gradio_client.Client(...)` could not be used because the installed client failed while parsing the remote schema with `TypeError: argument of type 'bool' is not iterable`; raw queue protocol worked.
84
+
85
+ Result: current old public Space queue works, but the required V2 workbench public Space smoke remains blocked until a token with write permission for `build-small-hackathon/dream-customs` is available.
86
+
87
+ ## Hosted MiniCPM Route
88
+
89
+ Runtime environment check:
90
+
91
+ - `DREAM_CUSTOMS_TEXT_ENDPOINT`: missing
92
+ - `DREAM_CUSTOMS_VISION_ENDPOINT`: missing
93
+ - `DREAM_CUSTOMS_HOSTED_TOKEN`: missing
94
+
95
+ Result: hosted MiniCPM text and vision route smoke was not run. No token or endpoint value was printed, stored, or committed.
96
+
97
+ ## Next Step
98
+
99
+ Create or provide an HF token with write access to the `build-small-hackathon/dream-customs` Space, or grant the cached token/org role that permission. Then push the current workbench files to Space `main` and rerun the V2 public Space browser and queue smoke.
docs/superpowers/plans/2026-06-05-dream-customs-uiux-v2.md ADDED
@@ -0,0 +1,146 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Dream Customs UI/UX V2 Execution Plan
2
+
3
+ > **For agentic workers:** Use `$impeccable craft Dream Customs UI/UX V2` or combine `superpowers:executing-plans` with the references named in `docs/design/2026-06-05-uiux-v2-brief.md`. Track progress by updating these checkboxes.
4
+
5
+ **Goal:** Replace the current one-shot Gradio form with a mobile-first, Codex-style Dream Customs workbench that supports multimodal evidence, iterative negotiation, draft revision, and final pact sealing.
6
+
7
+ **Current problem:** The deployed app works, but it looks like a basic form. The main button is not placed where the user expects, the color system is flat, the output card feels like a paper certificate, and the flow ends too quickly.
8
+
9
+ **Design north star:** Night Desk: a dream customs desk with disciplined product controls, dark atmospheric surfaces, cobalt actions, aurora evidence light, and coral stamp ink.
10
+
11
+ ---
12
+
13
+ ## Task 1: Lock Design Context
14
+
15
+ - [x] Add `PRODUCT.md`.
16
+ - [x] Add target `DESIGN.md`.
17
+ - [x] Add `.impeccable/design.json`.
18
+ - [x] Generate and store three visual probes in `docs/design/assets/`.
19
+ - [x] Add `docs/design/2026-06-05-uiux-v2-brief.md`.
20
+
21
+ ## Task 2: Refactor Product State
22
+
23
+ Files:
24
+
25
+ - `dream_customs/schema.py`
26
+ - `dream_customs/pipeline.py`
27
+ - `dream_customs/app_logic.py`
28
+ - `tests/`
29
+
30
+ Steps:
31
+
32
+ - [x] Add session state schema for phases: `empty`, `declaring`, `negotiating`, `drafting`, `sealed`, `error`.
33
+ - [x] Add `EvidenceItem` schema with `type`, `label`, `status`, `content`, `source_path`, and `error`.
34
+ - [x] Add `CustomsSession` schema that stores `DreamIntake`, evidence items, question history, answer history, draft pact, sealed pact, and safety flags.
35
+ - [x] Add tests for adding text, image, audio, mood, answer, skip, revise, and seal actions.
36
+ - [x] Keep text-only path working even if image/audio/model routes fail.
37
+
38
+ ## Task 3: Build Iterative Pipeline
39
+
40
+ Files:
41
+
42
+ - `dream_customs/pipeline.py`
43
+ - `dream_customs/models.py`
44
+ - `dream_customs/prompts.py`
45
+ - `dream_customs/app_logic.py`
46
+
47
+ Steps:
48
+
49
+ - [x] Split the one-shot `run_customs_once` into actions: `add_evidence`, `ask_questions`, `answer_question`, `draft_pact`, `revise_pact`, `seal_pact`.
50
+ - [x] Make each action return structured state plus render-ready view models.
51
+ - [x] Add prompt templates for continuing negotiation and revising pact tone.
52
+ - [x] Add buttons for `Add material`, `Ask another question`, `Draft pact`, `Revise pact`, and `Seal today's pact`.
53
+ - [x] Add a deterministic demo path for all actions.
54
+
55
+ ## Task 4: Add Hosted Model Path
56
+
57
+ Files:
58
+
59
+ - `dream_customs/models.py`
60
+ - `requirements.txt`
61
+ - deployment docs or secrets docs
62
+
63
+ Steps:
64
+
65
+ - [x] Keep `demo` as fallback.
66
+ - [x] Add `model` backend option that can call hosted MiniCPM text and vision endpoints.
67
+ - [x] Use Modal credits for MiniCPM experiments if local or HF Space loading is too slow.
68
+ - [x] Use HF credits for Space hardware only if needed for stable public demo.
69
+ - [x] Do not store tokens in repo, logs, docs, or examples.
70
+ - [x] Add short smoke command for hosted text generation and hosted image clue extraction.
71
+
72
+ ## Task 5: Rebuild Gradio UI
73
+
74
+ Files:
75
+
76
+ - `app.py`
77
+ - possible new `dream_customs/ui.py`
78
+ - `dream_customs/render.py`
79
+
80
+ Steps:
81
+
82
+ - [x] Replace two-column form with app shell: header, timeline, pact inspector, bottom composer.
83
+ - [x] Composer contains multiline dream input, image upload, audio upload/record, mood chip, backend menu, and primary action.
84
+ - [x] Timeline renders user evidence, model questions, answers, extraction statuses, and pact drafts.
85
+ - [x] Pact inspector renders live draft and final sealed card.
86
+ - [x] Primary actions are visible above the fold on mobile.
87
+ - [x] Debug JSON moves into a collapsed diagnostics panel.
88
+
89
+ ## Task 6: Apply Visual System
90
+
91
+ Files:
92
+
93
+ - `app.py`
94
+ - `dream_customs/render.py`
95
+ - optional static assets
96
+
97
+ Steps:
98
+
99
+ - [x] Implement the OKLCH tokens from `DESIGN.md`.
100
+ - [x] Use generated probe imagery as header or empty-state atmosphere.
101
+ - [x] Replace parchment card styling with dark inspector plus sealed pact treatment.
102
+ - [x] Add focus, hover, disabled, loading, and error states.
103
+ - [x] Add reduced-motion fallback.
104
+ - [x] Verify contrast for body, labels, placeholders, chips, and disabled controls.
105
+
106
+ ## Task 7: Verify Locally And On Space
107
+
108
+ Commands:
109
+
110
+ ```bash
111
+ python -m pytest -q
112
+ python app.py
113
+ ```
114
+
115
+ Checks:
116
+
117
+ - [x] Text-only declaration can reach sealed pact.
118
+ - [x] User can add material after receiving questions.
119
+ - [x] User can ask another question before sealing.
120
+ - [x] User can revise pact before sealing.
121
+ - [x] Image and audio failure keep text path alive.
122
+ - [x] Mobile width is readable.
123
+ - [ ] V2 Space deployment builds and remote queue prediction works.
124
+
125
+ 2026-06-05 verification notes:
126
+
127
+ - [x] Local tests passed with `.venv/bin/python -m pytest -q`: 31 passed.
128
+ - [x] Local V2 Gradio smoke passed on `127.0.0.1:7861` because `7860` was already occupied by another Python listener.
129
+ - [x] Local V2 browser flow reached sealed pact: `Send to customs -> Ask another question -> Add material -> Draft pact -> Revise pact -> Seal today's pact`.
130
+ - [x] Local mobile viewport `390x844` remained readable; pact inspector showed `DC-DEMO-014`, `Sealed`, 3 evidence items, 4 questions, suggestion, weird task, contraband, and bedtime release.
131
+ - [x] Local diagnostics panel opened and showed `status: sealed`, `text_backend: demo`, `vision_backend: demo`, empty `safety_flags`, and empty ordinary-case `safety_note`; the header safety copy stayed visible as playful reflection, not medical advice.
132
+ - [x] Existing public Space opened and the currently deployed old one-shot app completed a text-only demo queue prediction through `run_customs_once`.
133
+ - [ ] Current V2 workbench was not deployed to public Space because `git push` to `https://huggingface.co/spaces/build-small-hackathon/dream-customs` was rejected by HF authorization (`You are not authorized to push to this repo`).
134
+ - [ ] Follow-up HF API deploy attempt also failed: the cached token belongs to `ADJCJH` and sees the `build-small-hackathon` org, but the token is fine-grained and scoped only to the user entity; `huggingface_hub.upload_folder(...)` failed with 403 on the Space LFS batch endpoint.
135
+ - [ ] Hosted MiniCPM route smoke was not run because `DREAM_CUSTOMS_TEXT_ENDPOINT`, `DREAM_CUSTOMS_VISION_ENDPOINT`, and `DREAM_CUSTOMS_HOSTED_TOKEN` were missing from the runtime environment.
136
+
137
+ Detailed record: `docs/smoke/2026-06-05-space-deployment-smoke.md`.
138
+
139
+ ## Task 8: Commit And Deploy
140
+
141
+ - [x] Commit implementation to `feature/dream-customs-mvp` or a new `feature/uiux-v2` branch.
142
+ - [x] Push GitHub branch.
143
+ - [ ] Upload/merge Space update. Blocked on HF push permission as of 2026-06-05.
144
+ - [ ] Re-run public Space V2 smoke test after the Space update is accepted.
145
+ - [x] Re-run existing public Space smoke test for the currently deployed old one-shot app.
146
+ - [x] Update README screenshots or demo instructions.
dream_customs/app_logic.py CHANGED
@@ -6,10 +6,26 @@ 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"
@@ -36,6 +52,11 @@ def _clients(text_backend: str, vision_backend: str):
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
 
@@ -44,12 +65,170 @@ def _clients(text_backend: str, vision_backend: str):
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,
 
6
  FakeASRClient,
7
  FakeTextClient,
8
  FakeVisionClient,
9
+ HostedMiniCPMTextClient,
10
+ HostedMiniCPMVisionClient,
11
  OllamaTextClient,
12
  OllamaVisionClient,
13
  )
14
+ from dream_customs.pipeline import (
15
+ add_evidence,
16
+ answer_question,
17
+ ask_questions,
18
+ create_session,
19
+ draft_pact,
20
+ generate_negotiation,
21
+ generate_pact,
22
+ intake_from_modalities,
23
+ revise_pact,
24
+ seal_pact,
25
+ skip_question,
26
+ )
27
+ from dream_customs.render import render_pact_card, render_pact_inspector, render_status_bar, render_timeline
28
+ from dream_customs.schema import CustomsSession
29
 
30
 
31
  DEFAULT_TEXT_MODEL = "hf.co/openbmb/MiniCPM5-1B-GGUF:Q8_0"
 
52
  model_name=os.getenv("DREAM_CUSTOMS_TEXT_MODEL", DEFAULT_TEXT_MODEL),
53
  base_url=os.getenv("DREAM_CUSTOMS_OLLAMA_URL", "http://localhost:11434"),
54
  )
55
+ elif text_backend == "model":
56
+ text_client = HostedMiniCPMTextClient(
57
+ endpoint=os.getenv("DREAM_CUSTOMS_TEXT_ENDPOINT", ""),
58
+ token=os.getenv("DREAM_CUSTOMS_HOSTED_TOKEN", ""),
59
+ )
60
  else:
61
  text_client = FakeTextClient()
62
 
 
65
  model_name=os.getenv("DREAM_CUSTOMS_VISION_MODEL", DEFAULT_VISION_MODEL),
66
  base_url=os.getenv("DREAM_CUSTOMS_OLLAMA_URL", "http://localhost:11434"),
67
  )
68
+ elif vision_backend == "model":
69
+ vision_client = HostedMiniCPMVisionClient(
70
+ endpoint=os.getenv("DREAM_CUSTOMS_VISION_ENDPOINT", ""),
71
+ token=os.getenv("DREAM_CUSTOMS_HOSTED_TOKEN", ""),
72
+ )
73
  else:
74
  vision_client = FakeVisionClient()
75
 
76
  return text_client, vision_client, FakeASRClient()
77
 
78
 
79
+ def _session_from_state(state: Any) -> CustomsSession:
80
+ if isinstance(state, CustomsSession):
81
+ return state
82
+ if isinstance(state, str) and state.strip():
83
+ try:
84
+ return CustomsSession.model_validate_json(state)
85
+ except ValueError:
86
+ return create_session()
87
+ if isinstance(state, dict):
88
+ try:
89
+ return CustomsSession.model_validate(state)
90
+ except ValueError:
91
+ return create_session()
92
+ return create_session()
93
+
94
+
95
+ def _debug_json(session: CustomsSession, text_backend: str, vision_backend: str) -> str:
96
+ payload = {
97
+ "status": session.phase,
98
+ "text_backend": text_backend,
99
+ "vision_backend": vision_backend,
100
+ "session": session.model_dump(mode="json"),
101
+ }
102
+ return json.dumps(payload, ensure_ascii=False, indent=2)
103
+
104
+
105
+ def _notice(session: CustomsSession) -> str:
106
+ latest_error = next((event for event in reversed(session.events) if event.role == "error"), None)
107
+ if latest_error:
108
+ return f"<div class='dc-inline-notice is-error'>{latest_error.body}</div>"
109
+ if session.phase == "sealed":
110
+ return "<div class='dc-inline-notice is-sealed'>Today's pact is sealed. The card below is ready for a screenshot.</div>"
111
+ if session.phase == "drafting":
112
+ return "<div class='dc-inline-notice'>Draft ready. Revise it, ask another question, add material, or seal today's pact.</div>"
113
+ if session.phase == "negotiating":
114
+ return "<div class='dc-inline-notice'>The clerk has questions. Answer, skip, add material, or draft a pact.</div>"
115
+ return "<div class='dc-inline-notice'>File any fragment. Text-only stays available if image or voice fails.</div>"
116
+
117
+
118
+ def _view(session: CustomsSession, text_backend: str = "demo", vision_backend: str = "demo"):
119
+ sealed_html = render_pact_card(session.sealed_pact) if session.sealed_pact else ""
120
+ return (
121
+ json.dumps(session.model_dump(mode="json"), ensure_ascii=False),
122
+ render_status_bar(session, text_backend, vision_backend),
123
+ render_timeline(session),
124
+ render_pact_inspector(session),
125
+ sealed_html,
126
+ _debug_json(session, text_backend, vision_backend),
127
+ _notice(session),
128
+ )
129
+
130
+
131
+ def initial_workbench_state(text_backend: str = "demo", vision_backend: str = "demo"):
132
+ return _view(create_session(), text_backend, vision_backend)
133
+
134
+
135
+ def start_declaration_action(
136
+ state: Any,
137
+ dream_text: str,
138
+ image_value: Any = None,
139
+ audio_value: Any = None,
140
+ mood: str = "",
141
+ text_backend: str = "demo",
142
+ vision_backend: str = "demo",
143
+ ):
144
+ session = _session_from_state(state)
145
+ text_client, vision_client, asr_client = _clients(text_backend, vision_backend)
146
+ session = add_evidence(
147
+ session,
148
+ dream_text=dream_text or "",
149
+ image_path=_file_path(image_value) or None,
150
+ audio_path=_file_path(audio_value) or None,
151
+ mood=mood or "",
152
+ vision_client=vision_client,
153
+ asr_client=asr_client,
154
+ )
155
+ if session.phase != "error":
156
+ session = ask_questions(session, text_client)
157
+ return _view(session, text_backend, vision_backend)
158
+
159
+
160
+ def add_material_action(
161
+ state: Any,
162
+ dream_text: str,
163
+ image_value: Any = None,
164
+ audio_value: Any = None,
165
+ mood: str = "",
166
+ text_backend: str = "demo",
167
+ vision_backend: str = "demo",
168
+ ):
169
+ session = _session_from_state(state)
170
+ _text_client, vision_client, asr_client = _clients(text_backend, vision_backend)
171
+ session = add_evidence(
172
+ session,
173
+ dream_text=dream_text or "",
174
+ image_path=_file_path(image_value) or None,
175
+ audio_path=_file_path(audio_value) or None,
176
+ mood=mood or "",
177
+ vision_client=vision_client,
178
+ asr_client=asr_client,
179
+ )
180
+ return _view(session, text_backend, vision_backend)
181
+
182
+
183
+ def answer_question_action(state: Any, answer: str, text_backend: str = "demo", vision_backend: str = "demo"):
184
+ session = answer_question(_session_from_state(state), answer or "")
185
+ return _view(session, text_backend, vision_backend)
186
+
187
+
188
+ def skip_question_action(state: Any, text_backend: str = "demo", vision_backend: str = "demo"):
189
+ session = skip_question(_session_from_state(state))
190
+ return _view(session, text_backend, vision_backend)
191
+
192
+
193
+ def ask_another_question_action(state: Any, text_backend: str = "demo", vision_backend: str = "demo"):
194
+ session = _session_from_state(state)
195
+ text_client, _vision_client, _asr_client = _clients(text_backend, vision_backend)
196
+ session = ask_questions(session, text_client, force_another=True)
197
+ return _view(session, text_backend, vision_backend)
198
+
199
+
200
+ def draft_pact_action(state: Any, text_backend: str = "demo", vision_backend: str = "demo"):
201
+ session = _session_from_state(state)
202
+ text_client, _vision_client, _asr_client = _clients(text_backend, vision_backend)
203
+ session = draft_pact(session, text_client)
204
+ return _view(session, text_backend, vision_backend)
205
+
206
+
207
+ def revise_pact_action(
208
+ state: Any,
209
+ revision_request: str,
210
+ text_backend: str = "demo",
211
+ vision_backend: str = "demo",
212
+ ):
213
+ session = _session_from_state(state)
214
+ text_client, _vision_client, _asr_client = _clients(text_backend, vision_backend)
215
+ session = revise_pact(session, revision_request or "", text_client)
216
+ return _view(session, text_backend, vision_backend)
217
+
218
+
219
+ def seal_pact_action(state: Any, text_backend: str = "demo", vision_backend: str = "demo"):
220
+ session = _session_from_state(state)
221
+ text_client, _vision_client, _asr_client = _clients(text_backend, vision_backend)
222
+ if not session.draft_pact and session.intake.merged_text():
223
+ session = draft_pact(session, text_client)
224
+ session = seal_pact(session)
225
+ return _view(session, text_backend, vision_backend)
226
+
227
+
228
+ def start_new_action(text_backend: str = "demo", vision_backend: str = "demo"):
229
+ return _view(create_session(), text_backend, vision_backend)
230
+
231
+
232
  def run_customs_once(
233
  dream_text: str,
234
  image_value: Any = None,
dream_customs/models.py CHANGED
@@ -300,3 +300,177 @@ class MiniCPMVisionClient:
300
  result = pipe(text=messages)
301
  text = str(result)
302
  return [part.strip() for part in text.replace("\n", ",").split(",") if part.strip()][:8]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
300
  result = pipe(text=messages)
301
  text = str(result)
302
  return [part.strip() for part in text.replace("\n", ",").split(",") if part.strip()][:8]
303
+
304
+
305
+ def _hosted_text_from_response(payload: Dict[str, Any]) -> str:
306
+ for key in ("response", "text", "generated_text", "output"):
307
+ value = payload.get(key)
308
+ if isinstance(value, str):
309
+ return value
310
+ choices = payload.get("choices")
311
+ if isinstance(choices, list) and choices:
312
+ first = choices[0]
313
+ if isinstance(first, dict):
314
+ message = first.get("message")
315
+ if isinstance(message, dict) and isinstance(message.get("content"), str):
316
+ return message["content"]
317
+ if isinstance(first.get("text"), str):
318
+ return first["text"]
319
+ data = payload.get("data")
320
+ if isinstance(data, list) and data and isinstance(data[0], dict):
321
+ return _hosted_text_from_response(data[0])
322
+ return ""
323
+
324
+
325
+ class HostedMiniCPMTextClient:
326
+ def __init__(
327
+ self,
328
+ endpoint: str = "",
329
+ token: str = "",
330
+ timeout: float = 60.0,
331
+ fallback: Optional[FakeTextClient] = None,
332
+ ):
333
+ self.endpoint = endpoint.strip()
334
+ self.token = token.strip()
335
+ self.timeout = timeout
336
+ self.fallback = fallback or FakeTextClient()
337
+
338
+ def _post_json(self, prompt: str, max_tokens: int = 700) -> Optional[Dict[str, Any]]:
339
+ if not self.endpoint:
340
+ return None
341
+ payload = {
342
+ "prompt": prompt,
343
+ "messages": [{"role": "user", "content": prompt}],
344
+ "temperature": 0.2,
345
+ "max_tokens": max_tokens,
346
+ }
347
+ headers = {"Content-Type": "application/json"}
348
+ if self.token:
349
+ headers["Authorization"] = f"Bearer {self.token}"
350
+ request = urllib.request.Request(
351
+ self.endpoint,
352
+ data=json.dumps(payload).encode("utf-8"),
353
+ headers=headers,
354
+ method="POST",
355
+ )
356
+ try:
357
+ with urllib.request.urlopen(request, timeout=self.timeout) as response:
358
+ return json.loads(response.read().decode("utf-8"))
359
+ except (OSError, TimeoutError, urllib.error.URLError, json.JSONDecodeError):
360
+ return None
361
+
362
+ def _generate_json(self, prompt: str, schema_hint: str, max_tokens: int = 700) -> Optional[Dict[str, Any]]:
363
+ strict_prompt = (
364
+ f"{prompt}\n\n"
365
+ "Return only a single valid JSON object. No markdown, no code fences, no hidden reasoning.\n"
366
+ f"Required schema: {schema_hint}"
367
+ )
368
+ payload = self._post_json(strict_prompt, max_tokens=max_tokens)
369
+ if not payload:
370
+ return None
371
+ return _extract_json_object(_hosted_text_from_response(payload))
372
+
373
+ def generate_negotiation(self, prompt: str) -> Dict[str, Any]:
374
+ parsed = self._generate_json(
375
+ prompt,
376
+ '{"visitor_name":"string","questions":["string"],"tone_note":"string"}',
377
+ max_tokens=360,
378
+ )
379
+ if not parsed:
380
+ return self.fallback.generate_negotiation(prompt)
381
+ questions = _as_string_list(parsed.get("questions"))
382
+ if not parsed.get("visitor_name") or not questions:
383
+ return self.fallback.generate_negotiation(prompt)
384
+ return {
385
+ "visitor_name": str(parsed.get("visitor_name", "")).strip(),
386
+ "questions": questions[:3],
387
+ "tone_note": str(parsed.get("tone_note", "")).strip(),
388
+ }
389
+
390
+ def generate_pact(self, prompt: str) -> PactCard:
391
+ parsed = self._generate_json(
392
+ prompt,
393
+ (
394
+ '{"visitor_name":"string","permit_id":"string","contraband":["string"],'
395
+ '"risk_level":"string","alliance_reading":"string","practical_suggestion":"string",'
396
+ '"weird_task":"string","bedtime_release":"string","safety_note":"string"}'
397
+ ),
398
+ max_tokens=780,
399
+ )
400
+ if not parsed:
401
+ return self.fallback.generate_pact(prompt)
402
+ try:
403
+ return PactCard(
404
+ visitor_name=str(parsed["visitor_name"]).strip(),
405
+ permit_id=str(parsed["permit_id"]).strip(),
406
+ contraband=_as_string_list(parsed["contraband"]) or ["unfiled dream fragment"],
407
+ risk_level=str(parsed["risk_level"]).strip(),
408
+ alliance_reading=str(parsed["alliance_reading"]).strip(),
409
+ practical_suggestion=str(parsed["practical_suggestion"]).strip(),
410
+ weird_task=str(parsed["weird_task"]).strip(),
411
+ bedtime_release=str(parsed["bedtime_release"]).strip(),
412
+ safety_note=str(parsed.get("safety_note", "")).strip(),
413
+ )
414
+ except (KeyError, TypeError, ValueError):
415
+ return self.fallback.generate_pact(prompt)
416
+
417
+
418
+ class HostedMiniCPMVisionClient:
419
+ def __init__(
420
+ self,
421
+ endpoint: str = "",
422
+ token: str = "",
423
+ timeout: float = 60.0,
424
+ fallback: Optional[FakeVisionClient] = None,
425
+ ):
426
+ self.endpoint = endpoint.strip()
427
+ self.token = token.strip()
428
+ self.timeout = timeout
429
+ self.fallback = fallback or FakeVisionClient()
430
+
431
+ def _post_image(self, image_path: str) -> Optional[Dict[str, Any]]:
432
+ if not self.endpoint:
433
+ return None
434
+ try:
435
+ with open(image_path, "rb") as image_file:
436
+ image_b64 = base64.b64encode(image_file.read()).decode("ascii")
437
+ except OSError:
438
+ return None
439
+ payload = {
440
+ "prompt": visual_clue_prompt(),
441
+ "image": image_b64,
442
+ "images": [image_b64],
443
+ "temperature": 0.1,
444
+ "max_tokens": 320,
445
+ }
446
+ headers = {"Content-Type": "application/json"}
447
+ if self.token:
448
+ headers["Authorization"] = f"Bearer {self.token}"
449
+ request = urllib.request.Request(
450
+ self.endpoint,
451
+ data=json.dumps(payload).encode("utf-8"),
452
+ headers=headers,
453
+ method="POST",
454
+ )
455
+ try:
456
+ with urllib.request.urlopen(request, timeout=self.timeout) as response:
457
+ return json.loads(response.read().decode("utf-8"))
458
+ except (OSError, TimeoutError, urllib.error.URLError, json.JSONDecodeError):
459
+ return None
460
+
461
+ def extract_clues(self, image_path: Optional[str]) -> List[str]:
462
+ if not image_path:
463
+ return []
464
+ payload = self._post_image(image_path)
465
+ if not payload:
466
+ return self.fallback.extract_clues(image_path)
467
+ parsed = _extract_json_object(_hosted_text_from_response(payload))
468
+ if parsed:
469
+ clues: List[str] = []
470
+ for key in ("objects", "places", "visible_text", "colors", "mood_cues", "uncertain_details"):
471
+ clues.extend(_as_string_list(parsed.get(key)))
472
+ if clues:
473
+ return clues[:8]
474
+ text = _hosted_text_from_response(payload)
475
+ clues = [part.strip() for part in re.split(r"[,,\n]", _strip_markdown_and_thinking(text)) if part.strip()]
476
+ return clues[:8] or self.fallback.extract_clues(image_path)
dream_customs/pipeline.py CHANGED
@@ -1,9 +1,10 @@
 
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(
@@ -56,3 +57,333 @@ def generate_pact(intake: DreamIntake, answers: str, text_client) -> Tuple[PactC
56
  if needs_escalation(merged):
57
  card.safety_note = safety_note()
58
  return card, render_pact_card(card)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import re
2
  from typing import Dict, List, Optional, Tuple
3
 
4
+ from dream_customs.prompts import followup_question_prompt, negotiation_prompt, pact_prompt, pact_revision_prompt
5
  from dream_customs.render import render_pact_card
6
  from dream_customs.safety import needs_escalation, safety_note
7
+ from dream_customs.schema import CustomsSession, DreamIntake, EvidenceItem, PactCard, TimelineEvent
8
 
9
 
10
  def build_intake(
 
57
  if needs_escalation(merged):
58
  card.safety_note = safety_note()
59
  return card, render_pact_card(card)
60
+
61
+
62
+ def create_session() -> CustomsSession:
63
+ return CustomsSession(
64
+ events=[
65
+ TimelineEvent(
66
+ role="system",
67
+ title="Dream Customs desk opened",
68
+ body="Start with any fragment: a sentence, a sketch, a voice note, or just the mood left behind.",
69
+ status="ready",
70
+ )
71
+ ]
72
+ )
73
+
74
+
75
+ def _contains_cjk(text: str) -> bool:
76
+ return bool(re.search(r"[\u4e00-\u9fff]", text))
77
+
78
+
79
+ def _append_text(existing: str, new_text: str) -> str:
80
+ new_text = new_text.strip()
81
+ if not new_text:
82
+ return existing
83
+ if not existing.strip():
84
+ return new_text
85
+ if new_text in existing:
86
+ return existing
87
+ return f"{existing.strip()}\n{new_text}"
88
+
89
+
90
+ def _merge_unique(existing: List[str], incoming: List[str]) -> List[str]:
91
+ seen = {item.strip().lower() for item in existing}
92
+ merged = list(existing)
93
+ for item in incoming:
94
+ clean = item.strip()
95
+ if clean and clean.lower() not in seen:
96
+ merged.append(clean)
97
+ seen.add(clean.lower())
98
+ return merged
99
+
100
+
101
+ def _event(role: str, title: str, body: str = "", meta: str = "", status: str = "") -> TimelineEvent:
102
+ return TimelineEvent(role=role, title=title, body=body, meta=meta, status=status)
103
+
104
+
105
+ def _record_safety(session: CustomsSession) -> None:
106
+ merged = "\n".join([session.intake.merged_text(), session.answers_text()])
107
+ if needs_escalation(merged) and "escalation" not in session.safety_flags:
108
+ session.safety_flags.append("escalation")
109
+ session.events.append(
110
+ _event(
111
+ "system",
112
+ "Safety note attached",
113
+ safety_note(),
114
+ status="support",
115
+ )
116
+ )
117
+
118
+
119
+ def add_evidence(
120
+ session: CustomsSession,
121
+ dream_text: str = "",
122
+ image_path: Optional[str] = None,
123
+ audio_path: Optional[str] = None,
124
+ mood: str = "",
125
+ vision_client=None,
126
+ asr_client=None,
127
+ ) -> CustomsSession:
128
+ next_session = session.model_copy(deep=True)
129
+ added_items: List[EvidenceItem] = []
130
+
131
+ if dream_text and dream_text.strip():
132
+ clean_text = dream_text.strip()
133
+ next_session.intake.dream_text = _append_text(next_session.intake.dream_text, clean_text)
134
+ added_items.append(EvidenceItem(type="text", label="Dream note", status="selected", content=clean_text))
135
+
136
+ if mood and mood.strip() and mood.strip() != next_session.intake.mood:
137
+ next_session.intake.mood = mood.strip()
138
+ added_items.append(EvidenceItem(type="mood", label=f"Mood: {mood.strip()}", status="selected", content=mood.strip()))
139
+
140
+ if image_path:
141
+ clues: List[str] = []
142
+ error = ""
143
+ try:
144
+ clues = vision_client.extract_clues(image_path) if vision_client else []
145
+ except Exception:
146
+ clues = []
147
+ error = "Image clue extraction failed. Text-only path remains available."
148
+ if clues:
149
+ next_session.intake.visual_clues = _merge_unique(next_session.intake.visual_clues, clues)
150
+ added_items.append(
151
+ EvidenceItem(
152
+ type="image",
153
+ label=f"Image clues ({len(clues)})",
154
+ status="extracted",
155
+ content=", ".join(clues),
156
+ source_path=image_path,
157
+ )
158
+ )
159
+ else:
160
+ added_items.append(
161
+ EvidenceItem(
162
+ type="image",
163
+ label="Image evidence",
164
+ status="failed",
165
+ source_path=image_path,
166
+ error=error or "No visual clues extracted. Continue with text or voice.",
167
+ )
168
+ )
169
+
170
+ if audio_path:
171
+ transcript = ""
172
+ error = ""
173
+ try:
174
+ transcript = asr_client.transcribe(audio_path) if asr_client else ""
175
+ except Exception:
176
+ error = "Voice transcription failed. Text-only path remains available."
177
+ if transcript.strip():
178
+ clean_transcript = transcript.strip()
179
+ next_session.intake.voice_transcript = _append_text(next_session.intake.voice_transcript, clean_transcript)
180
+ added_items.append(
181
+ EvidenceItem(
182
+ type="audio",
183
+ label="Voice transcript",
184
+ status="extracted",
185
+ content=clean_transcript,
186
+ source_path=audio_path,
187
+ )
188
+ )
189
+ else:
190
+ added_items.append(
191
+ EvidenceItem(
192
+ type="audio",
193
+ label="Voice evidence",
194
+ status="failed",
195
+ source_path=audio_path,
196
+ error=error or "No transcript returned. Continue by typing the fragment.",
197
+ )
198
+ )
199
+
200
+ if not added_items:
201
+ next_session.phase = "error"
202
+ next_session.events.append(
203
+ _event("error", "No material added", "Add text, image, or voice before asking the clerk.", status="empty")
204
+ )
205
+ return next_session
206
+
207
+ next_session.evidence_items.extend(added_items)
208
+ next_session.phase = "declaring"
209
+ summary = "\n".join(f"{item.label}: {item.content or item.error}" for item in added_items)
210
+ next_session.events.append(_event("user", "Material added", summary, status="filed"))
211
+ _record_safety(next_session)
212
+ return next_session
213
+
214
+
215
+ def ask_questions(session: CustomsSession, text_client, force_another: bool = False) -> CustomsSession:
216
+ next_session = session.model_copy(deep=True)
217
+ if not next_session.intake.merged_text():
218
+ next_session.phase = "error"
219
+ next_session.events.append(
220
+ _event("error", "Customs has no declaration yet", "Add one dream fragment before asking a question.", status="empty")
221
+ )
222
+ return next_session
223
+
224
+ prompt = (
225
+ followup_question_prompt(next_session.intake, next_session.question_history, next_session.answer_history)
226
+ if force_another
227
+ else negotiation_prompt(next_session.intake)
228
+ )
229
+ negotiation = text_client.generate_negotiation(prompt)
230
+ questions = [question for question in negotiation.get("questions", []) if question]
231
+ fresh_questions = [question for question in questions if question not in next_session.question_history]
232
+ if force_another and not fresh_questions:
233
+ if _contains_cjk(next_session.intake.merged_text()):
234
+ fresh_questions = ["如果海关只批准一个更小的动作,你希望今天先放行哪一件事?"]
235
+ else:
236
+ fresh_questions = ["If customs approves one smaller action today, which one should it release first?"]
237
+ if not fresh_questions:
238
+ fresh_questions = questions[:3]
239
+
240
+ next_session.question_history.extend(fresh_questions[:3])
241
+ next_session.phase = "negotiating"
242
+ next_session.events.append(
243
+ _event(
244
+ "customs",
245
+ "Customs question filed" if len(fresh_questions) == 1 else "Customs questions filed",
246
+ "\n".join(fresh_questions[:3]),
247
+ meta=str(negotiation.get("visitor_name", "")),
248
+ status="question",
249
+ )
250
+ )
251
+ return next_session
252
+
253
+
254
+ def answer_question(session: CustomsSession, answer: str) -> CustomsSession:
255
+ next_session = session.model_copy(deep=True)
256
+ if not answer or not answer.strip():
257
+ next_session.phase = "error"
258
+ next_session.events.append(
259
+ _event("error", "No answer filed", "Write a reply, or choose to skip the question.", status="empty")
260
+ )
261
+ return next_session
262
+ next_session.answer_history.append(answer.strip())
263
+ next_session.phase = "negotiating"
264
+ next_session.events.append(_event("user", "Answer filed", answer.strip(), status="answered"))
265
+ _record_safety(next_session)
266
+ return next_session
267
+
268
+
269
+ def skip_question(session: CustomsSession) -> CustomsSession:
270
+ next_session = session.model_copy(deep=True)
271
+ if _contains_cjk(next_session.intake.merged_text()):
272
+ skip_text = "用户选择跳过这一轮问题。"
273
+ else:
274
+ skip_text = "The user chose to skip this question."
275
+ next_session.answer_history.append(skip_text)
276
+ next_session.phase = "negotiating"
277
+ next_session.events.append(_event("user", "Question skipped", skip_text, status="skipped"))
278
+ return next_session
279
+
280
+
281
+ def draft_pact(session: CustomsSession, text_client) -> CustomsSession:
282
+ next_session = session.model_copy(deep=True)
283
+ if not next_session.intake.merged_text():
284
+ next_session.phase = "error"
285
+ next_session.events.append(
286
+ _event("error", "Pact needs dream material", "Add at least one fragment before drafting a pact.", status="empty")
287
+ )
288
+ return next_session
289
+
290
+ answers = next_session.answers_text() or "The user has not answered yet; infer a gentle pact from the declaration."
291
+ card, _html = generate_pact(next_session.intake, answers, text_client)
292
+ next_session.draft_pact = card
293
+ next_session.phase = "drafting"
294
+ next_session.events.append(
295
+ _event(
296
+ "pact",
297
+ "Draft pact prepared",
298
+ f"{card.visitor_name}\n{card.practical_suggestion}\n{card.weird_task}",
299
+ meta=card.permit_id,
300
+ status="draft",
301
+ )
302
+ )
303
+ _record_safety(next_session)
304
+ return next_session
305
+
306
+
307
+ def _apply_revision_hint(card: PactCard, revision_request: str) -> PactCard:
308
+ request = revision_request.lower()
309
+ revised = card.model_copy(deep=True)
310
+ cjk = _contains_cjk(" ".join([revision_request, card.visitor_name, card.alliance_reading]))
311
+ if any(term in request for term in ["strange", "weird", "怪", "更奇怪", "更怪"]):
312
+ revised.weird_task = (
313
+ "把今天最小的任务写在纸上,给它盖一个看不见的放行章。"
314
+ if cjk
315
+ else "Write the smallest task on paper and stamp it with an invisible release mark."
316
+ )
317
+ elif any(term in request for term in ["gentle", "softer", "温和", "轻一点"]):
318
+ revised.risk_level = (
319
+ "浅橙色:先安置它,不急着解释它"
320
+ if cjk
321
+ else "soft orange: place it gently before interpreting it"
322
+ )
323
+ revised.practical_suggestion = (
324
+ "今天先选一个不需要立刻完成的小开头,做 5 分钟就停。"
325
+ if cjk
326
+ else "Choose one start that does not need finishing today. Stop after five minutes."
327
+ )
328
+ elif revision_request.strip():
329
+ revised.practical_suggestion = (
330
+ f"{revised.practical_suggestion}(按你的修订请求:{revision_request.strip()})"
331
+ if cjk
332
+ else f"{revised.practical_suggestion} Revision note: {revision_request.strip()}"
333
+ )
334
+ return revised
335
+
336
+
337
+ def revise_pact(session: CustomsSession, revision_request: str, text_client) -> CustomsSession:
338
+ next_session = session.model_copy(deep=True)
339
+ if not next_session.draft_pact:
340
+ next_session = draft_pact(next_session, text_client)
341
+ if not next_session.draft_pact:
342
+ return next_session
343
+
344
+ answers = next_session.answers_text()
345
+ prompt = pact_revision_prompt(
346
+ next_session.intake,
347
+ answers,
348
+ next_session.draft_pact.to_plain_text(),
349
+ revision_request,
350
+ )
351
+ card = text_client.generate_pact(prompt)
352
+ merged = next_session.intake.merged_text() + "\n" + answers
353
+ if needs_escalation(merged):
354
+ card.safety_note = safety_note()
355
+ card = _apply_revision_hint(card, revision_request or "")
356
+ next_session.draft_pact = card
357
+ next_session.phase = "drafting"
358
+ next_session.events.append(
359
+ _event(
360
+ "pact",
361
+ "Draft pact revised",
362
+ revision_request.strip() or "The draft was tightened for today's smallest action.",
363
+ meta=card.permit_id,
364
+ status="revised",
365
+ )
366
+ )
367
+ return next_session
368
+
369
+
370
+ def seal_pact(session: CustomsSession) -> CustomsSession:
371
+ next_session = session.model_copy(deep=True)
372
+ if not next_session.draft_pact:
373
+ next_session.phase = "error"
374
+ next_session.events.append(
375
+ _event("error", "Nothing to seal yet", "Draft a pact before sealing today's agreement.", status="empty")
376
+ )
377
+ return next_session
378
+ next_session.sealed_pact = next_session.draft_pact
379
+ next_session.phase = "sealed"
380
+ next_session.events.append(
381
+ _event(
382
+ "pact",
383
+ "Today's pact sealed",
384
+ next_session.sealed_pact.bedtime_release,
385
+ meta=next_session.sealed_pact.permit_id,
386
+ status="sealed",
387
+ )
388
+ )
389
+ return next_session
dream_customs/prompts.py CHANGED
@@ -26,6 +26,27 @@ Return JSON with:
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.
@@ -43,3 +64,27 @@ 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()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
26
  """.strip()
27
 
28
 
29
+ def followup_question_prompt(intake: DreamIntake, question_history: list[str], answer_history: list[str]) -> str:
30
+ return f"""
31
+ You are the Dream Customs diplomat. Ask one more gentle customs question.
32
+ Do not diagnose. Do not repeat previous questions. The question should help the user decide today's pact.
33
+
34
+ Dream intake:
35
+ {intake.merged_text()}
36
+
37
+ Previous questions:
38
+ {chr(10).join(question_history) if question_history else "None yet."}
39
+
40
+ User answers:
41
+ {chr(10).join(answer_history) if answer_history else "No answers yet."}
42
+
43
+ Return JSON with:
44
+ - visitor_name: short vivid name
45
+ - questions: one gentle, specific question
46
+ - tone_note: one sentence explaining why this question matters today
47
+ """.strip()
48
+
49
+
50
  def pact_prompt(intake: DreamIntake, answers: str) -> str:
51
  return f"""
52
  You are the Dream Customs diplomat. Generate a final Today's Pact card.
 
64
  visitor_name, permit_id, contraband, risk_level, alliance_reading,
65
  practical_suggestion, weird_task, bedtime_release, safety_note.
66
  """.strip()
67
+
68
+
69
+ def pact_revision_prompt(intake: DreamIntake, answers: str, current_pact: str, revision_request: str) -> str:
70
+ return f"""
71
+ You are the Dream Customs diplomat. Revise the draft Today's Pact card.
72
+ Keep the same dream visitor unless the user's new material clearly changes it.
73
+ Do not diagnose. Do not make the dream sound certain or frightening.
74
+
75
+ Dream intake:
76
+ {intake.merged_text()}
77
+
78
+ User answers:
79
+ {answers or "No answers yet."}
80
+
81
+ Current draft:
82
+ {current_pact}
83
+
84
+ Revision request:
85
+ {revision_request or "Make the pact more specific and useful for today."}
86
+
87
+ Return strict JSON with:
88
+ visitor_name, permit_id, contraband, risk_level, alliance_reading,
89
+ practical_suggestion, weird_task, bedtime_release, safety_note.
90
+ """.strip()
dream_customs/render.py CHANGED
@@ -1,20 +1,189 @@
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;
@@ -22,41 +191,59 @@ def render_pact_card(card: PactCard) -> str:
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">
 
1
  from html import escape
2
 
3
+ from dream_customs.schema import CustomsSession, EvidenceItem, PactCard, TimelineEvent
4
+
5
+
6
+ def _nl2br(text: str) -> str:
7
+ return "<br>".join(escape(text).splitlines())
8
+
9
+
10
+ def _phase_label(phase: str) -> str:
11
+ labels = {
12
+ "empty": "Empty desk",
13
+ "declaring": "Declare",
14
+ "negotiating": "Negotiate",
15
+ "drafting": "Draft",
16
+ "sealed": "Sealed",
17
+ "error": "Needs attention",
18
+ }
19
+ return labels.get(phase, phase.title())
20
+
21
+
22
+ def _evidence_chip(item: EvidenceItem) -> str:
23
+ return (
24
+ f"<span class='dc-evidence-chip is-{escape(item.status)}'>"
25
+ f"<span class='dc-chip-dot'></span>{escape(item.label)}</span>"
26
+ )
27
+
28
+
29
+ def render_status_bar(session: CustomsSession, text_backend: str = "demo", vision_backend: str = "demo") -> str:
30
+ safety = "Support note ready" if session.safety_flags else "Playful reflection, not medical advice"
31
+ return f"""
32
+ <header class="dc-statusbar">
33
+ <div class="dc-brand-lockup">
34
+ <span class="dc-brand-mark">DC</span>
35
+ <div>
36
+ <h1>Dream Customs / 梦境海关</h1>
37
+ <p>Night desk for filing dream fragments into today's pact.</p>
38
+ </div>
39
+ </div>
40
+ <nav class="dc-phase-rail" aria-label="Dream Customs phase">
41
+ <span class="{'is-active' if session.phase in ['empty', 'declaring'] else ''}">Declare</span>
42
+ <span class="{'is-active' if session.phase == 'negotiating' else ''}">Inspect</span>
43
+ <span class="{'is-active' if session.phase == 'drafting' else ''}">Draft</span>
44
+ <span class="{'is-active' if session.phase == 'sealed' else ''}">Seal</span>
45
+ </nav>
46
+ <div class="dc-system-status">
47
+ <span>{escape(_phase_label(session.phase))}</span>
48
+ <span>Text: {escape(text_backend or "demo")}</span>
49
+ <span>Vision: {escape(vision_backend or "demo")}</span>
50
+ <span>{escape(safety)}</span>
51
+ </div>
52
+ </header>
53
+ """.strip()
54
+
55
+
56
+ def _render_event(event: TimelineEvent) -> str:
57
+ body = f"<p>{_nl2br(event.body)}</p>" if event.body else ""
58
+ meta = f"<span>{escape(event.meta)}</span>" if event.meta else ""
59
+ status = f"<span>{escape(event.status)}</span>" if event.status else ""
60
+ return f"""
61
+ <article class="dc-timeline-event is-{escape(event.role)}">
62
+ <div class="dc-event-meta">{meta}{status}</div>
63
+ <h3>{escape(event.title)}</h3>
64
+ {body}
65
+ </article>
66
+ """.strip()
67
+
68
+
69
+ def render_timeline(session: CustomsSession) -> str:
70
+ if not session.events:
71
+ events = [
72
+ TimelineEvent(
73
+ role="system",
74
+ title="The night desk is open",
75
+ body="Start with one fragment. The clerk can work from text, image, voice, or only the mood.",
76
+ status="ready",
77
+ )
78
+ ]
79
+ else:
80
+ events = session.events
81
+
82
+ evidence = "".join(_evidence_chip(item) for item in session.evidence_items[-8:])
83
+ evidence_tray = (
84
+ f"<div class='dc-evidence-tray'>{evidence}</div>"
85
+ if evidence
86
+ else (
87
+ "<div class='dc-evidence-tray is-empty'>"
88
+ "<span class='dc-evidence-chip is-queued'><span class='dc-chip-dot'></span>Text</span>"
89
+ "<span class='dc-evidence-chip is-queued'><span class='dc-chip-dot'></span>Image</span>"
90
+ "<span class='dc-evidence-chip is-queued'><span class='dc-chip-dot'></span>Voice</span>"
91
+ "<span class='dc-evidence-chip is-queued'><span class='dc-chip-dot'></span>Mood</span>"
92
+ "</div>"
93
+ )
94
+ )
95
+ return f"""
96
+ <section class="dc-timeline-shell" aria-label="Dream Customs timeline">
97
+ <div class="dc-timeline-head">
98
+ <div>
99
+ <h2>Customs timeline</h2>
100
+ <p>Evidence, questions, answers, and pact drafts stay in one file.</p>
101
+ </div>
102
+ <span>{session.evidence_count()} filed</span>
103
+ </div>
104
+ {evidence_tray}
105
+ <div class="dc-timeline-list">
106
+ {''.join(_render_event(event) for event in events)}
107
+ </div>
108
+ </section>
109
+ """.strip()
110
+
111
+
112
+ def render_pact_inspector(session: CustomsSession) -> str:
113
+ card = session.sealed_pact or session.draft_pact
114
+ state = "sealed" if session.sealed_pact else "draft" if session.draft_pact else "waiting"
115
+ if not card:
116
+ return f"""
117
+ <aside class="dc-inspector is-waiting" aria-label="Pact inspector">
118
+ <div class="dc-inspector-kicker">Pact inspector</div>
119
+ <h2>No pact drafted yet</h2>
120
+ <p>Add material, ask a customs question, then draft a pact when the file feels useful enough.</p>
121
+ <dl>
122
+ <div><dt>Evidence</dt><dd>{session.evidence_count()} filed</dd></div>
123
+ <div><dt>Phase</dt><dd>{escape(_phase_label(session.phase))}</dd></div>
124
+ <div><dt>Questions</dt><dd>{len(session.question_history)} asked</dd></div>
125
+ </dl>
126
+ </aside>
127
+ """.strip()
128
+
129
+ contraband = "".join(f"<li>{escape(item)}</li>" for item in card.contraband)
130
+ safety = (
131
+ f"<div class='dc-support-note'><strong>Support note</strong><p>{escape(card.safety_note)}</p></div>"
132
+ if card.safety_note
133
+ else ""
134
+ )
135
+ return f"""
136
+ <aside class="dc-inspector is-{state}" aria-label="Pact inspector">
137
+ <div class="dc-inspector-kicker">Pact inspector</div>
138
+ <div class="dc-permit-row">
139
+ <span>{escape(card.permit_id)}</span>
140
+ <span>{escape(state.title())}</span>
141
+ </div>
142
+ <h2>{escape(card.visitor_name)}</h2>
143
+ <dl>
144
+ <div><dt>Risk level</dt><dd>{escape(card.risk_level)}</dd></div>
145
+ <div><dt>Evidence</dt><dd>{session.evidence_count()} filed</dd></div>
146
+ <div><dt>Questions</dt><dd>{len(session.question_history)} asked</dd></div>
147
+ </dl>
148
+ <section>
149
+ <h3>Alliance reading</h3>
150
+ <p>{escape(card.alliance_reading)}</p>
151
+ </section>
152
+ <section>
153
+ <h3>Today</h3>
154
+ <p>{escape(card.practical_suggestion)}</p>
155
+ </section>
156
+ <section>
157
+ <h3>5-minute task</h3>
158
+ <p>{escape(card.weird_task)}</p>
159
+ </section>
160
+ <section>
161
+ <h3>Contraband</h3>
162
+ <ul>{contraband}</ul>
163
+ </section>
164
+ {safety}
165
+ </aside>
166
+ """.strip()
167
 
168
 
169
  def render_pact_card(card: PactCard) -> str:
170
  contraband = "".join(f"<li>{escape(item)}</li>" for item in card.contraband)
171
+ safety = (
172
+ f"<div class='dc-sealed-safety'><strong>Safety note</strong><p>{escape(card.safety_note)}</p></div>"
173
+ if card.safety_note
174
+ else ""
175
+ )
176
  return f"""
177
  <style>
178
  .pact-card {{
179
+ color: oklch(0.965 0.010 235);
180
+ background:
181
+ radial-gradient(circle at 78% 0%, oklch(0.705 0.160 28 / 0.18), transparent 28%),
182
+ linear-gradient(135deg, oklch(0.205 0.035 238), oklch(0.145 0.030 238));
183
+ border: 2px solid oklch(0.705 0.160 28);
184
+ border-radius: 14px;
185
+ box-shadow: 0 8px 0 oklch(0.080 0.010 250 / 0.55);
186
+ font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
187
  line-height: 1.55;
188
  margin: 8px 0;
189
  max-width: 860px;
 
191
  }}
192
  .pact-header {{
193
  align-items: center;
194
+ border-bottom: 1px solid oklch(0.430 0.055 235);
195
  display: flex;
196
+ font-weight: 720;
197
  justify-content: space-between;
198
  margin-bottom: 16px;
199
  padding-bottom: 10px;
200
  }}
201
  .pact-card h2 {{
202
+ color: oklch(0.965 0.010 235) !important;
203
+ font-size: 2rem;
204
  line-height: 1.1;
205
  margin: 0 0 4px;
206
+ text-wrap: balance;
207
  }}
208
  .pact-card h3 {{
209
+ color: oklch(0.820 0.110 185) !important;
210
+ font-size: 1.35rem;
211
  margin: 0 0 18px;
212
  }}
213
+ .pact-card p,
214
+ .pact-card li,
215
+ .pact-card div,
216
+ .pact-card span {{
217
+ color: oklch(0.900 0.018 235) !important;
218
+ }}
219
+ .pact-header span {{
220
+ color: oklch(0.965 0.010 235) !important;
221
+ }}
222
  .dc-label {{
223
+ color: oklch(0.820 0.110 185) !important;
224
+ font-weight: 720;
225
  }}
226
  .dc-seal {{
227
+ border: 2px solid oklch(0.705 0.160 28);
228
  border-radius: 999px;
229
+ color: oklch(0.705 0.160 28) !important;
230
  display: inline-block;
231
+ font-weight: 820;
232
  margin-top: 12px;
233
  padding: 8px 14px;
234
  transform: rotate(-4deg);
235
  }}
236
+ .dc-sealed-safety {{
237
+ background: oklch(0.705 0.160 28 / 0.13);
238
+ border: 1px solid oklch(0.705 0.160 28 / 0.72);
239
+ border-radius: 10px;
240
+ padding: 12px 14px;
241
+ }}
242
+ .dc-sealed-safety p {{
243
+ margin: 6px 0 0;
244
+ }}
245
+ .pact-card ul {{
246
+ margin-top: 8px;
247
  }}
248
  </style>
249
  <section class="pact-card">
dream_customs/schema.py CHANGED
@@ -1,3 +1,5 @@
 
 
1
  from pydantic import BaseModel, Field
2
 
3
 
@@ -49,3 +51,44 @@ class PactCard(BaseModel):
49
  if self.safety_note:
50
  lines.append(f"Safety note: {self.safety_note}")
51
  return "\n".join(lines)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Literal, Optional
2
+
3
  from pydantic import BaseModel, Field
4
 
5
 
 
51
  if self.safety_note:
52
  lines.append(f"Safety note: {self.safety_note}")
53
  return "\n".join(lines)
54
+
55
+
56
+ SessionPhase = Literal["empty", "declaring", "negotiating", "drafting", "sealed", "error"]
57
+ EvidenceType = Literal["text", "image", "audio", "mood"]
58
+ EvidenceStatus = Literal["queued", "extracting", "extracted", "failed", "selected"]
59
+ TimelineRole = Literal["system", "user", "customs", "pact", "error"]
60
+
61
+
62
+ class EvidenceItem(BaseModel):
63
+ type: EvidenceType
64
+ label: str
65
+ status: EvidenceStatus = "queued"
66
+ content: str = ""
67
+ source_path: str = ""
68
+ error: str = ""
69
+
70
+
71
+ class TimelineEvent(BaseModel):
72
+ role: TimelineRole
73
+ title: str
74
+ body: str = ""
75
+ meta: str = ""
76
+ status: str = ""
77
+
78
+
79
+ class CustomsSession(BaseModel):
80
+ phase: SessionPhase = "empty"
81
+ intake: DreamIntake = Field(default_factory=DreamIntake)
82
+ evidence_items: list[EvidenceItem] = Field(default_factory=list)
83
+ question_history: list[str] = Field(default_factory=list)
84
+ answer_history: list[str] = Field(default_factory=list)
85
+ draft_pact: Optional[PactCard] = None
86
+ sealed_pact: Optional[PactCard] = None
87
+ safety_flags: list[str] = Field(default_factory=list)
88
+ events: list[TimelineEvent] = Field(default_factory=list)
89
+
90
+ def answers_text(self) -> str:
91
+ return "\n".join(answer.strip() for answer in self.answer_history if answer.strip())
92
+
93
+ def evidence_count(self) -> int:
94
+ return len([item for item in self.evidence_items if item.status != "failed"])
tests/test_app_logic.py CHANGED
@@ -1,6 +1,14 @@
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():
@@ -23,3 +31,46 @@ def test_run_customs_once_requires_one_modality():
23
  assert "Please add text" in pact_text
24
  assert html == ""
25
  assert json.loads(debug_json) == {"status": "empty"}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  import json
2
 
3
+ from dream_customs.app_logic import (
4
+ add_material_action,
5
+ ask_another_question_action,
6
+ draft_pact_action,
7
+ initial_workbench_state,
8
+ run_customs_once,
9
+ seal_pact_action,
10
+ start_declaration_action,
11
+ )
12
 
13
 
14
  def test_run_customs_once_generates_demo_outputs():
 
31
  assert "Please add text" in pact_text
32
  assert html == ""
33
  assert json.loads(debug_json) == {"status": "empty"}
34
+
35
+
36
+ def test_workbench_actions_progress_to_sealed_pact():
37
+ state, _status, timeline, inspector, sealed_html, debug_json, _notice = initial_workbench_state()
38
+ assert "Customs timeline" in timeline
39
+ assert "No pact drafted yet" in inspector
40
+ assert sealed_html == ""
41
+ assert json.loads(debug_json)["status"] == "empty"
42
+
43
+ state, _status, _timeline, _inspector, _sealed_html, debug_json, _notice = start_declaration_action(
44
+ state,
45
+ dream_text="I found a blue stamp in my pillow.",
46
+ mood="curious",
47
+ )
48
+ debug = json.loads(debug_json)
49
+ assert debug["status"] == "negotiating"
50
+ assert debug["session"]["question_history"]
51
+
52
+ state, _status, _timeline, inspector, _sealed_html, debug_json, _notice = draft_pact_action(state)
53
+ assert json.loads(debug_json)["status"] == "drafting"
54
+ assert "Pact inspector" in inspector
55
+
56
+ state, _status, _timeline, _inspector, sealed_html, debug_json, _notice = seal_pact_action(state)
57
+ assert json.loads(debug_json)["status"] == "sealed"
58
+ assert "Today's Pact" in sealed_html
59
+
60
+
61
+ def test_workbench_model_route_without_endpoint_falls_back_to_demo():
62
+ state, *_rest = initial_workbench_state(text_backend="model", vision_backend="model")
63
+ state, _status, _timeline, _inspector, _sealed_html, debug_json, _notice = add_material_action(
64
+ state,
65
+ dream_text="Text-only path should stay alive.",
66
+ text_backend="model",
67
+ vision_backend="model",
68
+ )
69
+ state, _status, _timeline, _inspector, _sealed_html, debug_json, _notice = ask_another_question_action(
70
+ state,
71
+ text_backend="model",
72
+ vision_backend="model",
73
+ )
74
+ debug = json.loads(debug_json)
75
+ assert debug["text_backend"] == "model"
76
+ assert debug["session"]["question_history"]
tests/test_ollama_models.py CHANGED
@@ -1,4 +1,4 @@
1
- from dream_customs.models import OllamaTextClient, OllamaVisionClient, _extract_json_object
2
 
3
 
4
  class StubOllamaTextClient(OllamaTextClient):
@@ -19,6 +19,15 @@ class StubOllamaVisionClient(OllamaVisionClient):
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"}
@@ -69,3 +78,23 @@ def test_ollama_vision_client_parses_visual_clues(tmp_path):
69
  )
70
  clues = client.extract_clues(str(image_path))
71
  assert clues == ["passport stamp", "customs desk", "blue", "foggy"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from dream_customs.models import HostedMiniCPMTextClient, OllamaTextClient, OllamaVisionClient, _extract_json_object
2
 
3
 
4
  class StubOllamaTextClient(OllamaTextClient):
 
19
  return {"response": self.response_text}
20
 
21
 
22
+ class StubHostedTextClient(HostedMiniCPMTextClient):
23
+ def __init__(self, payload):
24
+ super().__init__(endpoint="https://example.invalid")
25
+ self.payload = payload
26
+
27
+ def _post_json(self, prompt: str, max_tokens: int = 700):
28
+ return self.payload
29
+
30
+
31
  def test_extract_json_object_handles_code_fence():
32
  parsed = _extract_json_object('```json\n{"visitor_name": "Gate 14"}\n```')
33
  assert parsed == {"visitor_name": "Gate 14"}
 
78
  )
79
  clues = client.extract_clues(str(image_path))
80
  assert clues == ["passport stamp", "customs desk", "blue", "foggy"]
81
+
82
+
83
+ def test_hosted_text_client_parses_common_response_shape():
84
+ client = StubHostedTextClient(
85
+ {
86
+ "choices": [
87
+ {
88
+ "message": {
89
+ "content": (
90
+ '{"visitor_name":"Gate 14","questions":["What does it ask?"],'
91
+ '"tone_note":"Keep it small."}'
92
+ )
93
+ }
94
+ }
95
+ ]
96
+ }
97
+ )
98
+ negotiation = client.generate_negotiation("dream")
99
+ assert negotiation["visitor_name"] == "Gate 14"
100
+ assert negotiation["questions"] == ["What does it ask?"]
tests/test_pipeline.py CHANGED
@@ -1,5 +1,18 @@
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():
@@ -46,3 +59,81 @@ 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
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  from dream_customs.models import FakeASRClient, FakeTextClient, FakeVisionClient
2
+ from dream_customs.pipeline import (
3
+ add_evidence,
4
+ answer_question,
5
+ ask_questions,
6
+ build_intake,
7
+ create_session,
8
+ draft_pact,
9
+ generate_negotiation,
10
+ generate_pact,
11
+ intake_from_modalities,
12
+ revise_pact,
13
+ seal_pact,
14
+ skip_question,
15
+ )
16
 
17
 
18
  def test_build_intake_merges_modalities():
 
59
  intake = build_intake(dream_text="I might hurt myself if I cannot sleep.")
60
  card, _html = generate_pact(intake, "", FakeTextClient())
61
  assert card.safety_note
62
+
63
+
64
+ def test_add_evidence_updates_session_with_text_image_audio_and_mood():
65
+ session = add_evidence(
66
+ create_session(),
67
+ dream_text="我梦见电梯按钮融化。",
68
+ image_path="demo.png",
69
+ audio_path="demo.wav",
70
+ mood="anxious",
71
+ vision_client=FakeVisionClient(),
72
+ asr_client=FakeASRClient(),
73
+ )
74
+ assert session.phase == "declaring"
75
+ assert "电梯" in session.intake.dream_text
76
+ assert "blue hallway" in session.intake.visual_clues
77
+ assert "The buttons melted" in session.intake.voice_transcript
78
+ assert session.intake.mood == "anxious"
79
+ assert {item.type for item in session.evidence_items} == {"text", "image", "audio", "mood"}
80
+
81
+
82
+ def test_ask_answer_skip_draft_revise_and_seal_actions():
83
+ session = add_evidence(
84
+ create_session(),
85
+ dream_text="I missed an elevator.",
86
+ mood="restless",
87
+ vision_client=FakeVisionClient(),
88
+ asr_client=FakeASRClient(),
89
+ )
90
+ session = ask_questions(session, FakeTextClient())
91
+ assert session.phase == "negotiating"
92
+ assert len(session.question_history) == 3
93
+
94
+ session = answer_question(session, "I want one small start.")
95
+ assert session.answer_history[-1] == "I want one small start."
96
+
97
+ session = ask_questions(session, FakeTextClient(), force_another=True)
98
+ assert len(session.question_history) == 4
99
+
100
+ session = skip_question(session)
101
+ assert "skip" in session.answer_history[-1].lower()
102
+
103
+ session = draft_pact(session, FakeTextClient())
104
+ assert session.phase == "drafting"
105
+ assert session.draft_pact is not None
106
+
107
+ original_task = session.draft_pact.weird_task
108
+ session = revise_pact(session, "make it stranger", FakeTextClient())
109
+ assert session.draft_pact is not None
110
+ assert session.draft_pact.weird_task != original_task
111
+
112
+ session = seal_pact(session)
113
+ assert session.phase == "sealed"
114
+ assert session.sealed_pact == session.draft_pact
115
+
116
+
117
+ def test_image_audio_failures_keep_text_path_alive():
118
+ class FailingVision:
119
+ def extract_clues(self, image_path):
120
+ raise RuntimeError("offline")
121
+
122
+ class EmptyASR:
123
+ def transcribe(self, audio_path):
124
+ return ""
125
+
126
+ session = add_evidence(
127
+ create_session(),
128
+ dream_text="Text still works.",
129
+ image_path="missing.png",
130
+ audio_path="missing.wav",
131
+ mood="foggy",
132
+ vision_client=FailingVision(),
133
+ asr_client=EmptyASR(),
134
+ )
135
+ session = ask_questions(session, FakeTextClient())
136
+ session = draft_pact(session, FakeTextClient())
137
+ assert session.draft_pact is not None
138
+ assert any(item.status == "failed" for item in session.evidence_items)
139
+ assert "Text still works." in session.intake.dream_text
tests/test_render.py CHANGED
@@ -1,4 +1,5 @@
1
- from dream_customs.render import render_pact_card
 
2
  from dream_customs.schema import PactCard
3
 
4
 
@@ -35,3 +36,32 @@ def test_render_pact_card_escapes_user_like_content():
35
  )
36
  assert "<bad>" not in html
37
  assert "<script" not in html.lower()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from dream_customs.pipeline import add_evidence, create_session
2
+ from dream_customs.render import render_pact_card, render_pact_inspector, render_timeline
3
  from dream_customs.schema import PactCard
4
 
5
 
 
36
  )
37
  assert "<bad>" not in html
38
  assert "<script" not in html.lower()
39
+
40
+
41
+ def test_render_pact_card_uses_night_desk_treatment():
42
+ html = render_pact_card(
43
+ PactCard(
44
+ visitor_name="Late Elevator",
45
+ permit_id="DC-1",
46
+ contraband=["unfiled anxiety"],
47
+ risk_level="orange",
48
+ alliance_reading="The dream asks for a smaller start.",
49
+ practical_suggestion="Open one task ten minutes early.",
50
+ weird_task="Write the elevator an apology note.",
51
+ bedtime_release="Today the elevator has docked.",
52
+ safety_note="Reach out for support if you feel unsafe.",
53
+ )
54
+ )
55
+ assert "oklch" in html
56
+ assert "#fff8ea" not in html
57
+ assert "border-left" not in html
58
+
59
+
60
+ def test_render_timeline_and_inspector_show_session_state():
61
+ session = add_evidence(create_session(), dream_text="A blue gate opened.", mood="foggy")
62
+ timeline = render_timeline(session)
63
+ inspector = render_pact_inspector(session)
64
+ assert "Customs timeline" in timeline
65
+ assert "Dream note" in timeline
66
+ assert "No pact drafted yet" in inspector
67
+ assert "2 filed" in inspector
tests/test_schema.py CHANGED
@@ -1,4 +1,4 @@
1
- from dream_customs.schema import DreamIntake, PactCard
2
 
3
 
4
  def test_dream_intake_defaults_lists():
@@ -37,3 +37,18 @@ def test_pact_card_requires_core_fields():
37
  )
38
  assert card.safety_note == ""
39
  assert "Late Elevator" in card.to_plain_text()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from dream_customs.schema import CustomsSession, DreamIntake, EvidenceItem, PactCard
2
 
3
 
4
  def test_dream_intake_defaults_lists():
 
37
  )
38
  assert card.safety_note == ""
39
  assert "Late Elevator" in card.to_plain_text()
40
+
41
+
42
+ def test_customs_session_defaults_to_empty_workbench():
43
+ session = CustomsSession()
44
+ assert session.phase == "empty"
45
+ assert session.evidence_items == []
46
+ assert session.question_history == []
47
+ assert session.evidence_count() == 0
48
+
49
+
50
+ def test_evidence_item_tracks_failure_state():
51
+ item = EvidenceItem(type="image", label="Image evidence", status="failed", error="No clues extracted")
52
+ assert item.type == "image"
53
+ assert item.status == "failed"
54
+ assert "No clues" in item.error