fix: ground dream customs story flow

#12
docs/superpowers/plans/2026-06-08-dream-customs-story-ux-polish.md ADDED
@@ -0,0 +1,291 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Dream Customs Story UX Polish Implementation Plan
2
+
3
+ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
+
5
+ **Goal:** Make the public Dream Customs flow feel like one complete dream-customs ritual by grounding generated cards in the submitted dream, hiding developer controls from ordinary users, and turning hosted-model wait time into story-aware status copy.
6
+
7
+ **Architecture:** Keep Hugging Face Space as the public Gradio frontend and Modal as the hidden hosted MiniCPM route. Add deterministic local grounding helpers in `dream_customs/pipeline.py` so weak hosted output is repaired before rendering. Keep the UI thin and mobile-first by moving backend controls behind a collapsed developer accordion and adding user-facing processing copy without adding new backend dependencies.
8
+
9
+ **Tech Stack:** Python, Pydantic, Gradio Blocks, pytest, Hugging Face Space, Modal hosted endpoints.
10
+
11
+ ---
12
+
13
+ ## File Structure
14
+
15
+ - Modify `dream_customs/pipeline.py`: extract concrete dream details, detect generic model output, and repair pact fields with dream-specific visitor/action/release copy.
16
+ - Modify `dream_customs/prompts.py`: tighten MiniCPM text prompts so generated questions and pact cards must reuse concrete dream details.
17
+ - Modify `dream_customs/ui/app.py`: collapse developer settings by default and add story-aware loading/status text near the main action.
18
+ - Modify `dream_customs/ui/copy.py`: add public-facing processing copy.
19
+ - Modify `dream_customs/ui/styles.py`: style the processing note and make the collapsed developer panel quieter on desktop and mobile.
20
+ - Modify `tests/test_pipeline.py`: add regression tests for dream-grounded hosted output repair.
21
+ - Modify `tests/test_ui_actions.py`: add regression tests for public UI defaults and processing copy.
22
+
23
+ ## Task 0: Baseline And Branch Hygiene
24
+
25
+ **Files:**
26
+ - Read: `AGENTS.md`
27
+ - Read: `PRODUCT.md`
28
+ - Read: `docs/spec.md`
29
+ - Read: `dream_customs/pipeline.py`
30
+ - Read: `dream_customs/ui/app.py`
31
+
32
+ - [x] **Step 1: Confirm repository and remotes**
33
+
34
+ Run:
35
+
36
+ ```bash
37
+ pwd
38
+ git status --short --branch
39
+ git remote -v
40
+ ```
41
+
42
+ Expected:
43
+
44
+ ```text
45
+ /Users/junhaocheng/working-dir/ai-competitions/build-small-hackthon
46
+ ## feature/dream-customs-ui-voice-settings...origin/feature/dream-customs-ui-voice-settings
47
+ origin https://github.com/adjcjh777/dream-customs-build-small.git
48
+ space https://huggingface.co/spaces/build-small-hackathon/dream-customs
49
+ ```
50
+
51
+ - [x] **Step 2: Sync latest code**
52
+
53
+ Run:
54
+
55
+ ```bash
56
+ git pull
57
+ ```
58
+
59
+ Expected:
60
+
61
+ ```text
62
+ Already up to date.
63
+ ```
64
+
65
+ ## Task 1: Ground Weak Pact Output In Dream Details
66
+
67
+ **Files:**
68
+ - Modify: `tests/test_pipeline.py`
69
+ - Modify: `dream_customs/pipeline.py`
70
+
71
+ - [x] **Step 1: Add failing regression test for generic hosted output**
72
+
73
+ Add a test in `tests/test_pipeline.py` that uses a weak text client returning a generic visitor, generic health tip, generic odd task, and bare time release for this input:
74
+
75
+ ```text
76
+ I dreamed I was at a customs window carrying a suitcase full of wet paper. The clerk asked me to declare every unfinished promise before sunrise.
77
+ ```
78
+
79
+ Expected repaired card:
80
+
81
+ - `visitor_name` contains either `Wet Paper` or `Unfinished Promise`.
82
+ - `alliance_reading` mentions at least one of `wet paper`, `customs window`, or `unfinished promise`.
83
+ - `practical_suggestion` mentions a first step or promise, not generic hydration.
84
+ - `weird_task` mentions paper, promise, suitcase, or customs.
85
+ - `bedtime_release` is not a bare time such as `7:00 PM`.
86
+
87
+ - [x] **Step 2: Run focused failing test**
88
+
89
+ Run:
90
+
91
+ ```bash
92
+ .venv/bin/python -m pytest tests/test_pipeline.py::test_generate_pact_repairs_generic_hosted_output_with_dream_details -q
93
+ ```
94
+
95
+ Expected: FAIL because current polishing does not repair all generic-but-valid hosted output.
96
+
97
+ - [x] **Step 3: Implement dream-detail extraction and repair**
98
+
99
+ In `dream_customs/pipeline.py`:
100
+
101
+ - Add `_extract_dream_anchors(intake)` returning up to three concrete lower-case noun phrases from dream text, voice transcript, and visual clues.
102
+ - Add `_is_generic_daily_tip(text)` for generic health/productivity suggestions that do not reference the dream.
103
+ - Add `_is_bare_time_or_generic_release(text)`.
104
+ - Extend `_polish_card_for_daily_use()` so weak visitor names, alliance readings, practical suggestions, weird tasks, and bedtime releases are repaired with the anchors.
105
+ - Keep existing safety behavior and dated permit stamping unchanged.
106
+
107
+ - [x] **Step 4: Run focused test until green**
108
+
109
+ Run:
110
+
111
+ ```bash
112
+ .venv/bin/python -m pytest tests/test_pipeline.py::test_generate_pact_repairs_generic_hosted_output_with_dream_details -q
113
+ ```
114
+
115
+ Expected: PASS.
116
+
117
+ ## Task 2: Tighten MiniCPM Prompt Contracts
118
+
119
+ **Files:**
120
+ - Modify: `tests/test_pipeline.py`
121
+ - Modify: `dream_customs/prompts.py`
122
+
123
+ - [x] **Step 1: Add prompt contract test**
124
+
125
+ Add a test proving `pact_prompt()` includes explicit grounding constraints:
126
+
127
+ - reuse at least two concrete dream details;
128
+ - avoid generic wellness filler;
129
+ - `bedtime_release` must be a sentence, not a time;
130
+ - `visitor_name` must be an object/event from the dream, not a human name unless a person appeared.
131
+
132
+ - [x] **Step 2: Run focused failing test**
133
+
134
+ Run:
135
+
136
+ ```bash
137
+ .venv/bin/python -m pytest tests/test_pipeline.py::test_pact_prompt_requires_dream_grounded_card -q
138
+ ```
139
+
140
+ Expected: FAIL until prompt copy is tightened.
141
+
142
+ - [x] **Step 3: Update prompts**
143
+
144
+ In `dream_customs/prompts.py`, add concrete instructions to `negotiation_prompt()`, `followup_question_prompt()`, `pact_prompt()`, and `pact_revision_prompt()` that keep the model plain, non-clinical, and dream-specific.
145
+
146
+ - [x] **Step 4: Run prompt contract test**
147
+
148
+ Run:
149
+
150
+ ```bash
151
+ .venv/bin/python -m pytest tests/test_pipeline.py::test_pact_prompt_requires_dream_grounded_card -q
152
+ ```
153
+
154
+ Expected: PASS.
155
+
156
+ ## Task 3: Hide Developer Settings From Ordinary Users
157
+
158
+ **Files:**
159
+ - Modify: `tests/test_ui_actions.py`
160
+ - Modify: `dream_customs/ui/app.py`
161
+ - Modify: `dream_customs/ui/styles.py`
162
+
163
+ - [x] **Step 1: Add UI source regression test**
164
+
165
+ Add a test that inspects `dream_customs.ui.app` source and asserts `Runtime settings` accordion is created with `open=False`.
166
+
167
+ - [x] **Step 2: Run focused failing test**
168
+
169
+ Run:
170
+
171
+ ```bash
172
+ .venv/bin/python -m pytest tests/test_ui_actions.py::test_runtime_settings_are_collapsed_for_public_flow -q
173
+ ```
174
+
175
+ Expected: FAIL while the accordion is still `open=True`.
176
+
177
+ - [x] **Step 3: Collapse and quiet the developer panel**
178
+
179
+ Change `with gr.Accordion("Runtime settings", open=True, elem_classes=["dc-dev"]):` to `open=False`, and update CSS so collapsed runtime settings look like a quiet secondary control rather than the next stage of the user flow.
180
+
181
+ - [x] **Step 4: Run focused UI test**
182
+
183
+ Run:
184
+
185
+ ```bash
186
+ .venv/bin/python -m pytest tests/test_ui_actions.py::test_runtime_settings_are_collapsed_for_public_flow -q
187
+ ```
188
+
189
+ Expected: PASS.
190
+
191
+ ## Task 4: Add Story-Aware Processing Copy
192
+
193
+ **Files:**
194
+ - Modify: `tests/test_ui_actions.py`
195
+ - Modify: `dream_customs/ui/copy.py`
196
+ - Modify: `dream_customs/ui/app.py`
197
+ - Modify: `dream_customs/ui/styles.py`
198
+
199
+ - [x] **Step 1: Add copy regression test**
200
+
201
+ Add a test that imports `PROCESSING_NOTE` and asserts it mentions the clerk/model route without using secret/backend jargon such as `token`, `endpoint`, or `debug`.
202
+
203
+ - [x] **Step 2: Run focused failing test**
204
+
205
+ Run:
206
+
207
+ ```bash
208
+ .venv/bin/python -m pytest tests/test_ui_actions.py::test_processing_note_is_story_copy_not_backend_jargon -q
209
+ ```
210
+
211
+ Expected: FAIL until `PROCESSING_NOTE` exists.
212
+
213
+ - [x] **Step 3: Add and render processing note**
214
+
215
+ Add `PROCESSING_NOTE` to `dream_customs/ui/copy.py` and render it near the submit buttons in `dream_customs/ui/app.py`.
216
+
217
+ Recommended copy:
218
+
219
+ ```text
220
+ When you file a dream, the clerk reads the fragment and drafts a pass. Model-backed routes may take a few seconds; text-only fallback stays ready.
221
+ ```
222
+
223
+ - [x] **Step 4: Style processing note**
224
+
225
+ Add `.dc-processing-note` styles in `dream_customs/ui/styles.py` with small, readable text and no layout shift on mobile.
226
+
227
+ - [x] **Step 5: Run focused copy test**
228
+
229
+ Run:
230
+
231
+ ```bash
232
+ .venv/bin/python -m pytest tests/test_ui_actions.py::test_processing_note_is_story_copy_not_backend_jargon -q
233
+ ```
234
+
235
+ Expected: PASS.
236
+
237
+ ## Task 5: Full Verification And Deployment
238
+
239
+ **Files:**
240
+ - Verify: all modified files
241
+
242
+ - [x] **Step 1: Run full test suite**
243
+
244
+ Run:
245
+
246
+ ```bash
247
+ .venv/bin/python -m pytest -q
248
+ ```
249
+
250
+ Expected: all tests pass.
251
+
252
+ - [x] **Step 2: Run whitespace check**
253
+
254
+ Run:
255
+
256
+ ```bash
257
+ git diff --check
258
+ ```
259
+
260
+ Expected: no output.
261
+
262
+ - [x] **Step 3: Run local Gradio smoke**
263
+
264
+ Run:
265
+
266
+ ```bash
267
+ GRADIO_SERVER_PORT=7862 .venv/bin/python app.py
268
+ ```
269
+
270
+ Open `http://127.0.0.1:7862`, submit the customs-window wet-paper dream, skip optional question, and verify:
271
+
272
+ - runtime settings are collapsed by default;
273
+ - the processing note is visible before submit;
274
+ - the final pass references wet paper/customs/unfinished promises;
275
+ - mobile width has no obvious overflow.
276
+
277
+ - [ ] **Step 4: Commit and push GitHub branch**
278
+
279
+ Run:
280
+
281
+ ```bash
282
+ git add docs/superpowers/plans/2026-06-08-dream-customs-story-ux-polish.md dream_customs/pipeline.py dream_customs/prompts.py dream_customs/ui/app.py dream_customs/ui/copy.py dream_customs/ui/styles.py tests/test_pipeline.py tests/test_ui_actions.py
283
+ git commit -m "fix: ground dream customs story flow"
284
+ git push origin feature/dream-customs-ui-voice-settings
285
+ ```
286
+
287
+ - [ ] **Step 5: Sync Hugging Face Space and verify live app**
288
+
289
+ Push or create a Space PR to `space/main` according to the current auth state. Then verify the live Space `/config` and public app page show the updated runtime settings behavior and pass generation flow.
290
+
291
+ Stop and report if HF auth, forced public `main` overwrite, or PR merge permissions block completion.
dream_customs/pipeline.py CHANGED
@@ -45,6 +45,114 @@ def _stamp_card_for_today(card: PactCard) -> PactCard:
45
  return stamped
