J.B-Lin commited on
Commit
68d23d2
·
1 Parent(s): 6cce707

用户手动整理文件,并备份deploy_backup.py文件

Browse files
docs/UI改进规划_用户反馈.md DELETED
@@ -1,159 +0,0 @@
1
- # PregoPal UI 改进规划 — 用户反馈汇总
2
-
3
- > 日期:2026-06-09
4
- > 负责:UI 设计 Cline
5
-
6
- ---
7
-
8
- ## 用户原始反馈(逐字保留)
9
-
10
- > 现在改成卡片式的确实好看一些了,但是卡片的布局不均匀。存在以下问题:
11
-
12
- ### 1. 首页 — 语音提示文案
13
- > "全双工语音交互,边说边听"请改为直接到提示,用户不是工程师,没必要提示全双工这样的词语
14
-
15
- - **问题**:`tap_hint` 字段中使用了「全双工语音交互」这种工程师术语
16
- - **位置**:`utils.py` `_home_texts()` 第 52 行
17
- - **修改**:改为用户友好的提示,如「点击开始对话」/「Tap to talk」
18
-
19
- ### 2. 首页 — 红色大按钮功能
20
- > 上面的红色大按钮怎么样才算是启动呢,这部分逻辑有设计吗?
21
-
22
- - **问题**:红色圆形按钮(`.voice-main-btn`)目前只是一个装饰性 HTML `<button>`,没有任何 `click` 事件绑定
23
- - **位置**:`ui/app_builder.py` 第 180-187 行
24
- - **现状**:纯 CSS 样式,没有实际的语音启动逻辑
25
- - **✅ 已决策**:用户要求真实语音对话启动,先用 `pass` 占位避免报错,设计好函数接口便于后续接入 Llama 大模型
26
- - **接口设计**:
27
- ```python
28
- async def start_voice_session(audio_data: bytes, lang: str = "zh") -> dict:
29
- """
30
- 语音会话入口(当前 pass 占位,待接入 Llama 模型)
31
-
32
- Args:
33
- audio_data: 原始音频数据(bytes)
34
- lang: 语言标识 "zh" / "en"
35
-
36
- Returns:
37
- dict: {
38
- "text": str, # ASR 转写文本
39
- "response": str, # AI 回复文本
40
- "speaker": str, # 识别出的说话人
41
- "audio_response": bytes | None # TTS 音频(可选)
42
- }
43
- """
44
- pass
45
- ```
46
-
47
- ### 3. 首页 — AI 思考框 UI 问题
48
- > 下面的"🤔 AI 思考中... 等待对话中..."UI设计也有问题,外面还有个方框写着textbox,下方的所有内容都是两个框包着,是不是哪里逻辑不对;并且文字框内部是灰色,这明显不对
49
-
50
- - **问题 3a**:`gr.Textbox` 外层有 Gradio 默认的 "textbox" 标签边框
51
- - **问题 3b**:文本框内部背景是灰色(默认 textarea 样式)
52
- - **问题 3c**:下方内容区域被两层框包裹(可能是 `.home-card` + `.glass-card` 嵌套)
53
- - **位置**:`ui/app_builder.py` 第 189-196 行,以及 CSS `.thinking-box` 样式
54
- - **修改**:
55
- - 将 `gr.Textbox` 改为 `gr.HTML` 或 `gr.Markdown` 来展示思考状态(去掉 textarea 外壳)
56
- - 或者通过 CSS 彻底隐藏 textarea 的边框和灰色背景
57
-
58
- ### 4. 顶部标题 — 缺乏英文和趣味字体
59
- > 页面最顶上的"孕期陪护AI助手 — 温馨的家庭式伴侣"请同时提供英文,并且尝试使用更有趣的字体
60
-
61
- - **问题**:顶部标题只有中文,没有英文副标题
62
- - **位置**:`ui/app_builder.py` 第 360-365 行(硬编码 HTML 字符串)
63
- - **修改**:
64
- - 添加英文副标题:`Pregnancy Companion AI — Your Cozy Family Partner`
65
- - 引入趣味字体(Google Fonts 圆体/手写体,如 `Nunito`、`Quicksand`、`Comic Neue` 或中文字体如站酷快乐体)
66
-
67
- ### 5. 家庭饮食习惯 — 子页面默认状态
68
- > 请默认打开家庭菜谱这样子页面,而不是在最开始的时候三个都不打开,看起来像是卡住了一样
69
-
70
- - **问题**:三个子 Tab(菜谱/偏好/记忆)进入时全部折叠,看起来像页面卡住了
71
- - **位置**:`ui/app_builder.py` 第 240-263 行,`gr.Tabs()` 没有设置默认选中
72
- - **修改**:首次加载时默认显示「🍳 家庭菜谱」Tab
73
-
74
- ### 6. 孕期阶段 — 嵌套边框问题
75
- > 孕期阶段也是显示不太对,黑色的文本框边缘外面还是黑色的文本框边缘
76
-
77
- - **问题**:嵌套的 Group 导致双层边框
78
- - **位置**:`ui/app_builder.py` 第 201-207 行,首页卡片行中的 `card-trimester`
79
- - **可能原因**:`gr.Group(elem_classes=["home-card", "card-trimester"])` 嵌套在 `gr.Column()` 内,Gradio 给每个 Grroup 都加了边框
80
-
81
- ### 7. 营养报告 — Slider 无法拖动
82
- > 营养报告中,分析天数的进度条完全没法拖动
83
-
84
- - **问题**:`gr.Slider` 在 Gradio 中有时渲染为不可拖动的进度条
85
- - **位置**:`ui/app_builder.py` 第 321-325 行
86
- - **修改**:改为 `gr.Number` 输入框 + 手动输入天数,或使用 `gr.Slider` 的 `interactive=True`(需确认)
87
-
88
- ### 8. 营养报告 — 右上角"刷新为7天"按钮多余
89
- > 右上角那个刷新为7天的可以去掉,没啥意义
90
-
91
- - **问题**:哪里来的"刷新为7天"按钮?可能在 `render_nutrition_report_html` 或 Slider 旁边
92
- - **待确认**:需要再查看报告 HTML 渲染代码
93
-
94
- ### 9. 营养报告 — 布局问题
95
- > 此外这个页面UI布局也有问题,生成报告按钮占了页面的一半,下面全是空的,右边则是报告挤在一起
96
-
97
- - **问题**:`gr.Row()` 中 `scale=1`(左侧按钮+Slider)和 `scale=3`��右侧报告)比例不合理
98
- - **位置**:`ui/app_builder.py` 第 320-332 行
99
- - **修改**:
100
- - 左侧:将 Slider 和按钮上下排列,占用更少宽度(scale=1)
101
- - 右侧:报告占主导(scale=4 或 5)
102
- - 或者改为上下布局:上方是 Slider,下方是全宽报告
103
-
104
- ### 10. 营养报告 — 自动生成
105
- > 此外,没有必要"生成报告"这个按钮,每次分析天数被用户拉动的时候,等待用户不拉动之后就生成报告就是了
106
-
107
- - **问题**:需要点击「生成报告」按钮才能看到报告
108
- - **修改**:使用 `gr.Slider` 的 `.change()` 事件(带 debounce 防抖)自动触发报告生成,去掉「生成报告」按钮
109
-
110
- ---
111
-
112
- ## 待实施修改清单
113
-
114
- - [ ] **P0** 写规划 md 文档(本文档)— 防止上下文压缩丢失用户原话
115
- - [ ] **P1** 第 1 项:修改首页语音提示文案(去技术术语)
116
- - [ ] **P1** 第 3 项:修复 AI 思考框 UI(去掉 textarea 外壳+灰色背景)
117
- - [ ] **P1** 第 5 项:家庭饮食习惯默认打开「家庭菜谱」
118
- - [ ] **P1** 第 9 项 + 第 10 项:营养报告布局重构 + 自动生成
119
- - [ ] **P2** 第 4 项:顶部标题添加英文 + 趣味字体
120
- - [ ] **P2** 第 6 项:修复孕期阶段嵌套边框
121
- - [ ] **P2** 第 7 项:修复 Slider 拖动问题
122
- - [ ] **P2** 第 8 项:移除多余的"刷新为7天"
123
- - [ ] **P3** 第 2 项:红色按钮功能设计(需与用户讨论)
124
-
125
- ---
126
-
127
- ## 技术要点
128
-
129
- ### 关于 gradio Slider 防抖自动生成
130
- ```python
131
- # 使用 gradio 的 change 事件 + 手动防抖
132
- import time
133
-
134
- _last_change = [0]
135
-
136
- def on_slider_change(days):
137
- _last_change[0] = time.time()
138
- # 等待 0.8 秒后执行生成
139
- ...
140
- ```
141
-
142
- ### 关于默认选中 Tab
143
- ```python
144
- with gr.Tabs(selected=0): # 或 selected="🍳 家庭菜谱"
145
- ```
146
-
147
- ### 关于改用 gr.HTML 代替 gr.Textbox 展示思考状态
148
- - 去掉 textarea 原生外壳
149
- - 保留渐变背景色由 HTML inline style 控制
150
-
151
- ---
152
-
153
- ## 文件修改范围
154
-
155
- | 文件 | 修改内容 |
156
- |------|----------|
157
- | `docs/UI改进规划_用户反馈.md` | 本文档(新建) |
158
- | `ui/app_builder.py` | 主要 UI 逻辑修改 |
159
- | `utils.py` | 文案修改(`_home_texts`、`CUSTOM_CSS`) |
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
docs/{llama_cpp_minicpm_技术报告.md → 云端部署旧方案_重新编译.md} RENAMED
@@ -1,3 +1,184 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  # llama.cpp 部署 MiniCPM-o 4.5 技术报告
2
 
3
  > 本文档记录在 Windows 环境下使用 llama.cpp 编译、部署 MiniCPM-o 4.5 模型的全过程,以及后续 Modal 云端部署的设计方案。作为排坑手册和技术参考。
 
1
+ # PregoPal MiniCPM-o-4_5 Modal 部署经验
2
+
3
+ > 日期:2026-06-09
4
+ > 目标:在 Modal.com 上用 llama.cpp 部署 MiniCPM-o 4.5 并暴露 API
5
+ >
6
+ > **部署状态:✅ 已成功部署**
7
+ > - **API URL**: `https://andrew-jiabin--prego-pal-minicpm-serve.modal.run`
8
+ > - **App**: `https://modal.com/apps/andrew-jiabin/main/deployed/prego-pal-minicpm`
9
+ > - **首次镜像构建耗时**: 458s (后续热部署 ~5s)
10
+ > - **冷启动**: 首次请求触发容器启动+模型加载 (~2-8min),闲置 300s 自动缩容
11
+
12
+ ## 整体架构
13
+
14
+ ```
15
+ 用户请求 → Modal ASGI (FastAPI) → llama-server (OpenAI-compatible)
16
+
17
+ Modal Volume (GGUF 模型持久存储)
18
+ ```
19
+
20
+ ## 关键决策
21
+
22
+ ### 1. 为什么不直接用 Hugging Face Spaces
23
+ - HF Spaces 仅 CPU,无法跑大模型
24
+ - Modal 提供 A100 GPU ($1-2/hr),有 300s idle 自动缩容
25
+
26
+ ### 2. 为什么选 llama.cpp 而不是 transformers
27
+ - llama.cpp 支持量化模型(Q4_K_M 仅 ~12GB,可装入单卡 A100)
28
+ - 内置 OpenAI-compatible server,省去适配层
29
+ - 支持 mmproj(多模态投影层),MiniCPM-o 的 vision/audio 都能挂载
30
+
31
+ ### 3. Volume vs 内嵌模型
32
+ - 模型 GGUF 文件不可放在 Docker 镜像中(镜像大小限制~10GB)
33
+ - Modal Volume 是持久化网络存储,模型只需上传一次
34
+
35
+ ## 部署步骤
36
+
37
+ ### Step 1: 安装 Modal CLI
38
+ ```bash
39
+ pip install modal
40
+ python -m modal setup
41
+ ```
42
+ 需要浏览器弹窗登录 Modal 账号(首次需创建团队 token)。
43
+
44
+ ### Step 2: 上传模型到 Modal Volume
45
+ ```bash
46
+ # 创建 Volume
47
+ modal volume create minicpm-o-4_5-models
48
+
49
+ # 上传模型目录(支持递归)
50
+ modal volume put minicpm-o-4_5-models ./models/MiniCPM-o-4_5-gguf /
51
+ ```
52
+
53
+ 模型目录结构(上传后):
54
+ ```
55
+ /models/MiniCPM-o-4_5-gguf/
56
+ MiniCPM-o-4_5-Q4_K_M.gguf # 主模型 4-bit 量化 ~12GB
57
+ vision/MiniCPM-o-4_5-vision-F16.gguf # 视觉投影层
58
+ audio/MiniCPM-o-4_5-audio-F16.gguf # 音频投影层
59
+ tts/MiniCPM-o-4_5-tts-F16.gguf # TTS 声码器
60
+ tts/MiniCPM-o-4_5-projector-F16.gguf # 音频→文本投影层
61
+ ```
62
+
63
+ **注意事项:**
64
+ - Volume 名称中不能有 `.`(点号),已改为 `minicpm-o-4_5-models`
65
+ - `volume put` 不支持断点续传,上传 12GB+ 需要稳定网络
66
+ - 上传速度约 100-200 MB/s(Modal S3 后端)
67
+
68
+ ### Step 3: 编写 deploy.py
69
+
70
+ 核心代码结构:
71
+
72
+ ```python
73
+ import modal
74
+
75
+ # 1. 自定义 Docker 镜像:编译 llama.cpp
76
+ llamacpp_image = (
77
+ Image.debian_slim(python_version="3.11")
78
+ .apt_install("curl", "git", "build-essential", "cmake", "libcurl4-openssl-dev")
79
+ .pip_install("fastapi", "uvicorn", "httpx", "numpy", "Pillow", "soundfile")
80
+ .run_commands(
81
+ "git clone --depth 1 https://github.com/ggerganov/llama.cpp /llama.cpp",
82
+ "cd /llama.cpp && cmake -B build ...",
83
+ "cd /llama.cpp && cmake --build build ... --target llama-server llama-mtmd-cli",
84
+ )
85
+ )
86
+
87
+ # 2. 挂载 Volume
88
+ model_volume = Volume.from_name("minicpm-o-4_5-models", create_if_missing=True)
89
+
90
+ # 3. ASGI app 包装 FastAPI → llama-server
91
+ @app.function(
92
+ image=llamacpp_image,
93
+ volumes={"/models": model_volume},
94
+ gpu="A100",
95
+ timeout=600,
96
+ )
97
+ @modal.concurrent(max_inputs=10)
98
+ @asgi_app()
99
+ def serve():
100
+ # 启动 llama-server 子进程
101
+ # 用 FastAPI 包装,提供 /v1/chat/completions 等端点
102
+ ...
103
+ ```
104
+
105
+ ### Step 4: 部署
106
+ ```bash
107
+ python -m modal deploy -m modal_deploy.deploy
108
+ ```
109
+
110
+ **编译耗时:~5-8 分钟**(在 A100 环境中编译 llama.cpp,主要是 C++ 代码编译等待)
111
+
112
+ ### Step 5: 获取 URL
113
+ ```bash
114
+ python -c "
115
+ import modal
116
+ from modal_deploy.deploy import app
117
+ # 或直接看 modal dashboard
118
+ "
119
+ ```
120
+ 或通过 `modal app list` 查看,在 Modal Dashboard 中获取 endpoint URL。
121
+
122
+ ## 踩坑记录
123
+
124
+ ### 坑 1: LLAMA_CURL 废弃警告
125
+ - 新版 llama.cpp 废弃了 `LLAMA_CURL=ON`,CMake 会发出警告但不影响构建
126
+ - 直接用 `-DLLAMA_CURL=ON` 即可,警告可忽略
127
+
128
+ ### 坑 2: 模型路径不匹配
129
+ - Volume `put` 后路径是 `/MiniCPM-o-4_5-gguf/...`,不是 `/...`
130
+ - deploy.py 中的路径需要加子目录前缀
131
+
132
+ ### 坑 3: cmake 编译目标名称
133
+ - 旧版用 `llama-server`,新版确认仍是此名称
134
+ - 多模态 CLI 用 `llama-mtmd-cli`(非 `llama-llava-cli`)
135
+
136
+ ### 坑 4: modal deploy 超时
137
+ - `python -m modal deploy` 在大模型编译时容易超时(>30s 没输出)
138
+ - 解决方法:让它后台运行,编译完成后会自动部署
139
+
140
+ ### 坑 5: --no-mmap 参数
141
+ - Modal 的 tmpfs 文件系统对 mmap 支持有限
142
+ - 加上 `--no-mmap` 使用标准 I/O 避免问题
143
+
144
+ ## API 接口设计
145
+
146
+ | 端点 | 方法 | 功能 |
147
+ |------|------|------|
148
+ | `/v1/chat/completions` | POST | OpenAI 兼容聊天(文本) |
149
+ | `/v1/embeddings` | POST | 文本嵌入 |
150
+ | `/v1/models` | GET | 模型列表 |
151
+ | `/v1/multimodal/chat` | POST | 多模态(图片+文本) |
152
+ | `/health` | GET | 健康检查 |
153
+ | `/` | GET | 服务信息 |
154
+
155
+ ## 成本估算
156
+
157
+ | 项目 | 费用 |
158
+ |------|------|
159
+ | A100 GPU / 小时 | ~$1.50 |
160
+ | 每次冷启动(编译+加载模型) | ~10 分钟 |
161
+ | idle 5 分钟后自动缩容 | $0 |
162
+ | 每月 1000 次请求(每次 2s) | ~$0.83 |
163
+
164
+ ## 后续优化方向
165
+
166
+ 1. **预热机制**:使用 Modal 的 `@app.function(schedule=...)` 定期保温
167
+ 2. **模型缓存**:利用 Volume 缓存编译产物,避免每次冷启动都编译
168
+ 3. **多模型支持**:同一 Volume 存放多个量化版本,按需切换
169
+ 4. **流式输出**:text/event-stream 目前已支持,需在客户端正确处理
170
+
171
+ ## 给后续 Clines 的要点
172
+
173
+ - 部署后 endpoint URL 在 Modal Dashboard 查看
174
+ - Python 客户端参考 `modal_deploy/client.py`
175
+ - 修改 deploy.py 后重新 `modal deploy` 即可热更新
176
+ - 模型更新用 `modal volume put` 覆盖原文件
177
+
178
+
179
+
180
+ # 另一个文档中的内容合并
181
+
182
  # llama.cpp 部署 MiniCPM-o 4.5 技术报告
