Files
memind/docs/memind-health-architecture.md
T
john f96fdcb3b9 feat(health): add P0 health channel kernel and encrypted health zone.
Lock health recording behind confirm-only state machines, reject public publishes for the health category, and ship the 14-day experiment Page Data templates with tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-09 18:04:26 +08:00

1207 lines
51 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# MeMind Health 架构与通道设计
日期:2026-09-02
状态:架构设计稿。**未改动业务代码、未碰生产、未读写用户数据。**
适用范围:MeMind H5、微信服务号、MindSpace、Page Data、Memory V2、Agent Gateway
---
## 0. 一句话定位
MeMind Health 不是「另一个健康 App」,而是:
> **持续理解本人健康基线,并在长期变化中发现「偏离」的个人健康 Agent。**
核心数据链路:
```text
本人 → 录入/设备/报告 → Health Timeline → Personal Baseline
→ 偏离事件 → Agent 推理 → 行动建议 → 医疗资料沉淀
```
与传统健康产品的根本差异:
| 传统健康 App | MeMind Health |
|---|---|
| `140/90 → 标红` | 该用户 90 天基线 `119–126/70–77`,近 14 天持续漂移 +8% |
| 数据看板 | 「最近和以前不一样」的事件叙事 |
| 用户自己盯曲线 | 只在偏离时介入,无事静默 |
| 单次录入 | 时间序列 + 关联假设 + 长期学习 |
**MVP 验证命题**:
> MeMind 能不能比我自己更早发现:我最近和以前不太一样?
若成立,血压计只是传感器;**Personal Baseline + Health Timeline + Event Engine** 才是产品。
---
## 1. 已确认的产品决策(不可随意推翻)
以下决策已确认,后续实现必须遵守。变更需重新评审本文档。
### D1 单人模型:只监测账号本人
- 监测对象 **默认且仅为账号本人**,`HealthProfile` 与 `user_id` 严格 1:1。
- **不做** `FamilyGroup` / 多 `Subject` / 子女代录代管。
- 理由:录入标准、测量情境、回顾深度必须统一,否则 Personal Baseline 失真(「参差不齐」)。
- 未来若做「关注家人」,只能是 **本人主动授权只读分享摘要**(P4),录入权永远在本人。
### D2 他人健康数据走辅轨,不进引擎
用户说「我妈上周体检血压 150/95」这类内容:
- 写入 Memory V2(`fact` / `experience`)或普通备忘,Agent 可对话召回。
- **禁止**进入 `HealthObservation`、Baseline、Trend、Event Engine。
- Agent 话术须明确告知:「这不会进入你的健康分析」。
### D3 存储走 Page Data(P0 决策)
- 结构化健康数值存入 **用户隔离的 PostgreSQL schema**(Page Data 体系,`private_data_execute` 建表 + `private_data_register_dataset` 注册)。
- 复用现成的用户级 schema 隔离、dataset 授权、操作日志、导出、软删除恢复能力。
- P1 若基线/趋势聚合出现性能瓶颈,再评估迁移到专用表;迁移方案见 §12.3。
### D4 服务号允许规则命中 + 强制确认
- 自由文本先走**确定性规则**(正则/关键词);命中即进入对应流程。
- 规则未命中 → 回交互式菜单,**不让 LLM 硬猜**。
- 所有由识别/解析得到的数值,**一律经用户确认后才落库**。
### D5 健康会话完全独立
- 健康会话与普通聊天会话**完全隔离**(独立 session、独立工具白名单)。
- 普通聊天中识别到健康意图,**只做引导跳转**,给入口链接/按钮。
- **绝不在普通聊天里直接落库健康数据。**
---
## 2. 四条不可违背的工程原则
1. **健康通道是「锁定态会话」**,不是「聊天里聊健康」。进入后 skill 被 pin,意图分类器不再参与判断。
2. **拍照/上传不直接落库**,一律「抽取 → 确认 → 提交」三段式。未确认数据不进基线引擎。
3. **意图不明确时不猜,直接给选项。** 菜单式交互在健康场景比自然语言理解更精准、更快、更可审计。
4. **健康区数据默认加密,无口令不允许发布。** 服务端硬阻断,不靠前端自觉。
配套的分层职责:
```text
规则判断事实 → Rule Engine(确定性、可审计)
算法发现趋势 → Trend Engine(统计、可复现)
LLM 负责解释 → Agent(不可作为 primary detector)
```
**禁止**的反模式:`血压 128/85 → LLM → "你觉得危险吗?"`
---
## 3. 系统总览
```text
MeMind Health(本人)
│
┌───────────────────┼───────────────────┐
│ │ │
Health Connector Health Timeline Medical Documents
(P2: Apple/蓝牙) (结构化时序) (报告 OCR + 归档)
│ │ │
└───────────────────┼───────────────────┘
↓
Personal Baseline Engine
↓
┌───────────────────┴───────────────────┐
↓ ↓
Rule Engine Trend Engine
(医学阈值 / 漏测 / 药物变化) (个体偏移 / 变异性 / 联动)
│ │
└───────────────────┬───────────────────┘
↓
Event Engine
(Health Event Cluster)
↓
MeMind Agent(解释 + 追问 + 建议)
↓
提醒 / 复测 / 就医建议 / 就诊摘要 / Memory 沉淀
```
### 3.1 与现有 MeMind 能力的映射
| 设计层 | 复用的现有能力 | 关键文件 |
|---|---|---|
| 通道锁定不漂移 | `selectedChatSkill` 短路 + `enforceSelectedSkillRuntime` | `chat-intent-router.mjs`、`agent-run-routes.mjs` |
| 结构化存储 | Page Data 用户隔离 PG schema | `page-data-service.mjs`、`mindspace-userdata-postgres.mjs` |
| 健康区与发布策略 | `h5_space_categories`(`visibility/ai_access/publish_policy`) | `mindspace.mjs`(`SYSTEM_CATEGORIES`) |
| 加密发布与口令 | 发布 `access_mode` + `password_hash` | `mindspace-pages.mjs`、`page-data-public-service.mjs` |
| 私有页不被收录 | `INDEXABLE_ACCESS_MODE = 'public'`,非 public 一律 noindex | `mindspace-index-policy.mjs` |
| 报告原文件存储 | MindSpace 资产(`h5_assets`)+ 分区归属 | `mindspace.mjs` |
| 服务号图文/语音接入 | `msgType` 分流 + `eventKey` 菜单事件 | `wechat-mp.mjs` |
| 图片轮次隔离 | 带图轮次摘掉 `read_image`、污染触发会话轮换 | `chat-image-turn-scope.mjs` |
| 用药/复诊提醒 | `h5_schedule_items` + `schedule-assistant` | `schedule-service.mjs` |
| 长期关联假设 | Memory V2 candidate → promote | `memory-v2.mjs`、`memory-v2-lifecycle.mjs` |
| 灰度 / fail-open | 现有 canary + fail-open 模式 | `memory-v2-runtime.mjs` 等 |
### 3.2 需要新增的模块(隔离在健康域内)
```text
health-channel-state.mjs 通道状态机(纯函数,可单测)
health-observation-service.mjs Observation 读写 + 合理性校验 + 幂等去重
health-extraction.mjs 图片/报告结构化抽取 + confidence 归一
health-baseline-engine.mjs 滚动基线计算(P1)
health-event-engine.mjs Rule + Trend + Event 聚合(P1)
health-document-service.mjs 报告归档 + 指标映射
health-share-service.mjs 就诊摘要快照与令牌(P3)
skills/health-assistant/ skill 定义
src/pages/health/* H5 健康入口页面
```
---
## 4. 入口设计:H5 主入口 + 服务号轻入口
两个入口共用同一个 Health Channel 内核,只是壳不同。
```text
┌──────────────────────────┐
H5 首页 ──────→│ 健康助手入口(卡片 / Tab) │
└────────────┬─────────────┘
│
服务号菜单 / 关键词 ─────────→│
「健康助手」 │
↓
┌────────────────────────────┐
│ Health Channel 内核 │
│ (skill pin + 状态机) │
└────────────┬───────────────┘
↓
┌────────────┬────────┴───────┬────────────┐
↓ ↓ ↓ ↓
健康录入 健康评估 报告归档 健康档案
(值/照片) (基线/趋势) (OCR→档案) (MindSpace 健康区)
```
### 4.1 H5 主入口(功能完整,主战场)
H5 是主入口,因为需要结构化表单、数值键盘、Timeline 图表、可编辑确认卡、档案浏览。
建议做成**独立页面**而非普通聊天里的一个 skill 按钮:
```text
/health 健康首页(今日录入状态 + 最近趋势 + 待确认项)
/health/chat 健康助手会话(锁定通道)
/health/records Health Timeline
/health/documents 报告档案
/health/profile 基线 / 既往史 / 长期用药
```
`/health/chat` 使用独立会话空间,不与普通聊天会话混用(见 §7)。
### 4.2 服务号轻入口(随手录入,主打拍照/语音)
服务号的价值是**随手**:量完血压直接拍照发送。
进入方式两条,均有现成钩子:
- **自定义菜单点击** → `msgType === 'event'` + `eventKey === 'MEMIND_HEALTH'`
- **关键词命中** → 文本命中「健康助手」「健康」「录血压」等
进入后回复固定菜单(服务号仅文本/图文,用数字选择):
```text
已进入【健康助手】专用通道
本通道只处理健康相关内容,数据自动归入你的健康档案。
请回复数字:
1 健康录入(血压/心率/体重/血氧/体温/睡眠/症状)
2 上传报告(直接发送照片即可)
3 健康评估(看看最近和平时比有什么变化)
4 查看健康档案
0 退出健康通道
```
关键约束:
- 进入后**所有消息(含图片)只走健康链路**,直到用户回复 `0` 或超时。
- 建议 **30 分钟无交互自动退出**,退出时必须明确告知,避免用户以为还在通道内。
- 通道状态与现有 canary/stable 会话钉选逻辑并存,**不改动现有分流**。
---
## 5. 防漂移三层保障
这是本设计的核心,对应用户最关心的「不允许漂移,要精准」。
### 5.1 第一层:Skill Pin(复用现有短路)
现有 `chat-intent-router.mjs` 已具备完整短路能力:
- `readSelectedChatSkillId(message)` 读取 `metadata.memindRun.selectedChatSkill`
- `buildSkillSelectionClassification()` 返回 `route: agent_orchestration`、`confidence: 1`、`reason: '用户已选择 skill'`
- 结果:**LLM 意图分类器根本不会被调用**
健康通道发消息时携带:
```json
{
"metadata": {
"memindRun": {
"selectedChatSkill": "health-assistant",
"healthChannel": {
"sessionKind": "health",
"step": "await_value",
"pendingDraftId": "drf_...",
"metricSet": "blood_pressure"
}
}
}
}
```
服务号侧从会话状态读出「当前在健康通道」,注入相同 metadata。
`agent-run-routes.mjs` 的 `enforceSelectedSkillRuntime` 进一步固定 `toolMode` / `taskType` / `requiredExecutor`,保证工具集不越界。
**这一层解决「会不会被路由到别的 skill」。**
### 5.2 第二层:Channel 状态机(解决「这句话是什么意思」)
Pin 住 skill 不够 —— 用户说「137」,是血压还是体重?必须有显式状态机,不靠 LLM 猜。
```text
IDLE
│ 选择「健康录入」
↓
AWAIT_METRIC_TYPE ──→ 展示可录入项(血压/心率/体重/血氧/体温/睡眠/症状/用药)
│ 选择「血压」
↓
AWAIT_CONTEXT ──────→ 晨起 / 睡前 / 服药后 / 其他(可跳过取默认)
│
↓
AWAIT_VALUE ────────→ 期望数值;接受手输 / 拍照 / 语音
│
↓
CONFIRM ────────────→ 结构化确认卡(可编辑)
│ 确认
↓
COMMITTED ──────────→ 写入 Observation + 返回 Timeline 即时反馈
```
完整状态转移表:
| 当前状态 | 期望输入 | 事件 | 下一状态 |
|---|---|---|---|
| `IDLE` | 菜单选择 / 规则命中文本 / 图片 | `select_action` | 对应流程首状态 |
| `IDLE` | 无法高置信解析 | `ambiguous` | `AWAIT_ACTION_CHOICE` |
| `AWAIT_ACTION_CHOICE` | 1/2/3/4 | `choose` | 对应流程首状态 |
| `AWAIT_METRIC_TYPE` | 指标名 | `pick_metric` | `AWAIT_CONTEXT` |
| `AWAIT_CONTEXT` | 情境 / 跳过 | `pick_context` | `AWAIT_VALUE` |
| `AWAIT_VALUE` | 数值 / 图片 / 语音 | `submit_value` | `CONFIRM` |
| `AWAIT_VALUE` | 无关输入 | `unexpected` | `AWAIT_VALUE`(回提示,不猜) |
| `CONFIRM` | 确认 | `commit` | `COMMITTED` |
| `CONFIRM` | 修改 | `revise` | `AWAIT_VALUE` |
| `CONFIRM` | 放弃 | `discard` | `IDLE` |
| 任意 | `0` / 「退出」 | `exit` | 通道退出 |
| 任意 | 30 分钟无交互 | `timeout` | 通道退出(告知用户) |
状态机每一步都有**明确的期望输入类型**。用户在 `AWAIT_VALUE` 说「137/82」语义完全确定,不存在歧义。
若在某状态收到完全无关输入,通道**不猜测**,回:
```text
当前正在录入「血压」。
- 直接发送数值,如 137/82
- 或拍照上传血压计读数
- 回复「取消」放弃本次录入
- 回复「0」退出健康通道
```
状态机应实现为**纯函数**(`health-channel-state.mjs`),输入 `(state, event)` 输出 `(nextState, actions)`,便于完整单测覆盖。
### 5.3 第三层:意图不明时强制选择(D4)
`IDLE` 状态自由发言的判定顺序,**确定性优先**:
```text
1. 确定性规则匹配(正则 / 关键词)→ 命中即执行,不调 LLM
2. 规则未命中 → LLM 做候选动作分类,要求返回 top-2 + confidence
3. confidence < 0.75 或 top-2 差距 < 0.15 → 不执行,返回选择卡
4. 用户选择后 → 进入状态机,此后无歧义
```
选择卡内容:
```text
我不太确定你想做什么,请选择:
1 记录健康数据
2 查看健康评估
3 上传 / 归档报告
4 查看健康档案
```
规则库设计(示例,需持续维护并单测):
| 意图 | 规则示例 | 落地动作 |
|---|---|---|
| 血压录入 | `(\d{2,3})\s*[//]\s*(\d{2,3})`,或含「血压」+ 两个数 | `metric_set=blood_pressure` → `CONFIRM` |
| 体重录入 | 含「体重」+ `(\d{2,3}(\.\d)?)` | `metric_set=weight` |
| 心率录入 | 含「心率/脉搏」+ `(\d{2,3})` | `metric_set=heart_rate` |
| 血氧 | 含「血氧/SpO2」+ `(\d{2,3})` | `metric_set=spo2` |
| 体温 | 含「体温/发烧」+ `(3\d(\.\d)?)` | `metric_set=temperature` |
| 睡眠 | 含「睡了」+ 时长表达 | `metric_set=sleep` |
| 症状 | 含「头晕/胸闷/乏力/心慌/疼」 | `metric_set=symptom` |
| 评估 | 「最近怎么样」「帮我看看」「有没有异常」 | 评估流 |
| 档案 | 「我的档案」「历史记录」「报告」 | 档案流 |
**关键原则**:宁可多问一次,也不要错记一条。错误的 Observation 会污染 Personal Baseline,代价远高于多一次交互。
歧义值必须强制确认,例如「体重 137」显然异常但格式合法 → 走合理性校验(§6.2)标记 `suspect`。
---
## 6. 拍照精准记录设计
最易漂移的环节,单独设计。
### 6.1 先分清两类图片
| 类型 | 内容 | 归属 |
|---|---|---|
| **设备读数照** | 血压计、体重秤、血氧仪、体温计屏幕 | → `HealthObservation`(数值) |
| **医疗报告** | 体检报告、化验单、影像报告、处方 | → `HealthDocument`(档案)+ 抽取指标 |
判定策略分层,成本递增:
1. **用户已在 `AWAIT_VALUE`**(先选「血压」再拍照)→ **类型已确定**,直接按血压解析。**这是最精准路径,产品应主推。**
2. **`IDLE` 直接发图** → 轻量视觉判断「设备屏幕 vs 文档」,然后**回确认**:「这看起来是血压计读数,要记录为血压吗?」
3. **判断不确定** → 直接给选项:「这张图是:1 设备读数 2 体检/化验报告 3 其他」
产品引导上把「先选类型再拍」做成主路径:
```text
健康录入 → 选「血压」 → 「请输入数值或拍照上传」 → 拍照
```
先验极强,几乎不可能错。
### 6.2 设备读数:抽取 → 确认 → 提交
**绝不允许「拍照即入库」。**
```text
图片
↓
① 图片指纹(内容 hash)去重 ← 防止同一照片重复录入
↓
② 结构化抽取(视觉模型 + 强约束 schema)
↓
③ 合理性校验(Rule Engine,确定性)
↓
④ 生成 ObservationDraft(草稿态,不进基线)
↓
⑤ 用户确认卡(H5 可编辑表单;服务号文本 + 回复确认)
↓
⑥ 确认后 → HealthObservation(正式)+ 原图存档 + 抽取留痕
```
第 ② 步要求模型输出严格 schema:
```json
{
"metric_set": "blood_pressure",
"systolic": 137,
"diastolic": 82,
"pulse": 72,
"device_time": "2026-09-02 07:20",
"confidence": { "systolic": 0.97, "diastolic": 0.95, "pulse": 0.88 },
"unreadable": []
}
```
第 ③ 步校验规则(确定性,不经 LLM):
| 校验 | 规则 | 不通过处理 |
|---|---|---|
| 量程 | 收缩压 60–260;舒张压 40–160;脉搏 30–200;SpO₂ 50–100;体温 33–43 | 标记 `unreadable`,要求重拍或手输 |
| 逻辑 | 收缩压 > 舒张压 | 提示可能识别颠倒,要求确认 |
| 基线偏离 | 与个人基线偏离 > 50% | 标记 `suspect`,**强制**人工确认 |
| 置信度 | 任一字段 `confidence < 0.9` | 该字段标记待确认,高亮展示 |
第 ⑥ 步的**留痕**至关重要:`raw_payload` 保留原始抽取 JSON、图片引用、用户是否修改过。将来审计「这条 137 是怎么来的」可完整回溯,也是 Event Engine evidence chain 的基础。
### 6.3 时间戳:隐藏的漂移源
「什么时候测的」与「值是多少」同样重要,且极易出错。优先级:
```text
1. 设备屏幕显示时间(OCR 得到) ← 最准
2. 图片 EXIF 拍摄时间 ← 次准
3. 用户手动指定 ← 兜底
4. 消息到达时间 ← 最后手段,必须提示用户
```
若只能用第 4 种,确认卡必须明确写:「测量时间按当前时间记录,如非刚测请修改」。
**必须支持补录改时间** —— 否则 Timeline 全错,基线随之失真。
### 6.4 报告类:归档与指标抽取分离
```text
报告图片 / PDF
↓
① 归档(必做,成功率高)
→ HealthDocument:类型、机构、报告日期、原文件、OCR 全文
→ 原文件落入 MindSpace 健康分区资产
↓
② 指标抽取(尽力而为,允许失败)
→ 化验项映射:血脂 / 血糖 / 肝功 / 肾功 / 血常规 ...
→ 每项带 confidence + 参考范围
→ 高置信项 → ObservationDraft(待确认)
→ 低置信项 → 仅存 OCR 文本,不入结构化
```
**归档必须成功,抽取允许失败。** 即使指标识别不出,用户报告也已安全存进档案随时可查 —— 这是产品底线价值。
### 6.5 与现有视觉守卫的兼容(硬约束)
`docs/regression-guards/vision-turn-read-image-isolation.md` 的守卫**必须继续生效**:
- 带图轮次必须摘掉 `read_image`
- 工具图片污染必须触发会话轮换
健康通道的图片处理**必须走同一套** `chat-image-turn-scope.mjs`,**禁止**另开绕过守卫的路径。健康通道只是在其**之上**叠加「结构化 schema 约束 + 确认闸门」,底层图片轮次隔离不变。
---
## 7. 会话隔离(D5)
「不影响现有功能」的关键。
```text
普通会话 健康会话
───────── ─────────
sessionKind: 'chat' sessionKind: 'health'
完整 skill 集 仅 health-assistant + 白名单工具
Memory V2 全量 resolve 健康上下文 + 有限 profile
可发布任意分区页面 仅健康区(强制加密)
```
具体隔离点:
1. **会话独立**:健康会话使用独立 `session_id` 命名空间。普通聊天历史不注入健康会话;健康数据不会出现在普通聊天上下文。
2. **工具白名单**:健康会话内由 `enforceSelectedSkillRuntime` 限定工具集 —— 仅允许健康数据读写、文档归档、健康区页面生成。**不允许** `edit_file` 写非健康区路径,**不允许**公开发布。
3. **Memory 分轨**:Observation 走独立数据面;仅「本人对睡眠敏感」这类**关联假设**晋升 Memory V2,带 `health_correlation` 标记。他人健康数据只进备忘(D2)。
4. **引导跳转**:普通聊天识别到健康意图时,**只回引导**,不落库:
```text
你想记录健康数据吗?健康数据需要在健康助手通道里录入,
这样才能保证记录准确并纳入你的健康基线分析。
[进入健康助手 →]
```
5. **服务号状态**:健康通道状态存于服务号会话状态,与现有 canary/stable 钉选并存。
---
## 8. 数据模型(Page Data 落地,D3)
### 8.1 分层存储决策
| 数据 | 存储 | 理由 |
|---|---|---|
| 健康数值(Observation) | Page Data 用户隔离 PG schema | 结构化、可聚合、已有隔离与审计 |
| 报告原文件(图片/PDF) | MindSpace 健康分区资产(`h5_assets`) | Page Data 不适合大文件 |
| 报告元数据 + OCR 全文 | Page Data(PG) | 需检索与关联 |
| 基线 / 事件 | Page Data(PG),P1 | 由引擎写入 |
| 关联假设 | Memory V2 | 需对话召回 |
| 提醒 / 复诊 | `h5_schedule_items` | 复用现有提醒链路 |
**注意**:报告文件与其元数据分离存储,通过 `asset_id` 关联。
### 8.2 表设计(PostgreSQL,用户隔离 schema)
```sql
-- 健康档案(本人,1 行)
CREATE TABLE IF NOT EXISTS health_profile (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
birth_year INT NULL,
sex TEXT NULL,
height_cm NUMERIC(5,1) NULL,
chronic_conditions JSONB NOT NULL DEFAULT '[]'::jsonb,
long_term_medications JSONB NOT NULL DEFAULT '[]'::jsonb,
notes TEXT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- 原子观测(所有体征 / 行为 / 主观状态的唯一写入格式)
CREATE TABLE IF NOT EXISTS health_observations (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
observed_at TIMESTAMPTZ NOT NULL, -- 测量时刻,非录入时刻
metric_type TEXT NOT NULL, -- bp_systolic | bp_diastolic | hr | spo2 | weight | temperature | sleep_minutes | symptom | ...
value_num NUMERIC(10,2) NULL,
value_text TEXT NULL, -- 症状等枚举/文本
unit TEXT NULL,
context TEXT NULL, -- morning | evening | post_medication | fasting | other
source TEXT NOT NULL, -- manual | photo | voice | ocr_report | apple_health | ble_device
source_ref TEXT NULL, -- 图片 hash / asset_id / 设备标识
time_source TEXT NOT NULL, -- device_screen | exif | user_specified | message_arrival
confidence NUMERIC(4,3) NULL,
quality_flag TEXT NULL, -- ok | suspect | user_corrected
raw_payload JSONB NULL, -- 原始抽取结果 + 审计留痕
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted_at TIMESTAMPTZ NULL
);
CREATE INDEX IF NOT EXISTS idx_health_obs_metric_time
ON health_observations (metric_type, observed_at DESC);
-- 幂等:同一图片 + 同一指标 + 同一测量时刻不重复入库
CREATE UNIQUE INDEX IF NOT EXISTS uq_health_obs_dedup
ON health_observations (metric_type, observed_at, source_ref)
WHERE source_ref IS NOT NULL AND deleted_at IS NULL;
-- 待确认草稿(不进基线引擎)
CREATE TABLE IF NOT EXISTS health_observation_drafts (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
draft_key TEXT NOT NULL UNIQUE,
metric_set TEXT NOT NULL, -- blood_pressure | weight | spo2 | ...
extracted JSONB NOT NULL, -- 抽取结果 + confidence
validation JSONB NOT NULL, -- 校验结果 + 标记
status TEXT NOT NULL DEFAULT 'pending', -- pending | committed | discarded | expired
channel TEXT NOT NULL, -- h5 | wechat
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
expires_at TIMESTAMPTZ NOT NULL,
committed_at TIMESTAMPTZ NULL
);
-- 医疗资料档案
CREATE TABLE IF NOT EXISTS health_documents (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
doc_type TEXT NOT NULL, -- checkup | lab | imaging | prescription | discharge | other
report_date DATE NULL,
institution TEXT NULL,
title TEXT NOT NULL,
asset_id TEXT NULL, -- MindSpace 健康分区资产引用(原文件)
ocr_text TEXT NULL,
extracted_metrics JSONB NOT NULL DEFAULT '[]'::jsonb,
extraction_status TEXT NOT NULL DEFAULT 'pending', -- pending | partial | done | failed
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted_at TIMESTAMPTZ NULL
);
-- 个人基线(P1,引擎写入)
-- 注意:基线必须按 context 分层(见 §15 O3)。晨间与晚间血压生理上不同,
-- 混算基线会同时掩盖晨峰异常并制造假偏离。
CREATE TABLE IF NOT EXISTS health_baselines (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
metric_type TEXT NOT NULL,
context TEXT NOT NULL, -- morning | evening | any(不分层指标用 any)
window_days INT NOT NULL, -- 7 | 30 | 90
computed_at TIMESTAMPTZ NOT NULL,
sample_count INT NOT NULL,
mean_val NUMERIC(10,2) NULL,
std_val NUMERIC(10,2) NULL,
p25_val NUMERIC(10,2) NULL,
p75_val NUMERIC(10,2) NULL,
typical_low NUMERIC(10,2) NULL,
typical_high NUMERIC(10,2) NULL,
maturity TEXT NOT NULL, -- cold | weak | stable
UNIQUE (metric_type, context, window_days)
);
-- 健康事件(P1)
CREATE TABLE IF NOT EXISTS health_events (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
event_type TEXT NOT NULL, -- threshold_breach | baseline_drift | missed_measurement | symptom_cluster | correlation_hypothesis
severity TEXT NOT NULL, -- info | watch | alert
detected_at TIMESTAMPTZ NOT NULL,
rule_id TEXT NULL, -- 可审计:哪条规则触发
metrics_involved JSONB NOT NULL DEFAULT '[]'::jsonb,
evidence_observation_ids JSONB NOT NULL DEFAULT '[]'::jsonb,
agent_summary TEXT NULL, -- LLM 生成,不可作为 detection 依据
status TEXT NOT NULL DEFAULT 'open', -- open | acknowledged | resolved | dismissed
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- 关联假设(P1;晋升到 Memory V2 后记录 memory_item_id)
CREATE TABLE IF NOT EXISTS health_correlations (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
factor_a TEXT NOT NULL, -- sleep_deterioration
factor_b TEXT NOT NULL, -- bp_elevation
confidence NUMERIC(4,3) NOT NULL,
occurrence_count INT NOT NULL DEFAULT 1,
first_seen_at TIMESTAMPTZ NOT NULL,
last_seen_at TIMESTAMPTZ NOT NULL,
user_confirmed BOOLEAN NOT NULL DEFAULT FALSE,
memory_item_id TEXT NULL
);
```
设计要点:
- `health_observations` 是**唯一写入格式**。血压拆成 `bp_systolic` / `bp_diastolic` 两行,便于统一基线计算;确认卡在 UI 层合并展示。
- 所有含软删除的表都带 `deleted_at`(Page Data `soft_delete` 硬要求)。
- 唯一索引实现**幂等**:重复发送同一张照片不会产生重复观测。
- `time_source` 显式记录时间来源,用于后续数据质量分析。
### 8.3 Dataset 注册
每个表独立注册 dataset,遵守 `page-data-collect` 的「一任务一表一 dataset」原则。
```json
{
"name": "health_observations",
"table": "health_observations",
"description": "MeMind Health 个人健康观测记录",
"actions": ["read", "insert", "update", "softDelete"],
"columns": {
"read": ["id", "observed_at", "metric_type", "value_num", "value_text", "unit", "context", "source", "quality_flag", "created_at"],
"insert": ["observed_at", "metric_type", "value_num", "value_text", "unit", "context", "source", "source_ref", "time_source", "confidence", "quality_flag", "raw_payload"],
"update": ["observed_at", "value_num", "value_text", "context", "quality_flag"]
}
}
```
**关键安全约束**:`raw_payload`、`source_ref`、`ocr_text` **不得**出现在任何公开页的 `read` 白名单中(见 §9.4)。
### 8.4 写入路径(重要澄清)
Page Data 有两条 API:公开页 API 与 Owner Admin API。健康数据的写入**不走公开页 API**:
```text
H5 健康录入 / 服务号录入
→ 平台内登录态请求
→ health-observation-service.mjs
→ Owner 侧 Page Data 访问(/api/admin/page-data/* 或服务端直连 dataset 授权)
→ 用户隔离 PG schema
MindSpace 健康区页面(Timeline / 档案展示)
→ 走公开页 API,但仅 read
→ 且只读「摘要快照 dataset」,不直连原始 observations(见 §9.4)
```
这样原始健康数据永不通过公开页 API 暴露。
---
## 9. MindSpace 健康区:强制加密
### 9.1 新增系统分区
在 `mindspace.mjs` 的 `SYSTEM_CATEGORIES` 增加一项(设计,尚未实现):
```js
{
code: 'health',
name: '健康档案区',
visibilityPolicy: 'private',
aiAccessPolicy: 'explicit_asset_grant', // AI 需显式授权才能读具体资料
publishPolicy: 'password_required', // 新增策略值
sortOrder: 30,
}
```
`h5_space_categories.publish_policy` 是 `VARCHAR(64)`,新增枚举值**不需要改表结构**。
现有系统分区(`oa` / `public` / `draft` / `archive`)策略保持不变。
### 9.2 发布闸门(服务端硬阻断)
```text
publish(pageId, accessMode, password)
↓
category = resolveCategory(pageId)
↓
if (category.publishPolicy === 'password_required') {
if (accessMode === 'public') → 拒绝:健康区禁止完全公开
if (!password && !hasExistingPasswordHash) → 拒绝:必须设置口令
if (accessMode not in ['password', 'login_required']) → 拒绝
}
↓
继续现有发布流程(不改动其他分区行为)
```
必须是**服务端阻断**,不是前端禁用选项。前端仅额外做「不展示完全公开选项」的体验优化。
口令须满足现有平台约束:**≥ 6 位**,bind 时必须传入并写入 `password_hash`。禁止只改 `access_mode` 不写 `password_hash`。
### 9.3 访问体验:本人免密,外部必密
```text
访问健康页
↓
是 owner 且已登录?
├─ 是 → 直接放行(不要求输口令)
└─ 否 → 要求口令 → 校验 → 发 page-data token → 放行
```
- 健康区默认 `password` 模式(可分享给医生)。
- owner 登录态访问**短路**口令校验。
- 口令可重置、令牌可撤销,复用现有能力:
- `POST /api/admin/page-data/policies/:pageId/password/reset`
- `POST /api/admin/page-data/policies/:pageId/tokens/revoke`
### 9.4 分享安全:快照而非直连(关键设计)
`password` 模式**无法区分访客身份** —— 拿到口令的人能读该页授权的所有行。因此:
**禁止**健康区公开页直接 `read` `health_observations` / `health_documents`。
**正确做法**:为分享场景生成**只读摘要快照 dataset**:
```sql
CREATE TABLE IF NOT EXISTS health_share_snapshots (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
snapshot_key TEXT NOT NULL UNIQUE,
scope TEXT NOT NULL, -- clinic_summary | trend_30d | lab_selected
period_start DATE NOT NULL,
period_end DATE NOT NULL,
payload JSONB NOT NULL, -- 已脱敏、已聚合的展示数据
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
expires_at TIMESTAMPTZ NULL,
revoked_at TIMESTAMPTZ NULL
);
```
- 分享页只 `read` `health_share_snapshots`,且 `columns.read` 仅含 `payload` 等必要字段。
- 快照**由用户显式生成**,范围、时间窗、包含项由用户勾选。
- 快照可设过期时间、可撤销。
- 原始 `observations` / `ocr_text` / `raw_payload` **永不**进入任何公开页授权。
这解决了「分享给医生」与「数据最小暴露」的矛盾。
### 9.5 SEO 自动安全(无需改动)
`mindspace-index-policy.mjs` 中 `INDEXABLE_ACCESS_MODE = 'public'`,`isPublicationIndexable()` 要求 `accessMode === 'public'`。
健康区强制 `password` / `login_required` 后 → **自动 noindex**,无需额外改 SEO 逻辑。
此行为落在现有回归守卫 `docs/regression-guards/mindspace-seo-geo.md` 保护范围内,改动健康区时须一并跑 `npm run verify:seo-geo`。
### 9.6 健康区内容结构
```text
健康档案区/
timeline/ 健康时间线页(加密,owner 免密)
documents/ 报告归档
2026-08-体检报告/
2026-09-血脂化验/
assessments/ 评估摘要
shares/ 就诊摘要快照页(口令 + 可撤销 + 可过期)
```
---
## 10. Health Timeline 与 Personal Baseline
### 10.1 Health Timeline(日聚合视图,非存储层)
```text
2026-09-01
BP 128/76
HR 68
Weight 67.2kg
Sleep 6h32m
Symptom none
2026-09-02
BP 137/82 ↑ vs 30d baseline +9.8%
HR 72
Weight 67.4kg
Sleep 5h10m ↓ vs 30d baseline -17%
7-day trend:
BP baseline deviation +9.8%
Sleep baseline deviation -17%
```
实现:`health_observations` 按日 rollup。P0 用查询时聚合;数据量增长后再引入物化视图或定时 job。
### 10.2 Personal Baseline(P1)
滚动窗口,每日凌晨重算 7 / 30 / 90 天基线。
冷启动策略(`maturity` 字段):
| 阶段 | 样本量 | maturity | 行为 |
|---|---|---|---|
| 冷启动 | < 7 个有效点 | `cold` | 只展示原始值,**不产生偏离事件** |
| 弱基线 | 7–29 | `weak` | 可产生 `info` 级提示,不发 `alert` |
| 稳定 | ≥ 30 | `stable` | 全量启用 Trend Engine |
这条很重要:**基线未成熟就报警会摧毁用户信任。**
有效点定义:`quality_flag != 'suspect'` 且 `deleted_at IS NULL`。`suspect` 只是**待裁决**状态 —— 用户确认后必须落到 `ok` 或 `user_corrected`,不得长期停留,否则真实的恶化会被永久过滤掉。
**样本量按 context 分层计算**(O3 决策的直接后果):晨间血压基线只统计 `context='morning'` 的观测。这意味着若用户每天只测一次晨间血压,达到 `stable` 需 30 天;若晨晚各一次,两条基线各自独立计数,**不会因为总数翻倍而提前成熟**。
`weight`、`sleep_minutes` 等本身不分晨晚的指标使用 `context='any'`。
### 10.3 Rule Engine(确定性)
| 规则类 | 示例 | 严重度 |
|---|---|---|
| 绝对阈值 | SpO₂ < 90% | `alert` |
| 绝对阈值 | 收缩压 ≥ 180 或 舒张压 ≥ 110 | `alert` |
| 漏测 | 连续 3 天无晨间血压 | `watch` |
| 新症状 | 连续 3 天报告同一症状 | `watch` |
| 药物变化 | 新增/停用药物后 7 天内加强监测 | `info` |
输出 `health_events(event_type='threshold_breach', rule_id=...)`。
### 10.4 Trend Engine(统计,不用 LLM)
| 检测项 | 方法 |
|---|---|
| 基线漂移 | 近 14 天均值 vs 90 天基线,偏离 > 阈值且持续 ≥ N 天 |
| 变异性增大 | 近 7 天 std > 历史 std × 1.5 |
| 多指标联动 | 睡眠 ↓ + 静息 HR ↑ + BP ↑ 同期发生 → cluster |
输出 `health_events(event_type='baseline_drift' | 'symptom_cluster')`。
### 10.5 Event Engine → Agent
Event Engine 合并 Rule + Trend 结果,去重、分级、聚合为 Cluster,然后才调用 Agent。
**Agent 的输入是结构化 Event,不是原始时序**:
```json
{
"cluster_id": "...",
"window": "14d",
"baseline_maturity": "stable",
"findings": [
{ "metric": "bp_systolic", "deviation": "+8%", "baseline": "119-126", "recent": "132-138", "days_above": 5 },
{ "metric": "sleep_minutes", "deviation": "-17%" }
],
"open_questions": ["recent_insomnia", "medication_change", "alcohol", "stress"]
}
```
Agent 职责:
1. 用自然语言解释「发生了什么变化」(**不是诊断**)
2. 提出 1–2 个高价值追问
3. 给出行动建议:复测计划 / 记录给医生 / 何时就医
4. 将用户回答与关联假设写入 `health_correlations` → 满足条件后晋升 Memory V2
Agent **不能**反向修改 Event 严重度 —— 那是 Engine 的职责。
理想输出示例:
> 过去 30 天整体生命体征较稳定。最近 7 天晨间收缩压较过去 90 日个人基线上升约 8 mmHg,其中 5 天高于近期常态;同期睡眠时间有所下降。建议未来 7 天按规范继续早晚测量,如持续偏高,可将趋势记录提供给医生评估。
### 10.6 关联假设的长期学习
```text
第一次失眠期 → BP +7%
第二次失眠期 → BP +9%
第三次失眠期 → BP +8%
↓
occurrence_count = 3, confidence ↑
↓
晋升 Memory V2(label: health_correlation)
↓
"这个人的血压可能对睡眠变化比较敏感"
```
**术语纪律**:必须称「关联假设」,**禁止**表述为医疗诊断或因果结论。
---
## 11. 零侵入保证与灰度
### 11.1 四条保证
1. **全局 Feature Flag**:`MEMIND_HEALTH_ENABLED=false` 时,健康入口不展示、路由不注册、服务号关键词不生效,现有链路**行为不变**。
2. **只做加法**:意图路由**不改判定逻辑**,健康会话天然携带 `selectedChatSkill`,走的是已存在的短路分支。
3. **发布闸门只对 `health` 分区生效**:其他分区 `publish_policy` 不变,现有发布行为不受影响。
4. **fail-open**:抽取/基线服务不可用时,退化为「仅归档原图 + 提示稍后重试」,不阻塞会话、不影响普通聊天。
### 11.2 灰度
沿用仓库既有 canary 模式:按 `user_id` 灰度,先内部账号 → 小流量 → 全量。健康引擎(P1)应支持 `off / shadow / active` 三态,shadow 模式下只记录会产生哪些事件、不实际通知用户。
### 11.3 必须遵守的既有守卫
改动触及以下路径时,按 `AGENTS.md` 规则必跑对应 verify:
```bash
npm run verify:mindspace-publish-guards
npm run verify:mindspace-publish-guards:full
npm run verify:seo-geo
npm run verify:seo-discovery
npm run verify:page-data
```
特别注意:
- `chat-image-turn-scope.mjs` — 带图轮次 `read_image` 隔离
- `mindspace-index-policy.mjs` — 私有页 noindex
- `mindspace-page-sync-service.mjs` — remote 模式也必须 sync public HTML
### 11.4 新增测试
```text
health-channel-state.test.mjs 状态机全转移覆盖 + 无关输入不猜测
health-extraction.test.mjs schema 约束 / 量程校验 / 置信度门限
health-observation-service.test.mjs 幂等去重 / 时间来源优先级 / suspect 标记
health-baseline-engine.test.mjs 冷启动 maturity / 有效点过滤
health-event-engine.test.mjs 规则触发 + evidence chain 完整性
health-publish-guard.test.mjs 健康区禁止 public 发布(硬阻断)
health-share-snapshot.test.mjs 分享页不得授权原始 observations
```
---
## 12. 合规与产品边界
1. **非医疗器械声明**:MeMind Health 是健康记录与趋势提醒,**不提供诊断**。
2. **关联假设 ≠ 诊断**:Agent 输出统一附 disclaimer 模板。
3. **Evidence chain 可追溯**:每条 alert 可展开到 `evidence_observation_ids` + `rule_id`。监管与客诉必备。
4. **数据分级**:健康观测为敏感数据。分享须用户显式生成快照、可过期、可撤销。
5. **LLM 边界**:detection 层**零 LLM**;explanation 层 LLM 输入仅为结构化 Event。
6. **删除权**:用户可删除任意 Observation / Document;删除后基线须重算。
### 12.1 隐私默认值
- 健康区默认 `private`,`aiAccessPolicy: explicit_asset_grant`。
- Agent 读取具体报告内容需显式授权,不默认全量注入上下文。
- 健康数据**不参与** SEO/GEO 收录(§9.5 自动保证)。
### 12.2 数据保留
复用 Memory V2 生命周期思路:Observation 长期保留(健康趋势需要长历史);但抽取中间产物(原始视觉输出)可设较短保留期。
### 12.3 P1 存储迁移预案
若 Page Data 聚合性能不足,迁移到专用表的前提:
- 保持 `health_observations` 列结构不变,仅换存储位置
- 提供双写 + 校验期
- 迁移前后 Timeline 与基线结果必须一致(回归对账脚本)
---
## 13. 分阶段路线
### P0 — 验证入口与录入精度(不做引擎)
- H5 `/health` 入口 + 通道状态机
- 手动录入 + 拍照录入(三段式确认)
- Health Timeline 展示
- MindSpace 健康区(强制加密)+ 报告归档
- 服务号:关键词/菜单进入 + 拍照上传 + 确认落库
- `health-assistant` skill v0(读 Timeline 摘要回答「最近怎么样」)
**不做**:基线、Event Engine、设备接入、分享。
**验证指标**:
- 拍照识别经确认后准确率 ≥ 98%
- 通道内漂移事件(被路由到非健康 skill)= 0
- 健康区无法以 `public` 模式发布(自动化测试保证)
- 连续 14 天录入完成率
### P1 — Personal Baseline + Event Engine(核心命题验证)
- Baseline Engine(7/30/90 天 + maturity)
- Trend Engine + Rule Engine v1
- Event Engine + Agent 解释
- 关联假设 v0 → Memory V2 晋升
**验证方式**:回溯已有 30 天数据,Event Engine 能否在用户主观感知前 3–7 天产生 `watch` 事件;并访谈「这条提醒有用吗?会不会太吵?」
### P2 — 设备接入与提醒
- Apple Health / 蓝牙设备 Connector(设备可信 → 免确认路径,但异常值仍标 `suspect`)
- 分级通知策略(`info` 静默 / `watch` 日摘要 / `alert` 即时)
### P3 — 医疗纵深
- 用药 Timeline(整合 `schedule-assistant`)
- 检验指标长期趋势
- 就诊摘要加密分享页(§9.4 快照机制)
### P4 — 授权分享
- 本人授权家人/医生**只读摘要**(非代录、非原始数据)
- 机构侧只读接口(如有需求)
---
## 14. P0 前置最小实验(先做,成本极低)
在写任何平台代码前,用 14 天人工实验验证核心假设。
**完整可执行方案见 [memind-health-p0-experiment.md](memind-health-p0-experiment.md)**,此处仅摘要。
```text
参与者:8–10 人(最低 5 人);有意纳入更可能出现波动的人群
工具: 现有 Page Data 匿名问卷页(方案 A),无需新代码
D1–D7 : 基线建设期,每日录入晨间 BP + 睡眠 + 症状 + 扰动因素
D8 : 手工计算 7 天基线(Excel 即可)
D8–D14: 继续录入,研究者每日手工标记偏离(不告知参与者)
D14 : 盲态访谈(必须在揭盲前完成)
```
**主结局 H1(一定可测)**:14 天晨测完成率 ≥ 80% 的参与者 ≥ 3/5。
**次结局 H2(可能不可判定)**:手工偏离标记早于主观察觉 ≥ 3 天的参与者 ≥ 3/5。
关键设计取舍:
- **H1 是主结局,不是 H2。** 若用户坚持不下来,再精妙的基线算法都无意义;此时正确反应是把设备自动同步提到最高优先级,而不是优化算法。
- **14 天内可能无人偏离**,这是最可能的失败模式,方案中已预设无偏离处理路径,不得因 H2 无结论就否定项目方向。
- **漏检比误报更值得研究**:用户明确感到变化而基线检测不到,说明指标组合或窗口有问题。
- **顺带产出照片校验集**:参与者同时提供设备屏幕照片 + 手工录入值,形成 `(图片 → 正确值)` 标注对,直接用作 P0 拍照识别的回归用例。这是很难另外获得的资产。
---
## 15. 已决事项(O1–O6)
以下六项已决策,实现必须遵守。变更需重新评审本章。
### O1 症状录入:固定枚举 + 严重度 + 可选备注
**决策**:采用固定枚举,附三级严重度,允许自由备注,但备注**不参与**结构化分析。
```text
枚举(可多选;无症状必须显式选「无」):
无 / 头晕 / 头痛 / 胸闷 / 心慌心悸 / 乏力 / 气短 / 下肢水肿 / 失眠 / 其他
严重度(每个勾选项必填):
轻 = 1 / 中 = 2 / 重 = 3
自由备注:可选,存 value_text,仅供 Agent 解释时参考
```
**理由**:`symptom_cluster` 检测需要可比较的离散值。自由文本会产生「有点晕」「头晕晕的」「晕」等语义漂移,无法计数、无法判断「连续 3 天同一症状」。
**存储**:每个症状一行 `health_observations`,`metric_type='symptom'`,`value_text=` 枚举码,`value_num=` 严重度。这样症状严重度也可做趋势(如「头晕从轻转中」)。
**「无症状」必须显式记录**,不能靠「没有记录」推断 —— 否则无法区分「今天没症状」与「今天忘了记」。
### O2 语音录入:必须确认,不得直接落库
**决策**:语音识别结果**一律**进入 `CONFIRM` 状态,禁止跳过确认。
**理由**:数字类语音识别错误率显著高于文本,且错误形态危险 —— 「一百三十七比八十二」可能识别为「137、82」也可能是「130、782」。血压场景一次错记就污染基线。
**附加要求**:语音录入的确认卡必须**同时展示识别原文**,让用户能判断是识别错了还是自己说错了:
```text
听到:「血压一百三十七比八十二」
识别:收缩压 137,舒张压 82
确认保存 / 修改 / 重说
```
### O3 血压强制记录情境,且基线按情境分层
**决策**:血压录入**强制**记录情境,三选一,按测量时刻自动预选,用户可改。
```text
morning 晨间(默认:测量时刻 < 10:00)
evening 晚间(默认:测量时刻 ≥ 18:00)
other 其他(10:00–18:00 默认,或用户手动指定)
```
**这不只是一个字段 —— 基线必须按 context 分层计算**(已反映到 §8.2 `health_baselines` 的 `UNIQUE (metric_type, context, window_days)`)。
**理由**(这是本次评审中最重要的修正):晨间血压与晚间血压生理上是不同指标,个体内可差 10–20 mmHg。若混算基线:
- 晨峰升高会被晚间的低值稀释 → **漏掉最有临床意义的异常**
- 用户某天只测了晚间 → 与混合基线比较 → **产生假偏离**
两种错误方向相反,但都由同一个原因造成。因此分层是必需的,不是优化项。
**代价**:达到 `stable` 所需天数不因多测而减少(见 §10.2)。这个代价可以接受 —— 宁可基线成熟慢,不要基线本身是错的。
`other` 情境的观测**记录但不参与**晨/晚基线计算,仅在 Timeline 展示。
### O4 报告 OCR:复用现有图片/文件分析链路,不新建 OCR 服务
**决策**:健康侧**不实现**独立 OCR。复用仓库现有的图片与文件分析能力(服务号侧 media analysis、Agent 侧图片/文件读取),健康域只在其**之上**叠加两层:
```text
现有图片/文件分析链路(不改动)
↓
health-extraction.mjs
├─ 强约束输出 schema(metric_set + 字段 + confidence)
└─ 合理性校验(量程 / 逻辑 / 基线偏离 / 置信门限)
↓
ObservationDraft(待确认)
```
**理由**:重复实现 OCR 会造成两套图片处理路径,直接威胁 `chat-image-turn-scope.mjs` 的回归守卫(带图轮次 `read_image` 隔离、工具图片污染触发会话轮换)。健康通道必须走同一条底层路径。
**实施前置检查**:动工前需确认现有链路对「多页 PDF」「长图化验单」的支持程度。若不支持,应作为独立需求补齐**通用能力**,而不是在健康域内私建分支。
### O5 alert 通知:不承诺推送,主机制为「下次进入即见」
**决策**:`alert` 触达**不作为产品承诺**。核心机制是进入即见,推送为 best-effort 补充。
分级策略:
| 严重度 | 触达方式 |
|---|---|
| `info` | 静默,仅健康首页与 Timeline 可见 |
| `watch` | 进入即见 + 可选日摘要(用户须主动开启) |
| `alert` | 进入即见(置顶未读)+ 48 小时内有交互时发服务号客服消息 + 已订阅者发订阅通知 |
**理由**:服务号客服消息受 48 小时交互窗口限制,订阅通知需用户主动订阅且有频次场景限制;H5 无主动推送能力。**恰恰是长期不互动的用户最需要提醒,而这类用户技术上最难触达。**
**因此产品措辞必须修正**:
```text
禁止:「异常时我们会通知你」
应为:「异常会记录在你的健康档案,并在你下次进入时优先展示」
```
**实现要求**:任何入口进入健康通道时(无论何种原因),必须先展示未读 `alert`,且未读状态在用户明确确认前不清除。
**同时**:应引导用户主动订阅服务号通知,并在引导文案中说明原因,而不是默认用户已可触达。
### O6 偏离阈值:P0 不定死,由实验数据标定
**决策**:不在设计阶段固化阈值。P0 采用下列**初始建议值**,并在前置实验与 P0 数据积累阶段完成标定。
初始建议值(仅供实验期手工判定与 P1 起点,**非最终值**):
| 指标 | 单日标记 | 持续偏离(触发事件) |
|---|---|---|
| 晨间收缩压 | `dev% ≥ +5%` 或 `z ≥ +1.5` | 连续 ≥ 2 天同向 → `watch`;≥ 5 天或 `dev% ≥ +10%` → `alert` 候选 |
| 晨间舒张压 | 同上 | 同上 |
| 睡眠时长 | `dev% ≤ -15%` 或 `z ≤ -1.5` | 连续 ≥ 3 天 → 参与 cluster 判定 |
| 静息心率 | `dev% ≥ +10%` 或 `z ≥ +1.5` | 连续 ≥ 3 天 → 参与 cluster 判定 |
| 体重 | 7 日内变化 ≥ 1.5kg | 直接 `watch` |
**双重判定(相对偏差 OR 标准分)的理由**:相对偏差直观但忽略个体变异性 —— 变异大的人频繁误报;z 分数考虑变异性但小样本下 `std` 不稳定。两者取「或」,宁可多标记后人工/规则复核,也不要漏检。
**标定流程**:
```text
前置实验(14 天,手工判定)
→ 记录首次偏离日 vs 主观察觉日
→ 计算不同阈值下的提前天数与误报数
→ 选择「提前 ≥ 3 天且误报可接受」的阈值组合
→ 写回本节,作为 P1 Trend Engine 初始配置
```
**阈值必须可配置**,不得硬编码在检测逻辑中 —— 后续需按真实数据持续调整。
---
## 16. 相关文档
- [memind-health-p0-experiment.md](memind-health-p0-experiment.md) — **P0 前置 14 天实验可执行方案**
- [ENGINEERING_WORKFLOW_RULES.md](../ENGINEERING_WORKFLOW_RULES.md)
- [PRODUCTION_RELEASE_RULES.md](../PRODUCTION_RELEASE_RULES.md)
- [page-data-api-usage.md](page-data-api-usage.md)
- [schedule-reminder-design.md](schedule-reminder-design.md)
- [memory-v2/README.md](memory-v2/README.md)
- [ai-mind-memind-integration-architecture.md](ai-mind-memind-integration-architecture.md)
- [regression-guards/vision-turn-read-image-isolation.md](regression-guards/vision-turn-read-image-isolation.md)
- [regression-guards/mindspace-seo-geo.md](regression-guards/mindspace-seo-geo.md)
- [regression-guards/README.md](regression-guards/README.md)