46
 
47
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
48
  def _looks_unclear_or_dream_literal(text: str) -> bool:
49
  clean = (text or "").strip()
50
  if len(clean) < 12:
@@ -72,6 +180,41 @@ def _looks_unclear_or_dream_literal(text: str) -> bool:
72
  return any(term in clean for term in dream_literals)
73
 
74
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
75
  def _safe_practical_suggestion(intake: DreamIntake) -> str:
76
  mood = intake.mood.strip().lower()
77
  if mood in {"uneasy", "foggy", "tired", "焦虑", "迷雾", "累"}:
@@ -86,27 +229,96 @@ def _safe_weird_task(intake: DreamIntake) -> str:
86
  return "Write your smallest task on paper, draw a tiny clearance stamp beside it, and work on it for just five minutes."
87
 
88
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
89
  def _polish_card_for_daily_use(card: PactCard, intake: DreamIntake, answers: str) -> PactCard:
90
  polished = card.model_copy(deep=True)
91
  merged = "\n".join([intake.merged_text(), answers or ""])
92
- if not polished.visitor_name.strip() or polished.visitor_name.strip().lower() == "dreamer":
93
- polished.visitor_name = "Night Visitor"
 
94
 
95
  if _looks_unclear_or_dream_literal(polished.practical_suggestion):