183
 
184
  > 本文档记录在 Windows 环境下使用 llama.cpp 编译、部署 MiniCPM-o 4.5 模型的全过程,以及后续 Modal 云端部署的设计方案。作为排坑手册和技术参考。
docs/{deploy_success_report.md → 云端部署经验.md} RENAMED
@@ -1,3 +1,142 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  # 🚀 PregoPal × MiniCPM-o-4_5 Modal 部署成功报告
2
 
3
  > **状态**: ✅ 已成功部署到 Modal (T4 GPU)
@@ -197,7 +336,7 @@ modal volume ls minicpm-o-4_5-models /MiniCPM-o-4_5-gguf
197
 
198
  | 文件 | 说明 |
199
  |------|------|
200
- | `modal_deploy/deploy.py` | **主部署文件** — FastAPI + llama-cpp-python |
201
  | `modal_deploy/client.py` | Python API 客户端 (OpenAI 兼容) |
202
  | `modal_deploy/README.md` | 部署文档 |
203
  | `core/model_loader.py` | 项目集成层 (默认走远端 API) |
 
1
+ # 技术日志:快速部署方案分析
2
+
3
+ > 📅 2025-06-10
4
+ > ⚠️ 本文记录了**方案一(预编译 wheel)**的部署经验。方案二(源码编译)见下文对比。
5
+
6
+ ## 1. 背景:两种部署方案
7
+
8
+ 在 Modal 上部署 llama-cpp-python(即 llama.cpp 的 Python 绑定)有两种方式:
9
+
10
+ | 方案 | 方法 | 耗时 | 优点 | 缺点 |
11
+ |------|------|------|------|------|
12
+ | **方案一 ✅(当前选用)** | `pip install` 预编译 CUDA wheel | 5s | 极快、稳定、无需安装编译工具链 | 镜像内无完整 llama.cpp 源码(但运行时完全一样) |
13
+ | **方案二 ⏳(本项目不尝试)** | 从源码 `cmake .. && make` 编译 | 20-30min | 可精细控制编译 flag(如特定 CUDA arch)、更易排查底层问题 | 慢、依赖多、Debian Slim 镜像容易缺头文件 |
14
+
15
+ **两者本质相同**:都使用 llama.cpp 的 C++ 推理引擎。预编译 wheel 只是把编译步骤提前在官方服务器上做好了,安装的是同一套二进制。
16
+
17
+ ## 2. 方案一详解(预编译 wheel)
18
+
19
+ ### 为什么这么快
20
+
21
+ ```
22
+ # 方案一(下载 5s,无需编译)
23
+ pip install llama-cpp-python \
24
+ --extra-index-url https://ggml-org.github.io/llama-cpp-python/whl/cu121
25
+ ```
26
+
27
+ `ggml-org.github.io` 上已有预编译好的 `.whl` 文件(CUDA 12.1 + Python 3.11 + x86_64),安装 = 下载 + 解压,**耗时 5 秒**。
28
+
29
+ ### 流程时间
30
+
31
+ | 步骤 | 耗时 | 说明 |
32
+ |------|------|------|
33
+ | 镜像构建(base image) | ~1.8s | 已有 docker 层缓存 |
34
+ | pip install (预编译 wheel) | 5s | 下载即装,不编译 |
35
+ | 模型加载 | 1.5s | 从 Modal Volume 读 4.7GB GGUF |
36
+ | 中文推理 | 16.2s | A100 上的速度 |
37
+ | 英文推理 | 24.5s | A100 上的速度 |
38
+ | deploy 总时间 | **4.7s** | 上传代码 + 配置,无需重构建 |
39
+
40
+ ### 关键代码
41
+
42
+ `modal_deploy/deploy.py`:
43
+
44
+ ```python
45
+ _image = (
46
+ Image.debian_slim(python_version="3.11")
47
+ .pip_install("fastapi", "uvicorn[standard]", "httpx", "numpy", "Pillow")
48
+ .pip_install(
49
+ "llama-cpp-python",
50
+ extra_index_url="https://ggml-org.github.io/llama-cpp-python/whl/cu121",
51
+ # 方案一:预编译 wheel,不走源码编译
52
+ )
53
+ .run_commands(
54
+ "python -c 'import llama_cpp; print(\"llama-cpp-python OK\")'",
55
+ )
56
+ )
57
+ ```
58
+
59
+ ### 关于 "Llama Champion / runs through llama.cpp" 的判断
60
+
61
+ ```
62
+ pip install llama-cpp-python
63
+ ```
64
+
65
+ 无论从 wheel 还是从源码安装,`llama-cpp-python` 底层调用的都是 llama.cpp 的 C 库(通过 pybind11 绑定)。
66
+ **所以方案一完全满足 "Your model runs through the llama.cpp runtime" 的加分条件。**
67
+
68
+ 区别仅在于:
69
+ - **wheel** 方式:官方预先在 CUDA 12.1 + manylinux 环境下编译好 `.so`,你下载直接用
70
+ - **源码编译**:你在自己的镜像里运行 cmake,生成一模一样的 `.so`
71
+
72
+ 运行时 100% 相同。
73
+
74
+
75
+
76
+
77
+
78
+
79
+
80
+
81
+
82
+
83
+
84
+
85
+
86
+
87
+ ## 3. 方案二探讨(源码编译,待尝试)
88
+
89
+ 如果选择从源码编译以获得更好的适配性,大致思路:
90
+
91
+ ```python
92
+ # 方案二伪代码(modal_deploy/deploy.py 中替换 Image 定义)
93
+ _image = (
94
+ Image.debian_slim(python_version="3.11")
95
+ .apt_install("cmake", "build-essential", "cuda-toolkit-12-1") # 安装编译工具
96
+ .pip_install("fastapi", "uvicorn[standard]", "httpx", "numpy", "Pillow")
97
+ .run_commands(
98
+ "CMAKE_ARGS='-DGGML_CUDA=ON -DCMAKE_CUDA_ARCHITECTURES=80' "
99
+ "pip install llama-cpp-python --no-binary llama-cpp-python",
100
+ # --no-binary 强制从源码编译
101
+ )
102
+ )
103
+ ```
104
+
105
+ 这样做的潜在好处:
106
+ - 可指定 CUDA arch(例如只编译 sm_80/A100,减少二进制体积)
107
+ - 可开启更多底层优化 flag(如 GGML_CUDA_F16, GGML_CUDA_MMV_Y)
108
+ - 镜像内保留完整 llama.cpp 源码,便于调试
109
+
110
+ 代价:Modal 镜像构建时间从 5s 变为 20-30min,且容易因环境问题失败。
111
+
112
+ ## 4. 模型来源
113
+
114
+ 模型不是从 HuggingFace 下载的。流程是:
115
+
116
+ 1. 本地 `ollama pull minicpm-o` → 得到 GGUF 模型
117
+ 2. 手动复制到 `models/MiniCPM-o-4_5-gguf/`
118
+ 3. `modal volume put` → 上传到 Modal Volume
119
+ 4. Modal 部署时 mount Volume,直接读
120
+
121
+ ## 5. HTTP API 当前状态
122
+
123
+ - Health check: `GET /health` → `{"status":"ok","cuda":true,"vision":true}`
124
+ - `modal run test_inference` 推理: 中文/英文均正常(16-24s)
125
+ - `POST /v1/chat/completions` → 返回 `Internal Server Error`(待排查——极可能只是 Modal 反向代理 readiness probe 超时,而非代码逻辑问题)
126
+ 已更新日志。现在 git 提交:
127
+
128
+ <execute_command>
129
+ <command>cd c:\Users\Andre\codes\LJB\hackthon\llamacpp\PregoPal && git add docs/技术日志_为什么这次部署这么快.md && git commit -m "docs: 补充两种方案对比(wheel vs 源码编译),明确预编译wheel仍属llama.cpp" && git push</command>
130
+ <requires_approval>false</requires_approval>
131
+
132
+
133
+
134
+
135
+
136
+
137
+
138
+
139
+
140
  # 🚀 PregoPal × MiniCPM-o-4_5 Modal 部署成功报告
141
 
142
  > **状态**: ✅ 已成功部署到 Modal (T4 GPU)
 
336
 
337
  | 文件 | 说明 |
338
  |------|------|
339
+ | `modal_deploy/deploy_backup.py` | **主部署文件(此文件为备份文件)** — FastAPI + llama-cpp-python |
340
  | `modal_deploy/client.py` | Python API 客户端 (OpenAI 兼容) |
341
  | `modal_deploy/README.md` | 部署文档 |
342
  | `core/model_loader.py` | 项目集成层 (默认走远端 API) |
docs/技术日志_为什么这次部署这么快.md DELETED
@@ -1,117 +0,0 @@
1
- # 技术日志:快速部署方案分析
2
-
3
- > 📅 2025-06-10
4
- > ⚠️ 本文记录了**方案一(预编译 wheel)**的部署经验。方案二(源码编译)见下文对比。
5
-
6
- ## 1. 背景:两种部署方案
7
-
8
- 在 Modal 上部署 llama-cpp-python(即 llama.cpp 的 Python 绑定)有两种方式:
9
-
10
- | 方案 | 方法 | 耗时 | 优点 | 缺点 |
11
- |------|------|------|------|------|
12
- | **方案一 ✅(当前选用)** | `pip install` 预编译 CUDA wheel | 5s | 极快、稳定、无需安装编译工具链 | 镜像内无完整 llama.cpp 源码(但运行时完全一样) |
13
- | **方案二 ⏳(待尝试)** | 从源码 `cmake .. && make` 编译 | 20-30min | 可精细控制编译 flag(如特定 CUDA arch)、更易排查底层问题 | 慢、依赖多、Debian Slim 镜像容易缺头文件 |
14
-
15
- **两者本质相同**:都使用 llama.cpp 的 C++ 推理引擎。预编译 wheel 只是把编译步骤提前在官方服务器上做好了,安装的是同一套二进制。
16
-
17
- ## 2. 方案一详解(预编译 wheel)
18
-
19
- ### 为什么这么快
20
-
21
- ```
22
- # 方案一(下载 5s,无需编译)
23
- pip install llama-cpp-python \
24
- --extra-index-url https://ggml-org.github.io/llama-cpp-python/whl/cu121
25
- ```
26
-
27
- `ggml-org.github.io` 上已有预编译好的 `.whl` 文件(CUDA 12.1 + Python 3.11 + x86_64),安装 = 下载 + 解压,**耗时 5 秒**。
28
-
29
- ### 流程时间
30
-
31
- | 步骤 | 耗时 | 说明 |
32
- |------|------|------|
33
- | 镜像构建(base image) | ~1.8s | 已有 docker 层缓存 |
34
- | pip install (预编译 wheel) | 5s | 下载即装,不编译 |
35
- | 模型加载 | 1.5s | 从 Modal Volume 读 4.7GB GGUF |
36
- | 中文推理 | 16.2s | A100 上的速度 |
37
- | 英文推理 | 24.5s | A100 上的速度 |
38
- | deploy 总时间 | **4.7s** | 上传代码 + 配置,无需重构建 |
39
-
40
- ### 关键代码
41
-
42
- `modal_deploy/deploy.py`:
43
-
44
- ```python
45
- _image = (
46
- Image.debian_slim(python_version="3.11")
47
- .pip_install("fastapi", "uvicorn[standard]", "httpx", "numpy", "Pillow")
48
- .pip_install(
49
- "llama-cpp-python",
50
- extra_index_url="https://ggml-org.github.io/llama-cpp-python/whl/cu121",
51
- # 方案一:预编译 wheel,不走源码编译
52
- )
53
- .run_commands(
54
- "python -c 'import llama_cpp; print(\"llama-cpp-python OK\")'",
55
- )
56
- )
57
- ```
58
-
59
- ### 关于 "Llama Champion / runs through llama.cpp" 的判断
60
-
61
- ```
62
- pip install llama-cpp-python
63
- ```
64
-
65
- 无论从 wheel 还是从源码安装,`llama-cpp-python` 底层调用的都是 llama.cpp 的 C 库(通过 pybind11 绑定)。
66
- **所以方案一完全满足 "Your model runs through the llama.cpp runtime" 的加分条件。**
67
-
68
- 区别仅在于:
69
- - **wheel** 方式:官方预先在 CUDA 12.1 + manylinux 环境下编译好 `.so`,你下载直接用
70
- - **源码编译**:你在自己的镜像里运行 cmake,生成一模一样的 `.so`
71
-
72
- 运行时 100% 相同。
73
-
74
- ## 3. 方案二探讨(源码编译,待尝试)
75
-
76
- 如果选择从源码编译以获得更好的适配性,大致思路:
77
-
78
- ```python
79
- # 方案二伪代码(modal_deploy/deploy.py 中替换 Image 定义)
80
- _image = (
81
- Image.debian_slim(python_version="3.11")
82
- .apt_install("cmake", "build-essential", "cuda-toolkit-12-1") # 安装编译工具
83
- .pip_install("fastapi", "uvicorn[standard]", "httpx", "numpy", "Pillow")
84
- .run_commands(
85
- "CMAKE_ARGS='-DGGML_CUDA=ON -DCMAKE_CUDA_ARCHITECTURES=80' "
86
- "pip install llama-cpp-python --no-binary llama-cpp-python",
87
- # --no-binary 强制从源码编译
88
- )
89
- )
90
- ```
91
-
92
- 这样做的潜在好处:
93
- - 可指定 CUDA arch(例如只编译 sm_80/A100,减少二进制体积)
94
- - 可开启更多底层优化 flag(如 GGML_CUDA_F16, GGML_CUDA_MMV_Y)
95
- - 镜像内保留完整 llama.cpp 源码,便于调试
96
-
97
- 代价:Modal 镜像构建时间从 5s 变为 20-30min,且容易因环境问题失败。
98
-
99
- ## 4. 模型来源
100
-
101
- 模型不是从 HuggingFace 下载的。流程是:
102
-
103
- 1. 本地 `ollama pull minicpm-o` → 得到 GGUF 模型
104
- 2. 手动复制到 `models/MiniCPM-o-4_5-gguf/`
105
- 3. `modal volume put` → 上传到 Modal Volume
106
- 4. Modal 部署时 mount Volume,直接读
107
-
108
- ## 5. HTTP API 当前状态
109
-
110
- - Health check: `GET /health` → `{"status":"ok","cuda":true,"vision":true}`
111
- - `modal run test_inference` 推理: 中文/英文均正常(16-24s)
112
- - `POST /v1/chat/completions` → 返回 `Internal Server Error`(待排查——极可能只是 Modal 反向代理 readiness probe 超时,而非代码逻辑问题)
113
- 已更新日志。现在 git 提交:
114
-
115
- <execute_command>
116
- <command>cd c:\Users\Andre\codes\LJB\hackthon\llamacpp\PregoPal && git add docs/技术日志_为什么这次部署这么快.md && git commit -m "docs: 补充两种方案对比(wheel vs 源码编译),明确预编译wheel仍属llama.cpp" && git push</command>
117
- <requires_approval>false</requires_approval>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
docs/{总结报告_modal部署过程.md → 本地部署经验.md} RENAMED
@@ -72,65 +72,3 @@
72
  - **结果**:成功识别图片中的 "↓买入" 图标并输出中文描述 ✓
73
  - **加载时间**:约 **4 秒**(GPU CUDA)✓
74
  - **警告**:`n_ctx_seq (4096) < n_ctx_train (40960)` — 不影响功能
