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