96
  polished.practical_suggestion = _safe_practical_suggestion(intake)
 
 
 
 
97
 
98
  if _looks_unclear_or_dream_literal(polished.weird_task) and polished.weird_task.strip() == polished.practical_suggestion.strip():
99
  polished.weird_task = _safe_weird_task(intake)
100
  elif len((polished.weird_task or "").strip()) < 8:
101
  polished.weird_task = _safe_weird_task(intake)
102
-
103
- if len((polished.alliance_reading or "").strip()) < 12 or "联盟成员" in polished.alliance_reading:
104
- polished.alliance_reading = (
105
- "You can treat this as a small signal from last night's feelings, not a prophecy. "
106
- "Today, protect a realistic pace."
107
- )
 
 
 
 
 
108
  if polished.risk_level.strip() in {"低", "中", "高", "low", "medium", "high"}:
109
  polished.risk_level = "medium: handle gently, without treating it as a warning sign"
 
 
 
 
110
 
111
  if not needs_escalation(merged):
112
  polished.safety_note = ""
@@ -318,6 +530,17 @@ def ask_questions(session: CustomsSession, text_client, force_another: bool = Fa
318
  fresh_questions = ["If today only needs one smaller first step, what should that step be?"]
319
  if not fresh_questions:
320
  fresh_questions = questions[:3]
 
 
 
 
 
 
 
 
 
 
 
321
 
322
  next_session.question_history.extend(fresh_questions[:3])
323
  next_session.phase = "negotiating"
 
45
  return stamped
46
 
47
 
48
+ _ANCHOR_STOPWORDS = {
49
+ "about",
50
+ "after",
51
+ "again",
52
+ "asked",
53
+ "before",
54
+ "behind",
55
+ "being",
56
+ "carrying",
57
+ "declare",
58
+ "dream",
59
+ "dreamed",
60
+ "dreamt",
61
+ "every",
62
+ "feeling",
63
+ "fragment",
64
+ "from",
65
+ "full",
66
+ "into",
67
+ "last",
68
+ "left",
69
+ "night",
70
+ "paper",
71
+ "promise",
72
+ "through",
73
+ "today",
74
+ "window",
75
+ "with",
76
+ }
77
+
78
+
79
+ def _dedupe_preserve_order(items: List[str]) -> List[str]:
80
+ seen = set()
81
+ result: List[str] = []
82
+ for item in items:
83
+ clean = re.sub(r"\s+", " ", item.strip(" .,:;!?\"'()[]{}")).lower()
84
+ if clean and clean not in seen:
85
+ result.append(clean)
86
+ seen.add(clean)
87
+ return result
88
+
89
+
90
+ def _extract_dream_anchors(intake: DreamIntake) -> List[str]:
91
+ text = " ".join(
92
+ [
93
+ intake.dream_text,
94
+ intake.voice_transcript,
95
+ " ".join(intake.visual_clues),
96
+ " ".join(intake.recurring_symbols),
97
+ ]
98
+ ).lower()
99
+ candidates: List[str] = []
100
+ pair_pattern = re.compile(
101
+ r"\b([a-z][a-z'-]+)\s+("
102
+ r"paper|papers|promise|promises|window|windows|suitcase|suitcases|"
103
+ r"clerk|clerks|sunrise|elevator|elevators|button|buttons|hallway|"
104
+ r"gate|gates|floor|floors|stamp|stamps|number|numbers"
105
+ r")\b"
106
+ )
107
+ for match in pair_pattern.finditer(text):
108
+ modifier, noun = match.groups()
109
+ phrase = f"{modifier} {noun.rstrip('s')}"
110
+ if modifier not in _ANCHOR_STOPWORDS:
111
+ candidates.append(phrase)
112
+
113
+ noun_pattern = re.compile(
114
+ r"\b(customs|suitcase|clerk|sunrise|elevator|button|hallway|gate|stamp|number|floor)\b"
115
+ )
116
+ candidates.extend(match.group(1) for match in noun_pattern.finditer(text))
117
+ candidates.extend(clue.lower() for clue in intake.visual_clues if clue.strip())
118
+
119
+ return _dedupe_preserve_order(candidates)[:3]
120
+
121
+
122
+ def _primary_anchor(intake: DreamIntake) -> str:
123
+ anchors = _extract_dream_anchors(intake)
124
+ return anchors[0] if anchors else "night visitor"
125
+
126
+
127
+ def _secondary_anchor(intake: DreamIntake) -> str:
128
+ anchors = _extract_dream_anchors(intake)
129
+ return anchors[1] if len(anchors) > 1 else _primary_anchor(intake)
130
+
131
+
132
+ def _title_anchor(text: str) -> str:
133
+ return " ".join(part.capitalize() for part in text.split())
134
+
135
+
136
+ def _text_uses_anchor(text: str, anchors: List[str]) -> bool:
137
+ clean = (text or "").lower()
138
+ return any(anchor in clean for anchor in anchors)
139
+
140
+
141
+ def _is_generic_visitor_name(text: str, intake: DreamIntake) -> bool:
142
+ clean = (text or "").strip()
143
+ if not clean:
144
+ return True
145
+ lowered = clean.lower()
146
+ merged = intake.merged_text().lower()
147
+ generic_names = {"dreamer", "night visitor", "elena", "visitor", "the visitor"}
148
+ if lowered in generic_names:
149
+ return True
150
+ anchors = _extract_dream_anchors(intake)
151
+ if anchors and not _text_uses_anchor(lowered, anchors) and lowered not in merged and len(clean.split()) <= 2:
152
+ return True
153
+ return False
154
+
155
+
156
  def _looks_unclear_or_dream_literal(text: str) -> bool:
157
  clean = (text or "").strip()
158
  if len(clean) < 12:
 
180
  return any(term in clean for term in dream_literals)
181
 
182
 
183
+ def _is_generic_daily_tip(text: str, anchors: List[str]) -> bool:
184
+ clean = (text or "").lower()
185
+ generic_markers = [
186
+ "hydrate",
187
+ "dehydration",
188
+ "piece of fruit",
189
+ "cognitive function",
190
+ "morning routine",
191
+ "take a short walk",
192
+ "eat something",
193
+ "drink water",
194
+ ]
195
+ return any(marker in clean for marker in generic_markers) and not _text_uses_anchor(clean, anchors)
196
+
197
+
198
+ def _is_generic_weird_task(text: str, anchors: List[str]) -> bool:
199
+ clean = (text or "").lower()
200
+ generic_markers = [
201
+ "count the number of birds",
202
+ "requires no special skills",
203
+ "harmless and playful activity",
204
+ "salute the kettle",
205
+ ]
206
+ return any(marker in clean for marker in generic_markers) and not _text_uses_anchor(clean, anchors)
207
+
208
+
209
+ def _is_bare_time_or_generic_release(text: str) -> bool:
210
+ clean = (text or "").strip()
211
+ if re.fullmatch(r"\d{1,2}:\d{2}\s*(?:am|pm|AM|PM)?", clean):
212
+ return True
213
+ if len(clean.split()) <= 3:
214
+ return True
215
+ return False
216
+
217
+
218
  def _safe_practical_suggestion(intake: DreamIntake) -> str:
219
  mood = intake.mood.strip().lower()
220
  if mood in {"uneasy", "foggy", "tired", "焦虑", "迷雾", "累"}:
 
229
  return "Write your smallest task on paper, draw a tiny clearance stamp beside it, and work on it for just five minutes."
230
 
231
 
232
+ def _grounded_practical_suggestion(intake: DreamIntake) -> str:
233
+ primary = _primary_anchor(intake)
234
+ secondary = _secondary_anchor(intake)
235
+ if "promise" in primary or "promise" in secondary:
236
+ return (
237
+ "Choose one unfinished promise and shrink it into a first step small enough to finish in "
238
+ "10 minutes."
239
+ )
240
+ return (
241
+ f"Pick one real task that feels like the {primary}, then define only its first step for the next 10 minutes."
242
+ )
243
+
244
+
245
+ def _grounded_weird_task(intake: DreamIntake) -> str:
246
+ primary = _primary_anchor(intake)
247
+ secondary = _secondary_anchor(intake)
248
+ if "paper" in primary or "paper" in secondary:
249
+ return "Write one unfinished promise on a scrap of paper, fold it like a tiny suitcase, and stamp it cleared."
250
+ if "customs" in primary or "customs" in secondary:
251
+ return "Make a one-line customs form for today's smallest task and mark it cleared after five minutes."
252
+ return f"Draw the {primary} as a tiny customs stamp, press it once, and work for five minutes."
253
+
254
+
255
+ def _grounded_bedtime_release(intake: DreamIntake) -> str:
256
+ primary = _primary_anchor(intake)
257
+ secondary = _secondary_anchor(intake)
258
+ return f"Tonight, the {primary} and the {secondary} are logged, cleared, and allowed to rest until morning."
259
+
260
+
261
+ def _grounded_alliance_reading(intake: DreamIntake) -> str:
262
+ primary = _primary_anchor(intake)
263
+ secondary = _secondary_anchor(intake)
264
+ return (
265
+ f"You can treat the {primary} and the {secondary} as last night's way of asking for one promise "
266
+ "to become smaller and easier to carry today."
267
+ )
268
+
269
+
270
+ def _grounded_question(intake: DreamIntake, question: str) -> str:
271
+ anchors = _extract_dream_anchors(intake)
272
+ if not anchors or _text_uses_anchor(question, anchors):
273
+ return question
274
+ primary = _primary_anchor(intake)
275
+ secondary = _secondary_anchor(intake)
276
+ return (
277
+ f"When you picture the {primary} and the {secondary}, what is one real-life promise "
278
+ "or task you want to make easier today?"
279
+ )
280
+
281
+
282
+ def _grounded_followup_question(intake: DreamIntake) -> str:
283
+ primary = _primary_anchor(intake)
284
+ return f"If the {primary} could hand you one smaller first step for today, what would that step be?"
285
+
286
+
287
  def _polish_card_for_daily_use(card: PactCard, intake: DreamIntake, answers: str) -> PactCard:
288
  polished = card.model_copy(deep=True)
289
  merged = "\n".join([intake.merged_text(), answers or ""])
290
+ anchors = _extract_dream_anchors(intake)
291
+ if _is_generic_visitor_name(polished.visitor_name, intake):
292
+ polished.visitor_name = _title_anchor(_primary_anchor(intake))
293
 
294
  if _looks_unclear_or_dream_literal(polished.practical_suggestion):
295
  polished.practical_suggestion = _safe_practical_suggestion(intake)
296
+ elif _is_generic_daily_tip(polished.practical_suggestion, anchors):
297
+ polished.practical_suggestion = _grounded_practical_suggestion(intake)
298
+ elif anchors and not _text_uses_anchor(polished.practical_suggestion, anchors):
299
+ polished.practical_suggestion = _grounded_practical_suggestion(intake)
300
 
301
  if _looks_unclear_or_dream_literal(polished.weird_task) and polished.weird_task.strip() == polished.practical_suggestion.strip():
302
  polished.weird_task = _safe_weird_task(intake)
303
  elif len((polished.weird_task or "").strip()) < 8:
304
  polished.weird_task = _safe_weird_task(intake)
305
+ elif _is_generic_weird_task(polished.weird_task, anchors):
306
+ polished.weird_task = _grounded_weird_task(intake)
307
+ elif anchors and not _text_uses_anchor(polished.weird_task, anchors):
308
+ polished.weird_task = _grounded_weird_task(intake)
309
+
310
+ if (
311
+ len((polished.alliance_reading or "").strip()) < 12
312
+ or "联盟成员" in polished.alliance_reading
313
+ or (anchors and not _text_uses_anchor(polished.alliance_reading, anchors))
314
+ ):
315
+ polished.alliance_reading = _grounded_alliance_reading(intake)
316
  if polished.risk_level.strip() in {"低", "中", "高", "low", "medium", "high"}:
317
  polished.risk_level = "medium: handle gently, without treating it as a warning sign"
318
+ if _is_bare_time_or_generic_release(polished.bedtime_release) or (
319
+ anchors and not _text_uses_anchor(polished.bedtime_release, anchors)
320
+ ):
321
+ polished.bedtime_release = _grounded_bedtime_release(intake)
322
 
323
  if not needs_escalation(merged):
324
  polished.safety_note = ""
 
530
  fresh_questions = ["If today only needs one smaller first step, what should that step be?"]
531
  if not fresh_questions:
532
  fresh_questions = questions[:3]
533
+ if fresh_questions:
534
+ fresh_questions = [_grounded_question(next_session.intake, fresh_questions[0])] + fresh_questions[1:]
535
+ seen_questions = set(next_session.question_history)
536
+ deduped_questions: List[str] = []
537
+ for question in fresh_questions:
538
+ if question and question not in seen_questions:
539
+ deduped_questions.append(question)
540
+ seen_questions.add(question)
541
+ if force_another and not deduped_questions:
542
+ deduped_questions = [_grounded_followup_question(next_session.intake)]
543
+ fresh_questions = deduped_questions
544
 
545
  next_session.question_history.extend(fresh_questions[:3])
546
  next_session.phase = "negotiating"
dream_customs/prompts.py CHANGED
@@ -18,6 +18,9 @@ The tone should be gentle, plain, and specific. Do not make medical claims.
18
  Ask questions that an ordinary person can understand without knowing the app lore.
19
  Prefer questions about today's mood, one small real-life concern, or one safe action.
20
  Do not ask vague symbolic questions such as what a stamp wants to release.
 
 
 
21
 
22
  Dream intake:
23
  {intake.merged_text()}
@@ -36,6 +39,8 @@ Do not diagnose. Do not repeat previous questions.
36
  The question must be plain and useful: ask what the user wants to make easier today,
37
  or whether there is one realistic thing they want help starting.
38
  Do not use unclear metaphors about stamps, release, fate, symbols, or hidden meanings.
 
 
39
 
40
  Dream intake:
41
  {intake.merged_text()}
@@ -59,11 +64,16 @@ You are the Dream Customs diplomat. Generate a final Today's Pact card.
59
  Do not diagnose. Do not claim the dream has one certain meaning.
60
  The card must be useful for the user's real day, not only poetic.
61
  Give:
62
- - practical_suggestion: one safe, concrete life tip for today, such as hydration, eating, writing one task down, taking a short walk, reducing one task, checking the calendar, or asking for help. It must not be mystical or dream-literal.
63
- - weird_task: one harmless, playful, slightly odd thing doable in 5 minutes. It can be imaginative, but it must be understandable.
 
 
 
64
  - safety_note: empty string unless the user mentions self-harm, harming others, severe distress, severe insomnia, panic, or inability to function.
65
  Use warm, non-clinical language.
66
- Avoid generic names like "Dreamer"; name the dream visitor with a short, vivid phrase.
 
 
67
  Write the final card in polished English for an English-language demo, even if the dream fragment contains another language.
68
 
69
  Dream intake:
@@ -85,6 +95,10 @@ Keep the same dream visitor unless the user's new material clearly changes it.
85
  Do not diagnose. Do not make the dream sound certain or frightening.
86
  Keep practical_suggestion safe, concrete, and useful for daily life.
87
  Keep weird_task playful but understandable and separate from the practical suggestion.
 
 
 
 
88
  Use safety_note only for severe distress or safety risk; otherwise return an empty string.
89
  Write the revised card in polished English for an English-language demo, even if the dream fragment contains another language.
90
 
 
18
  Ask questions that an ordinary person can understand without knowing the app lore.
19
  Prefer questions about today's mood, one small real-life concern, or one safe action.
20
  Do not ask vague symbolic questions such as what a stamp wants to release.
21
+ Ground every question in a concrete detail from the intake when possible, such as an object,
22
+ place, action, color, or phrase the user actually provided.
23
+ Do not invent a human name unless the user mentioned a person.
24
 
25
  Dream intake:
26
  {intake.merged_text()}
 
39
  The question must be plain and useful: ask what the user wants to make easier today,
40
  or whether there is one realistic thing they want help starting.
41
  Do not use unclear metaphors about stamps, release, fate, symbols, or hidden meanings.
42
+ Reuse one concrete dream detail from the intake so the user can feel the question belongs
43
+ to this dream rather than to a generic reflection form.
44
 
45
  Dream intake:
46
  {intake.merged_text()}
 
64
  Do not diagnose. Do not claim the dream has one certain meaning.
65
  The card must be useful for the user's real day, not only poetic.
66
  Give:
67
+ - visitor_name: a short object/event phrase from the dream, not a human name unless a person appears.
68
+ - alliance_reading: reuse at least two concrete dream details from the intake and say what the dream may be trying to protect today.
69
+ - practical_suggestion: one safe, concrete next step for today. Tie it to the user's dream details or answers. avoid generic wellness filler such as hydration, fruit, exercise, or sleep hygiene unless the user explicitly asked for that.
70
+ - weird_task: one harmless, playful, slightly odd thing doable in 5 minutes. It must reuse at least one concrete dream detail and be understandable.
71
+ - bedtime_release: bedtime_release must be a sentence-length release phrase, not a time, schedule, diagnosis, or command.
72
  - safety_note: empty string unless the user mentions self-harm, harming others, severe distress, severe insomnia, panic, or inability to function.
73
  Use warm, non-clinical language.
74
+ Avoid generic names like "Dreamer"; name the dream visitor with a short, vivid phrase from the user's own dream.
75
+ The final card must reuse at least two concrete dream details across visitor_name,
76
+ alliance_reading, practical_suggestion, weird_task, and bedtime_release.
77
  Write the final card in polished English for an English-language demo, even if the dream fragment contains another language.
78
 
79
  Dream intake:
 
95
  Do not diagnose. Do not make the dream sound certain or frightening.
96
  Keep practical_suggestion safe, concrete, and useful for daily life.
97
  Keep weird_task playful but understandable and separate from the practical suggestion.
98
+ The revised card must reuse at least two concrete dream details from the intake.
99
+ visitor_name should be an object or event from the dream, not a human name unless a person appears.
100
+ bedtime_release must be a sentence, not a time or schedule.
101
+ Avoid generic wellness filler unless the user explicitly requested it.
102
  Use safety_note only for severe distress or safety risk; otherwise return an empty string.
103
  Write the revised card in polished English for an English-language demo, even if the dream fragment contains another language.
104
 
dream_customs/ui/app.py CHANGED
@@ -35,6 +35,7 @@ from dream_customs.ui.copy import (
35
  EXAMPLE_MOOD,
36
  DEFAULT_MOOD,
37
  MOOD_OPTIONS,
 
38
  )
39
  from dream_customs.ui.styles import CSS
40
 
@@ -371,6 +372,7 @@ def build_demo() -> gr.Blocks:
371
  with gr.Row(elem_classes=["dc-submit-row"]):
372
  example_button = gr.Button("Try the sample →", variant="secondary")
373
  submit_button = gr.Button("Issue today's pass →", variant="primary")
 
374
  with gr.Accordion("Additional material", open=False, elem_classes=["dc-attachment-drawer"]):
375
  image_input = gr.Image(label="Image clue", type="filepath", height=160)
376
 
@@ -420,7 +422,7 @@ def build_demo() -> gr.Blocks:
420
  </div>
421
  """.strip()
422
  )
423
- with gr.Accordion("Runtime settings", open=True, elem_classes=["dc-dev"]):
424
  gr.HTML(
425
  """
426
  <div class="dc-dev-help">
 
35
  EXAMPLE_MOOD,
36
  DEFAULT_MOOD,
37
  MOOD_OPTIONS,
38
+ PROCESSING_NOTE,
39
  )