75
-
76
- ---
77
-
78
- ## 当前问题与关键发现
79
-
80
- ### ❌ 第一轮部署模型加载失败
81
- ```
82
- ValueError: Failed to load model from file:
83
- /models/MiniCPM-o-4_5-gguf/MiniCPM-o-4_5-Q4_K_M.gguf
84
- ```
85
- - 损坏的 721 MB GGUF 文件是根本原因
86
- - 现在已上传 5.0 GB 完整文件,预计可以解决
87
-
88
- ### ⚠️ llama-cpp-python 兼容性问题
89
- - 本地验证 `llama-mtmd-cli` 可以正常工作
90
- - deploy.py 用的 `llama-cpp-python`(Python 绑定)可能不支持 `mtmd` 架构
91
- - 如果 Python 绑定失败,需要改用 `llama-server` 方案(在 Modal 内直接启动编译好的 `llama-mtmd-cli` 作为独立进程)
92
-
93
- ---
94
-
95
- ## 已创建的工作文件
96
-
97
- | 文件 | 用途 |
98
- |------|------|
99
- | `tmp_download_instructions.md` | 下载完整模型的命令 |
100
- | `tmp_official_deploy_guide.md` | 官方教程关键发现汇总 |
101
- | `tmp_upload_and_test_guide.md` | 上传到 Modal + 本地检查步骤 |
102
- | `tmp_volume_cleanup_guide.md` | Volume 清理指南 |
103
- | `tmp_build_llamacpp_guide.md` | 编译 llama.cpp 步骤 |
104
- | `tmp_llama_mtmd_test_guide.md` | 本地推理测试命令 |
105
-
106
- ---
107
-
108
- ## 对项目整体架构的见解
109
-
110
- ### 当前架构过于复杂
111
-
112
- | 问题 | 具体表现 |
113
- |------|----------|
114
- | 模块太多 | plugins/ 下有 9 个插件,很多未收尾 |
115
- | 耦合过深 | core/、modules/、plugins/ 三层抽象,实际功能重复 |
116
- | 数据分散 | data/ 下有多个 json 文件,结构不一致 |
117
- | 前端缺失 | deploy.py 已部署但前端 app.py 还未真正对接 |
118
- | 依赖复杂 | requirements.txt 依赖多,部署环境兼容性难保证 |
119
-
120
- ### 建议:简化到最小可行产品
121
-
122
- 对于一个黑客松项目,建议:
123
- 1. **只保留核心 API**:`/v1/chat/completions`(文本)+ `/v1/vision`(多模态)
124
- 2. **前端直接对接 OpenAI 格式**:任何兼容 OpenAI SDK 的客户端都能用
125
- 3. **删除冗余模块**:plugins/ 和 modules/ 中未收尾的部分
126
- 4. **模型部署按官方教程走**:MiniCPM-V-Cookbook 有现成例子
127
-
128
- ---
129
-
130
- ## 下一步计划
131
-
132
- 1. ✅ 下载完整模型(已完成 5.0 GB)
133
- 2. ✅ 上传到 Modal Volume(已完成)
134
- 3. ✅ 编译 llama.cpp + `llama-mtmd-cli`(已完成)
135
- 4. ✅ 本地验证推理(已完成 — 成功识别图片)
136
- 5. ❌ 重新部署到 Modal + 测试 API
 
72
  - **结果**:成功识别图片中的 "↓买入" 图标并输出中文描述 ✓
73
  - **加载时间**:约 **4 秒**(GPU CUDA)✓
