Spaces:
Runtime error
Runtime error
J.B-Lin commited on
Commit ·
68d23d2
1
Parent(s): 6cce707
用户手动整理文件,并备份deploy_backup.py文件
Browse files- docs/UI改进规划_用户反馈.md +0 -159
- docs/{llama_cpp_minicpm_技术报告.md → 云端部署旧方案_重新编译.md} +181 -0
- docs/{deploy_success_report.md → 云端部署经验.md} +140 -1
- docs/技术日志_为什么这次部署这么快.md +0 -117
- docs/{总结报告_modal部署过程.md → 本地部署经验.md} +0 -62
- docs/部署经验_Modal_MiniCPM-o.md +0 -176
- docs/项目架构说明书.md +0 -995
- docs/项目理解_技术架构.md +0 -656
- modal_deploy/deploy_backup.py +369 -0
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/
|
| 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}")
|