40
  from dream_customs.ui.styles import CSS
41
 
 
372
  with gr.Row(elem_classes=["dc-submit-row"]):
373
  example_button = gr.Button("Try the sample →", variant="secondary")
374
  submit_button = gr.Button("Issue today's pass →", variant="primary")
375
+ gr.HTML(f"<p class='dc-processing-note'>{escape(PROCESSING_NOTE)}</p>")
376
  with gr.Accordion("Additional material", open=False, elem_classes=["dc-attachment-drawer"]):
377
  image_input = gr.Image(label="Image clue", type="filepath", height=160)
378
 
 
422
  </div>
423
  """.strip()
424
  )
425
+ with gr.Accordion("Runtime settings", open=False, elem_classes=["dc-dev"]):
426
  gr.HTML(
427
  """
428
  <div class="dc-dev-help">
dream_customs/ui/copy.py CHANGED
@@ -1,5 +1,9 @@
1
  APP_TITLE = "Dream Customs"
2
  APP_SUBTITLE = "Turn last night's strange fragment into one gentle clearance pass for today."
 
 
 
 
3
 
4
  DREAM_PLACEHOLDER = "For example: I kept missing an elevator, and the buttons melted like wax."
5
  ANSWER_PLACEHOLDER = "Add one sentence if it helps, or leave this blank and skip ahead."
 