74
  - **警告**:`n_ctx_seq (4096) < n_ctx_train (40960)` — 不影响功能
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
docs/部署经验_Modal_MiniCPM-o.md DELETED
@@ -1,176 +0,0 @@
1
- # PregoPal MiniCPM-o-4_5 Modal 部署经验
2
-
3
- > 日期:2026-06-09
4
- > 目标:在 Modal.com 上用 llama.cpp 部署 MiniCPM-o 4.5 并暴露 API
5
- >
6
- > **部署状态:✅ 已成功部署**
7
- > - **API URL**: `https://andrew-jiabin--prego-pal-minicpm-serve.modal.run`
8
- > - **App**: `https://modal.com/apps/andrew-jiabin/main/deployed/prego-pal-minicpm`
9
- > - **首次镜像构建耗时**: 458s (后续热部署 ~5s)
10
- > - **冷启动**: 首次请求触发容器启动+模型加载 (~2-8min),闲置 300s 自动缩容
11
-
12
- ## 整体架构
13
-
14
- ```
15
- 用户请求 → Modal ASGI (FastAPI) → llama-server (OpenAI-compatible)
16
-
17
- Modal Volume (GGUF 模型持久存储)
18
- ```
19
-
20
- ## 关键决策
21
-
22
- ### 1. 为什么不直接用 Hugging Face Spaces
23
- - HF Spaces 仅 CPU,无法跑大模型
24
- - Modal 提供 A100 GPU ($1-2/hr),有 300s idle 自动缩容
25
-
26
- ### 2. 为什么选 llama.cpp 而不是 transformers
27
- - llama.cpp 支持量化模型(Q4_K_M 仅 ~12GB,可装入单卡 A100)
28
- - 内置 OpenAI-compatible server,省去适配层
29
- - 支持 mmproj(多模态投影层),MiniCPM-o 的 vision/audio 都能挂载
30
-
31
- ### 3. Volume vs 内嵌模型
32
- - 模型 GGUF 文件不可放在 Docker 镜像中(镜像大小限制~10GB)
33
- - Modal Volume 是持久化网络存储,模型只需上传一次
34
-
35
- ## 部署步骤
36
-
37
- ### Step 1: 安装 Modal CLI
38
- ```bash
39
- pip install modal
40
- python -m modal setup
41
- ```
42
- 需要浏览器弹窗登录 Modal 账号(首次需创建团队 token)。
43
-
44
- ### Step 2: 上传模型到 Modal Volume
45
- ```bash
46
- # 创建 Volume
47
- modal volume create minicpm-o-4_5-models
48
-
49
- # 上传模型目录(支持递归)
50
- modal volume put minicpm-o-4_5-models ./models/MiniCPM-o-4_5-gguf /
51
- ```
52
-
53
- 模型目录结构(上传后):
54
- ```
55
- /models/MiniCPM-o-4_5-gguf/
56
- MiniCPM-o-4_5-Q4_K_M.gguf # 主模型 4-bit 量化 ~12GB
57
- vision/MiniCPM-o-4_5-vision-F16.gguf # 视觉投影层
58
- audio/MiniCPM-o-4_5-audio-F16.gguf # 音频投影层
59
- tts/MiniCPM-o-4_5-tts-F16.gguf # TTS 声码器
60
- tts/MiniCPM-o-4_5-projector-F16.gguf # 音频→文本投影层
61
- ```
62
-
63
- **注意事项:**
64
- - Volume 名称中不能有 `.`(点号),已改为 `minicpm-o-4_5-models`
65
- - `volume put` 不支持断点续传,上传 12GB+ 需要稳定网络
66
- - 上传速度约 100-200 MB/s(Modal S3 后端)
67
-
68
- ### Step 3: 编写 deploy.py
69
-
70
- 核心代码结构:
71
-
72
- ```python
73
- import modal
74
-
75
- # 1. 自定义 Docker 镜像:编译 llama.cpp
76
- llamacpp_image = (
77
- Image.debian_slim(python_version="3.11")
78
- .apt_install("curl", "git", "build-essential", "cmake", "libcurl4-openssl-dev")
79
- .pip_install("fastapi", "uvicorn", "httpx", "numpy", "Pillow", "soundfile")
80
- .run_commands(
81
- "git clone --depth 1 https://github.com/ggerganov/llama.cpp /llama.cpp",
82
- "cd /llama.cpp && cmake -B build ...",
83
- "cd /llama.cpp && cmake --build build ... --target llama-server llama-mtmd-cli",
84
- )
85
- )
86
-
87
- # 2. 挂载 Volume
88
- model_volume = Volume.from_name("minicpm-o-4_5-models", create_if_missing=True)
89
-
90
- # 3. ASGI app 包装 FastAPI → llama-server
91
- @app.function(
92
- image=llamacpp_image,
93
- volumes={"/models": model_volume},
94
- gpu="A100",
95
- timeout=600,
96
- )
97
- @modal.concurrent(max_inputs=10)
98
- @asgi_app()
99
- def serve():
100
- # 启动 llama-server 子进程
101
- # 用 FastAPI 包装,提供 /v1/chat/completions 等端点
102
- ...
103
- ```
104
-
105
- ### Step 4: 部署
106
- ```bash
107
- python -m modal deploy -m modal_deploy.deploy
108
- ```
109
-
110
- **编译耗时:~5-8 分钟**(在 A100 环境中编译 llama.cpp,主要是 C++ 代码编译等待)
111
-
112
- ### Step 5: 获取 URL
113
- ```bash
114
- python -c "
115
- import modal
116
- from modal_deploy.deploy import app
117
- # 或直接看 modal dashboard
118
- "
119
- ```
120
- 或通过 `modal app list` 查看,在 Modal Dashboard 中获取 endpoint URL。
121
-
122
- ## 踩坑记录
123
-
124
- ### 坑 1: LLAMA_CURL 废弃警告
125
- - 新版 llama.cpp 废弃了 `LLAMA_CURL=ON`,CMake 会发出警告但不影响构建
126
- - 直接用 `-DLLAMA_CURL=ON` 即可,警告可忽略
127
-
128
- ### 坑 2: 模型路径不匹配
129
- - Volume `put` 后路径是 `/MiniCPM-o-4_5-gguf/...`,不是 `/...`
130
- - deploy.py 中的路径需要加子目录前缀
131
-
132
- ### 坑 3: cmake 编译目标名称
133
- - 旧版用 `llama-server`,新版确认仍是此名称
134
- - 多模态 CLI 用 `llama-mtmd-cli`(非 `llama-llava-cli`)
135
-
136
- ### 坑 4: modal deploy 超时
137
- - `python -m modal deploy` 在大模型编译时容易超时(>30s 没输出)
138
- - 解决方法:让它后台运行,编译完成后会自动部署
139
-
140
- ### 坑 5: --no-mmap 参数
141
- - Modal 的 tmpfs 文件系统对 mmap 支持有限
142
- - 加上 `--no-mmap` 使用标准 I/O 避免问题
143
-
144
- ## API 接口设计
145
-
146
- | 端点 | 方法 | 功能 |
147
- |------|------|------|
148
- | `/v1/chat/completions` | POST | OpenAI 兼容聊天(文本) |
149
- | `/v1/embeddings` | POST | 文本嵌入 |
150
- | `/v1/models` | GET | 模型列表 |
151
- | `/v1/multimodal/chat` | POST | 多模态(图片+文本) |
152
- | `/health` | GET | 健康检查 |
153
- | `/` | GET | 服务信息 |
154
-
155
- ## 成本估算
156
-
157
- | 项目 | 费用 |
158
- |------|------|
159
- | A100 GPU / 小时 | ~$1.50 |
160
- | 每次冷启动(编译+加载模型) | ~10 分钟 |
161
- | idle 5 分钟后自动缩容 | $0 |
162
- | 每月 1000 次请求(每次 2s) | ~$0.83 |
163
-
164
- ## 后续优化方向
165
-
166
- 1. **预热机制**:使用 Modal 的 `@app.function(schedule=...)` 定期保温
167
- 2. **模型缓存**:利用 Volume 缓存编译产物,避免每次冷启动都编译
168
- 3. **多模型支持**:同一 Volume 存放多个量化版本,按需切换
169
- 4. **流式输出**:text/event-stream 目前已支持,需在客户端正确处理
170
-
171
- ## 给后续 Clines 的要点
172
-
173
- - 部署后 endpoint URL 在 Modal Dashboard 查看
174
- - Python 客户端参考 `modal_deploy/client.py`
175
- - 修改 deploy.py 后重新 `modal deploy` 即可热更新
176
- - 模型更新用 `modal volume put` 覆盖原文件
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
docs/项目架构说明书.md DELETED
@@ -1,995 +0,0 @@
1
- # PregoPal 项目架构说明书
2
-
3
- > **📌 关于批注区的重要说明**
4
- >
5
- > 本文档每个模块/小节均设有「批注区」(用 `> **批注:**` 标记),用于记录开发过程中的讨论、决策、待办事项。
6
- >
7
- > **批注区中由你(项目负责人)亲手写入的文字,是重要的原始参考依据,任何人不得修改或删除。**
8
- >
9
- > - 如有新的观察或建议,请在 `> **批注:**` 下方以 `>>` 追加新行
10
- > - 如需修正已有批注内容,请在后方追加 `[修正: ...]` 标记,不得覆盖原始文字
11
- > - 批注区中的意见将指导后续迭代方向,所有 Cline 协作修改前应先阅读对应批注
12
-
13
- ---
14
-
15
- ## 目录
16
-
17
- - [一、项目概览](#一项目概览)
18
- - [二、整体文件结构](#二整体文件结构)
19
- - [三、app.py — 主入口](#三apppy--主入口)
20
- - [四、config.py — 全局配置](#四configpy--全局配置)
21
- - [五、utils.py — 工具函数](#五utilspy--工具函数)
22
- - [六、loop.py — 核心循环引擎](#六looppy--核心循环引擎)
23
- - [七、plugins/ — 插件系统](#七plugins--插件系统)
24
- - [八、modules/ — 业务逻辑层](#八modules--业务逻辑层)
25
- - [九、core/ — AI 核心层](#九core--ai-核心层)
26
- - [十、ui/ — 表现层](#十ui--表现层)
27
- - [十一、data/ — 数据存储](#十一data--数据存储)
28
- - [十二、tests/ — 测试套件](#十二tests--测试套件)
29
- - [十三、配置文件](#十三配置文件)
30
- - [十四、协作规范与 Git 工作流](#十四协作规范与-git-工作流)
31
- - [十五、当前运行状态](#十五当前运行状态)
32
- - [十六、待办与已知问题](#十六待办与已知问题)
33
-
34
- ---
35
-
36
- ## 一、项目概览
37
-
38
- ### 一句话定位
39
-
40
- **一个基于每日生命周期 Loop 驱动的孕期陪护 AI 助手**:每天首次启动时自动总结昨日饮食、对比国家营养标准(DRIs/膳食指南/体重标准),生成今日简报注入对话上下文;白天与家庭成员(通过声纹区分)自然交互,实时记录饮食、推荐食谱;次日循环往复。
41
-
42
- ### 技术栈
43
-
44
- | 层级 | 技术 | 用途 |
45
- |------|------|------|
46
- | 核心模型 | **MiniCPM-o 4.5**(统一多模态模型) | 全双工推理:视觉+语音+文本+语音输出 |
47
- | 前端框架 | **Gradio 6.x** | Web 界面,4 个子页面 |
48
- | 声纹识别 | 频谱统计特征(baseline) | 说话人身份识别 |
49
- | 频谱方案 | NumPy + SoundFile(备选 fallback) | 声纹识别兜底 |
50
- | 数据存储 | JSON + Markdown | 饮食记录 + 报告存档 + 每日简报 + 家庭信息 |
51
- | 营养标准 | 三个 MD 文件(已下载)+ 自写解析器 | 体重标准/膳食指南/DRIs |
52
- | 循环引擎 | 自研状态机 + 插件注册 | 每日生命周期管理 |
53
- | 数据提取 | 正则表达式(system prompt 标记格式) | 从对话中提取结构化数据 |
54
- | 部署平台 | HuggingFace Spaces(目前)+ Modal(后续) | 云端部署 |
55
-
56
- > **批注:** ________
57
-
58
- ---
59
-
60
- ## 二、整体文件结构
61
-
62
- ```
63
- PregoPal/
64
-
65
- ├── app.py # 主入口:启动 Gradio + Loop
66
- ├── config.py # 全局配置(路径/常量/数据模板)
67
- ├── utils.py # 工具函数(中文字体设置/国际化/首页卡片数据)
68
- ├── loop.py # 核心循环引擎(状态机驱动)
69
- ├── requirements.txt # 依赖清单
70
- ├── .gitignore # Git 忽略规则
71
- ├── README.md # 并行 Cline 协作接口文档
72
-
73
- ├── plugins/ # Loop 插件
74
- │ ├── __init__.py
75
- │ ├── base.py # LoopPlugin 基类 + PluginRegistry + LoopContext
76
- │ ├── family_quiz.py # 家庭菜谱/体重询问检查
77
- │ ├── diet_summary.py # 昨日饮食总结
78
- │ ├── weight_check.py # 体重检查(WS/T 801)
79
- │ ├── family_memory.py # 家庭记忆加载
80
- │ ├── dri_analysis.py # DRIs 营养素对比分析
81
- │ ├── briefing_generator.py # 今日简报生成器
82
- │ ├── three_day_summary.py # 每三天总结
83
- │ └── preset_writer.py # 预设文件写入
84
-
85
- ├── modules/ # 业务逻辑层(纯函数/无状态)
86
- │ ├── __init__.py
87
- │ ├── voiceprint.py # 声纹识别
88
- │ ├── meal_recommender.py # 菜品推荐
89
- │ ├── diet_extractor.py # AI 回复数据提取
90
- │ ├── diet_logger.py # 饮食记录存储
91
- │ ├── family_manager.py # 家庭信息管理
92
- │ ├── nutrition_standards.py # 中国官方营养标准
93
- │ └── nutrition_analyzer.py # 营养分析与可视化
94
-
95
- ├── core/ # AI 核心层(等待 MiniCPM-o)
96
- │ ├── __init__.py
97
- │ ├── model_loader.py # 模型加载器(空接口)
98
- │ ├── voice_processor.py # 语音处理器(空接口)
99
- │ ├── vision_processor.py # 视觉处理器(空接口)
100
- │ └── conversation_manager.py # 对话管理器(部分实现)
101
-
102
- ├── ui/ # 表现层
103
- │ ├── __init__.py
104
- │ └── app_builder.py # Gradio 界面(4 个 Tab)
105
-
106
- ├── data/ # 持久化存储
107
- │ ├── nutrition/
108
- │ │ └── raw/ # 三个官方 MD 文件
109
- │ ├── family/ # 家庭信息
110
- │ │ ├── recipes.md
111
- │ │ ├── preferences.md
112
- │ │ └── memory.md
113
- │ ├── presets/ # 每日简报 + 状态位
114
- │ ├── voices/ # 声纹音频文件
115
- │ ├── logs/ # 每日饮食日志 Markdown
116
- │ ├── reports/ # 营养报告 Markdown
117
- │ ├── diet_logs.json # 结构化饮食记录 JSON
118
- │ ├── nutrition_db.json # 营养数据库 JSON
119
- │ └── family.json # 家庭成员声纹 JSON
120
-
121
- ├── tests/ # 测试套件
122
- │ ├── __init__.py
123
- │ ├── run_tests.py # 测试运行器
124
- │ ├── test_diet_extractor.py
125
- │ ├── test_family_manager.py
126
- │ ├── test_nutrition_standards.py
127
- │ ├── test_loop.py
128
- │ └── test_plugins.py
129
-
130
- └── docs/ # 文档
131
- ├── 项目理解_技术架构.md
132
- ├── 开发日志.md
133
- └── 项目架构说明书.md # ← 本文件
134
- ```
135
-
136
- > **批注:** 文档中的目录结构以实际代码为准,后续新增模块/文件需同步更新此处。三代营养文档(体重标准/膳食指南/DRIs)的原始 md 文件在 `data/nutrition/raw/` 中,解析器在 `modules/nutrition_standards.py` 中硬编码了结构化数据。
137
-
138
- ---
139
-
140
- ## 三、app.py — 主入口
141
-
142
- ### 文件路径
143
-
144
- `app.py`
145
-
146
- ### 职责
147
-
148
- Gradio 应用的薄入口,不包含任何业务逻辑。启动时:
149
- 1. 设置中文字体(调用 `utils.setup_chinese_font()`)
150
- 2. 检查并运行每日 Loop(调用 `loop.check_and_run_loop()`)
151
- 3. 创建 Gradio 应用(调用 `ui.app_builder.create_app()`)
152
- 4. 启动 Gradio 服务(绑定 `0.0.0.0:7860`)
153
-
154
- ### 关键代码
155
-
156
- ```python
157
- # 启动时设置中文字体
158
- _CHINESE_FONT = setup_chinese_font()
159
-
160
- if __name__ == "__main__":
161
- loop = check_and_run_loop()
162
- demo = create_app(loop)
163
- demo.launch(server_name="0.0.0.0", server_port=7860, ...)
164
- ```
165
-
166
- ### 外部依赖
167
-
168
- - `ui.app_builder.create_app` — 构建 Gradio 界面
169
- - `loop.check_and_run_loop` — 检查并运行每日循环
170
- - `utils.setup_chinese_font` — 中文字体设置
171
-
172
- > **批注:** ________
173
-
174
- ---
175
-
176
- ## 四、config.py — 全局配置
177
-
178
- ### 文件路径
179
-
180
- `config.py`
181
-
182
- ### 职责
183
-
184
- 所有全局常量的集中管理。包含六大配置区:
185
-
186
- #### 4.1 目录路径
187
-
188
- | 常量 | 值 | 用途 |
189
- |------|-----|------|
190
- | `DATA_DIR` | `data/` | 数据根目录 |
191
- | `VOICE_DIR` | `data/voices/` | 声纹音频存储 |
192
- | `LOGS_DIR` | `data/logs/` | 饮食日志 Markdown |
193
- | `REPORTS_DIR` | `data/reports/` | 营养报告导出 |
194
- | `FAMILY_FILE` | `data/family.json` | 家庭成员声纹数据 |
195
- | `DIET_LOG_FILE` | `data/diet_logs.json` | 饮食记录 JSON |
196
- | `NUTRITION_DB_FILE` | `data/nutrition_db.json` | 营养数据库 |
197
-
198
- 启动时自动创建所有目录。
199
-
200
- #### 4.2 常量枚举
201
-
202
- - `FAMILY_ROLES = ["孕妇", "丈夫", "婆婆", "妈妈", "爸爸", "其他家人"]`
203
- - `TRIMESTERS = ["孕早期", "孕中期", "孕晚期"]`
204
-
205
- #### 4.3 数据模板
206
-
207
- - `DEFAULT_NUTRITION_DB` — 10 种营养素的内置推荐数据
208
- - `MEAL_TEMPLATES` — 4 餐次 × 4 选项的食谱模板
209
- - `TRIMESTER_ADJUSTMENTS` — 各孕期阶段 focus/avoid 建议
210
- - `TRIMESTER_TIPS` — 各孕期阶段饮食提示字符串
211
-
212
- #### 4.4 Schema 版本
213
-
214
- - `DIET_LOG_SCHEMA_VERSION = "1.0"`
215
-
216
- #### 4.5 声纹阈值
217
-
218
- - `VOICEPRINT_SIMILARITY_THRESHOLD = 0.7`
219
-
220
- ### 修改规范
221
-
222
- - 新增常量追加到同类型区域末尾,不删改已有常量名
223
- - 改版本号前需评估向后兼容
224
- - commit message 需注明新增常量的用途
225
-
226
- > **批注:** ________
227
-
228
- ---
229
-
230
- ## 五、utils.py — 工具函数
231
-
232
- ### 文件路径
233
-
234
- `utils.py`
235
-
236
- ### 职责
237
-
238
- 提供通用工具函数,包含三个主要功能:
239
-
240
- #### 5.1 中文字体设置
241
-
242
- `setup_chinese_font()` — 遍历候选字体列表(SimHei → Microsoft YaHei → SimSun → ...),找到可用字体后设置 `matplotlib.rcParams`。支持 Windows 系统字体文件 fallback。
243
-
244
- #### 5.2 国际化(i18n)
245
-
246
- 提供中英文字典(`ZH` / `EN`)+ `t(key, lang)` 翻译函数。
247
-
248
- 字典覆盖:应用标题、4 个 Tab 名称、语音交互页标签、家庭饮食习惯页标签、三天总结页标��、营养报告页标签。
249
-
250
- #### 5.3 首页卡片数据
251
-
252
- `get_home_cards(loop)` — 从 Loop 上下文提取首页展示数据:
253
- - `trimester` — 孕期阶段
254
- - `focus_nutrients` — 营养关注点
255
- - `recommended_foods` — 推荐食材
256
- - `recipe_count` / `recipe_names` — 家庭菜谱统计
257
- - `yesterday_summary` / `meal_count` — 昨日饮食摘要
258
- - `weight_status` / `weight_trend` — 体重评估
259
- - `thinking_keywords` — AI 思考关键词
260
-
261
- #### 5.4 自定义 CSS
262
-
263
- `CUSTOM_CSS` — Gradio 界面样式表,包含:
264
- - 圆角卡片(`border-radius: 16px`)
265
- - 大圆形语音按钮(`160px × 160px`)
266
- - 状态指示灯(`green/gray dot`)
267
- - 聊天气泡样式
268
- - Tab 按钮美化
269
- - 响应式适配
270
-
271
- > **批注:** ________
272
-
273
- ---
274
-
275
- ## 六、loop.py — 核心循环引擎
276
-
277
- ### 文件路径
278
-
279
- `loop.py`
280
-
281
- ### 职责
282
-
283
- 状态机驱动的每日生命周期管理。是 PregoPal 与「被动响应式应用」的核心区别。
284
-
285
- ### 6.1 状态枚举
286
-
287
- ```python
288
- class LoopState(Enum):
289
- LAUNCH # 启动检查:检查今日状态位
290
- FAMILY_QUIZ # 家庭问卷:检查是否需要询问菜谱/体重
291
- SUMMARIZE # 昨日总结:分析昨日饮食/体重/家庭记忆
292
- ANALYZE # 营养分析:对比 DRIs
293
- BRIEF # 生成今日简报
294
- INTERACT # 白天交互模式(等待用户操作)
295
- THREE_DAY # 每三天自动总结
296
- CONSOLIDATE # 晚间整理
297
- DONE # 标记今日完成
298
- ```
299
-
300
- ### 6.2 状态转移表
301
-
302
- ```
303
- LAUNCH -- need_summary → FAMILY_QUIZ
304
- LAUNCH -- already_done → INTERACT
305
- FAMILY_QUIZ -- ok → SUMMARIZE
306
- SUMMARIZE -- ok → ANALYZE
307
- ANALYZE -- ok → BRIEF
308
- BRIEF -- ok → INTERACT
309
- INTERACT -- day_ended → CONSOLIDATE
310
- CONSOLIDATE -- need_3day → THREE_DAY
311
- CONSOLIDATE -- ok → DONE
312
- THREE_DAY -- ok → DONE
313
- ```
314
-
315
- ### 6.3 主类:`PregoPalLoop`
316
-
317
- #### 核心属性
318
- - `plugins` — `PluginRegistry` 实例,管理所有插件
319
- - `state` — 当前状态(`LoopState`)
320
- - `context` — `LoopContext` 实例,插件间共享数据
321
-
322
- #### 主循环
323
- ```python
324
- async def run(self):
325
- while self.state is not LoopState.DONE:
326
- handler = getattr(self, f"_state_{self.state.value}")
327
- event = await handler()
328
- next_state = _TRANSITIONS[(self.state, event)]
329
- self.state = next_state
330
- ```
331
-
332
- #### 状态处理器(9 个)
333
- 每个状态对应一个 `_state_xxx` 异步方法,负责执行该阶段逻辑或调用对应插件。
334
-
335
- #### 外部接口
336
- - `get_briefing()` → `dict`(获取今日简报)
337
- - `get_thinking_keywords()` → `str`(获取 AI 思考关键词)
338
- - `get_errors()` → `list[str]`(获取错误列表)
339
- - `run_sync()` → 同步运行入口
340
-
341
- ### 6.4 状态位管理:`DailyStatus`
342
-
343
- 管理 `data/presets/.daily_status.json`,记录每日完成状态。
344
-
345
- 关键方法:
346
- - `is_today_done()` — 检查今日是否已完成
347
- - `mark_summary_done()` — 标记今日总结完成
348
- - `mark_day_ended()` — 标记今日结束
349
- - `days_since_last_summary()` — 距离上次总结天数
350
- - `should_three_day_summary()` — 是否需要进行三天总结
351
-
352
- ### 6.5 便捷函数
353
-
354
- - `run_daily_loop()` — 创建并运行每日循环
355
- - `check_and_run_loop()` — 检查状态位后决定是否运行循环(供 Gradio 启动时调用)
356
-
357
- > **批注:** 状态机设计根据目的迭代。INTERACT 状态目前是简单的 `return "day_ended"`,实际使用时需通过 Gradio 事件队列触发状态转换。三天的触发条件在 `DailyStatus.should_three_day_summary()` 中定义为 `days_since_last_summary() >= 3`。
358
-
359
- ---
360
-
361
- ## 七、plugins/ — 插件系统
362
-
363
- ### 7.1 基类与注册中心
364
-
365
- **文件:** `plugins/base.py`
366
-
367
- 核心类型:
368
-
369
- | 类型 | 说明 |
370
- |------|------|
371
- | `LoopStage` | 阶段枚举:`FAMILY_QUIZ`, `SUMMARIZE`, `ANALYZE`, `BRIEF`, `THREE_DAY`, `CONSOLIDATE` |
372
- | `LoopContext` | 插件间共享数据容器(`briefing`, `weight_data`, `diet_records`, `family_recipes`, `family_memory`, `analysis_results`, `errors`) |
373
- | `PluginResult` | 插件执行结果(`success`, `data`, `message`) |
374
- | `LoopPlugin` | 插件抽象基类(`stage()`, `name()`, `run(ctx)`) |
375
- | `PluginRegistry` | 注册中心(`register`, `get_plugins`, `get_all`, `unregister`) |
376
-
377
- ### 7.2 插件清单
378
-
379
- | 插件名 | 类名 | 注册阶段 | 文件 | 功能 | 数据源 |
380
- |--------|------|---------|------|------|--------|
381
- | 家庭菜谱检查 | `FamilyRecipeQuizPlugin` | `FAMILY_QUIZ` | `family_quiz.py` | 检查是否需要询问新菜谱 | `data/family/recipes.md` |
382
- | 体重询问检查 | `WeightQuizPlugin` | `FAMILY_QUIZ` | `family_quiz.py` | 检查是否需要询问体重 | `data/logs/` |
383
- | 昨日饮食总结 | `DietSummaryPlugin` | `SUMMARIZE` | `diet_summary.py` | 读取昨日饮食日志,汇总三餐 | `data/logs/饮食日志_*.md` |
384
- | 体重检查 | `WeightCheckPlugin` | `SUMMARIZE` | `weight_check.py` | 检查体重记录,对比 WS/T 801 | `WeightStandardParser` |
385
- | 家庭记忆加载 | `FamilyMemoryPlugin` | `SUMMARIZE` | `family_memory.py` | 加载家庭关系/事件记忆 | `data/family/memory.md` |
386
- | DRIs 分析 | `DRIAnalysisPlugin` | `ANALYZE` | `dri_analysis.py` | 对比营养素参考摄入量 | `DRIsParser` + 昨日饮食 |
387
- | 简报生成 | `BriefingGeneratorPlugin` | `BRIEF` | `briefing_generator.py` | 汇总分析为结构化 JSON | `LoopContext` |
388
- | 三天总结 | `ThreeDaySummaryPlugin` | `THREE_DAY` | `three_day_summary.py` | 分析营养缺失趋势,改进家庭菜单 | 近三天饮食记录 |
389
- | 预设写入 | `PresetWriterPlugin` | `CONSOLIDATE` | `preset_writer.py` | 生成明日预设文件 | `data/presets/` |
390
-
391
- ### 7.3 插件向 `ctx.briefing` 写入的 Key
392
-
393
- ```
394
- trimester # str
395
- need_ask_weight # bool
396
- weight_quiz_message # str
397
- weight_evaluation # dict
398
- need_ask_recipe # bool
399
- recipe_quiz_message # str
400
- yesterday_diet # dict {"status","summary","meal_count"}
401
- dri_analysis # dict {"focus_nutrients","summary","comparison_table"}
402
- recommended_foods # list[str]
403
- family_memory # dict
404
- thinking_keywords # str
405
- three_day_summary # dict(仅 THREE_DAY 阶段)
406
- ```
407
-
408
- ### 7.4 扩展方式
409
-
410
- ```python
411
- # 新增功能只需两步:
412
- class NewPlugin(LoopPlugin):
413
- def stage(self) -> LoopStage: return LoopStage.SUMMARIZE
414
- def name(self) -> str: return "new_feature"
415
- async def run(self, ctx: LoopContext) -> PluginResult: ...
416
-
417
- loop.plugins.register(NewPlugin())
418
- # 不需要修改 loop.py 的状态机代码
419
- ```
420
-
421
- > **批注:** ________
422
-
423
- ---
424
-
425
- ## 八、modules/ — 业务逻辑层
426
-
427
- ### 8.1 声纹识别 — `modules/voiceprint.py`
428
-
429
- **类:** `VoiceprintManager`
430
-
431
- | 方法 | 输入 | 输出 | 说明 |
432
- |------|------|------|------|
433
- | `register_member(name, relation, audio_path)` | str, str, str | (dict\|None, str) | 注册新成员声纹 |
434
- | `identify_speaker(audio_path)` | str | (dict\|None, str) | 识别说话人 |
435
- | `get_members_list()` | — | str | 格式化成员列表 |
436
- | `delete_member(member_id)` | str | str | 删除指定成员 |
437
-
438
- **技术方案:**
439
- - 当前:频谱统计特征(mean/std/max/min/zero_crossing_rate/energy/duration)
440
- - 后续:Whisper-medium encoder embedding
441
-
442
- **数据存储:** `data/family.json`(JSON 格式)
443
- ```json
444
- {
445
- "members": [{"id", "name", "relation", "registered_at", "audio_path", "features"}],
446
- "voiceprints": {"member_id": {"mean": ..., "std": ..., ...}}
447
- }
448
- ```
449
-
450
- 依赖:`config.VOICE_DIR`、`config.FAMILY_FILE`、`config.VOICEPRINT_SIMILARITY_THRESHOLD`(默认 0.7)
451
-
452
- > **批注:** 当前为频谱统计特征 baseline,后续升级 Whisper encoder 时需保持 `register_member`/`identify_speaker` 签名不变。
453
-
454
- ---
455
-
456
- ### 8.2 菜品推荐 — `modules/meal_recommender.py`
457
-
458
- **类:** `MealRecommender`
459
-
460
- | 方法 | 输入 | 输出 | 说明 |
461
- |------|------|------|------|
462
- | `get_recommendation(preference, trimester, restrictions)` | str, str, str | dict | 返回今日推荐食谱 |
463
- | `format_meal_plan(recommendation)` | dict | str | 格式化为可读文本 |
464
-
465
- **输出 schema:**
466
- ```json
467
- {
468
- "date": "2026-06-09",
469
- "trimester": "孕中期",
470
- "preference": "想吃清淡的",
471
- "focus": "补充蛋白质、钙、铁",
472
- "meals": {"早餐": "...", "午餐": "...", "晚餐": "...", "加餐": "..."},
473
- "tips": ["🌿 ...", "💡 ..."]
474
- }
475
- ```
476
-
477
- **当前方案:** 随机模板推荐(从 `config.MEAL_TEMPLATES` 中随机选取)
478
- **后续方案:** MiniCPM-o 4.5 AI 对话推荐
479
-
480
- > **批注:** 当前为随机模板推荐。后续对接 AI 对话推荐时需保持 `get_recommendation` 签名,内部逻辑可任意替换。
481
-
482
- ---
483
-
484
- ### 8.3 AI 对话数据提取 — `modules/diet_extractor.py`
485
-
486
- **类:** `DietExtractor`(全静态方法)
487
-
488
- | 方法 | 输入 | 输出 | 说明 |
489
- |------|------|------|------|
490
- | `extract_all(text)` | str | dict | 正则提取所有结构化数据 |
491
- | `fallback_extract_diet(text)` | str | dict\|None | 关键词 fallback 提取饮食 |
492
- | `fallback_extract_thinking(text)` | str | dict\|None | 关键词 fallback 提取思考 |
493
- | `robust_extract(text)` | str | dict | 正则优先 → fallback 兜底 |
494
-
495
- **extract_all 返回 schema:**
496
- ```json
497
- {
498
- "diets": [{"meals": {"早餐": "...", "午餐": "..."}, "日期": "...", "记录人": "...", "备注": "..."}],
499
- "recipes": [{"菜名": "...", "制作人": "...", "难度": "...", "食材": "...", "备注": "..."}],
500
- "preferences": [{"人员": "...", "类型": "偏好/忌口/过敏", "内容": "..."}],
501
- "weights": [{"日期": "...", "体重": "...", "记录人": "..."}],
502
- "memories": [{"类型": "关系/事件/日常", "内容": "..."}],
503
- "thinking": {"当前步骤": "...", "下一步": "..."}
504
- }
505
- ```
506
-
507
- **Markdown 提取标记格式**(需在 system prompt 中告知模型使用):
508
- ```
509
- [EXTRACT_DIET]...[/EXTRACT_DIET]
510
- [EXTRACT_RECIPE]...[/EXTRACT_RECIPE]
511
- [EXTRACT_PREFERENCE]...[/EXTRACT_PREFERENCE]
512
- [EXTRACT_WEIGHT]...[/EXTRACT_WEIGHT]
513
- [EXTRACT_MEMORY]...[/EXTRACT_MEMORY]
514
- [THINKING]...[/THINKING]
515
- ```
516
-
517
- **辅助函数:** `get_extract_prompt(date_str=None) → str` — 返回含日期占位符的 System Prompt 模板
518
-
519
- > **批注:** ________
520
-
521
- ---
522
-
523
- ### 8.4 饮食记录存储 — `modules/diet_logger.py`
524
-
525
- **类:** `DietLogger`
526
-
527
- | 方法 | 输入 | 输出 | 说明 |
528
- |------|------|------|------|
529
- | `add_record(member_name, member_relation, date, meals, mood, notes)` | str, str, str, dict, str, str | (record, md_path) | JSON + MD 双写 |
530
- | `get_recent_records(days=7)` | int | list[dict] | 获取近 N 天记录 |
531
- | `get_all_markdown_files()` | — | list[Path] | 所有 MD 日志文件 |
532
- | `parse_diet_record(text) — static` | str | dict\|None | [DIET_RECORD] 标记解析 |
533
-
534
- **add_record 返回的 record schema:**
535
- ```json
536
- {
537
- "id": "a1b2c3d4",
538
- "member_name": "小红",
539
- "member_relation": "孕妇",
540
- "date": "2026-06-09",
541
- "meals": {"早餐": "燕麦粥+坚果", "午餐": "清蒸鱼+米饭"},
542
- "mood": "挺好",
543
- "notes": "今天胃口不错",
544
- "extensions": {},
545
- "created_at": "2026-06-09T12:00:00"
546
- }
547
- ```
548
-
549
- **双写机制:**
550
- 1. `data/diet_logs.json` — 结构化 JSON(所有记录累积)
551
- 2. `data/logs/饮食日志_YYYY-MM-DD.md` — 每日可读 Markdown
552
-
553
- > **批注:** schema 已加版本号,`extensions: {}` 字段可向后兼容扩展。
554
-
555
- ---
556
-
557
- ### 8.5 家庭信息管理 — `modules/family_manager.py`
558
-
559
- 三个管理器类,全部使用 `@classmethod`:
560
-
561
- #### RecipeManager — 家庭菜谱
562
- | 方法 | 说明 |
563
- |------|------|
564
- | `load_all()` → `list[dict]` | 读取所有菜谱 |
565
- | `add_recipe(name, cook, difficulty, ingredients, notes)` → `str` | 添加菜谱 |
566
- | `get_names()` → `list[str]` | 所有菜名 |
567
- | `format_for_prompt()` → `str` | System Prompt 格式 |
568
-
569
- 菜谱 dict:`{"name", "cook", "difficulty", "ingredients", "notes"}`
570
-
571
- #### PreferenceManager — 饮食偏好
572
- | 方法 | 说明 |
573
- |------|------|
574
- | `load_all()` → `list[dict]` | 所有成员偏好 |
575
- | `add_member(name, role, preferences, avoid, allergies, notes)` → `str` | 添加/更新(覆盖同名) |
576
- | `format_for_prompt()` → `str` | System Prompt 格式 |
577
-
578
- 偏好 dict:`{"name", "role", "preferences", "avoid", "allergies", "notes"}`
579
-
580
- #### MemoryManager — 家庭记忆
581
- | 方法 | 说明 |
582
- |------|------|
583
- | `load_all()` → `dict` | 所有记忆(relationships/events/daily) |
584
- | `add_event(description)` → `str` | 添加重要事件 |
585
- | `add_daily(content)` → `str` | 添加日常记录 |
586
- | `format_for_prompt()` → `str` | System Prompt 格式 |
587
-
588
- #### 统一接口
589
- - `load_all_family_info() → dict` — 一次性加载所有家庭信息
590
- - `format_all_for_prompt() → str` — 全量 System Prompt 文本
591
-
592
- #### 数据文件(`data/family/`)
593
- | 文件 | 示例内容 |
594
- |------|---------|
595
- | `recipes.md` | 5 道菜谱(番茄牛腩/清蒸鲈鱼/鲫鱼豆腐汤/番茄炒蛋/红枣枸杞鸡汤) |
596
- | `preferences.md` | 3 位成员(小红-孕妇/小明-丈夫/张阿姨-婆婆) |
597
- | `memory.md` | 家庭关系 + 重要事件 + 日常记录 |
598
-
599
- > **批注:** 更新 `add_member` 覆盖逻辑时注意保留已有数据。
600
-
601
- ---
602
-
603
- ### 8.6 营养标准 — `modules/nutrition_standards.py`
604
-
605
- 三个只读数据类,全部使用 `@classmethod`:
606
-
607
- #### BMIStandards — 体重增长标准
608
- | 方法 | 说明 |
609
- |------|------|
610
- | `get_standard(bmi_before_pregnancy)` → `dict` | 根据孕前 BMI 获取标准 |
611
- | `evaluate(bmi_before_pregnancy, current_week, total_gain)` → `dict` | 评估体重增长是否在推荐范围内 |
612
- | `load()` → `dict` | 兼容接口 |
613
-
614
- 4 个 BMI 分类(低体重/正常/超重/肥胖)的硬编码标准(WS/T 801-2022 表1)。
615
-
616
- #### DietaryGuideStandards — 膳食指南
617
- | 方法 | 说明 |
618
- |------|------|
619
- | `get_recommendations(trimester)` → `dict` | 返回指定孕期食物推荐量 |
620
- | `get_core_principles()` → `list[str]` | 六条核心建议 |
621
- | `format_for_prompt(trimester)` → `str` | System Prompt 格式 |
622
-
623
- 4 个阶段(备孕/孕中期/孕晚期/哺乳期)的平衡膳食宝塔数据。
624
-
625
- #### DRIsParser — 膳食营养素参考摄入量
626
- | 方法 | 说明 |
627
- |------|------|
628
- | `get_rni(stage)` → `dict` | 返回指定阶段 RNI 数据 |
629
- | `compare_with_intake(stage, intake)` → `dict` | 对比实际摄入与 RNI |
630
- | `format_comparison_table(comparisons)` → `str` | 格式化为文本对比表 |
631
-
632
- 12 种营养素的 RNI 数据(4 阶段),含分类和推荐食物源。
633
-
634
- #### 统一接口
635
- - `load_all_standards() → dict` — 加载所有标准
636
- - `compile_standards_to_presets()` — 编译为 JSON 缓存
637
-
638
- > **批注:** ________
639
-
640
- ---
641
-
642
- ### 8.7 营养分析与可视化 — `modules/nutrition_analyzer.py`
643
-
644
- **类:** `NutritionAnalyzer`
645
-
646
- | 方法 | 输入 | 输出 | 说明 |
647
- |------|------|------|------|
648
- | `analyze_diet(records)` | list[dict] | dict | 营养覆盖分析 |
649
- | `generate_report_chart(analysis)` | dict | matplotlib Figure | 4 面板可视化图表 |
650
- | `generate_report_text(analysis)` | dict | str | 文本格式报告 |
651
- | `export_report_markdown(analysis, filename)` | dict, str | Path | 导出 Markdown 报告 |
652
-
653
- **analyze_diet 返回 schema:**
654
- ```json
655
- {
656
- "total_days": 7,
657
- "total_records": 21,
658
- "meal_counts": {"早餐": 5, "午餐": 6, "晚餐": 6, "加餐": 4},
659
- "food_items": ["全麦面包", "鸡蛋", ...],
660
- "nutrition_coverage": {
661
- "叶酸": {"matched_foods": ["菠菜"], "covered": true, ...},
662
- ...
663
- },
664
- "diversity_score": {"score": 75, "details": [...]},
665
- "suggestions": ["⚠️ 以下营养素摄入不足: 铁, 钙", ...]
666
- }
667
- ```
668
-
669
- **图表布局(4 面板):**
670
- 1. 左上:营养覆盖雷达图
671
- 2. 右上:各餐次频率柱状图
672
- 3. 左下:饮食多样性评分环形图
673
- 4. 右下:营养建议文本框
674
-
675
- > **批注:** **高优先级待改造** — 当前使用通用 `DEFAULT_NUTRITION_DB` 做营养覆盖分析,尚未对接 `modules/nutrition_standards.py` 的 DRIs 数据。改造时需保持 `analyze_diet(records) -> dict` 签名不变。
676
-
677
- ---
678
-
679
- ## 九、core/ — AI 核心层
680
-
681
- ### 文件路径
682
-
683
- `core/` 目录下所有文件当前均为「空接口」状态,等待 MiniCPM-o 4.5 部署后填充。
684
-
685
- ### 9.1 `__init__.py`
686
-
687
- 空模块,标注等待 MiniCPM-o 部署。
688
-
689
- ### 9.2 `model_loader.py`
690
-
691
- **类:** `ModelLoader`
692
-
693
- | 方法 | 说明 |
694
- |------|------|
695
- | `load()` | 加载模型(`raise NotImplementedError`) |
696
- | `unload()` | 卸载模型释放显存 |
697
-
698
- ### 9.3 `voice_processor.py`
699
-
700
- **类:** `VoiceProcessor`
701
-
702
- | 方法 | 说明 |
703
- |------|------|
704
- | `transcribe(audio_path)` | 语音转文字(`raise NotImplementedError`) |
705
- | `extract_speaker_embedding(audio_path)` | 提取说话人声纹 embedding(`raise NotImplementedError`) |
706
-
707
- ### 9.4 `vision_processor.py`
708
-
709
- **类:** `VisionProcessor`
710
-
711
- | 方法 | 说明 |
712
- |------|------|
713
- | `analyze_frame(image_path)` | 分析单帧图像(`raise NotImplementedError`) |
714
-
715
- ### 9.5 `conversation_manager.py`
716
-
717
- **类:** `ConversationManager`
718
-
719
- 当前已有部分骨架实现:
720
- - `current_mode` / `current_speaker` / `conversation_history`
721
- - `switch_mode(mode)` / `set_speaker(speaker_info)`
722
- - `parse_response(response)` — 解析 `[DIET_RECORD]` 标记
723
-
724
- 待实现:
725
- - `build_system_prompt()` — 构建完整系统提示词
726
-
727
- > **批注:** ________
728
-
729
- ---
730
-
731
- ## 十、ui/ — 表现层
732
-
733
- ### 文件路径
734
-
735
- `ui/app_builder.py`
736
-
737
- ### 职责
738
-
739
- 基于 Gradio 6.x 构建 4 个子页面的 Web 界面。
740
-
741
- ### 10.1 主入口
742
-
743
- `create_app(loop=None)` — 创建 Gradio Blocks 应用。
744
-
745
- 设计要点:
746
- - 只使用 **一个** `@gr.render` 包裹所有 Tab,避免多个 `@gr.render` 并发触发导致 `RuntimeError`
747
- - 语言切换通过 `gr.State(value="zh")` + `lang_selector` Radio 实现
748
- - 每个 Tab 的标题通过 `t()` 函数实现中英文切换
749
-
750
- ### 10.2 页面结构
751
-
752
- | Tab | ID | 功能说明 |
753
- |-----|-----|---------|
754
- | 🏠 首页 | `tab_home` | 语音启动按钮 + AI 思考状态 + 5 个信息卡片 + 最近饮食记录表格 |
755
- | 👨‍👩‍👧‍👦 家庭饮食习惯 | `tab_family` | 3 个子 Tab:偏好设置 / 菜谱管理 / 家庭记忆 |
756
- | 📊 三天总结 | `tab_summary` | 孕期阶段选择 + 分析结果 + 菜单改进建议 |
757
- | 📈 营养报告 | `tab_report` | 天数滑块 + 文本报告 + 可视化图表 + 导出 Markdown |
758
-
759
- ### 10.3 全局实例
760
-
761
- ```python
762
- voiceprint_mgr = VoiceprintManager()
763
- meal_recommender = MealRecommender()
764
- diet_logger = DietLogger()
765
- nutrition_analyzer = NutritionAnalyzer()
766
- ```
767
-
768
- > **批注:** ________
769
-
770
- ---
771
-
772
- ## 十一、data/ — 数据存储
773
-
774
- ### 11.1 数据文件一览
775
-
776
- | 文件/目录 | 格式 | 内容 | 读模块 | 写模块 |
777
- |-----------|------|------|--------|--------|
778
- | `data/diet_logs.json` | JSON | 所有饮食记录(累积) | `diet_logger`, `nutrition_analyzer` | `diet_logger` |
779
- | `data/nutrition_db.json` | JSON | 10 种营养素数据 | `nutrition_analyzer` | `nutrition_analyzer` |
780
- | `data/family.json` | JSON | 成员声纹数据 | `voiceprint` | `voiceprint` |
781
- | `data/family/recipes.md` | Markdown | 5 道家庭菜谱 | `family_manager.RecipeManager` | `family_manager.RecipeManager` |
782
- | `data/family/preferences.md` | Markdown | 3 位成员偏好 | `family_manager.PreferenceManager` | `family_manager.PreferenceManager` |
783
- | `data/family/memory.md` | Markdown | 家庭关系/事件/日常 | `family_manager.MemoryManager` | `family_manager.MemoryManager` |
784
- | `data/logs/饮食日志_*.md` | Markdown | 每日饮食记录 | `plugins/diet_summary` | `diet_logger` |
785
- | `data/reports/营养报告_*.md` | Markdown | 营养分析报告 | 用户 | `nutrition_analyzer` |
786
- | `data/presets/.daily_status.json` | JSON | 每日完成状态 | `loop.DailyStatus` | `loop.DailyStatus` |
787
- | `data/presets/今日简报_*.json` | JSON | 每日简报缓存 | `loop` | `plugins/preset_writer` |
788
- | `data/voices/*.wav` | WAV | 声纹注册音频 | `voiceprint` | `voiceprint` |
789
- | `data/nutrition/raw/*.md` | Markdown | 三个官方营养文档 | 解析器 | 手动下载 |
790
-
791
- ### 11.2 家庭信息示例数据
792
-
793
- **`recipes.md`**:5 道菜谱(番茄牛腩/清蒸鲈鱼/鲫鱼豆腐汤/番茄炒蛋/红枣枸杞鸡汤)
794
-
795
- **`preferences.md`**:
796
- - 小红(孕妇):爱吃水果/鱼/清淡,不吃辣/油腻,芒果过敏
797
- - 小明(丈夫):爱吃肉/面食,不吃香菜
798
- - 张阿姨(婆婆):会做各种汤
799
-
800
- **`memory.md`**:
801
- - 3 条家庭关系
802
- - 3 条重要事件(2026-06-07 ~ 06-09)
803
- - 3 条日常记录
804
-
805
- ### 11.3 状态位文件
806
-
807
- ```json
808
- {
809
- "2026-06-09": {
810
- "summary_done": true,
811
- "completed_at": "2026-06-09T00:40:53.606526"
812
- }
813
- }
814
- ```
815
-
816
- ### 11.4 营养标准原始文档
817
-
818
- `data/nutrition/raw/` 中的三个 MD 文件:
819
- 1. `妊娠期妇女体重增长推荐值标准.md`(WS/T 801-2022)
820
- 2. `中国孕期妇女膳食指南2022图片转md版.md`
821
- 3. `备孕&孕早中晚三期和哺乳期的所有膳食营养素参考摄入量.md`
822
-
823
- > **批注:** 所有 md 文件都需要我们自己写函数读取,而不是让大模型自己读。数据类的需要写全面的读取比较函数接口,文本类的需要设计好自动提取输出的函数。
824
-
825
- ---
826
-
827
- ## 十二、tests/ — 测试套件
828
-
829
- ### 12.1 测试运行器
830
-
831
- **文件:** `tests/run_tests.py`
832
-
833
- 无需 pytest,内置 `TestRunner` 类:
834
- - 自动发现 `test_*.py` 中的 `Test*` 类和方法
835
- - 每个测试方法独立实例(setup/teardown 隔离)
836
- - 生成 HTML 和 JSON 两种报告格式
837
-
838
- 运行方式:
839
- ```bash
840
- python tests/run_tests.py
841
- ```
842
-
843
- ### 12.2 测试覆盖
844
-
845
- | 测试文件 | 覆盖内容 | 状态 |
846
- |---------|---------|------|
847
- | `test_diet_extractor.py` | 正则解析 + fallback 提取(18 个测试用例) | ✅ |
848
- | `test_family_manager.py` | 菜谱/偏好/记忆 CRUD | ✅ |
849
- | `test_nutrition_standards.py` | BMI/膳食指南/DRIs 数据 | ✅ |
850
- | `test_loop.py` | 循环执行测试 | 待确认 |
851
- | `test_plugins.py` | 插件集成测试 | 待确认 |
852
-
853
- > **批注:** ________
854
-
855
- ---
856
-
857
- ## 十三、配置文件
858
-
859
- ### 13.1 `.gitignore`
860
-
861
- ```gitignore
862
- .vscode/ # IDE 配置
863
- __pycache__/ # Python 缓存
864
- *.pyc / *.pyo
865
- *.egg-info/ / dist/ / build/
866
- *.gguf / models/ # 模型文件
867
- llamacpp/
868
- *.wav / *.mp3 # 音频文件
869
- .env / .venv/ / venv/
870
- *.tmp / *.log / debug*.txt
871
- _check_hf.py / _download_models.py
872
- ```
873
-
874
- ### 13.2 `requirements.txt`
875
-
876
- ```
877
- gradio>=6.0.0
878
- modal>=0.60.0
879
- numpy>=1.24.0
880
- matplotlib>=3.7.0
881
- pandas>=2.0.0
882
- soundfile>=0.12.0
883
- pillow>=10.0.0
884
- ```
885
-
886
- > **批注:** 依赖相对精简,核心依赖为 Gradio 6.x + NumPy + Matplotlib。后续集成 MiniCPM-o 后可能需要追加 PyTorch / Transformers 等。
887
-
888
- ---
889
-
890
- ## 十四、协作规范与 Git 工作流
891
-
892
- ### 14.1 并行 Cline 分工建议
893
-
894
- ```
895
- Cline A: Modules 强化(营养分析对接 DRIs、菜谱推荐 AI 化)
896
- Cline B: UI 界面优化(Gradio 前端增强、报告模板美化)
897
- Cline C: 声纹升级(Whisper encoder 替换频谱特征)
898
- Cline D: 数据处理与插件增强(diet_extractor fallback、新插件)
899
- ```
900
-
901
- ### 14.2 关键约定
902
-
903
- 1. **改接口前先 grep**:用 `search_files` 搜索方法名找到所有调用方
904
- 2. **config.py 修改需沟通**:新增常量追加到同类型区域,不删改已有常量名
905
- 3. **数据文件向后兼容**:新增字段优先用 `extensions: {}` 或新增可选 key,不删除已有 key
906
- 4. **测试保持通过**:`python tests/run_tests.py` 零失败
907
- 5. **README 同步更新**:接口签名变更 → 更新 README 对应章节
908
-
909
- ### 14.3 Git 工作流
910
-
911
- ```bash
912
- # 工作前
913
- git pull --rebase origin main
914
-
915
- # 开发中:每完成一个独立功能点就提交
916
- git add -A
917
- git commit -m "feat/fix/test/refactor/docs/chore: 描述"
918
-
919
- # 推送前再次 pull
920
- git pull --rebase origin main
921
-
922
- # 测试通过后推送
923
- python tests/run_tests.py
924
- git push origin main
925
- ```
926
-
927
- ### 14.4 Commit Message 规范
928
-
929
- ```
930
- feat: 新功能/新模块/新接口
931
- fix: 修复 bug
932
- test: 添加或修改测试
933
- refactor: 重构(不改功能)
934
- docs: 文档更新
935
- chore: 配置/依赖/路径调整
936
- ```
937
-
938
- ### 14.5 冲突处理策略
939
-
940
- | 冲突类型 | 处理方式 |
941
- |---------|---------|
942
- | `config.py` 常量冲突 | 取并集,双方新增常量都保留 |
943
- | 同函数不同实现 | 保留逻辑更完整的一方,另一方的无冲突改动合并到合适位置 |
944
- | 数据文件(JSON/MD)冲突 | 保留两个版本共有的条目 + 各自独有条目(去重) |
945
- | 测试文件冲突 | **取并集**:保留所有测试用例 |
946
- | 本架构说明书冲突 | **取行数更长的一方**,手动整合 |
947
- | `.gitignore` 冲突 | 取并集 |
948
-
949
- > **批注:** ________
950
-
951
- ---
952
-
953
- ## 十五、当前运行状态
954
-
955
- | 项目 | 状态 |
956
- |------|------|
957
- | Gradio 6.16.0 运行端口 | `localhost:7860` |
958
- | 4 个 Tab | 全部正常加载 |
959
- | 声纹识别 | 可用(频谱统计 baseline) |
960
- | 菜品推荐 | 可用(随机模板) |
961
- | 饮食记录 | 可用(JSON + MD 双写) |
962
- | 营养分析 | 可用(内置数据库) |
963
- | 每日 Loop | 可用(状��机驱动) |
964
- | core/ 层 | 空接口(等待 MiniCPM-o) |
965
- | Modal 部署 | 代码就绪,CUDA 编译中 |
966
- | Git 远程 | HuggingFace Spaces `build-small-hackathon/PregoPal` |
967
-
968
- ---
969
-
970
- ## 十六、待办与已知问题
971
-
972
- ### 16.1 高优先级
973
-
974
- - [ ] **营养分析对接 DRIs**:`NutritionAnalyzer` 当前使用内置 `DEFAULT_NUTRITION_DB`,需替换为 `DRIsParser` 的中国官方标准
975
- - [ ] **菜谱推荐 AI 化**:当前为随机模板,后续需对接 MiniCPM-o 对话推荐
976
-
977
- ### 16.2 中优先级
978
-
979
- - [ ] **core/ 层实现**:MiniCPM-o 4.5 模型加载、语音/视觉处理
980
- - [ ] **声纹升级**:从频谱统计特征升级到 Whisper encoder embedding
981
- - [ ] **INTERACT 状态激活**:当前 `_state_interact()` 直接返回 `day_ended`,需对接 Gradio 事件队列
982
-
983
- ### 16.3 低优先级
984
-
985
- - [ ] **测试补全**:`test_loop.py` 和 `test_plugins.py` 需确认状态
986
- - [ ] **Modal 部署修复**:`@modal.concurrent` 兼容性、`build_server_args` 参数命名、多模态端点验证
987
- - [ ] **data/nutrition/raw/ 中 MD 文件的解析**:当前营养标准数据为硬编码,后续可从原始 MD 文件中自动解析
988
-
989
- > **批注:** ________
990
-
991
- ---
992
-
993
- *文档生成时间:2026-06-09 | 由 PregoPal 项目架构说明书 v1 生成*
994
-
995
- *本文档各小节批注区中的原始文字为项目负责人的重要参考依据,所有 Cline 在修改对应模块前应先阅读对应批注。*
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
docs/项目理解_技术架构.md DELETED
@@ -1,656 +0,0 @@
1
- # 🌸 PregoPal 项目理解与技术架构(v5 - 整合批注,细化实现)
2
-
3
- > 本文档已整合你所有批注意见,每个小节保留批注区供继续讨论。
4
-
5
- ---
6
-
7
- ## 一、一句话定位
8
-
9
- **一个基于每日生命周期 Loop 驱动的孕期陪护 AI 助手**:每天首次启动时自动总结昨日饮食、对比国家营养标准(DRIs/膳食指南/体重标准),生成今日简报注入对话上下文;白天与家庭成员(通过声纹区分)自然交互,实时记录饮食、推荐食谱;次日循环往复。
10
-
11
- > **批注(已采纳):** 实时记录饮食 = 主动提取孕妇的饮食习惯。场景包括:①孕妇自己说想吃什么 ②家人说孕妇之前想吃什么 → 都记录到孕妇的饮食习惯 md 文件。同时主动询问家人会做什么菜 → 记录到家庭菜单。通过 system prompt + 正则化提取实现。
12
-
13
- ---
14
-
15
- ## 二、三个营养文档的操作方法
16
-
17
- 你已经下载到 `data/nutrition/raw/` 的三个文件,各自承担不同角色:
18
-
19
- | 文档 | 文件 | 用途 | PregoPal 的操作方式 |
20
- |------|------|------|-------------------|
21
- | **① 体重增长标准** | `妊娠期妇女体重增长推荐值标准.md` | 体重监测基准 | **主动询问体重** — 每天 Loop 中检查是否需要询问,对照 BMI 分类给出评价 |
22
- | **② 膳食指南** | `中国孕期妇女膳食指南2022图片转md版.md` | 食谱推荐基准 | **作为推荐食谱的约束基础** — 根据孕期阶段用宝塔推荐量生成模板 |
23
- | **③ DRIs** | `备孕&孕早中晚三期和哺乳期的所有膳食营养素参考摄入量.md` | 营养分析基准 | **Agent 定期思考** — 每天自动对比 RNI,分析不足,写入次日预设 |
24
-
25
- > **批注(已采纳):** 所有 md 文件都需要我们自己写函数读取,而不是让大模型自己读。数据类的需要写全面的读取比较函数接口,文本类的需要设计好自动提取输出的函数。每日第一次对话前 PregoPal 自动整理当天的前置 system prompt 时,要考虑:营养、体重、家庭菜谱、营养素需求、核心记忆文件(家庭关系、谁和谁亲近、家里发生过什么)。
26
-
27
- ### 2.1 体重增长标准 → 主动询问体重
28
-
29
- **实现方式**:写一个 `WeightStandardParser` 类,将 WS/T 801-2022 表1 编译为结构化数据,提供查询接口。
30
-
31
- ```python
32
- # modules/nutrition_standards.py(新增)
33
- class WeightStandardParser:
34
- """解析体重增长标准 md 文件,提供查询接口"""
35
-
36
- @staticmethod
37
- def load() -> dict:
38
- """读取 md 文件,返回结构化数据"""
39
- # 返回:
40
- # {
41
- # "bmi_standards": [
42
- # {"category": "低体重", "bmi_range": (0, 18.5),
43
- # "total_gain": (11.0, 16.0), "weekly_gain": 0.46,
44
- # "weekly_range": (0.37, 0.56)},
45
- # ...
46
- # ]
47
- # }
48
-
49
- @staticmethod
50
- def evaluate(bmi_before_pregnancy: float, current_week: int,
51
- total_gain: float) -> dict:
52
- """评估体重增长是否在推荐范围内"""
53
- # 返回 {"status": "ok"/"warning", "message": "...", "suggestion": "..."}
54
- ```
55
-
56
- **调用时机**:每天早上 Loop 的 `weight_check` 插件调用此函数,结果写入今日简报。
57
-
58
- > **批注(已采纳):** 整理成函数,不要期待 32B 小参数模型去做这些,而是自动放到上下文中。
59
-
60
- ### 2.2 膳食指南 → 食谱推荐基础
61
-
62
- **实现方式**:写一个 `DietaryGuideParser` 类,将平衡膳食宝塔编译为结构化数据。
63
-
64
- ```python
65
- # modules/nutrition_standards.py
66
- class DietaryGuideParser:
67
- """解析膳食指南 md 文件,提供各孕期推荐摄入量"""
68
-
69
- @staticmethod
70
- def get_recommendations(trimester: str) -> dict:
71
- """返回指定孕期的食物推荐量"""
72
- # 返回孕中期/孕晚期/哺乳期的宝塔数据
73
-
74
- @staticmethod
75
- def get_core_principles() -> list[str]:
76
- """返回六条核心建议"""
77
- ```
78
-
79
- **与家庭菜谱结合**:
80
- - `data/family/recipes.md` — 记录家人会做的菜(主动询问后记录)
81
- - `data/family/preferences.md` — 记录所有家人的饮食偏好、忌口、过敏
82
- - 推荐食谱时:膳食指南约束 + 家庭菜谱过滤 + 个人偏好排序
83
-
84
- > **批注(已采纳):** 需要设计好如何与用户家庭菜谱结合,而不是去提出做一些不可能的菜。饮食偏好应该记录所有家人(不只是孕妇),分模块记录,设计好提取的函数接口。注意记录家里所有人是否有忌口、过敏。
85
-
86
- ### 2.3 DRIs → Agent 定期分析(自动运行 → 写入次日预设)
87
-
88
- **改进方案**:
89
-
90
- ```python
91
- # modules/nutrition_standards.py
92
- class DRIsParser:
93
- """解析 DRIs md 文件(表15-18),提供营养素参考摄入量查询"""
94
-
95
- @staticmethod
96
- def get_rni(stage: str) -> dict:
97
- """返回指定阶段的 RNI 数据
98
- stage: "pre_pregnancy" / "early" / "mid" / "late" / "lactation"
99
- """
100
-
101
- @staticmethod
102
- def compare_with_intake(stage: str, intake: dict) -> dict:
103
- """对比实际摄入与 RNI,返回差异分析"""
104
- # 返回每个营养素的差异百分比 + 重点关注标记
105
- ```
106
-
107
- **分析流程**:
108
- 1. 读取昨日饮食日志(`data/logs/饮食日志_YYYY-MM-DD.md`)
109
- 2. 用 `DRIsParser.compare_with_intake()` 对比
110
- 3. 结合家庭菜谱,给出「今天建议多吃哪些家里能做的菜」
111
- 4. 写入 `data/presets/今日简报_YYYY-MM-DD.json`
112
-
113
- > **批注(待改进):** 请基于其它批注改进方案。✅ 已改进:结合家庭菜谱,建议具体可做的菜。
114
-
115
- ---
116
-
117
- ## 三、核心架构:Loop 驱动(状态机 + 插件注册)
118
-
119
- ### 3.1 设计理念
120
-
121
- PregoPal 不是一个「用户点按钮 → 出结果」的被动应用,而是**有每日生命周期的主动伴侣**。
122
-
123
- 参考你提供的 `nanobot_core/agent/loop.py` 中的**状态机事件驱动模式**,PregoPal 的 Loop 提取两个核心设计:
124
-
125
- 1. **TurnState 状态机** — 管理每日生命周期的阶段流转
126
- 2. **可注册的扩展能力** — 每个阶段的具体逻辑由 Plugin 实现
127
-
128
- > **批注(已采纳):** 具体实现不能太复杂,达到目的,具有可拓展性,具有稳定性(不会因为模型偶尔的犯错或者输出不稳定而完全逻辑混乱)。
129
-
130
- ### 3.2 每日生命周期(v5 改进版)
131
-
132
- 根据你的批注,关键变更:
133
- - **触发时机**:每晚 00:00 自动总结(或程序启动时检查状态位)
134
- - **新增阶段**:`FAMILY_QUIZ`(主动询问家庭菜谱/偏好)、`THREE_DAY_SUMMARY`(每三天总结)
135
- - **状态位机制**:`data/presets/.daily_status.json` 记录每日完成状态
136
-
137
- ```
138
- ┌─────────────┐
139
- │ LAUNCH │ ← 检查今日状态位
140
- │ (启动) │ 若已总结 → 直接 INTERACT
141
- └──────┬──────┘ 若未总结 → 进入总结流程
142
-
143
- ┌───────────┴───────────┐
144
- │ │
145
- 今日未总结 今日已总结
146
- │ │
147
- ▼ ▼
148
- ┌──────────────┐ ┌──────────────┐
149
- │ FAMILY_QUIZ │ │ │
150
- │ (家庭问卷) │ │ │
151
- │ ├ 检查是否需要 │ │ │
152
- │ │ 询问新菜谱 │ │ │
153
- │ └ 检查是否需要 │ │ │
154
- │ 询问体重 │ │ │
155
- └──────┬───────┘ │ │
156
- │ │ │
157
- ▼ │ │
158
- ┌──────────────┐ │ │
159
- │ SUMMARIZE │ │ │
160
- │ (昨日总结) │ │ │
161
- │ ├ 读取饮食日志 │ │ │
162
- │ ├ 读取体重记录 │ │ │
163
- │ └ 读取家庭记忆 │ │ │
164
- └──────┬───────┘ │ │
165
- │ │ │
166
- ▼ │ │
167
- ┌──────────────┐ │ │
168
- │ ANALYZE │ │ │
169
- │ (营养分析) │ │ │
170
- │ └ 对比DRIs │ │ │
171
- └──────┬───────┘ │ │
172
- │ │ │
173
- ▼ │ │
174
- ┌──────────────┐ │ │
175
- │ BRIEF │ │ │
176
- │ (生成简报) │ │ │
177
- │ └ 写JSON │ │ │
178
- └──────┬───────┘ │ │
179
- │ │ │
180
- ▼ ▼ │
181
- ┌──────────────────────────────────┐ │
182
- │ INTERACT │◄─┘
183
- │ (白天交互模式) │
184
- │ ├ 声纹识别 → 区分说��人 │
185
- │ ├ 对话理解 → 正则提取饮食记录 │
186
- │ │ → 写入饮食习惯.md │
187
- │ │ → 写入家庭菜谱.md │
188
- │ ├ 推荐 → 膳食指南+家庭菜谱约束 │
189
- │ └ 简报注入 → AI 知道今日关注点 │
190
- └──────────────┬───────────────────┘
191
-
192
- 每晚 00:00(或关闭时)
193
-
194
-
195
- ┌──────────────┐
196
- │ CONSOLIDATE │
197
- │ (晚间整理) │
198
- │ ├ 汇总今日 │
199
- │ ├ 检查是否到 │
200
- │ │ 三天总结 │
201
- │ └ 写明日预设 │
202
- └──────┬───────┘
203
-
204
-
205
- ┌──────────────┐
206
- │ DONE │
207
- │ 标记今日完成 │
208
- └──────────────┘
209
- ```
210
-
211
- > **批注(已采纳):** 每晚 00:00 自动总结,设置状态位。程序关闭后第二天启动自动检查状态位。界面设计:4 个子页面。
212
-
213
- ### 3.3 状态机定义(v5 改进版)
214
-
215
- ```python
216
- class LoopState(Enum):
217
- LAUNCH = "launch" # 启动检查:检查今日状态位
218
- FAMILY_QUIZ = "family_quiz" # 家庭问卷:检查是否需要询问菜谱/体重
219
- SUMMARIZE = "summarize" # 昨日总结:分析昨日饮食/体重/家庭记忆
220
- ANALYZE = "analyze" # 营养分析:对比 DRIs
221
- BRIEF = "brief" # 生成今日简报
222
- INTERACT = "interact" # 白天交互模式(等待用户操作)
223
- THREE_DAY = "three_day" # 每三天自动总结(由 CONSOLIDATE 触发)
224
- CONSOLIDATE = "consolidate" # 晚间整理
225
- DONE = "done"
226
-
227
- _TRANSITIONS = {
228
- (LoopState.LAUNCH, "need_summary"): LoopState.FAMILY_QUIZ,
229
- (LoopState.LAUNCH, "already_done"): LoopState.INTERACT,
230
- (LoopState.FAMILY_QUIZ, "ok"): LoopState.SUMMARIZE,
231
- (LoopState.SUMMARIZE, "ok"): LoopState.ANALYZE,
232
- (LoopState.ANALYZE, "ok"): LoopState.BRIEF,
233
- (LoopState.BRIEF, "ok"): LoopState.INTERACT,
234
- (LoopState.INTERACT, "day_ended"): LoopState.CONSOLIDATE,
235
- (LoopState.CONSOLIDATE, "need_3day"): LoopState.THREE_DAY,
236
- (LoopState.CONSOLIDATE, "ok"): LoopState.DONE,
237
- (LoopState.THREE_DAY, "ok"): LoopState.DONE,
238
- }
239
- ```
240
-
241
- > **批注(已采纳):** 状态机设计根据目的迭代,已整合上下批注。
242
-
243
- ### 3.4 状态处理器
244
-
245
- | 状态 | Handler | 功能 |
246
- |------|---------|------|
247
- | `LAUNCH` | `_state_launch()` | 读取 `data/presets/.daily_status.json` → 决定流程 |
248
- | `FAMILY_QUIZ` | `_state_family_quiz()` | 检查是否需要询问新菜谱/体重 → 写入今日简报待办 |
249
- | `SUMMARIZE` | `_state_summarize()` | 遍历执行 SUMMARIZE 阶段 Plugin |
250
- | `ANALYZE` | `_state_analyze()` | 遍历执行 ANALYZE 阶段 Plugin |
251
- | `BRIEF` | `_state_brief()` | 汇总为 `今日简报.json` |
252
- | `INTERACT` | `_state_interact()` | 等待用户交互,消费 Gradio event queue |
253
- | `THREE_DAY` | `_state_three_day()` | 每三天运行:分析营养缺失 → 改进家庭菜单建议 |
254
- | `CONSOLIDATE` | `_state_consolidate()` | 晚间整理,检查是否触发 THREE_DAY |
255
- | `DONE` | `_state_done()` | 写入状态位,清理资源 |
256
-
257
- > **批注(已采纳):** 状态处理器根据目的迭代,已整合上下批注。
258
-
259
- ### 3.5 主循环入口
260
-
261
- ```python
262
- class PregoPalLoop:
263
- def __init__(self):
264
- self.plugins = PluginRegistry()
265
- self.state = LoopState.LAUNCH
266
- self._register_default_plugins()
267
-
268
- async def run(self) -> None:
269
- while self.state is not LoopState.DONE:
270
- handler_name = f"_state_{self.state.value}"
271
- handler = getattr(self, handler_name)
272
- event = await handler()
273
- next_state = _TRANSITIONS.get((self.state, event))
274
- if next_state is None:
275
- raise RuntimeError(f"No transition from {self.state} on {event}")
276
- self.state = next_state
277
- ```
278
-
279
- > **批注:** ________
280
-
281
- ---
282
-
283
- ## 四、插件系统设计(可扩展性)
284
-
285
- ### 4.1 插件基类
286
-
287
- ```python
288
- class LoopStage(Enum):
289
- FAMILY_QUIZ = "family_quiz"
290
- SUMMARIZE = "summarize"
291
- ANALYZE = "analyze"
292
- BRIEF = "brief"
293
- THREE_DAY = "three_day"
294
- CONSOLIDATE = "consolidate"
295
-
296
- @dataclass
297
- class LoopContext:
298
- briefing: dict = field(default_factory=dict)
299
- weight_data: dict = field(default_factory=dict)
300
- diet_records: list = field(default_factory=list)
301
- family_recipes: list = field(default_factory=list) # 新增:家庭菜谱
302
- family_memory: dict = field(default_factory=dict) # 新增:家庭记忆
303
- analysis_results: dict = field(default_factory=dict)
304
- errors: list = field(default_factory=list)
305
-
306
- class LoopPlugin(ABC):
307
- @abstractmethod
308
- def stage(self) -> LoopStage: ...
309
- @abstractmethod
310
- def name(self) -> str: ...
311
- @abstractmethod
312
- async def run(self, ctx: LoopContext) -> PluginResult: ...
313
- ```
314
-
315
- > **批注:** ________
316
-
317
- ### 4.2 内置插件清单(v5 更新版)
318
-
319
- | 插件 | 类名 | 注册阶段 | 功能 | 数据源 |
320
- |------|------|---------|------|--------|
321
- | 家庭菜谱检查 | `FamilyRecipeQuizPlugin` | FAMILY_QUIZ | 检查是否需要询问新菜谱 | `data/family/recipes.md` |
322
- | 体重询问检查 | `WeightQuizPlugin` | FAMILY_QUIZ | 检查是否需要询问体重 | `data/logs/` |
323
- | 昨日饮食总结 | `DietSummaryPlugin` | SUMMARIZE | 读取昨日饮食日志,汇总三餐 | `data/logs/*.md` |
324
- | 体重检查 | `WeightCheckPlugin` | SUMMARIZE | 检查体重记录,对比 WS/T 801 | `WeightStandardParser` |
325
- | 家庭记忆加载 | `FamilyMemoryPlugin` | SUMMARIZE | 加载家庭关系/事件记忆 | `data/family/memory.md` |
326
- | DRIs 分析 | `DRIAnalysisPlugin` | ANALYZE | 对比营养素参考摄入量 | `DRIsParser` + 昨日饮食 |
327
- | 简报生成 | `BriefingGeneratorPlugin` | BRIEF | 汇总分析为结构化 JSON | `LoopContext` |
328
- | 三天总结 | `ThreeDaySummaryPlugin` | THREE_DAY | 分析营养缺失趋势,改进家庭菜单 | 近三天饮食记录 |
329
- | 预设写入 | `PresetWriterPlugin` | CONSOLIDATE | 生成明日预设文件 | `data/presets/` |
330
-
331
- > **批注:** ________
332
-
333
- ### 4.3 扩展方式
334
-
335
- ```python
336
- # 新增功能只需两步:
337
- class NewPlugin(LoopPlugin):
338
- def stage(self) -> LoopStage: return LoopStage.SUMMARIZE
339
- def name(self) -> str: return "new_feature"
340
- async def run(self, ctx: LoopContext) -> PluginResult: ...
341
-
342
- loop.plugins.register(NewPlugin())
343
- # 不需要修改 loop.py 的状态机代码
344
- ```
345
-
346
- > **批注:** ________
347
-
348
- ---
349
-
350
- ## 五、整体文件结构(v5 更新版)
351
-
352
- ```
353
- PregoPal/
354
- ├── app.py # 主入口:启动 Gradio + Loop
355
- ├── config.py # 全局配置
356
- ├── utils.py # 工具函数
357
- ├── loop.py ← 🆕 新增 # 核心循环引擎(状态机驱动)
358
-
359
- ├── plugins/ ← 🆕 新增 # Loop 插件
360
- │ ├── __init__.py
361
- │ ├── base.py # LoopPlugin 基类 + PluginRegistry
362
- │ ├── family_quiz.py # 家庭菜谱/体重询问检查
363
- │ ├── diet_summary.py # 昨日饮食总结
364
- │ ├── weight_check.py # 体重检查(WS/T 801)
365
- │ ├── family_memory.py # 家庭记忆加载
366
- │ ├── dri_analysis.py # DRIs 营养素对比分析
367
- │ ├── briefing_generator.py # 今日简报生成器
368
- │ ├── three_day_summary.py # 每三天总结
369
- │ └── preset_writer.py # 预设文件写入
370
-
371
- ├── modules/ # 业务逻辑层
372
- │ ├── __init__.py
373
- │ ├── nutrition_standards.py # ← 🆕 新增:营养标准解析器
374
- │ │ ├── WeightStandardParser # 体重标准解析
375
- │ │ ├── DietaryGuideParser # 膳食指南解析
376
- │ │ └── DRIsParser # DRIs 解析
377
- │ ├── family_manager.py # ← 🆕 新增:家庭信息管理
378
- │ │ ├── 读取/写入 recipes.md
379
- │ │ ├── 读取/写入 preferences.md
380
- │ │ └── 读取/写入 memory.md
381
- │ ├── diet_extractor.py # ← 🆕 新增:system prompt + 正则提取
382
- │ │ ├── 从对话中提取饮食记录
383
- │ │ ├── 从对话中提取菜谱信息
384
- │ │ └── 从对话中提取家庭关系
385
- │ ├── voiceprint.py # 声纹识别
386
- │ ├── meal_recommender.py # 菜品推荐
387
- │ ├── diet_logger.py # 饮食记录 + Markdown 生成
388
- │ └── nutrition_analyzer.py # 营养分析 + 可视化
389
-
390
- ├── core/ # AI 核心层(等待 MiniCPM-o)
391
- │ ├── __init__.py
392
- │ ├── model_loader.py
393
- │ ├── voice_processor.py
394
- │ ├── vision_processor.py
395
- │ └── conversation_manager.py
396
-
397
- ├── ui/ # 表现层
398
- │ ├── __init__.py
399
- │ └── app_builder.py # Gradio 界面
400
-
401
- ├── data/
402
- │ ├── nutrition/
403
- │ │ └── raw/ # 三个 MD 文件
404
- │ ├── family/ ← 🆕 新增 # 家庭信息
405
- │ │ ├── recipes.md # 家庭菜谱(家人会做的菜)
406
- │ │ ├── preferences.md # 饮食偏好/忌口/过敏
407
- │ │ └── memory.md # 家庭记忆(关系/事件)
408
- │ ├── presets/ # 每���简报
409
- │ │ └── .daily_status.json # 状态位文件
410
- │ ├── voices/
411
- │ ├── logs/ # Markdown 饮食日志
412
- │ └── reports/ # 导出报告
413
-
414
- ├── docs/
415
- │ ├── 项目理解_技术架构.md
416
- │ └── 开发日志.md
417
- ├── requirements.txt
418
- └── README.md
419
- ```
420
-
421
- > **批注:** ________
422
-
423
- ---
424
-
425
- ## 六、UI 界面设计(根据批注新增)
426
-
427
- 根据你的批注,Gradio 界面改为 **4 个子页面**:
428
-
429
- ### 页面 1:语音交互(主界面)
430
- - 语音输入/输出(未来支持视频)
431
- - **显示 PregoPal 当前思考/操作关键词**(通过 system prompt + 正则提取,每次执行下一步时用特定字符包裹下一步主题)
432
- - **自动展示相关文件操作的输出结果**(截断 + 省略号)
433
- - 声纹识别状态显示
434
-
435
- ### 页面 2:家庭成员饮食习惯
436
- - 展示所有家庭成员的饮食偏好、忌口、过敏
437
- - 展示家庭菜谱(家人会做的菜)
438
- - 支持手动添加/修改
439
-
440
- ### 页面 3:每三天自动总结
441
- - 分析孕妇饮食是否存在营养缺失
442
- - 对家庭菜单给出改进建议
443
- - 由 `THREE_DAY` 状态触发
444
-
445
- ### 页面 4:营养报告
446
- - 可视化报告(保留原有)
447
- - 体重增长趋势图
448
- - 营养素摄入对比图
449
-
450
- > **批注:** ________
451
-
452
- ---
453
-
454
- ## 七、system prompt + 正则提取设计(根据批注新增)
455
-
456
- 这是 PregoPal 的核心机制,用于在不依赖 toolcall 的情况下实现结构化数据提取。
457
-
458
- ### 7.1 提取标记格式
459
-
460
- 在 system prompt 中告诉模型,当需要记录信息时,使用以下标记格式:
461
-
462
- ```
463
- [EXTRACT_DIET]
464
- 日期: 2026-06-08
465
- 餐次: 午餐
466
- 食物: 番茄牛腩, 米饭
467
- 份量: 一碗
468
- 记录人: 丈夫
469
- 备注: 孕妇说想吃
470
- [/EXTRACT_DIET]
471
-
472
- [EXTRACT_RECIPE]
473
- 菜名: 清蒸鲈鱼
474
- 制作人: 丈夫
475
- 难度: 中等
476
- 食材: 鲈鱼, 葱, 姜, 蒸鱼豉油
477
- 备注: 孕妇爱吃
478
- [/EXTRACT_RECIPE]
479
-
480
- [EXTRACT_PREFERENCE]
481
- 人员: 丈夫
482
- 类型: 忌口
483
- 内容: 不吃香菜
484
- [/EXTRACT_PREFERENCE]
485
-
486
- [EXTRACT_WEIGHT]
487
- 日期: 2026-06-08
488
- 体重: 62.5
489
- 记录人: 孕妇
490
- [/EXTRACT_WEIGHT]
491
-
492
- [THINKING]
493
- 当前步骤: 分析昨日钙摄入
494
- 下一步: 对比DRIs标准
495
- [/THINKING]
496
- ```
497
-
498
- ### 7.2 后端正则解析
499
-
500
- ```python
501
- # modules/diet_extractor.py
502
- import re
503
-
504
- class DietExtractor:
505
- """从 AI 回复中提取结构化数据"""
506
-
507
- DIET_PATTERN = re.compile(
508
- r'\[EXTRACT_DIET\](.*?)\[/EXTRACT_DIET\]', re.DOTALL
509
- )
510
- RECIPE_PATTERN = re.compile(
511
- r'\[EXTRACT_RECIPE\](.*?)\[/EXTRACT_RECIPE\]', re.DOTALL
512
- )
513
- PREFERENCE_PATTERN = re.compile(
514
- r'\[EXTRACT_PREFERENCE\](.*?)\[/EXTRACT_PREFERENCE\]', re.DOTALL
515
- )
516
- WEIGHT_PATTERN = re.compile(
517
- r'\[EXTRACT_WEIGHT\](.*?)\[/EXTRACT_WEIGHT\]', re.DOTALL
518
- )
519
- THINKING_PATTERN = re.compile(
520
- r'\[THINKING\](.*?)\[/THINKING\]', re.DOTALL
521
- )
522
-
523
- @staticmethod
524
- def extract_all(text: str) -> dict:
525
- """从文本中提取所有结构化数据"""
526
- return {
527
- "diets": DietExtractor._parse_diets(text),
528
- "recipes": DietExtractor._parse_recipes(text),
529
- "preferences": DietExtractor._parse_preferences(text),
530
- "weights": DietExtractor._parse_weights(text),
531
- "thinking": DietExtractor._parse_thinking(text),
532
- }
533
-
534
- @staticmethod
535
- def _parse_diets(text: str) -> list[dict]:
536
- """解析 [EXTRACT_DIET] 块"""
537
- results = []
538
- for match in DietExtractor.DIET_PATTERN.finditer(text):
539
- block = match.group(1)
540
- entry = {}
541
- for line in block.strip().split('\n'):
542
- if ':' in line:
543
- key, val = line.split(':', 1)
544
- entry[key.strip()] = val.strip()
545
- if entry:
546
- results.append(entry)
547
- return results
548
- ```
549
-
550
- ### 7.3 稳定性设计
551
-
552
- - **正则匹配失败时的 fallback**:如果模型输出格式不完整,尝试用关键词匹配(如 "吃了"、"想吃"、"记录" 等)
553
- - **用户确认机制**:提取的数据先展示给用户确认,再写入文件
554
- - **多轮累积**:同一餐次的信息可能分散在多轮对话中,需要累积合并
555
-
556
- > **批注:** ________
557
-
558
- ---
559
-
560
- ## 八、数据流全景(v5 更新版)
561
-
562
- ```
563
- data/nutrition/raw/ data/family/
564
- ├── 体重标准.md ├── recipes.md
565
- ├── 膳食指南.md ├── preferences.md
566
- └── DRIs.md └── memory.md
567
- │ │
568
- ▼ ▼
569
- ┌──────────────────┐ ┌──────────────────────┐
570
- │ nutrition_ │ │ family_manager.py │
571
- │ standards.py │ │ 读取/��入家庭信息 │
572
- │ 编译为结构化数据 │ └──────────┬───────────┘
573
- └────────┬─────────┘ │
574
- │ │
575
- ▼ ▼
576
- ┌──────────────────────────────────────────────────┐
577
- │ loop.py │
578
- │ LAUNCH → FAMILY_QUIZ → SUMMARIZE → ANALYZE │
579
- │ → BRIEF → INTERACT → CONSOLIDATE → (THREE_DAY) │
580
- │ │
581
- │ 每个阶段调用对应 Plugin │
582
- │ Plugin 调用 modules/ 层的函数 │
583
- └──────────────────────┬───────────────────────────┘
584
-
585
-
586
- ┌──────────────────────────────────────────────────┐
587
- │ data/presets/今日简报_YYYY-MM-DD.json │
588
- │ { │
589
- │ "date": "2026-06-08", │
590
- │ "weight_check": {"need_ask": true/false}, │
591
- │ "recipe_check": {"need_ask": true/false}, │
592
- │ "nutrient_focus": ["钙", "铁", "叶酸"], │
593
- │ "recommended_foods": ["菠菜", "瘦肉", "豆腐"],│
594
- │ "family_recipes_available": ["番茄牛腩",...], │
595
- │ "conversation_topics": ["今天想吃鱼吗?"], │
596
- │ "family_memory": "昨天丈夫说...", │
597
- │ "thinking_keywords": "分析钙摄入" │
598
- │ } │
599
- └──────────────────────┬───────────────────────────┘
600
-
601
-
602
- ┌──────────────────────────────────────────────────┐
603
- │ ui/app_builder.py (Gradio 4页面) │
604
- │ 页面1: 语音交互 (显示 thinking_keywords + 截断) │
605
- │ 页面2: 家庭饮食习惯 │
606
- │ 页面3: 三天总结 │
607
- │ 页面4: 营养报告 │
608
- └──────────────────────┬───────────────────────────┘
609
-
610
-
611
- modules/diet_extractor.py (从对话中正则提取)
612
- modules/family_manager.py (写入家庭信息)
613
- modules/diet_logger.py (记录饮食)
614
-
615
-
616
- 次日 loop.py 再次启动 → 循环
617
- ```
618
-
619
- > **批注:** ________
620
-
621
- ---
622
-
623
- ## 九、技术栈一览
624
-
625
- | 层级 | 技术 | 用途 |
626
- |------|------|------|
627
- | 核心模型 | **MiniCPM-o 4.5**(统一多模态模型) | 全双工推理:视觉+语音+文本+语音输出 |
628
- | 前端框架 | **Gradio 6.x** | Web 界面,4 个子页面 |
629
- | 声纹识别 | Whisper-medium encoder embedding(优先)| 说话人身份识别 |
630
- | 频谱方案 | NumPy + SoundFile(备选 fallback) | 声纹识别兜底 |
631
- | 数据存储 | JSON + Markdown | 饮食记录 + 报告存档 + 每日简报 + 家庭信息 |
632
- | 营养标准 | 三个 MD 文件(已下载)+ 自写解析器 | 体重标准/膳食指南/DRIs |
633
- | 循环引擎 | 自研状态机 + 插件注册 | 每日生命周期管理 |
634
- | 数据提取 | 正则表达式(system prompt 标记格式) | 从对话中提取结构化数据 |
635
- | 部署平台 | HuggingFace Spaces(目前)+ Modal(后续) | 云端部署 |
636
-
637
- > **批注:** ________
638
-
639
- ---
640
-
641
- ## 十、待讨论问题清单
642
-
643
- | # | 问题 | 你的选择 / 意见 |
644
- |---|------|----------------|
645
- | 1 | **Loop 触发时机**:每晚 00:00 自动总结 + 程序启动时检查状态位(已采纳) | ✅ 已确认 |
646
- | 2 | **简报承载形式**:JSON 文件 `data/presets/` 落盘(已采纳) | ✅ 已确认 |
647
- | 3 | **状态位机制**:`data/presets/.daily_status.json` 记录每日完成状态 | ________ |
648
- | 4 | **家庭信息文件格式**:三个 md 文件(recipes.md / preferences.md / memory.md) | ________ |
649
- | 5 | **system prompt 标记格式**:`[EXTRACT_DIET]` / `[EXTRACT_RECIPE]` / `[THINKING]` 等 | ________ |
650
- | 6 | **UI 4 页面**:语音交互 / 家庭饮食习惯 / 三天总结 / 营养报告 | ________ |
651
- | 7 | **三天总结触发条件**:每自然日 00:00 检查是否满 3 天?还是每 3 次 CONSOLIDATE? | ________ |
652
- | 8 | **还有什么需要调整的?** | ________ |
653
-
654
- ---
655
-
656
- *文档生成时间:2026-06-08 | v5 - 整合批注,细化实现方案*
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
modal_deploy/deploy_backup.py ADDED
@@ -0,0 +1,369 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ PregoPal × MiniCPM-o-4_5 — Modal 部署 (预编译 llama-cpp-python)
3
+
4
+ 架构:
5
+ FastAPI (ASGI) ←→ llama-cpp-python (CUDA via pre-built wheel)
6
+
7
+ Modal Volume: GGUF models
8
+
9
+ 用法:
10
+ pip install modal # 安装 Modal CLI
11
+ modal token new # 登录 Modal
12
+ modal deploy modal_deploy.deploy # 部署 (~1 min)
13
+
14
+ 测试:
15
+ modal run modal_deploy.deploy::test_inference
16
+
17
+ API:
18
+ POST /v1/chat/completions — OpenAI 兼容 (支持 streaming)
19
+ POST /v1/completions — Text completion
20
+ POST /v1/embeddings — Embeddings
21
+ POST /v1/vision — 多模态 (图片+文字)
22
+ GET /health — 健康检查
23
+ GET /v1/models — 模型列表
24
+ """
25
+
26
+ import os
27
+ import modal
28
+ from modal import Image, App, Volume, asgi_app
29
+
30
+ # ════════════════════════════════════════════════════════════════════
31
+ # 1. IMAGE — 预编译 CUDA wheel (不从头编译,构建 < 1 min)
32
+ # ════════════════════════════════════════════════════════════════════
33
+
34
+ _image = (
35
+ Image.debian_slim(python_version="3.11")
36
+ .pip_install("fastapi", "uvicorn[standard]", "httpx", "numpy", "Pillow")
37
+ .pip_install(
38
+ "llama-cpp-python",
39
+ extra_index_url="https://ggml-org.github.io/llama-cpp-python/whl/cu121",
40
+ )
41
+ .run_commands(
42
+ "python -c 'import llama_cpp; print(\"llama-cpp-python OK\")'",
43
+ )
44
+ )
45
+
46
+ # ════════════════════════════════════════════════════════════════════
47
+ # 2. CONSTANTS
48
+ # ════════════════════════════════════════════════════════════════════
49
+
50
+ MODEL_DIR = "/models"
51
+ MODEL_SUBDIR = f"{MODEL_DIR}/MiniCPM-o-4_5-gguf"
52
+ MAIN_GGUF = "MiniCPM-o-4_5-Q4_K_M.gguf"
53
+ VISION_MMPROJ = "vision/MiniCPM-o-4_5-vision-F16.gguf"
54
+
55
+ model_volume = Volume.from_name("minicpm-o-4_5-models", create_if_missing=True)
56
+ app = App("prego-pal-minicpm")
57
+
58
+
59
+ def get_model_paths(base_dir: str) -> dict:
60
+ """返回经验证的模型路径."""
61
+ main_path = os.path.join(base_dir, MAIN_GGUF)
62
+ vision_path = os.path.join(base_dir, VISION_MMPROJ)
63
+ paths = {"main": main_path, "vision": vision_path}
64
+ for key, path in paths.items():
65
+ print(f"[PregoPal] {key}: {path} (exists={os.path.isfile(path)})")
66
+ return paths
67
+
68
+
69
+ # ════════════════════════════════════════════════════════════════════
70
+ # 3. ASGI APP — 多模态 API
71
+ # ════════════════════════════════════════════════════════════════════
72
+
73
+ @app.function(
74
+ image=_image,
75
+ volumes={MODEL_DIR: model_volume},
76
+ gpu="T4",
77
+ timeout=1200,
78
+ scaledown_window=300,
79
+ )
80
+ @modal.concurrent(max_inputs=10)
81
+ @asgi_app()
82
+ def serve():
83
+ import asyncio
84
+ import json
85
+ import logging
86
+ import base64
87
+ from fastapi import FastAPI, Request
88
+ from fastapi.responses import StreamingResponse, JSONResponse
89
+ from fastapi.middleware.cors import CORSMiddleware
90
+ from llama_cpp import Llama
91
+
92
+ logging.basicConfig(level=logging.INFO)
93
+ logger = logging.getLogger("prego-pal")
94
+
95
+ web_app = FastAPI(title="PregoPal MiniCPM-o-4_5 API")
96
+ web_app.add_middleware(
97
+ CORSMiddleware,
98
+ allow_origins=["*"],
99
+ allow_credentials=True,
100
+ allow_methods=["*"],
101
+ allow_headers=["*"],
102
+ )
103
+
104
+ # ── Model Loading ──────────────────────────────────────────────
105
+ paths = get_model_paths(MODEL_SUBDIR)
106
+ model_path = paths["main"]
107
+ vision_path = paths["vision"]
108
+
109
+ kwargs: dict = dict(
110
+ model_path=model_path,
111
+ n_gpu_layers=-1,
112
+ n_ctx=8192,
113
+ verbose=False,
114
+ n_threads=os.cpu_count() or 4,
115
+ )
116
+ if os.path.isfile(vision_path):
117
+ kwargs["mmproj"] = vision_path
118
+ logger.info("[PregoPal] [OK] Vision mmproj enabled")
119
+ else:
120
+ logger.warning(f"[PregoPal] [WARN] mmproj not found at {vision_path} — vision disabled")
121
+
122
+ logger.info("[PregoPal] Loading model (30-90s)...")
123
+ try:
124
+ llm = Llama(**kwargs)
125
+ logger.info("[PregoPal] [OK] Model loaded!")
126
+ except Exception as e:
127
+ logger.error(f"[PregoPal] [FAIL] Failed to load model: {e}")
128
+ raise
129
+
130
+ # ── Endpoints ──────────────────────────────────────────────────
131
+
132
+ @web_app.post("/v1/chat/completions")
133
+ async def chat_completions(request: Request):
134
+ import traceback
135
+ try:
136
+ body = await request.json()
137
+ except Exception as e:
138
+ logger.error(f"[PregoPal] JSON parse error: {e}")
139
+ return JSONResponse({"error": "Invalid JSON"}, status_code=400)
140
+ stream = body.get("stream", False)
141
+ messages = body.get("messages", [])
142
+ max_tokens = body.get("max_tokens", 512)
143
+ temperature = body.get("temperature", 0.7)
144
+ top_p = body.get("top_p", 0.9)
145
+
146
+ if stream:
147
+ async def event_stream():
148
+ for chunk in llm.create_chat_completion(
149
+ messages=messages,
150
+ max_tokens=max_tokens,
151
+ temperature=temperature,
152
+ top_p=top_p,
153
+ stream=True,
154
+ ):
155
+ yield f"data: {json.dumps(chunk)}\n\n"
156
+ yield "data: [DONE]\n\n"
157
+ return StreamingResponse(event_stream(), media_type="text/event-stream")
158
+
159
+ try:
160
+ result = llm.create_chat_completion(
161
+ messages=messages,
162
+ max_tokens=max_tokens,
163
+ temperature=temperature,
164
+ top_p=top_p,
165
+ stream=False,
166
+ )
167
+ return JSONResponse(result)
168
+ except Exception as e:
169
+ logger.error(f"[PregoPal] Chat completion error: {e}\n{traceback.format_exc()}")
170
+ return JSONResponse({"error": str(e)}, status_code=500)
171
+
172
+ @web_app.post("/v1/completions")
173
+ async def completions(request: Request):
174
+ body = await request.json()
175
+ prompt = body.get("prompt", "")
176
+ max_tokens = body.get("max_tokens", 256)
177
+
178
+ result = llm.create_completion(
179
+ prompt=prompt,
180
+ max_tokens=max_tokens,
181
+ temperature=body.get("temperature", 0.7),
182
+ stream=False,
183
+ )
184
+ return JSONResponse(result)
185
+
186
+ @web_app.post("/v1/embeddings")
187
+ async def embeddings(request: Request):
188
+ body = await request.json()
189
+ result = llm.create_embedding(
190
+ input=body.get("input", ""),
191
+ model=body.get("model", "MiniCPM-o-4_5"),
192
+ )
193
+ return JSONResponse(result)
194
+
195
+ @web_app.post("/v1/vision")
196
+ async def vision(request: Request):
197
+ """多模态推理:接收 base64 图片."""
198
+ body = await request.json()
199
+ messages = body.get("messages", [])
200
+ max_tokens = body.get("max_tokens", 512)
201
+ temperature = body.get("temperature", 0.7)
202
+
203
+ if not os.path.isfile(vision_path):
204
+ return JSONResponse(
205
+ {"error": "Vision mmproj not loaded"},
206
+ status_code=400,
207
+ )
208
+
209
+ result = llm.create_chat_completion(
210
+ messages=messages,
211
+ max_tokens=max_tokens,
212
+ temperature=temperature,
213
+ stream=False,
214
+ )
215
+ return JSONResponse(result)
216
+
217
+ @web_app.get("/health")
218
+ async def health():
219
+ try:
220
+ vol_files = os.listdir(MODEL_SUBDIR) if os.path.isdir(MODEL_SUBDIR) else []
221
+ except Exception:
222
+ vol_files = []
223
+ return {
224
+ "status": "ok",
225
+ "model": "MiniCPM-o-4_5",
226
+ "cuda": True,
227
+ "vision": os.path.isfile(vision_path),
228
+ "volume_files": vol_files,
229
+ }
230
+
231
+ @web_app.get("/v1/models")
232
+ async def list_models():
233
+ return {
234
+ "object": "list",
235
+ "data": [{
236
+ "id": "MiniCPM-o-4_5",
237
+ "object": "model",
238
+ "created": 1,
239
+ "owned_by": "prego-pal",
240
+ }],
241
+ }
242
+
243
+ @web_app.get("/")
244
+ async def root():
245
+ return {
246
+ "service": "PregoPal MiniCPM-o-4_5 API",
247
+ "version": "2.0.0",
248
+ "model": MAIN_GGUF,
249
+ "endpoints": {
250
+ "chat": "POST /v1/chat/completions",
251
+ "completions": "POST /v1/completions",
252
+ "embeddings": "POST /v1/embeddings",
253
+ "vision": "POST /v1/vision (多模态)",
254
+ "models": "GET /v1/models",
255
+ "health": "GET /health",
256
+ },
257
+ }
258
+
259
+ return web_app
260
+
261
+
262
+ # ════════════════════════════════════════════════════════════════════
263
+ # 4. MODEL UPLOAD 指引
264
+ # ════════════════════════════════════════════════════════════════════
265
+
266
+ @app.function(
267
+ image=_image,
268
+ volumes={MODEL_DIR: model_volume},
269
+ timeout=3600,
270
+ )
271
+ def upload_models():
272
+ """打印上传模型指引."""
273
+ print("=" * 60)
274
+ print("[UPLOAD] 上传模型至 Modal Volume 指引:")
275
+ print()
276
+ print(" modal volume put minicpm-o-4_5-models \\")
277
+ print(" ./models/MiniCPM-o-4_5-gguf /MiniCPM-o-4_5-gguf")
278
+ print()
279
+ print(" # 验证:")
280
+ print(" modal volume ls minicpm-o-4_5-models /MiniCPM-o-4_5-gguf")
281
+ print("=" * 60)
282
+
283
+ # 验证 volume 中现有文件
284
+ test_main = os.path.join(MODEL_SUBDIR, MAIN_GGUF)
285
+ test_vision = os.path.join(MODEL_SUBDIR, VISION_MMPROJ)
286
+ main_ok = os.path.isfile(test_main)
287
+ vision_ok = os.path.isfile(test_vision)
288
+
289
+ print(f"\n当前 Volume 状态:")
290
+ print(f" {MAIN_GGUF}: [{'OK' if main_ok else 'FAIL'}] ({os.path.getsize(test_main) if main_ok else 'N/A'} bytes)")
291
+ print(f" {VISION_MMPROJ}: [{'OK' if vision_ok else 'FAIL'}] ({os.path.getsize(test_vision) if vision_ok else 'N/A'} bytes)")
292
+
293
+ if main_ok and vision_ok:
294
+ print(f"\n[OK] 模型就绪,可以执行 deploy!")
295
+ else:
296
+ print(f"\n[FAIL] 模型上传不完整,请重新上传")
297
+
298
+
299
+ # ════════════════════════════════════════════════════════════════════
300
+ # 5. TEST INFERENCE
301
+ # ════════════════════════════════════════════════════════════════════
302
+
303
+ @app.function(
304
+ image=_image,
305
+ volumes={MODEL_DIR: model_volume},
306
+ gpu="T4",
307
+ timeout=600,
308
+ )
309
+ def test_inference():
310
+ """在 Modal 上测试推理."""
311
+ import time
312
+ import json
313
+ from llama_cpp import Llama
314
+
315
+ print("[PregoPal] ========== TEST INFERENCE ==========")
316
+
317
+ paths = get_model_paths(MODEL_SUBDIR)
318
+ main_path = paths["main"]
319
+ vision_path = paths["vision"]
320
+
321
+ if not os.path.isfile(main_path):
322
+ print(f"[PregoPal] [FAIL] Model not found at {main_path}")
323
+ return
324
+
325
+ t0 = time.time()
326
+ kwargs = dict(
327
+ model_path=main_path,
328
+ n_gpu_layers=-1,
329
+ n_ctx=4096,
330
+ verbose=False,
331
+ )
332
+ if os.path.isfile(vision_path):
333
+ kwargs["mmproj"] = vision_path
334
+
335
+ print("[PregoPal] Loading model...")
336
+ llm = Llama(**kwargs)
337
+ load_time = time.time() - t0
338
+ print(f"[PregoPal] [OK] Model loaded in {load_time:.1f}s")
339
+ print(f" Main: {os.path.getsize(main_path) / 1024**3:.1f} GiB")
340
+ if os.path.isfile(vision_path):
341
+ print(f" Vision: {os.path.getsize(vision_path) / 1024**3:.1f} GiB")
342
+
343
+ # Test 1: 中文
344
+ print("\n[Test 1] 中文提问...")
345
+ t0 = time.time()
346
+ result = llm.create_chat_completion(
347
+ messages=[{"role": "user", "content": "用中文说你好,不超过10个字"}],
348
+ max_tokens=30,
349
+ temperature=0.1,
350
+ )
351
+ elapsed = time.time() - t0
352
+ content = result.get("choices", [{}])[0].get("message", {}).get("content", "")
353
+ print(f"Response ({elapsed:.1f}s): {content}")
354
+
355
+ # Test 2: 英文
356
+ print("\n[Test 2] 英文提问...")
357
+ t0 = time.time()
358
+ result = llm.create_chat_completion(
359
+ messages=[{"role": "user", "content": "What is the capital of France? Answer in 5 words."}],
360
+ max_tokens=30,
361
+ temperature=0.1,
362
+ )
363
+ elapsed = time.time() - t0
364
+ content = result.get("choices", [{}])[0].get("message", {}).get("content", "")
365
+ print(f"Response ({elapsed:.1f}s): {content}")
366
+
367
+ print(f"\n{'='*50}")
368
+ print(f"[OK] Test complete! Loading: {load_time:.1f}s")
369
+ print(f"{'='*50}")