1
  APP_TITLE = "Dream Customs"
2
  APP_SUBTITLE = "Turn last night's strange fragment into one gentle clearance pass for today."
3
+ PROCESSING_NOTE = (
4
+ "When you file a dream, the clerk reads the fragment and drafts a pass. "
5
+ "Model-backed routes may take a few seconds; text-only fallback stays ready."
6
+ )
7
 
8
  DREAM_PLACEHOLDER = "For example: I kept missing an elevator, and the buttons melted like wax."
9
  ANSWER_PLACEHOLDER = "Add one sentence if it helps, or leave this blank and skip ahead."
dream_customs/ui/styles.py CHANGED
@@ -591,6 +591,20 @@ button.secondary {
591
  font-size: 1.05rem !important;
592
  }
593
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
594
  .dc-attachment-drawer {
595
  background: rgba(230, 221, 208, 0.52) !important;
596
  border: 1px solid var(--dc-line) !important;
@@ -810,12 +824,18 @@ a.built-with[href*="gradio.app"] {
810
  }
811
 
812
  .dc-dev {
813
- background: rgba(255, 249, 238, 0.7) !important;
814
- border: 1px solid rgba(189, 168, 143, 0.58) !important;
815
  border-radius: var(--dc-radius-md) !important;
816
  box-shadow: none;
817
  margin-top: 0;
818
- padding: 12px !important;
 
 
 
 
 
 
819
  }
820
 
821
  .dc-dev-help {
 
591
  font-size: 1.05rem !important;
592
  }
593
 
594
+ .dc-processing-note {
595
+ color: var(--dc-muted);
596
+ font-size: 0.84rem;
597
+ line-height: 1.45;
598
+ margin: 12px 2px 0;
599
+ max-width: 44rem;
600
+ }
601
+
602
+ .dc-processing-note::before {
603
+ color: var(--dc-coral-dark);
604
+ content: "While the stamp dries: ";
605
+ font-weight: 850;
606
+ }
607
+
608
  .dc-attachment-drawer {
609
  background: rgba(230, 221, 208, 0.52) !important;
610
  border: 1px solid var(--dc-line) !important;
 
824
  }
825
 
826
  .dc-dev {
827
+ background: rgba(255, 249, 238, 0.46) !important;
828
+ border: 1px dashed rgba(189, 168, 143, 0.55) !important;
829
  border-radius: var(--dc-radius-md) !important;
830
  box-shadow: none;
831
  margin-top: 0;
832
+ padding: 10px !important;
833
+ }
834
+
835
+ .dc-dev > button {
836
+ color: rgba(19, 41, 47, 0.74) !important;
837
+ font-size: 0.84rem !important;
838
+ min-height: 42px !important;
839
  }
840
 
841
  .dc-dev-help {
tests/test_pipeline.py CHANGED
@@ -16,6 +16,7 @@ from dream_customs.pipeline import (
16
  seal_pact,
17
  skip_question,
18
  )
 
19
  from dream_customs.schema import PactCard
20
 
21
 
@@ -57,6 +58,23 @@ def test_generate_negotiation_returns_questions():
57
  assert len(negotiation["questions"]) == 3
58
 
59
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
60
  def test_generate_pact_returns_card_and_html():
61
  intake = build_intake(dream_text="I missed an elevator.", mood="anxious")
62
  card, html = generate_pact(intake, "I want a small start.", FakeTextClient())
@@ -95,6 +113,70 @@ def test_generate_pact_polishes_unclear_model_output_into_daily_tip():
95
  assert "Life tip" in html
96
 
97
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
98
  def test_add_evidence_updates_session_with_text_image_audio_and_mood():
99
  session = add_evidence(
100
  create_session(),
 
16
  seal_pact,
17
  skip_question,
18
  )
19
+ from dream_customs.prompts import pact_prompt
20
  from dream_customs.schema import PactCard
21
 
22
 
 
58
  assert len(negotiation["questions"]) == 3
59
 
60
 
61
+ def test_ask_questions_grounds_generic_question_in_dream_detail():
62
+ session = add_evidence(
63
+ create_session(),
64
+ dream_text=(
65
+ "I dreamed I was at a customs window carrying a suitcase full of wet paper. "
66
+ "The clerk asked me to declare every unfinished promise before sunrise."
67
+ ),
68
+ mood="Uneasy",
69
+ vision_client=FakeVisionClient(),
70
+ asr_client=FakeASRClient(),
71
+ )
72
+
73
+ session = ask_questions(session, FakeTextClient())
74
+
75
+ assert "customs window" in session.question_history[0].lower() or "wet paper" in session.question_history[0].lower()
76
+
77
+
78
  def test_generate_pact_returns_card_and_html():
79
  intake = build_intake(dream_text="I missed an elevator.", mood="anxious")
80
  card, html = generate_pact(intake, "I want a small start.", FakeTextClient())
 
113
  assert "Life tip" in html
114
 
115
 
116
+ def test_generate_pact_repairs_generic_hosted_output_with_dream_details():
117
+ class GenericHostedTextClient:
118
+ def generate_pact(self, prompt):
119
+ return PactCard(
120
+ visitor_name="Elena",
121
+ permit_id="DC-015",
122
+ contraband=["unfiled worry", "one stamp asking to be noticed"],
123
+ risk_level="medium",
124
+ alliance_reading=(
125
+ "You can treat this as a small signal from last night's feelings, not a prophecy. "
126
+ "Today, protect a realistic pace."
127
+ ),
128
+ practical_suggestion=(
129
+ "Hydrate and eat a piece of fruit to support your morning routine, as dehydration "
130
+ "can affect cognitive function."
131
+ ),
132
+ weird_task=(
133
+ "Count the number of birds in the sky before bed, as it is a harmless and playful "
134
+ "activity that requires no special skills."
135
+ ),
136
+ bedtime_release="7:00 PM",
137
+ safety_note="",
138
+ )
139
+
140
+ intake = build_intake(
141
+ dream_text=(
142
+ "I dreamed I was at a customs window carrying a suitcase full of wet paper. "
143
+ "The clerk asked me to declare every unfinished promise before sunrise."
144
+ ),
145
+ mood="Uneasy",
146
+ )
147
+
148
+ card, html = generate_pact(intake, "", GenericHostedTextClient())
149
+ joined = "\n".join(
150
+ [
151
+ card.visitor_name,
152
+ card.alliance_reading,
153
+ card.practical_suggestion,
154
+ card.weird_task,
155
+ card.bedtime_release,
156
+ ]
157
+ ).lower()
158
+
159
+ assert "wet paper" in joined or "unfinished promise" in joined
160
+ assert "customs window" in card.alliance_reading.lower() or "wet paper" in card.alliance_reading.lower()
161
+ assert "promise" in card.practical_suggestion.lower() or "first step" in card.practical_suggestion.lower()
162
+ assert any(anchor in card.weird_task.lower() for anchor in ["paper", "promise", "suitcase", "customs"])
163
+ assert card.bedtime_release != "7:00 PM"
164
+ assert "Hydrate and eat a piece of fruit" not in html
165
+
166
+
167
+ def test_pact_prompt_requires_dream_grounded_card():
168
+ intake = build_intake(
169
+ dream_text="I carried wet paper through a customs window before sunrise.",
170
+ mood="Uneasy",
171
+ )
172
+ prompt = pact_prompt(intake, "I want to keep one promise small today.")
173
+
174
+ assert "reuse at least two concrete dream details" in prompt
175
+ assert "avoid generic wellness filler" in prompt
176
+ assert "bedtime_release must be a sentence" in prompt
177
+ assert "not a human name unless a person appears" in prompt
178
+
179
+
180
  def test_add_evidence_updates_session_with_text_image_audio_and_mood():
181
  session = add_evidence(
182
  create_session(),
tests/test_ui_actions.py CHANGED
@@ -1,9 +1,11 @@
1
  import json
 
2
  from datetime import date
3
 
4
  from dream_customs.ui.actions import answer_to_card_action, initial_mobile_state, skip_to_card_action, submit_dream_action
 
5
  from dream_customs.ui.app import _reset
6
- from dream_customs.ui.copy import DEFAULT_MOOD
7
 
8
 
9
  def test_mobile_defaults_to_model_backends():
@@ -14,6 +16,22 @@ def test_mobile_defaults_to_model_backends():
14
  assert view["debug"]["vision_backend"] == "model"
15
 
16
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
17
  def test_mobile_reset_restores_calm_mood():
18
  settings_values = [
19
  "",
 
1
  import json
2
+ import inspect
3
  from datetime import date
4
 
5
  from dream_customs.ui.actions import answer_to_card_action, initial_mobile_state, skip_to_card_action, submit_dream_action
6
+ import dream_customs.ui.app as ui_app
7
  from dream_customs.ui.app import _reset
8
+ from dream_customs.ui.copy import DEFAULT_MOOD, PROCESSING_NOTE
9
 
10
 
11
  def test_mobile_defaults_to_model_backends():
 
16
  assert view["debug"]["vision_backend"] == "model"
17
 
18
 
19
+ def test_runtime_settings_are_collapsed_for_public_flow():
20
+ source = inspect.getsource(ui_app.build_demo)
21
+
22
+ assert 'gr.Accordion("Runtime settings", open=False' in source
23
+
24
+
25
+ def test_processing_note_is_story_copy_not_backend_jargon():
26
+ lowered = PROCESSING_NOTE.lower()
27
+
28
+ assert "clerk" in lowered
29
+ assert "model" in lowered
30
+ assert "token" not in lowered
31
+ assert "endpoint" not in lowered
32
+ assert "debug" not in lowered
33
+
34
+
35
  def test_mobile_reset_restores_calm_mood():
36
  settings_values = [
37
  "",