diff --git a/chat-intent-router.mjs b/chat-intent-router.mjs index 9042321..8bb690e 100644 --- a/chat-intent-router.mjs +++ b/chat-intent-router.mjs @@ -75,6 +75,7 @@ const SKILL_PROMPT_KEYS = { 'table-viewer': 'table-viewer', 'product-campaign-page': 'product-campaign-page', 'excel-analyst': 'excel-analyst', + 'health-assistant': 'health-assistant', }; const OBVIOUS_AGENT_PATTERNS = [ diff --git a/chat-skills.mjs b/chat-skills.mjs index 4c02143..b3f9663 100644 --- a/chat-skills.mjs +++ b/chat-skills.mjs @@ -270,6 +270,14 @@ export const CHAT_SKILL_DEFINITIONS = [ requiresPublish: true, promptKey: 'page-data-collect', }, + { + id: 'health-assistant', + label: '健康助手', + icon: 'analyze', + skillName: 'health-assistant', + requiresSkill: 'health-assistant', + promptKey: 'health-assistant', + }, { id: 'table-view', label: '数据表格', @@ -572,6 +580,12 @@ export function buildChatSkillPrompt(promptKey, skillName) { '先澄清缺失的执行时间或 taskSpec,再调用 scheduled_task_create / scheduled_task_list / scheduled_task_cancel。' + '禁止在当前对话立即执行长任务;工具成功返回后才能确认已设置。一次性任务用 runAtLocal,循环任务用 hour/minute/weekday。我的需求是:' ); + case 'health-assistant': + return ( + `请使用 ${skillName ?? 'health-assistant'} 技能:这是本人健康通道。` + + '只处理健康录入、评估和档案;数值必须确认后才保存;意图不明时给出数字选项,不要猜测。' + + '禁止诊断,禁止把他人健康数据写入观测表,禁止公开发布健康页。用户消息:' + ); default: if (isPageTemplatePromptKey(promptKey)) { return buildPageTemplateSkillPrompt(promptKey); diff --git a/chat-skills.test.mjs b/chat-skills.test.mjs index 7836f2e..e4f9476 100644 --- a/chat-skills.test.mjs +++ b/chat-skills.test.mjs @@ -15,6 +15,16 @@ import { applyPageTemplatePrefill, } from './chat-skills.mjs'; +test('filterChatSkills hides health assistant unless granted', () => { + const hidden = filterChatSkills(CHAT_SKILL_DEFINITIONS, { canPublish: true }); + assert.equal(hidden.some((item) => item.id === 'health-assistant'), false); + const visible = filterChatSkills(CHAT_SKILL_DEFINITIONS, { + canPublish: true, + grantedSkills: ['health-assistant'], + }); + assert.ok(visible.some((item) => item.id === 'health-assistant')); +}); + test('filterChatSkills shows summarize and analyze without granted skills', () => { const visible = filterChatSkills(CHAT_SKILL_DEFINITIONS, { canPublish: false }); assert.ok(visible.some((item) => item.id === 'summarize')); diff --git a/docs/memind-health-architecture.md b/docs/memind-health-architecture.md new file mode 100644 index 0000000..1bab8f9 --- /dev/null +++ b/docs/memind-health-architecture.md @@ -0,0 +1,1206 @@ +# 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) diff --git a/docs/memind-health-p0-experiment.md b/docs/memind-health-p0-experiment.md new file mode 100644 index 0000000..057a55d --- /dev/null +++ b/docs/memind-health-p0-experiment.md @@ -0,0 +1,573 @@ +# MeMind Health P0 前置实验方案(14 天人工实验) + +日期:2026-09-02 +状态:可执行方案。**不涉及业务代码改动,不碰生产链路。** +上游文档:[memind-health-architecture.md](memind-health-architecture.md) §14 + +--- + +## 1. 为什么先做这个实验 + +在写任何健康平台代码前,必须先验证两件事: + +1. **用户会不会持续录入**(依从性)—— 这是产品的生死线。没有连续数据,基线永远不成熟,Event Engine 永远不起作用,整套架构价值归零。 +2. **基线偏离是否真的早于主观察觉** —— 这是产品的核心命题。 + +这两件事**不需要任何平台代码**就能验证。实验成本极低,但能避免在错误方向上投入数月开发。 + +### 1.1 实验的额外产出(同等重要) + +除验证假设外,本实验还应顺带产出三项 P0 开发的必要资产: + +| 产出 | 用途 | +|---|---| +| **设备照片校验集** | `health-extraction.test.mjs` 的回归用例,防止换模型后精度回退 | +| **真实偏离阈值** | 标定 O6,替代凭空设定的百分比 | +| **入口摩擦排序** | 决定 H5 与服务号谁是留存主战场 | + +--- + +## 2. 假设与结局指标 + +### 2.1 主结局(一定可测,决定是否继续) + +**H1 依从性**:用户能否在无强制手段下连续 14 天录入晨间血压。 + +```text +主指标:14 天晨间血压录入完成率 = 实际录入天数 / 14 + +成功阈值:≥ 3/5 参与者完成率 ≥ 80%(即 ≥ 12/14 天) +警戒线: 若 ≥ 3/5 参与者完成率 < 50%,P0 入口形态必须重新设计 +``` + +### 2.2 次结局(可能无法判定,需预设应对) + +**H2 核心命题**:手工基线偏离标记是否早于用户主观察觉。 + +```text +指标:首次偏离标记日期 vs 用户主观首次察觉日期 + +成功阈值:≥ 3/5 参与者出现「标记早于主观察觉 ≥ 3 天」 +``` + +**重要风险**:14 天内健康人群可能**完全没有偏离**,导致 H2 无法判定。这是本实验最可能的失败模式,必须提前应对(见 §3.2 招募策略、§7.3 无偏离处理)。 + +### 2.3 探索性观察(不设阈值,仅记录) + +- **H3 摩擦**:不同录入渠道的实际使用比例与放弃点。 +- **H4 数据质量**:手输 vs 拍照的一致性;用户是否记错测量时间。 +- **H5 联动**:睡眠时长与次日晨间血压是否存在个体内相关。 + +--- + +## 3. 参与者 + +### 3.1 样本量 + +```text +建议:8–10 人(原方案 5 人对 H2 统计力不足) +最低:5 人(仅能验证 H1,H2 视为探索性) +``` + +若只能招募 5 人,必须明确:**H1 是主结局,H2 降级为探索性观察**,不以 H2 结果决定项目走向。 + +### 3.2 招募策略(针对 H2 风险) + +单纯招健康人,14 天内很可能零偏离。应**有意纳入更可能出现波动的人群**: + +| 优先纳入 | 理由 | +|---|---| +| 血压偏高但未服药(正常高值) | 波动概率高 | +| 作息不规律 / 经常熬夜 | 睡眠-血压联动可观察 | +| 50 岁以上 | 基线漂移概率高 | +| 有服药且近期可能调整 | 药物变化是天然扰动 | + +**排除**: +- 服用降压药且血压被严格控制的(波动被药物压平) +- 无法自行完成测量的 +- 近期计划手术/住院的 + +**混合建议**:6 人「可能波动」+ 2–4 人「相对健康」作为对照。 + +### 3.3 知情同意(必须书面确认) + +参与前必须明确告知并留存确认记录: + +```text +1. 本实验为产品可行性研究,不提供任何医疗诊断或治疗建议 +2. 若测量值异常或身体不适,请立即就医,不要等待实验结论 +3. 数据仅用于本次产品研究,不对外提供,实验结束后可要求删除 +4. 可随时无理由退出,退出不需要解释 +5. 实验期间不会有人替你判断健康状况 +``` + +**特别强调第 2 条**:实验中若出现 SpO₂ < 90%、收缩压 ≥ 180、舒张压 ≥ 110 或明显症状,研究者必须立即建议就医并终止该参与者实验。这是伦理底线,不能因为"要收集数据"而延迟。 + +--- + +## 4. 测量规程(数据质量的核心) + +这一节是整个实验的关键。「参差不齐」的根源就是测量规程不统一 —— 若此处松散,后面所有基线计算都无意义。 + +### 4.1 晨间血压(必测,主指标来源) + +```text +时间:起床后 1 小时内,且服药前(若有服药) +准备:排空膀胱 → 静坐休息 5 分钟 +姿势:坐姿,背有支撑,双脚平放,不交叉腿 +袖带:同一侧上臂(固定左或右,全程不换),袖带下缘距肘窝 2–3cm + 袖带与心脏同高 +测量:连续测 2 次,间隔 1 分钟,记录第 2 次读数 +禁忌:测量前 30 分钟内不吸烟、不饮咖啡/浓茶、不运动 +记录:收缩压 / 舒张压 / 脉搏 / 实际测量时刻 +``` + +**固定同一侧手臂**很重要 —— 双臂血压可差 5–10 mmHg,换臂会直接制造假偏离。 + +### 4.2 晚间血压(可选但推荐) + +```text +时间:睡前 1 小时内 +其余规程同晨间 +``` + +### 4.3 睡眠时长 + +```text +记录:入睡时间(估计)+ 起床时间 → 计算时长 +精度:15 分钟取整即可,不要求精确 +若有手表/手环数据:优先用设备数据,并注明来源 +``` + +### 4.4 症状 + +```text +每日勾选(可多选,无症状则选"无"): +无 / 头晕 / 头痛 / 胸闷 / 心慌心悸 / 乏力 / 气短 / 下肢水肿 / 失眠 / 其他 + +每个勾选项标注严重度:轻 / 中 / 重 +``` + +固定枚举而非自由文本 —— 否则无法做 cluster 分析(对应架构文档 O1)。 + +### 4.5 体重(隔日) + +```text +时间:晨起排空后,空腹,同一体重秤 +着装:尽量一致(如仅内衣) +频率:隔日即可,不必每天 +``` + +### 4.6 扰动因素(每日记录,用于关联分析) + +```text +昨日是否饮酒:无 / 少量 / 较多 +昨日情绪压力:低 / 中 / 高 +用药变化:无 / 新增 / 停用 / 剂量调整(注明) +其他:感冒、发烧、加班、旅行等(自由备注) +``` + +**这一项是 H5 联动假设的关键。** 没有扰动记录,就无法解释偏离原因。 + +### 4.7 设备照片(用于建校验集) + +```text +请参与者在每次晨测后额外拍一张血压计屏幕照片,通过微信发给研究者。 + +要求: +- 屏幕完整可见,不要求角度端正(正是要收集"不完美"样本) +- 不同光照、不同角度、有反光的都要,越多样越好 +- 若屏幕显示时间,请一并拍进去 + +用途:作为 P0 拍照识别的回归校验集。照片与其手工录入的数值配对, + 形成 (图片 → 正确值) 标注对,可直接用于 health-extraction 测试。 +``` + +**这是本实验最被低估的产出。** 真实设备照片标注集很难获得,而它直接决定 P0 拍照识别能否达到 98% 准确率。照片单独收集,**不进 Page Data**(Page Data 不适合存文件)。 + +--- + +## 5. 录入工具 + +### 5.1 方案:Page Data 匿名问卷页(方案 A) + +用现有 `page-data-collect` 能力搭建,**无需任何新代码**。仓库已提供可直接 bind 的模板: + +- `templates/health-p0-log.html`(参与者录入) +- `templates/health-p0-admin.html`(研究者口令后台) + +对应 SQL、dataset 与 bind 规格在 `health-p0-experiment.mjs`。 + +选择方案 A(匿名 insert + 口令后台)而非登录方案,理由:**参与者无需注册登录,摩擦最低**。这也顺带测试了「零登录摩擦」下的依从性上限。 + +```text +参与者页:public 模式,仅 insert +研究者页:password 模式,仅 read +``` + +参与者用 `participant_code`(如 `P01`–`P10`)区分身份,无需账号。 + +### 5.2 建表 SQL + +```sql +CREATE TABLE IF NOT EXISTS health_p0_daily_log ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + participant_code TEXT NOT NULL, + log_date DATE NOT NULL, + + -- 晨间血压(必填) + am_measured_at TEXT NULL, -- 实际测量时刻,如 "07:20" + am_systolic INT NULL, + am_diastolic INT NULL, + am_pulse INT NULL, + + -- 晚间血压(可选) + pm_measured_at TEXT NULL, + pm_systolic INT NULL, + pm_diastolic INT NULL, + pm_pulse INT NULL, + + -- 睡眠 + sleep_start TEXT NULL, + sleep_end TEXT NULL, + sleep_minutes INT NULL, + sleep_source TEXT NULL, -- self_estimate | device + + -- 体重(隔日) + weight_kg NUMERIC(5,1) NULL, + + -- 症状(JSON 数组,含严重度) + symptoms JSONB NOT NULL DEFAULT '[]'::jsonb, + + -- 扰动因素 + alcohol TEXT NULL, -- none | light | heavy + stress TEXT NULL, -- low | mid | high + medication_change TEXT NULL, -- none | added | stopped | dose_changed + medication_note TEXT NULL, + other_note TEXT NULL, + + -- 元信息 + entry_channel TEXT NULL, -- h5_form | wechat_manual | other + photo_sent BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMPTZ NULL +); + +CREATE INDEX IF NOT EXISTS idx_p0_participant_date + ON health_p0_daily_log (participant_code, log_date DESC); +``` + +### 5.3 Dataset 注册 + +```json +{ + "name": "health_p0_daily_log", + "table": "health_p0_daily_log", + "description": "MeMind Health P0 前置实验每日记录", + "actions": ["read", "insert"], + "columns": { + "read": ["id", "participant_code", "log_date", "am_measured_at", "am_systolic", "am_diastolic", "am_pulse", "pm_measured_at", "pm_systolic", "pm_diastolic", "pm_pulse", "sleep_start", "sleep_end", "sleep_minutes", "sleep_source", "weight_kg", "symptoms", "alcohol", "stress", "medication_change", "medication_note", "other_note", "entry_channel", "photo_sent", "created_at"], + "insert": ["participant_code", "log_date", "am_measured_at", "am_systolic", "am_diastolic", "am_pulse", "pm_measured_at", "pm_systolic", "pm_diastolic", "pm_pulse", "sleep_start", "sleep_end", "sleep_minutes", "sleep_source", "weight_kg", "symptoms", "alcohol", "stress", "medication_change", "medication_note", "other_note", "entry_channel", "photo_sent"] + } +} +``` + +### 5.4 页面绑定 + +```json +// 参与者录入页 +{ + "relativePath": "public/health-p0-log.html", + "accessMode": "public", + "datasets": { + "health_p0_daily_log": { + "insert": true, + "read": false + } + } +} + +// 研究者查看页(独立文件) +{ + "relativePath": "public/health-p0-admin.html", + "accessMode": "password", + "password": "<设置 ≥6 位口令>", + "datasets": { + "health_p0_daily_log": { + "insert": false, + "read": true + } + } +} +``` + +**注意**:参与者页与研究者页必须是**两个 HTML 文件、各 bind 一次**。同页不能同时「匿名 insert」+「口令 read 全量」(平台硬约束)。 + +### 5.5 表单设计要点(直接影响 H1) + +录入页必须做到**60 秒内完成**,否则依从性数据不可信: + +- `participant_code` 首次输入后记住(用 URL 参数传递,如 `?p=P01`,**不用 localStorage**,平台禁止) +- `log_date` 默认今天 +- 数值输入用 `type="number"` + `inputmode="numeric"`,唤起数字键盘 +- 症状默认选中「无」,多数天一键跳过 +- 晚间血压、体重、扰动因素默认折叠,不展开不影响提交 +- 提交后立即显示「已记录第 N 天」+ 本人历史晨间血压范围(给即时回报,见 §6.3) + +--- + +## 6. 执行流程 + +### 6.1 时间线 + +```text +D-3 ~ D-1 招募、知情同意、发放测量规程、确认设备可用、试录 1 次 +D1 ~ D7 基线建设期:每日录入,不做任何偏离反馈 +D8 中期分析:手工计算 7 天基线(研究者侧,不告知参与者) +D8 ~ D14 观察期:每日录入,研究者每日手工标记是否偏离(不告知参与者) +D14 盲态访谈(关键:必须在告知任何系统判定前进行) +D15 揭盲、对比、结论 +``` + +### 6.2 每日提醒 + +由研究者手工发送(不做自动化),固定时间: + +```text +早 07:00 「早上好,起床后 1 小时内测量血压,记得静坐 5 分钟。[录入链接]」 +晚 21:00 (仅对当日未录入者)「今天还没有记录,方便补录吗?」 +``` + +**同时记录提醒效果**:有多少天是提醒后才录的、有多少天是主动录的。这直接反映真实依从性 —— 若 90% 依赖提醒,说明产品必须有可靠推送机制(而这恰恰是弱项,见架构文档 O5)。 + +### 6.3 基线建设进度反馈(H1 的关键干预) + +用户录了 7 天什么反馈都没有,必然放弃。所以 `cold` 期必须给**非偏离类**的即时价值: + +```text +D3 「已连续记录 3 天。你的晨间收缩压目前在 122–128 之间。」 +D7 「7 天记录完成。初步基线:118–126 / 72–78。继续记录将开启趋势观察。」 +D10 「已记录 10 天,数据完整度 100%。」 +``` + +**注意**:D8–D14 观察期**不得**向参与者透露偏离判定,否则污染 D14 盲态访谈。进度反馈只说「记录了多少天」和「基线范围」,不说「你最近偏高」。 + +### 6.4 退出处理 + +参与者中断时,必须记录: + +```text +- 中断发生在第几天 +- 是否有明确原因(忘记 / 麻烦 / 出差 / 设备问题 / 不想继续 / 其他) +- 是暂停还是彻底退出 +``` + +**中断本身就是最有价值的数据。** 不要试图挽留 —— 挽留会污染依从性测量。 + +--- + +## 7. 分析方法 + +### 7.1 D8 基线计算 + +对每位参与者,用 D1–D7 的晨间数据计算: + +```text +n 有效天数(缺失不补) +mean 均值 +std 样本标准差 +min, max 极值 +p25, p75 四分位 +typical 建议表示为 round(p25) – round(p75) +``` + +**分别计算收缩压与舒张压**,不合并。 + +若某参与者 D1–D7 有效天数 < 5,标记为「基线不可用」,该参与者 H2 不参与判定(但仍计入 H1)。 + +### 7.2 D8–D14 偏离标记 + +每天对晨间收缩压做双重判定: + +```text +相对偏差: dev% = (value - mean) / mean × 100% +标准分: z = (value - mean) / std + +单日标记条件(满足任一): + dev% ≥ +5% 或 z ≥ +1.5 + +持续偏离标记(首次偏离日期以此为准): + 连续 ≥ 2 天出现同向单日标记 +``` + +**为什么用双重判定**:相对偏差直观但忽略个体变异性;z 分数考虑变异性但小样本下 std 不稳。两者取或,宁可多标记,后续人工复核。 + +**记录每位参与者的「首次持续偏离日期」**,这是与主观察觉对比的关键值。 + +同时对睡眠做同样计算(方向相反:`dev% ≤ -15%` 或 `z ≤ -1.5`),用于观察 H5 联动。 + +### 7.3 无偏离情况的处理(必须预设) + +若某参与者 D8–D14 完全无偏离标记: + +```text +不算失败,记录为「稳定期」。 + +处理: +1. 该参与者不参与 H2 主判定 +2. 但需确认其 D14 主观回答也是「和平时一样」 + → 若主观也说一样 → 这是一次正确的"真阴性",同样有价值 + → 若主观说"不太一样"但系统无标记 → 这是漏检,必须深挖原因 +``` + +**漏检比误报更值得研究。** 若用户明确感到变化而基线检测不到,说明指标组合或窗口选择有问题 —— 这是本实验最有价值的发现之一。 + +若 **全部参与者均无偏离**,则 H2 无法判定。此时: + +```text +1. H1 结论仍然有效,可据此决定 P0 入口形态 +2. H2 需延长观察至 30 天,或扩大样本,或调整招募标准 +3. 不得因 H2 无结论就否定整个项目方向 +``` + +### 7.4 计算工具 + +D8 与 D14 的计算量很小(每人 7–14 行数据),**用 Excel / Numbers 即可**,不需要写代码: + +```text +mean: =AVERAGE(range) +std: =STDEV.S(range) +p25: =QUARTILE.INC(range, 1) +p75: =QUARTILE.INC(range, 3) +dev%: =(value - mean) / mean +z: =(value - mean) / std +``` + +保持手工计算的另一个好处:**逼迫研究者逐个看数据**,容易发现异常模式(如某人明显记错时间、某天数值突变),这是自动化脚本会掩盖的。 + +--- + +## 8. D14 盲态访谈脚本 + +**执行纪律**:访谈必须在**告知任何判定结果之前**完成。研究者不得在访谈中透露「你最近偏高」之类信息,否则 H2 结论无效。 + +按顺序提问,**不要跳问,不要引导**: + +```text +Q1(开放,不提任何指标) + 「这两周,你觉得自己的身体状态和平时一样吗?」 + → 记录原话 + +Q2(仅在 Q1 回答"不太一样"时问) + 「大概从哪天开始感觉不一样的?」 + → 记录具体日期或"第几天左右" + → 这是"主观首次察觉日期" + +Q3(不一样的具体表现) + 「具体是哪里不太一样?」 + → 记录原话,不要提示选项 + +Q4(扰动因素回溯,此时才可提具体项) + 「这两周有没有出现:失眠、感冒、饮酒增加、情绪压力大、 + 用药变化、加班或作息改变?」 + → 逐项确认,记录时间范围 + +Q5(录入体验,H1/H3) + 「每天记录这件事,麻烦吗?哪一步最烦?」 + 「如果没有我提醒,你觉得自己能坚持吗?」 + 「你更愿意用哪种方式记录:网页表单、微信拍照、还是别的?」 + +Q6(价值感知) + 「这两周的记录,对你有用吗?有用在哪里?」 + 「如果有个助手能告诉你'最近和以前不一样',你会想用吗?」 + +Q7(继续意愿) + 「实验结束后,你会继续记录吗?为什么?」 +``` + +访谈结束后才可揭盲,告知手工分析结果,并观察其反应(这本身是对 Agent 话术的预演)。 + +--- + +## 9. 数据记录模板 + +### 9.1 参与者台账 + +| 编号 | 年龄段 | 纳入类型 | 设备型号 | 起止日期 | 晨测完成天数 | 完成率 | 中断天 | 中断原因 | 状态 | +|---|---|---|---|---|---|---|---|---|---| +| P01 | | 可能波动/对照 | | | /14 | | | | 完成/退出 | + +### 9.2 基线与偏离结果 + +| 编号 | 有效基线天数 | SBP mean | SBP std | SBP typical | 首次持续偏离日 | 主观首次察觉日 | 提前天数 | 判定 | +|---|---|---|---|---|---|---|---|---| +| P01 | | | | | D__ | D__ / 无 | | 命中/漏检/真阴性/不可判定 | + +### 9.3 依从性明细 + +| 编号 | 主动录入天数 | 提醒后录入天数 | 未录入天数 | 平均录入耗时 | 主要渠道 | +|---|---|---|---|---|---| + +### 9.4 照片校验集 + +| 照片编号 | 参与者 | 日期 | 手工录入值 | 屏幕是否有时间 | 光照/角度备注 | +|---|---|---|---|---|---| + +--- + +## 10. 决策矩阵:实验结果如何影响 P0 + +| H1 结果 | H2 结果 | 结论与行动 | +|---|---|---| +| 完成率高 | 命中 | **全速推进 P0**,按架构文档执行 | +| 完成率高 | 无偏离/不可判定 | 推进 P0,但延长 H2 观察至 30 天;P1 引擎暂缓 | +| 完成率高 | 漏检为主 | 推进 P0,但**必须先重新设计指标组合与窗口**再做 P1 | +| 完成率低 | 任意 | **暂停 P0,先解决入口摩擦**:优先做设备自动同步(原 P2 提前),或彻底简化录入形态 | +| 大面积中断 | 任意 | 重新评估产品前提:手动录入路线可能不可行,需以设备接入为第一形态 | + +**最重要的判断**:如果 H1 失败(大家坚持不下来),那么再精妙的基线算法都没有意义。此时正确的反应不是「优化算法」,而是**把设备自动同步提到第一优先级**,因为它是唯一能把录入成本降到零的手段。 + +--- + +## 11. 与后续开发的衔接 + +实验结束后应产出四份交付物: + +1. **实验结论报告**(含 §9 全部表格 + 决策矩阵结论) +2. **偏离阈值标定值** → 写回架构文档 O6 +3. **照片校验集** → 归档为 P0 `health-extraction.test.mjs` 的固定用例 +4. **入口形态结论** → 决定 H5 与服务号的主次关系 + +### 11.1 实验数据的处置 + +实验用 `health_p0_daily_log` 表与页面在结论产出后: + +```text +1. 导出结论所需数据 → 归档 +2. 应参与者要求删除其数据 +3. 关闭 dataset 的公开访问(private_data_close_page_dataset) +4. 下线两个实验页面 +``` + +**不要**把实验表直接当作 P0 的生产表 —— 字段设计目标不同(实验表是宽表便于人工分析,生产表是原子观测便于聚合)。 + +--- + +## 12. 实验本身的风险 + +| 风险 | 影响 | 对策 | +|---|---|---| +| 14 天内无人偏离 | H2 无法判定 | 招募倾向波动人群(§3.2);预设无偏离处理(§7.3) | +| 提醒污染依从性 | H1 高估 | 分别统计主动/提醒后录入(§6.2) | +| 访谈引导 | H2 无效 | 严格盲态,先访谈后揭盲(§8) | +| 测量规程不统一 | 假偏离 | 规程书面化 + 试录 1 次 + 固定同侧手臂(§4.1) | +| 参与者出现真实健康问题 | 伦理风险 | 预设终止阈值,立即建议就医(§3.3) | +| 样本量过小 | H2 统计力不足 | 争取 8–10 人;否则 H2 降级为探索性 | + +--- + +## 13. 相关文档 + +- [memind-health-architecture.md](memind-health-architecture.md) — 上游架构设计 +- [page-data-api-usage.md](page-data-api-usage.md) — Page Data API +- `skills/page-data-collect/SKILL.md` — 建表与页面绑定规范 diff --git a/health-baseline-maturity.mjs b/health-baseline-maturity.mjs new file mode 100644 index 0000000..b1e93d0 --- /dev/null +++ b/health-baseline-maturity.mjs @@ -0,0 +1,26 @@ +export const BASELINE_MATURITY = Object.freeze({ + COLD: 'cold', + WEAK: 'weak', + STABLE: 'stable', +}); + +export function resolveBaselineMaturity(sampleCount) { + const n = Number(sampleCount ?? 0); + if (!Number.isFinite(n) || n < 7) return BASELINE_MATURITY.COLD; + if (n < 30) return BASELINE_MATURITY.WEAK; + return BASELINE_MATURITY.STABLE; +} + +export function allowedEventSeverities(maturity) { + if (maturity === BASELINE_MATURITY.STABLE) return ['info', 'watch', 'alert']; + if (maturity === BASELINE_MATURITY.WEAK) return ['info']; + return []; +} + +export function canEmitHealthEvent(maturity, severity) { + return allowedEventSeverities(maturity).includes(String(severity ?? '')); +} + +export function isValidObservationForBaseline(row = {}) { + return row.quality_flag !== 'suspect' && row.deleted_at == null; +} diff --git a/health-baseline-maturity.test.mjs b/health-baseline-maturity.test.mjs new file mode 100644 index 0000000..0307961 --- /dev/null +++ b/health-baseline-maturity.test.mjs @@ -0,0 +1,23 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + BASELINE_MATURITY, + canEmitHealthEvent, + isValidObservationForBaseline, + resolveBaselineMaturity, +} from './health-baseline-maturity.mjs'; + +test('maturity gates alerts until the baseline is stable', () => { + assert.equal(resolveBaselineMaturity(3), BASELINE_MATURITY.COLD); + assert.equal(resolveBaselineMaturity(10), BASELINE_MATURITY.WEAK); + assert.equal(resolveBaselineMaturity(30), BASELINE_MATURITY.STABLE); + assert.equal(canEmitHealthEvent('cold', 'watch'), false); + assert.equal(canEmitHealthEvent('weak', 'alert'), false); + assert.equal(canEmitHealthEvent('weak', 'info'), true); + assert.equal(canEmitHealthEvent('stable', 'alert'), true); +}); + +test('suspect rows are excluded from baseline samples', () => { + assert.equal(isValidObservationForBaseline({ quality_flag: 'ok', deleted_at: null }), true); + assert.equal(isValidObservationForBaseline({ quality_flag: 'suspect', deleted_at: null }), false); +}); diff --git a/health-channel-state.mjs b/health-channel-state.mjs new file mode 100644 index 0000000..d6216a9 --- /dev/null +++ b/health-channel-state.mjs @@ -0,0 +1,225 @@ +export const HEALTH_CHANNEL_STEPS = Object.freeze({ + IDLE: 'idle', + AWAIT_ACTION_CHOICE: 'await_action_choice', + AWAIT_METRIC_TYPE: 'await_metric_type', + AWAIT_CONTEXT: 'await_context', + AWAIT_VALUE: 'await_value', + CONFIRM: 'confirm', + COMMITTED: 'committed', + EXITED: 'exited', +}); + +export const HEALTH_ACTIONS = Object.freeze({ + RECORD: 'record', + ASSESS: 'assess', + DOCUMENT: 'document', + ARCHIVE: 'archive', +}); + +export const HEALTH_METRIC_SETS = Object.freeze([ + 'blood_pressure', + 'heart_rate', + 'weight', + 'spo2', + 'temperature', + 'sleep', + 'symptom', + 'medication', +]); + +const ACTION_BY_CHOICE = Object.freeze({ + 1: HEALTH_ACTIONS.RECORD, + 2: HEALTH_ACTIONS.ASSESS, + 3: HEALTH_ACTIONS.DOCUMENT, + 4: HEALTH_ACTIONS.ARCHIVE, +}); + +const METRIC_BY_CHOICE = Object.freeze({ + 1: 'blood_pressure', + 2: 'heart_rate', + 3: 'weight', + 4: 'spo2', + 5: 'temperature', + 6: 'sleep', + 7: 'symptom', + 8: 'medication', +}); + +export function createIdleHealthChannelState({ channel = 'h5' } = {}) { + return { + sessionKind: 'health', + channel, + step: HEALTH_CHANNEL_STEPS.IDLE, + action: null, + metricSet: null, + context: null, + pendingDraftId: null, + lastPrompt: null, + }; +} + +export function healthChannelUnexpectedPrompt(state) { + if (state.step === HEALTH_CHANNEL_STEPS.AWAIT_VALUE && state.metricSet === 'blood_pressure') { + return [ + '当前正在录入「血压」。', + '- 直接发送数值,如 137/82', + '- 或拍照上传血压计读数', + '- 回复「取消」放弃本次录入', + '- 回复「0」退出健康通道', + ].join('\n'); + } + return '当前操作无法识别。请回复数字选择,或回复「0」退出健康通道。'; +} + +export function reduceHealthChannel(state, event) { + const current = state ?? createIdleHealthChannelState(); + const type = String(event?.type ?? '').trim(); + + if (type === 'exit' || event?.choice === 0 || event?.text === '0' || event?.text === '退出') { + return { + state: { ...createIdleHealthChannelState({ channel: current.channel }), step: HEALTH_CHANNEL_STEPS.EXITED }, + actions: [{ type: 'exit_channel' }], + }; + } + if (type === 'timeout') { + return { + state: { ...createIdleHealthChannelState({ channel: current.channel }), step: HEALTH_CHANNEL_STEPS.EXITED }, + actions: [{ type: 'timeout_exit' }], + }; + } + if (type === 'cancel' || event?.text === '取消') { + return { + state: createIdleHealthChannelState({ channel: current.channel }), + actions: [{ type: 'discard_draft' }], + }; + } + + if (current.step === HEALTH_CHANNEL_STEPS.IDLE) { + if (type === 'select_action' && event.action === HEALTH_ACTIONS.RECORD) { + return { + state: { ...current, step: HEALTH_CHANNEL_STEPS.AWAIT_METRIC_TYPE, action: HEALTH_ACTIONS.RECORD }, + actions: [{ type: 'prompt_metric_type' }], + }; + } + if (type === 'select_action' && event.action === HEALTH_ACTIONS.ASSESS) { + return { + state: { ...current, step: HEALTH_CHANNEL_STEPS.IDLE, action: HEALTH_ACTIONS.ASSESS }, + actions: [{ type: 'run_assessment' }], + }; + } + if (type === 'select_action' && (event.action === HEALTH_ACTIONS.DOCUMENT || event.action === HEALTH_ACTIONS.ARCHIVE)) { + return { + state: { ...current, step: HEALTH_CHANNEL_STEPS.IDLE, action: event.action }, + actions: [{ type: event.action === HEALTH_ACTIONS.DOCUMENT ? 'await_document' : 'show_archive' }], + }; + } + if (type === 'ambiguous') { + return { + state: { ...current, step: HEALTH_CHANNEL_STEPS.AWAIT_ACTION_CHOICE }, + actions: [{ type: 'prompt_action_choice' }], + }; + } + if (type === 'unexpected') { + return { + state: current, + actions: [{ type: 'prompt_unexpected', prompt: healthChannelUnexpectedPrompt(current) }], + }; + } + } + + if (current.step === HEALTH_CHANNEL_STEPS.AWAIT_ACTION_CHOICE && type === 'choose') { + const action = ACTION_BY_CHOICE[Number(event.choice)]; + if (!action) { + return { + state: current, + actions: [{ type: 'prompt_action_choice' }], + }; + } + return reduceHealthChannel( + { ...current, step: HEALTH_CHANNEL_STEPS.IDLE }, + { type: 'select_action', action }, + ); + } + + if (current.step === HEALTH_CHANNEL_STEPS.AWAIT_METRIC_TYPE && (type === 'pick_metric' || type === 'choose')) { + const metricSet = event.metricSet || METRIC_BY_CHOICE[Number(event.choice)]; + if (!HEALTH_METRIC_SETS.includes(metricSet)) { + return { + state: current, + actions: [{ type: 'prompt_metric_type' }], + }; + } + const next = { + ...current, + metricSet, + step: metricSet === 'blood_pressure' ? HEALTH_CHANNEL_STEPS.AWAIT_CONTEXT : HEALTH_CHANNEL_STEPS.AWAIT_VALUE, + }; + return { + state: next, + actions: [{ type: next.step === HEALTH_CHANNEL_STEPS.AWAIT_CONTEXT ? 'prompt_context' : 'prompt_value' }], + }; + } + + if (current.step === HEALTH_CHANNEL_STEPS.AWAIT_CONTEXT && type === 'pick_context') { + return { + state: { + ...current, + context: event.context || 'other', + step: HEALTH_CHANNEL_STEPS.AWAIT_VALUE, + }, + actions: [{ type: 'prompt_value' }], + }; + } + + if (current.step === HEALTH_CHANNEL_STEPS.AWAIT_VALUE) { + if (type === 'submit_value' || type === 'image' || type === 'voice') { + return { + state: { + ...current, + step: HEALTH_CHANNEL_STEPS.CONFIRM, + pendingDraftId: event.draftId ?? current.pendingDraftId ?? 'draft', + }, + actions: [{ type: 'prompt_confirm', draft: event.draft ?? null, requireConfirm: true }], + }; + } + if (type === 'unexpected') { + return { + state: current, + actions: [{ type: 'prompt_unexpected', prompt: healthChannelUnexpectedPrompt(current) }], + }; + } + } + + if (current.step === HEALTH_CHANNEL_STEPS.CONFIRM) { + if (type === 'commit') { + return { + state: { ...current, step: HEALTH_CHANNEL_STEPS.COMMITTED }, + actions: [{ type: 'commit_observation', draftId: current.pendingDraftId }], + }; + } + if (type === 'revise') { + return { + state: { ...current, step: HEALTH_CHANNEL_STEPS.AWAIT_VALUE }, + actions: [{ type: 'prompt_value' }], + }; + } + if (type === 'discard') { + return { + state: createIdleHealthChannelState({ channel: current.channel }), + actions: [{ type: 'discard_draft', draftId: current.pendingDraftId }], + }; + } + } + + if (current.step === HEALTH_CHANNEL_STEPS.COMMITTED && type === 'continue') { + return { + state: createIdleHealthChannelState({ channel: current.channel }), + actions: [{ type: 'reset' }], + }; + } + + return { + state: current, + actions: [{ type: 'noop' }], + }; +} diff --git a/health-channel-state.test.mjs b/health-channel-state.test.mjs new file mode 100644 index 0000000..d3dcf45 --- /dev/null +++ b/health-channel-state.test.mjs @@ -0,0 +1,55 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + HEALTH_ACTIONS, + HEALTH_CHANNEL_STEPS, + createIdleHealthChannelState, + reduceHealthChannel, +} from './health-channel-state.mjs'; + +test('record flow pins metric then waits for value without guessing', () => { + let { state } = reduceHealthChannel(createIdleHealthChannelState(), { + type: 'select_action', + action: HEALTH_ACTIONS.RECORD, + }); + assert.equal(state.step, HEALTH_CHANNEL_STEPS.AWAIT_METRIC_TYPE); + + ({ state } = reduceHealthChannel(state, { type: 'pick_metric', metricSet: 'blood_pressure' })); + assert.equal(state.step, HEALTH_CHANNEL_STEPS.AWAIT_CONTEXT); + + ({ state } = reduceHealthChannel(state, { type: 'pick_context', context: 'morning' })); + assert.equal(state.step, HEALTH_CHANNEL_STEPS.AWAIT_VALUE); + + const unexpected = reduceHealthChannel(state, { type: 'unexpected', text: '帮我做个页面' }); + assert.equal(unexpected.state.step, HEALTH_CHANNEL_STEPS.AWAIT_VALUE); + assert.equal(unexpected.actions[0].type, 'prompt_unexpected'); + assert.match(unexpected.actions[0].prompt, /当前正在录入「血压」/); +}); + +test('ambiguous idle input forces action choice instead of guessing', () => { + const result = reduceHealthChannel(createIdleHealthChannelState(), { type: 'ambiguous' }); + assert.equal(result.state.step, HEALTH_CHANNEL_STEPS.AWAIT_ACTION_CHOICE); + const chosen = reduceHealthChannel(result.state, { type: 'choose', choice: 1 }); + assert.equal(chosen.state.step, HEALTH_CHANNEL_STEPS.AWAIT_METRIC_TYPE); +}); + +test('photo in await_value always requires confirm before commit', () => { + const awaiting = { + ...createIdleHealthChannelState(), + step: HEALTH_CHANNEL_STEPS.AWAIT_VALUE, + metricSet: 'blood_pressure', + }; + const confirm = reduceHealthChannel(awaiting, { type: 'image', draftId: 'drf-1' }); + assert.equal(confirm.state.step, HEALTH_CHANNEL_STEPS.CONFIRM); + assert.equal(confirm.actions[0].requireConfirm, true); + const committed = reduceHealthChannel(confirm.state, { type: 'commit' }); + assert.equal(committed.state.step, HEALTH_CHANNEL_STEPS.COMMITTED); + assert.equal(committed.actions[0].type, 'commit_observation'); +}); + +test('exit and timeout leave the health channel', () => { + const exited = reduceHealthChannel(createIdleHealthChannelState(), { type: 'exit' }); + assert.equal(exited.state.step, HEALTH_CHANNEL_STEPS.EXITED); + const timedOut = reduceHealthChannel(createIdleHealthChannelState(), { type: 'timeout' }); + assert.equal(timedOut.state.step, HEALTH_CHANNEL_STEPS.EXITED); +}); diff --git a/health-extraction.mjs b/health-extraction.mjs new file mode 100644 index 0000000..78bb243 --- /dev/null +++ b/health-extraction.mjs @@ -0,0 +1,57 @@ +import { validateBloodPressurePair, validateMetricValue } from './health-observation-validate.mjs'; + +export const EXTRACTION_CONFIDENCE_MIN = 0.9; + +const METRIC_SET_FIELDS = Object.freeze({ + blood_pressure: ['systolic', 'diastolic'], + heart_rate: ['hr'], + weight: ['weight'], + spo2: ['spo2'], + temperature: ['temperature'], +}); + +export function validateExtractionResult(extracted = {}, { metricSetHint = null } = {}) { + const metricSet = extracted.metric_set || metricSetHint; + if (!metricSet || !METRIC_SET_FIELDS[metricSet]) { + return { ok: false, error: 'unknown_metric_set', unreadable: true }; + } + const confidence = extracted.confidence && typeof extracted.confidence === 'object' + ? extracted.confidence + : {}; + const lowConfidence = []; + const values = {}; + + if (metricSet === 'blood_pressure') { + const pair = validateBloodPressurePair(extracted.systolic, extracted.diastolic); + if (!pair.ok) { + return { ok: false, error: pair.error, unreadable: pair.error === 'out_of_range', field: pair.field }; + } + values.systolic = pair.systolic; + values.diastolic = pair.diastolic; + if (extracted.pulse != null) { + const pulse = validateMetricValue('hr', extracted.pulse); + if (!pulse.ok) return { ok: false, error: pulse.error, field: 'pulse' }; + values.pulse = pulse.value; + } + } else { + const field = METRIC_SET_FIELDS[metricSet][0]; + const metricType = metricSet === 'heart_rate' ? 'hr' : metricSet === 'weight' ? 'weight' : metricSet; + const checked = validateMetricValue(metricType, extracted[field] ?? extracted.value); + if (!checked.ok) return { ok: false, error: checked.error, unreadable: checked.error === 'out_of_range' }; + values[field] = checked.value; + } + + for (const field of Object.keys(values)) { + const score = Number(confidence[field] ?? extracted.confidence?.[field] ?? 1); + if (score < EXTRACTION_CONFIDENCE_MIN) lowConfidence.push(field); + } + + return { + ok: true, + metricSet, + values, + lowConfidence, + requireConfirm: true, + unreadable: false, + }; +} diff --git a/health-extraction.test.mjs b/health-extraction.test.mjs new file mode 100644 index 0000000..ed9c94e --- /dev/null +++ b/health-extraction.test.mjs @@ -0,0 +1,48 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { validateExtractionResult } from './health-extraction.mjs'; + +test('accepts a high-confidence blood pressure extraction', () => { + const result = validateExtractionResult({ + metric_set: 'blood_pressure', + systolic: 137, + diastolic: 82, + pulse: 72, + confidence: { systolic: 0.97, diastolic: 0.95, pulse: 0.96 }, + }); + assert.equal(result.ok, true); + assert.equal(result.requireConfirm, true); + assert.deepEqual(result.lowConfidence, []); +}); + +test('rejects out-of-range values instead of committing', () => { + const result = validateExtractionResult({ + metric_set: 'blood_pressure', + systolic: 300, + diastolic: 82, + confidence: { systolic: 0.99, diastolic: 0.99 }, + }); + assert.equal(result.ok, false); + assert.equal(result.unreadable, true); +}); + +test('rejects inverted systolic/diastolic without guessing a swap', () => { + const result = validateExtractionResult({ + metric_set: 'blood_pressure', + systolic: 80, + diastolic: 120, + confidence: { systolic: 0.99, diastolic: 0.99 }, + }); + assert.equal(result.ok, false); + assert.equal(result.error, 'systolic_not_greater'); +}); + +test('low confidence fields stay pending', () => { + const result = validateExtractionResult({ + metric_set: 'weight', + weight: 67.2, + confidence: { weight: 0.4 }, + }); + assert.equal(result.ok, true); + assert.deepEqual(result.lowConfidence, ['weight']); +}); diff --git a/health-feature.mjs b/health-feature.mjs new file mode 100644 index 0000000..7ae3386 --- /dev/null +++ b/health-feature.mjs @@ -0,0 +1,8 @@ +export const MEMIND_HEALTH_ENV = 'MEMIND_HEALTH_ENABLED'; +export const HEALTH_ASSISTANT_SKILL_NAME = 'health-assistant'; +export const HEALTH_CATEGORY_CODE = 'health'; +export const HEALTH_PUBLISH_POLICY = 'password_required'; + +export function isMemindHealthEnabled(env = process.env) { + return /^(1|true|yes)$/i.test(String(env?.[MEMIND_HEALTH_ENV] ?? '').trim()); +} diff --git a/health-intent-rules.mjs b/health-intent-rules.mjs new file mode 100644 index 0000000..2266680 --- /dev/null +++ b/health-intent-rules.mjs @@ -0,0 +1,114 @@ +export const HEALTH_RULE_INTENTS = Object.freeze({ + BLOOD_PRESSURE: 'blood_pressure', + WEIGHT: 'weight', + HEART_RATE: 'heart_rate', + SPO2: 'spo2', + TEMPERATURE: 'temperature', + SLEEP: 'sleep', + SYMPTOM: 'symptom', + ASSESS: 'assess', + ARCHIVE: 'archive', +}); + +const BP_PAIR = /(\d{2,3})\s*[//]\s*(\d{2,3})/; + +export function matchHealthIdleRules(text) { + const raw = String(text ?? '').trim(); + if (!raw) return { matched: false, confidence: 0 }; + + const bp = raw.match(BP_PAIR); + if (bp || /血压/.test(raw) && /\d{2,3}/.test(raw)) { + const systolic = bp ? Number(bp[1]) : null; + const diastolic = bp ? Number(bp[2]) : null; + return { + matched: true, + intent: HEALTH_RULE_INTENTS.BLOOD_PRESSURE, + confidence: bp ? 0.95 : 0.8, + extracted: { systolic, diastolic }, + }; + } + + const weight = raw.match(/体重\s*(\d{2,3}(?:\.\d)?)/); + if (weight) { + return { + matched: true, + intent: HEALTH_RULE_INTENTS.WEIGHT, + confidence: 0.9, + extracted: { weight: Number(weight[1]) }, + }; + } + + const hr = raw.match(/(?:心率|脉搏)\s*(\d{2,3})/); + if (hr) { + return { + matched: true, + intent: HEALTH_RULE_INTENTS.HEART_RATE, + confidence: 0.9, + extracted: { hr: Number(hr[1]) }, + }; + } + + const spo2 = raw.match(/(?:血氧|SpO2|spo2)\s*(\d{2,3})/i); + if (spo2) { + return { + matched: true, + intent: HEALTH_RULE_INTENTS.SPO2, + confidence: 0.9, + extracted: { spo2: Number(spo2[1]) }, + }; + } + + const temp = raw.match(/(?:体温|发烧)\s*(3\d(?:\.\d)?)/); + if (temp) { + return { + matched: true, + intent: HEALTH_RULE_INTENTS.TEMPERATURE, + confidence: 0.9, + extracted: { temperature: Number(temp[1]) }, + }; + } + + if (/睡了|睡眠/.test(raw) && /\d/.test(raw)) { + return { matched: true, intent: HEALTH_RULE_INTENTS.SLEEP, confidence: 0.75, extracted: {} }; + } + + if (/头晕|胸闷|乏力|心慌|头痛|气短/.test(raw)) { + return { matched: true, intent: HEALTH_RULE_INTENTS.SYMPTOM, confidence: 0.8, extracted: {} }; + } + + if (/(?:最近怎么样|有没有异常|健康评估)|(?:身体|血压|健康).{0,12}(?:怎么样|帮我看看)|帮我看看.{0,12}(?:身体|血压|健康)/.test(raw)) { + return { matched: true, intent: HEALTH_RULE_INTENTS.ASSESS, confidence: 0.85, extracted: {} }; + } + + if (/我的档案|历史记录|报告/.test(raw)) { + return { matched: true, intent: HEALTH_RULE_INTENTS.ARCHIVE, confidence: 0.8, extracted: {} }; + } + + return { matched: false, confidence: 0 }; +} + +export function shouldForceActionChoice(ruleResult, llm = null) { + if (ruleResult?.matched && ruleResult.confidence >= 0.75) return false; + const confidence = Number(llm?.confidence ?? 0); + const gap = Number(llm?.top1 ?? 0) - Number(llm?.top2 ?? 0); + if (confidence < 0.75 || gap < 0.15) return true; + return false; +} + +export function shouldRedirectOrdinaryChatToHealth(text) { + const hit = matchHealthIdleRules(text); + if (!hit.matched) return false; + return [ + HEALTH_RULE_INTENTS.BLOOD_PRESSURE, + HEALTH_RULE_INTENTS.WEIGHT, + HEALTH_RULE_INTENTS.HEART_RATE, + HEALTH_RULE_INTENTS.SPO2, + HEALTH_RULE_INTENTS.TEMPERATURE, + HEALTH_RULE_INTENTS.SLEEP, + HEALTH_RULE_INTENTS.SYMPTOM, + HEALTH_RULE_INTENTS.ASSESS, + ].includes(hit.intent); +} + +export const HEALTH_REDIRECT_COPY = + '你想记录健康数据吗?健康数据需要在健康助手通道里录入,这样才能保证记录准确并纳入你的健康基线分析。'; diff --git a/health-intent-rules.test.mjs b/health-intent-rules.test.mjs new file mode 100644 index 0000000..a4874f7 --- /dev/null +++ b/health-intent-rules.test.mjs @@ -0,0 +1,26 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + matchHealthIdleRules, + shouldForceActionChoice, + shouldRedirectOrdinaryChatToHealth, +} from './health-intent-rules.mjs'; + +test('rule hits blood pressure pairs without LLM', () => { + const hit = matchHealthIdleRules('血压 137/82'); + assert.equal(hit.matched, true); + assert.equal(hit.extracted.systolic, 137); + assert.equal(hit.extracted.diastolic, 82); + assert.equal(shouldForceActionChoice(hit), false); +}); + +test('unmatched chatter forces a menu', () => { + const miss = matchHealthIdleRules('帮我看看明天天气'); + assert.equal(miss.matched, false); + assert.equal(shouldForceActionChoice(miss, { confidence: 0.4, top1: 0.4, top2: 0.35 }), true); +}); + +test('ordinary chat health phrases only redirect and never imply a write', () => { + assert.equal(shouldRedirectOrdinaryChatToHealth('帮我记一下血压 128/76'), true); + assert.equal(shouldRedirectOrdinaryChatToHealth('做个旅游攻略页面'), false); +}); diff --git a/health-observation-validate.mjs b/health-observation-validate.mjs new file mode 100644 index 0000000..387f1c6 --- /dev/null +++ b/health-observation-validate.mjs @@ -0,0 +1,75 @@ +const RANGES = Object.freeze({ + bp_systolic: { min: 60, max: 260, unit: 'mmHg' }, + bp_diastolic: { min: 40, max: 160, unit: 'mmHg' }, + hr: { min: 30, max: 200, unit: 'bpm' }, + spo2: { min: 50, max: 100, unit: '%' }, + temperature: { min: 33, max: 43, unit: 'celsius' }, + weight: { min: 20, max: 250, unit: 'kg' }, + sleep_minutes: { min: 0, max: 16 * 60, unit: 'min' }, +}); + +export const HEALTH_CONTEXTS = Object.freeze(['morning', 'evening', 'other']); + +export function inferBloodPressureContext(observedAt, { now = new Date(), timeZone = 'Asia/Shanghai' } = {}) { + const date = observedAt instanceof Date ? observedAt : new Date(observedAt ?? now); + if (Number.isNaN(date.getTime())) return 'other'; + const hourText = new Intl.DateTimeFormat('en-GB', { + hour: '2-digit', + hourCycle: 'h23', + timeZone, + }).format(date); + const hour = Number(hourText); + if (hour < 10) return 'morning'; + if (hour >= 18) return 'evening'; + return 'other'; +} + +export function validateMetricValue(metricType, value) { + const range = RANGES[metricType]; + if (!range) { + return { ok: false, error: 'unknown_metric' }; + } + const num = Number(value); + if (!Number.isFinite(num)) { + return { ok: false, error: 'not_numeric' }; + } + if (num < range.min || num > range.max) { + return { ok: false, error: 'out_of_range', range }; + } + return { ok: true, value: num, unit: range.unit }; +} + +export function validateBloodPressurePair(systolic, diastolic) { + const sys = validateMetricValue('bp_systolic', systolic); + const dia = validateMetricValue('bp_diastolic', diastolic); + if (!sys.ok) return { ok: false, field: 'bp_systolic', ...sys }; + if (!dia.ok) return { ok: false, field: 'bp_diastolic', ...dia }; + if (sys.value <= dia.value) { + return { ok: false, error: 'systolic_not_greater', field: 'pair' }; + } + return { ok: true, systolic: sys.value, diastolic: dia.value }; +} + +export function markSuspectVsBaseline(value, baselineMean, { ratio = 0.5 } = {}) { + const mean = Number(baselineMean); + const num = Number(value); + if (!Number.isFinite(mean) || mean <= 0 || !Number.isFinite(num)) { + return { qualityFlag: 'ok', suspect: false }; + } + const deviation = Math.abs(num - mean) / mean; + if (deviation >= ratio) { + return { qualityFlag: 'suspect', suspect: true, deviation }; + } + return { qualityFlag: 'ok', suspect: false, deviation }; +} + +export function resolveQualityAfterConfirm({ suspect = false, userCorrected = false } = {}) { + if (userCorrected) return 'user_corrected'; + if (suspect) return 'ok'; + return 'ok'; +} + +export function observationDedupKey({ metricType, observedAt, sourceRef } = {}) { + if (!sourceRef) return null; + return `${metricType}|${observedAt}|${sourceRef}`; +} diff --git a/health-observation-validate.test.mjs b/health-observation-validate.test.mjs new file mode 100644 index 0000000..b3990d0 --- /dev/null +++ b/health-observation-validate.test.mjs @@ -0,0 +1,27 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + inferBloodPressureContext, + markSuspectVsBaseline, + resolveQualityAfterConfirm, + validateBloodPressurePair, +} from './health-observation-validate.mjs'; + +test('blood pressure range and ordering', () => { + assert.equal(validateBloodPressurePair(128, 76).ok, true); + assert.equal(validateBloodPressurePair(50, 40).ok, false); + assert.equal(validateBloodPressurePair(120, 130).ok, false); +}); + +test('context is inferred from clock hour', () => { + assert.equal(inferBloodPressureContext('2026-09-02T07:20:00+08:00'), 'morning'); + assert.equal(inferBloodPressureContext('2026-09-02T21:10:00+08:00'), 'evening'); + assert.equal(inferBloodPressureContext('2026-09-02T14:00:00+08:00'), 'other'); +}); + +test('large baseline deviation is suspect until the user confirms', () => { + const marked = markSuspectVsBaseline(190, 120); + assert.equal(marked.suspect, true); + assert.equal(resolveQualityAfterConfirm({ suspect: true, userCorrected: false }), 'ok'); + assert.equal(resolveQualityAfterConfirm({ suspect: true, userCorrected: true }), 'user_corrected'); +}); diff --git a/health-p0-experiment.mjs b/health-p0-experiment.mjs new file mode 100644 index 0000000..c4696d7 --- /dev/null +++ b/health-p0-experiment.mjs @@ -0,0 +1,118 @@ +export const HEALTH_P0_DATASET_NAME = 'health_p0_daily_log'; + +export const HEALTH_P0_INSERT_COLUMNS = Object.freeze([ + 'participant_code', + 'log_date', + 'am_measured_at', + 'am_systolic', + 'am_diastolic', + 'am_pulse', + 'pm_measured_at', + 'pm_systolic', + 'pm_diastolic', + 'pm_pulse', + 'sleep_start', + 'sleep_end', + 'sleep_minutes', + 'sleep_source', + 'weight_kg', + 'symptoms', + 'alcohol', + 'stress', + 'medication_change', + 'medication_note', + 'other_note', + 'entry_channel', + 'photo_sent', +]); + +export const HEALTH_P0_READ_COLUMNS = Object.freeze([ + 'id', + ...HEALTH_P0_INSERT_COLUMNS, + 'created_at', +]); + +export const HEALTH_P0_CREATE_TABLE_SQL = ` +CREATE TABLE IF NOT EXISTS health_p0_daily_log ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + participant_code TEXT NOT NULL, + log_date DATE NOT NULL, + am_measured_at TEXT NULL, + am_systolic INT NULL, + am_diastolic INT NULL, + am_pulse INT NULL, + pm_measured_at TEXT NULL, + pm_systolic INT NULL, + pm_diastolic INT NULL, + pm_pulse INT NULL, + sleep_start TEXT NULL, + sleep_end TEXT NULL, + sleep_minutes INT NULL, + sleep_source TEXT NULL, + weight_kg NUMERIC(5,1) NULL, + symptoms JSONB NOT NULL DEFAULT '[]'::jsonb, + alcohol TEXT NULL, + stress TEXT NULL, + medication_change TEXT NULL, + medication_note TEXT NULL, + other_note TEXT NULL, + entry_channel TEXT NULL, + photo_sent BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMPTZ NULL +); +`.trim(); + +export function buildHealthP0DatasetRegistration() { + return { + name: HEALTH_P0_DATASET_NAME, + table: HEALTH_P0_DATASET_NAME, + description: 'MeMind Health P0 前置实验每日记录', + actions: ['read', 'insert'], + columns: { + read: [...HEALTH_P0_READ_COLUMNS], + insert: [...HEALTH_P0_INSERT_COLUMNS], + }, + }; +} + +export function buildHealthP0BindSpecs({ password } = {}) { + return { + participant: { + relativePath: 'public/health-p0-log.html', + accessMode: 'public', + datasets: { + [HEALTH_P0_DATASET_NAME]: { insert: true, read: false }, + }, + }, + researcher: { + relativePath: 'public/health-p0-admin.html', + accessMode: 'password', + password, + datasets: { + [HEALTH_P0_DATASET_NAME]: { insert: false, read: true }, + }, + }, + }; +} + +export function assertHealthP0HtmlContract(html, { role } = {}) { + const source = String(html ?? ''); + const issues = []; + if (!source.includes('/assets/page-data-client.js')) issues.push('missing_page_data_client'); + if (/\bon(?:click|input|submit|change)\s*=/i.test(source)) issues.push('inline_event_handler'); + if (/localStorage|sessionStorage|IndexedDB/i.test(source)) issues.push('browser_storage'); + if (/127\.0\.0\.1:\d+/.test(source)) issues.push('hardcoded_localhost'); + if (role === 'participant') { + if (!source.includes(`insertRow('${HEALTH_P0_DATASET_NAME}'`) + && !source.includes(`insertRow("${HEALTH_P0_DATASET_NAME}"`)) { + issues.push('missing_insert'); + } + if (source.includes('listRows(')) issues.push('participant_must_not_read'); + } + if (role === 'researcher') { + if (!source.includes('authenticate(')) issues.push('missing_authenticate'); + if (!source.includes('listRows(')) issues.push('missing_list_rows'); + } + return { ok: issues.length === 0, issues }; +} diff --git a/health-p0-experiment.test.mjs b/health-p0-experiment.test.mjs new file mode 100644 index 0000000..0898b57 --- /dev/null +++ b/health-p0-experiment.test.mjs @@ -0,0 +1,55 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; +import { evaluatePageDataHtmlContent } from './mindspace-page-data-finish-guard.mjs'; +import { + HEALTH_P0_CREATE_TABLE_SQL, + HEALTH_P0_DATASET_NAME, + assertHealthP0HtmlContract, + buildHealthP0BindSpecs, + buildHealthP0DatasetRegistration, +} from './health-p0-experiment.mjs'; +import { assertSymptomRecord } from './health-symptoms.mjs'; +import { isMemindHealthEnabled } from './health-feature.mjs'; + +const root = path.dirname(fileURLToPath(import.meta.url)); + +test('feature flag is off by default', () => { + assert.equal(isMemindHealthEnabled({}), false); + assert.equal(isMemindHealthEnabled({ MEMIND_HEALTH_ENABLED: 'true' }), true); +}); + +test('P0 dataset registration matches insert columns and split pages', () => { + const dataset = buildHealthP0DatasetRegistration(); + assert.equal(dataset.name, HEALTH_P0_DATASET_NAME); + assert.ok(HEALTH_P0_CREATE_TABLE_SQL.includes('deleted_at TIMESTAMPTZ')); + assert.ok(dataset.columns.insert.includes('am_systolic')); + const binds = buildHealthP0BindSpecs({ password: '88888888' }); + assert.equal(binds.participant.accessMode, 'public'); + assert.equal(binds.researcher.accessMode, 'password'); + assert.equal(binds.participant.datasets[HEALTH_P0_DATASET_NAME].read, false); + assert.equal(binds.researcher.datasets[HEALTH_P0_DATASET_NAME].insert, false); +}); + +test('P0 HTML pages satisfy Page Data contracts', () => { + const participant = fs.readFileSync(path.join(root, 'templates/health-p0-log.html'), 'utf8'); + const researcher = fs.readFileSync(path.join(root, 'templates/health-p0-admin.html'), 'utf8'); + assert.deepEqual(assertHealthP0HtmlContract(participant, { role: 'participant' }).issues, []); + assert.deepEqual(assertHealthP0HtmlContract(researcher, { role: 'researcher' }).issues, []); + assert.deepEqual( + evaluatePageDataHtmlContent(participant, { relativePath: 'public/health-p0-log.html' }).issues, + [], + ); + assert.deepEqual( + evaluatePageDataHtmlContent(researcher, { relativePath: 'public/health-p0-admin.html' }).issues, + [], + ); +}); + +test('symptoms require an explicit none or a severity', () => { + assert.equal(assertSymptomRecord({ code: '无' }).ok, true); + assert.equal(assertSymptomRecord({ code: 'dizziness', severity: 2 }).ok, true); + assert.equal(assertSymptomRecord({ code: 'dizziness' }).ok, false); +}); diff --git a/health-publish-guard.mjs b/health-publish-guard.mjs new file mode 100644 index 0000000..d34a914 --- /dev/null +++ b/health-publish-guard.mjs @@ -0,0 +1,77 @@ +import { HEALTH_CATEGORY_CODE, HEALTH_PUBLISH_POLICY } from './health-feature.mjs'; + +export const HEALTH_ALLOWED_ACCESS_MODES = Object.freeze(['password', 'login_required']); + +export const HEALTH_SHARE_FORBIDDEN_COLUMNS = Object.freeze([ + 'raw_payload', + 'source_ref', + 'ocr_text', +]); + +export const HEALTH_SHARE_FORBIDDEN_DATASETS = Object.freeze([ + 'health_observations', + 'health_observation_drafts', + 'health_documents', +]); + +export function isHealthPublishPolicy(publishPolicy, categoryCode) { + return ( + String(publishPolicy ?? '') === HEALTH_PUBLISH_POLICY + || String(categoryCode ?? '') === HEALTH_CATEGORY_CODE + ); +} + +export function assertHealthPublicationAllowed({ + categoryCode = null, + publishPolicy = null, + accessMode, + password = '', + existingPasswordHash = null, +} = {}) { + if (!isHealthPublishPolicy(publishPolicy, categoryCode)) { + return { ok: true, skipped: true }; + } + const mode = String(accessMode ?? '').trim(); + if (mode === 'public') { + const error = Object.assign(new Error('健康区禁止完全公开发布'), { + code: 'health_public_forbidden', + }); + throw error; + } + if (!HEALTH_ALLOWED_ACCESS_MODES.includes(mode)) { + const error = Object.assign(new Error('健康区仅允许口令或登录访问'), { + code: 'health_access_mode_forbidden', + }); + throw error; + } + if (mode === 'password') { + const hasPassword = String(password ?? '').trim().length > 0; + const hasHash = Boolean(existingPasswordHash); + if (!hasPassword && !hasHash) { + const error = Object.assign(new Error('健康区发布必须设置口令'), { + code: 'health_password_required', + }); + throw error; + } + } + return { ok: true, skipped: false, accessMode: mode }; +} + +export function assertHealthShareDatasetPolicy({ datasetName, columns = {} } = {}) { + const name = String(datasetName ?? '').trim(); + if (HEALTH_SHARE_FORBIDDEN_DATASETS.includes(name)) { + const error = Object.assign(new Error('健康分享页不得直连原始健康 dataset'), { + code: 'health_share_raw_dataset_forbidden', + }); + throw error; + } + const readColumns = Array.isArray(columns.read) ? columns.read : []; + const forbidden = readColumns.filter((column) => HEALTH_SHARE_FORBIDDEN_COLUMNS.includes(column)); + if (forbidden.length > 0) { + const error = Object.assign(new Error(`健康分享页不得暴露字段:${forbidden.join(', ')}`), { + code: 'health_share_column_forbidden', + }); + throw error; + } + return { ok: true }; +} diff --git a/health-publish-guard.test.mjs b/health-publish-guard.test.mjs new file mode 100644 index 0000000..8433d2e --- /dev/null +++ b/health-publish-guard.test.mjs @@ -0,0 +1,72 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + assertHealthPublicationAllowed, + assertHealthShareDatasetPolicy, +} from './health-publish-guard.mjs'; + +test('non-health pages skip the gate', () => { + const result = assertHealthPublicationAllowed({ + categoryCode: 'public', + publishPolicy: 'security_scan_required', + accessMode: 'public', + }); + assert.equal(result.skipped, true); +}); + +test('health pages cannot publish as public', () => { + assert.throws( + () => assertHealthPublicationAllowed({ + categoryCode: 'health', + publishPolicy: 'password_required', + accessMode: 'public', + }), + (error) => error.code === 'health_public_forbidden', + ); +}); + +test('health password mode requires a password or existing hash', () => { + assert.throws( + () => assertHealthPublicationAllowed({ + categoryCode: 'health', + publishPolicy: 'password_required', + accessMode: 'password', + }), + (error) => error.code === 'health_password_required', + ); + const ok = assertHealthPublicationAllowed({ + categoryCode: 'health', + publishPolicy: 'password_required', + accessMode: 'password', + password: '88888888', + }); + assert.equal(ok.ok, true); +}); + +test('login_required is allowed without a share password', () => { + const ok = assertHealthPublicationAllowed({ + categoryCode: 'health', + publishPolicy: 'password_required', + accessMode: 'login_required', + }); + assert.equal(ok.ok, true); +}); + +test('share snapshots cannot bind raw observation datasets or columns', () => { + assert.throws( + () => assertHealthShareDatasetPolicy({ datasetName: 'health_observations' }), + (error) => error.code === 'health_share_raw_dataset_forbidden', + ); + assert.throws( + () => assertHealthShareDatasetPolicy({ + datasetName: 'health_share_snapshots', + columns: { read: ['payload', 'ocr_text'] }, + }), + (error) => error.code === 'health_share_column_forbidden', + ); + const ok = assertHealthShareDatasetPolicy({ + datasetName: 'health_share_snapshots', + columns: { read: ['payload', 'scope'] }, + }); + assert.equal(ok.ok, true); +}); diff --git a/health-symptoms.mjs b/health-symptoms.mjs new file mode 100644 index 0000000..4c0f430 --- /dev/null +++ b/health-symptoms.mjs @@ -0,0 +1,49 @@ +export const HEALTH_SYMPTOM_CODES = Object.freeze([ + 'none', + 'dizziness', + 'headache', + 'chest_tightness', + 'palpitation', + 'fatigue', + 'shortness_of_breath', + 'leg_edema', + 'insomnia', + 'other', +]); + +export const HEALTH_SYMPTOM_LABELS = Object.freeze({ + none: '无', + dizziness: '头晕', + headache: '头痛', + chest_tightness: '胸闷', + palpitation: '心慌心悸', + fatigue: '乏力', + shortness_of_breath: '气短', + leg_edema: '下肢水肿', + insomnia: '失眠', + other: '其他', +}); + +export const HEALTH_SYMPTOM_SEVERITIES = Object.freeze([1, 2, 3]); + +export function normalizeSymptomCode(value) { + const raw = String(value ?? '').trim(); + if (HEALTH_SYMPTOM_CODES.includes(raw)) return raw; + const byLabel = Object.entries(HEALTH_SYMPTOM_LABELS).find(([, label]) => label === raw); + return byLabel ? byLabel[0] : null; +} + +export function assertSymptomRecord({ code, severity, note = '' } = {}) { + const symptom = normalizeSymptomCode(code); + if (!symptom) { + return { ok: false, error: 'unknown_symptom' }; + } + if (symptom === 'none') { + return { ok: true, code: 'none', severity: null, note: String(note ?? '').trim() || null }; + } + const level = Number(severity); + if (!HEALTH_SYMPTOM_SEVERITIES.includes(level)) { + return { ok: false, error: 'invalid_severity' }; + } + return { ok: true, code: symptom, severity: level, note: String(note ?? '').trim() || null }; +} diff --git a/mindspace-publications.mjs b/mindspace-publications.mjs index c499d15..41380a4 100644 --- a/mindspace-publications.mjs +++ b/mindspace-publications.mjs @@ -23,6 +23,7 @@ import { } from './mindspace-html-download-links.mjs'; import { createImgproxySigner } from './imgproxy-signer.mjs'; import { assertPageWriteQuota } from './mindspace-space-quota.mjs'; +import { assertHealthPublicationAllowed } from './health-publish-guard.mjs'; const SCANNER_VERSION = 'mindspace-content-v1'; const PRIVATE_ASSET_DOWNLOAD_URL_PATTERN = @@ -526,6 +527,18 @@ export function createPublicationService(pool, options = {}) { throw lastError ?? Object.assign(new Error('存储文件不存在'), { code: 'storage_not_found' }); }; + const loadPageCategory = async (userId, pageId) => { + const [rows] = await pool.query( + `SELECT c.category_code, c.publish_policy + FROM h5_page_records p + JOIN h5_space_categories c ON c.id = p.category_id + WHERE p.id = ? AND p.user_id = ? AND p.status <> 'deleted' + LIMIT 1`, + [pageId, userId], + ); + return rows[0] ?? null; + }; + const loadVersion = async (userId, pageId, pageVersionId) => { const [rows] = await pool.query( `SELECT p.id AS page_id, p.title, p.summary, p.page_type, p.template_id, p.current_version_id, @@ -666,6 +679,15 @@ export function createPublicationService(pool, options = {}) { }); } const page = await loadVersion(userId, pageId, input.pageVersionId); + const category = await loadPageCategory(userId, pageId); + if (category) { + assertHealthPublicationAllowed({ + categoryCode: category.category_code, + publishPolicy: category.publish_policy, + accessMode, + password: input.password, + }); + } if (page.page_version_id !== page.current_version_id) { throw publicationError('只能发布页面的当前版本', 'invalid_state_transition'); } diff --git a/mindspace.mjs b/mindspace.mjs index a70f42d..8191f1d 100644 --- a/mindspace.mjs +++ b/mindspace.mjs @@ -21,6 +21,14 @@ export const SYSTEM_CATEGORIES = Object.freeze([ publishPolicy: 'security_scan_required', sortOrder: 20, }, + { + code: 'health', + name: '健康档案区', + visibilityPolicy: 'private', + aiAccessPolicy: 'explicit_asset_grant', + publishPolicy: 'password_required', + sortOrder: 30, + }, { code: 'draft', name: '页面草稿', @@ -181,7 +189,7 @@ export function createMindSpaceService(pool, options = {}) { c.ai_access_policy, c.publish_policy, c.is_system, c.sort_order, CASE WHEN c.category_code = 'draft' THEN COALESCE(pc.item_count, 0) - WHEN c.category_code IN ('oa', 'public') THEN COALESCE(pc.item_count, 0) + COALESCE(ac.item_count, 0) + WHEN c.category_code IN ('oa', 'public', 'health') THEN COALESCE(pc.item_count, 0) + COALESCE(ac.item_count, 0) ELSE COALESCE(ac.item_count, 0) END AS item_count FROM h5_space_categories c diff --git a/mindspace.test.mjs b/mindspace.test.mjs index 6f21fde..18217a3 100644 --- a/mindspace.test.mjs +++ b/mindspace.test.mjs @@ -33,7 +33,7 @@ test('initializeDefaultSpace creates one space and all system categories', async queries .filter(({ sql }) => sql.includes('INSERT INTO h5_space_categories')) .map(({ params }) => params[3]), - ['oa', 'public', 'draft', 'archive'], + ['oa', 'public', 'health', 'draft', 'archive'], ); }); @@ -106,7 +106,7 @@ test('getSpace scopes space and categories to the authenticated user', async () assert.equal(space.categories[0].code, 'private'); assert.deepEqual(calls[0].params, ['user-1']); assert.deepEqual(calls[1].params, ['user-1', 'user-1', 'user-1', 'space-1']); - assert.match(calls[1].sql, /WHEN c\.category_code IN \('oa', 'public'\) THEN COALESCE\(pc\.item_count, 0\) \+ COALESCE\(ac\.item_count, 0\)/); + assert.match(calls[1].sql, /WHEN c\.category_code IN \('oa', 'public', 'health'\) THEN COALESCE\(pc\.item_count, 0\) \+ COALESCE\(ac\.item_count, 0\)/); assert.doesNotMatch(calls[1].sql, /source_type\s*=\s*'upload'/); }); diff --git a/package.json b/package.json index 7d90083..1db487a 100644 --- a/package.json +++ b/package.json @@ -101,7 +101,7 @@ "verify:experience-agent-run-local": "node scripts/verify-experience-agent-run-local.mjs", "verify:experience-reflect-local": "node scripts/verify-experience-reflect-local.mjs", "test:scenario:john4-diet": "node scripts/run-scenario-test.mjs --scenario john4-children-hobby-diet-update", - "test": "node --test api-core-retry.test.mjs auth.test.mjs asr-proxy.test.mjs billing.test.mjs billing-token-state.test.mjs billing-recharge.test.mjs wechat-pay.test.mjs wechat-oauth.test.mjs wechat-voice-reco.test.mjs wechat-mp.test.mjs wechat-media.test.mjs wechat/image-generation-policy.test.mjs wechat/verify/generated-thumbnail.test.mjs schedule-intent.test.mjs schedule-reminder-worker.test.mjs scheduled-task-intent.test.mjs scheduled-task-service.test.mjs scheduled-task-executor.test.mjs scheduled-task-worker.test.mjs scheduled-task-worker-config.test.mjs wechat/handlers/scheduled-task.test.mjs capabilities.test.mjs policies.test.mjs server/portal-api-auth-middleware.test.mjs server/portal-config-routes.test.mjs server/portal-plaza-discovery-routes.test.mjs server/portal-runtime-routes.test.mjs server/portal-gateway-services-bootstrap.test.mjs chat-skills.test.mjs chat-intent-router.test.mjs chat-finish-sync.test.mjs chat-agent-run-gate.test.mjs conversation-display.test.mjs user-publish.test.mjs user-memory-profile.test.mjs skills-registry.test.mjs skill-runtime-policy.test.mjs excel-analyst.test.mjs agent-run-gateway.test.mjs agent-run-routes.test.mjs session-broker.test.mjs sse-event-taxonomy.test.mjs goosed-proxy-boundary.test.mjs agent-run-stream.test.mjs mindspace-h5-html-finish-guard.test.mjs admin-routes.test.mjs services/orchestrator/admin-config.test.mjs services/orchestrator/contracts.test.mjs services/orchestrator/checkpoint.test.mjs services/orchestrator/runtime.test.mjs services/orchestrator/app.test.mjs services/orchestrator/server.test.mjs services/orchestrator/shadow-dispatcher.test.mjs services/orchestrator/shadow-observer.test.mjs services/orchestrator/observability.test.mjs services/orchestrator/executor-gateway.test.mjs services/orchestrator/executor-job-store.test.mjs image-make-admin-config.test.mjs asset-gateway.test.mjs image-make-client.test.mjs mindspace-image-generation.test.mjs mindspace-image-generation-routes.test.mjs mindspace-image-review.test.mjs mindspace-run-public-html-scope.test.mjs direct-chat-service.test.mjs tool-gateway.test.mjs mindspace.test.mjs mindspace-scan.test.mjs mindspace-assets.test.mjs mindspace-local-runtime-services.test.mjs mindspace-local-server-adapter.test.mjs mindspace-public-asset-token.test.mjs mindspace-remote-server-adapter.test.mjs mindspace-server-adapter.test.mjs mindspace-pages.test.mjs mindspace-page-sync-service.test.mjs public-site-bases.test.mjs mindspace-html-download-links.test.mjs mindspace-long-image.test.mjs mindspace-page-purge.test.mjs mindspace-public-delivery.test.mjs mindspace-public-page-context.test.mjs mindspace-published-page-csp.test.mjs mindspace-published-script-localize.test.mjs agent-run-deliverable-check.test.mjs mindspace-public-route.test.mjs mindspace-publications.test.mjs mindspace-public-links.test.mjs mindspace-chat-save.test.mjs mindspace-chat-save-service.test.mjs mindspace-chat-docx-package.test.mjs mindspace-public-finish-sync.test.mjs mindspace-wechat-html-delivery.test.mjs mindspace-chat-context.test.mjs mindspace-canonical-url.test.mjs mindspace-conversation-package.test.mjs mindspace-conversation-package-artifact-service.test.mjs mindspace-conversation-package-audit.test.mjs mindspace-conversation-package-backfill.test.mjs mindspace-conversation-package-public-html.test.mjs mindspace-conversation-package-verify.test.mjs mindspace-conversation-package-registry.test.mjs mindspace-conversation-package-routes.test.mjs mindspace-conversation-package-store.test.mjs mindspace-conversation-schema.test.mjs mindspace-runtime-config.test.mjs mindspace-config.test.mjs mindspace-analytics.test.mjs mindspace-service.test.mjs mindspace-storage-adapter.test.mjs mindspace-content-scan.test.mjs mindspace-html-localize.test.mjs mindspace-visual-editor.test.mjs mindspace-cleanup.test.mjs mindspace-thumbnails.test.mjs mindspace-workspace-thumbnails.test.mjs mindspace-workspace-sync.test.mjs mindspace-workspace-publication-delivery-service.test.mjs mindspace-workspace-tool-service.test.mjs mindspace-mcp-scoped-token.test.mjs mindspace-asset-preview.test.mjs mindspace-agent-jobs.test.mjs mindspace-agent-runner.test.mjs mindspace-sandbox-mcp.test.mjs mindspace-userdata-postgres.test.mjs postgres-user-data-space-service.test.mjs user-data-space-service.test.mjs page-data-routes.test.mjs page-access-policy.test.mjs page-access-visitor.test.mjs page-data-public-service.test.mjs page-data-integration.test.mjs page-data-log-store.test.mjs page-data-ops.test.mjs page-data-session-store.test.mjs page-data-browser-client.test.mjs page-data-policy-index.test.mjs message-stream.test.mjs mindspace-service/mindspace-rpc-server.test.mjs plaza-posts.test.mjs plaza-interactions.test.mjs plaza-algorithm.test.mjs plaza-seo.test.mjs plaza-ops.test.mjs user-auth.test.mjs llm-providers.test.mjs admin-guard.test.mjs user-feedback.test.mjs memory-v2.test.mjs memory-v2-admin-config.test.mjs memory-v2-lifecycle.test.mjs memory-v2-adapter-scaffold.test.mjs memory-v2-backend-contract.test.mjs memory-v2-health.test.mjs memory-v2-runtime.test.mjs memory-v2-plugin-backends.test.mjs memory-v2-pgvector.test.mjs memory-v2-pgvector-schema.test.mjs memory-v2-pgvector-backfill.test.mjs memory-v2-pgvector-smoke.test.mjs memory-v2-qdrant.test.mjs memory-v2-weaviate.test.mjs memory-v2-mem0.test.mjs memory-v2-letta.test.mjs memory-v2-external-adapters.test.mjs scripts/embed-memory-v2-local-hash.test.mjs scripts/check-memory-v2-app-canary.test.mjs scripts/check-memory-v2-config-gaps.test.mjs scripts/check-memory-v2-contracts.test.mjs scripts/check-memory-v2-health.test.mjs scripts/check-memory-v2-session-flow.test.mjs scripts/check-memory-v2-stack.test.mjs scripts/setup-memory-v2-pgvector-schema.test.mjs scripts/backfill-memory-v2-pgvector.test.mjs scripts/scaffold-memory-v2-backend.test.mjs scripts/smoke-memory-v2-pgvector.test.mjs scripts/smoke-memory-v2-qdrant.test.mjs scripts/smoke-memory-v2-external.test.mjs scripts/mock-memory-v2-services.test.mjs", + "test": "node --test api-core-retry.test.mjs auth.test.mjs asr-proxy.test.mjs billing.test.mjs billing-token-state.test.mjs billing-recharge.test.mjs wechat-pay.test.mjs wechat-oauth.test.mjs wechat-voice-reco.test.mjs wechat-mp.test.mjs wechat-media.test.mjs wechat/image-generation-policy.test.mjs wechat/verify/generated-thumbnail.test.mjs schedule-intent.test.mjs schedule-reminder-worker.test.mjs scheduled-task-intent.test.mjs scheduled-task-service.test.mjs scheduled-task-executor.test.mjs scheduled-task-worker.test.mjs scheduled-task-worker-config.test.mjs wechat/handlers/scheduled-task.test.mjs capabilities.test.mjs policies.test.mjs server/portal-api-auth-middleware.test.mjs server/portal-config-routes.test.mjs server/portal-plaza-discovery-routes.test.mjs server/portal-runtime-routes.test.mjs server/portal-gateway-services-bootstrap.test.mjs chat-skills.test.mjs chat-intent-router.test.mjs chat-finish-sync.test.mjs chat-agent-run-gate.test.mjs conversation-display.test.mjs user-publish.test.mjs user-memory-profile.test.mjs skills-registry.test.mjs skill-runtime-policy.test.mjs excel-analyst.test.mjs agent-run-gateway.test.mjs agent-run-routes.test.mjs session-broker.test.mjs sse-event-taxonomy.test.mjs goosed-proxy-boundary.test.mjs agent-run-stream.test.mjs mindspace-h5-html-finish-guard.test.mjs admin-routes.test.mjs services/orchestrator/admin-config.test.mjs services/orchestrator/contracts.test.mjs services/orchestrator/checkpoint.test.mjs services/orchestrator/runtime.test.mjs services/orchestrator/app.test.mjs services/orchestrator/server.test.mjs services/orchestrator/shadow-dispatcher.test.mjs services/orchestrator/shadow-observer.test.mjs services/orchestrator/observability.test.mjs services/orchestrator/executor-gateway.test.mjs services/orchestrator/executor-job-store.test.mjs image-make-admin-config.test.mjs asset-gateway.test.mjs image-make-client.test.mjs mindspace-image-generation.test.mjs mindspace-image-generation-routes.test.mjs mindspace-image-review.test.mjs mindspace-run-public-html-scope.test.mjs direct-chat-service.test.mjs tool-gateway.test.mjs mindspace.test.mjs health-channel-state.test.mjs health-intent-rules.test.mjs health-extraction.test.mjs health-observation-validate.test.mjs health-baseline-maturity.test.mjs health-publish-guard.test.mjs health-p0-experiment.test.mjs mindspace-scan.test.mjs mindspace-assets.test.mjs mindspace-local-runtime-services.test.mjs mindspace-local-server-adapter.test.mjs mindspace-public-asset-token.test.mjs mindspace-remote-server-adapter.test.mjs mindspace-server-adapter.test.mjs mindspace-pages.test.mjs mindspace-page-sync-service.test.mjs public-site-bases.test.mjs mindspace-html-download-links.test.mjs mindspace-long-image.test.mjs mindspace-page-purge.test.mjs mindspace-public-delivery.test.mjs mindspace-public-page-context.test.mjs mindspace-published-page-csp.test.mjs mindspace-published-script-localize.test.mjs agent-run-deliverable-check.test.mjs mindspace-public-route.test.mjs mindspace-publications.test.mjs mindspace-public-links.test.mjs mindspace-chat-save.test.mjs mindspace-chat-save-service.test.mjs mindspace-chat-docx-package.test.mjs mindspace-public-finish-sync.test.mjs mindspace-wechat-html-delivery.test.mjs mindspace-chat-context.test.mjs mindspace-canonical-url.test.mjs mindspace-conversation-package.test.mjs mindspace-conversation-package-artifact-service.test.mjs mindspace-conversation-package-audit.test.mjs mindspace-conversation-package-backfill.test.mjs mindspace-conversation-package-public-html.test.mjs mindspace-conversation-package-verify.test.mjs mindspace-conversation-package-registry.test.mjs mindspace-conversation-package-routes.test.mjs mindspace-conversation-package-store.test.mjs mindspace-conversation-schema.test.mjs mindspace-runtime-config.test.mjs mindspace-config.test.mjs mindspace-analytics.test.mjs mindspace-service.test.mjs mindspace-storage-adapter.test.mjs mindspace-content-scan.test.mjs mindspace-html-localize.test.mjs mindspace-visual-editor.test.mjs mindspace-cleanup.test.mjs mindspace-thumbnails.test.mjs mindspace-workspace-thumbnails.test.mjs mindspace-workspace-sync.test.mjs mindspace-workspace-publication-delivery-service.test.mjs mindspace-workspace-tool-service.test.mjs mindspace-mcp-scoped-token.test.mjs mindspace-asset-preview.test.mjs mindspace-agent-jobs.test.mjs mindspace-agent-runner.test.mjs mindspace-sandbox-mcp.test.mjs mindspace-userdata-postgres.test.mjs postgres-user-data-space-service.test.mjs user-data-space-service.test.mjs page-data-routes.test.mjs page-access-policy.test.mjs page-access-visitor.test.mjs page-data-public-service.test.mjs page-data-integration.test.mjs page-data-log-store.test.mjs page-data-ops.test.mjs page-data-session-store.test.mjs page-data-browser-client.test.mjs page-data-policy-index.test.mjs message-stream.test.mjs mindspace-service/mindspace-rpc-server.test.mjs plaza-posts.test.mjs plaza-interactions.test.mjs plaza-algorithm.test.mjs plaza-seo.test.mjs plaza-ops.test.mjs user-auth.test.mjs llm-providers.test.mjs admin-guard.test.mjs user-feedback.test.mjs memory-v2.test.mjs memory-v2-admin-config.test.mjs memory-v2-lifecycle.test.mjs memory-v2-adapter-scaffold.test.mjs memory-v2-backend-contract.test.mjs memory-v2-health.test.mjs memory-v2-runtime.test.mjs memory-v2-plugin-backends.test.mjs memory-v2-pgvector.test.mjs memory-v2-pgvector-schema.test.mjs memory-v2-pgvector-backfill.test.mjs memory-v2-pgvector-smoke.test.mjs memory-v2-qdrant.test.mjs memory-v2-weaviate.test.mjs memory-v2-mem0.test.mjs memory-v2-letta.test.mjs memory-v2-external-adapters.test.mjs scripts/embed-memory-v2-local-hash.test.mjs scripts/check-memory-v2-app-canary.test.mjs scripts/check-memory-v2-config-gaps.test.mjs scripts/check-memory-v2-contracts.test.mjs scripts/check-memory-v2-health.test.mjs scripts/check-memory-v2-session-flow.test.mjs scripts/check-memory-v2-stack.test.mjs scripts/setup-memory-v2-pgvector-schema.test.mjs scripts/backfill-memory-v2-pgvector.test.mjs scripts/scaffold-memory-v2-backend.test.mjs scripts/smoke-memory-v2-pgvector.test.mjs scripts/smoke-memory-v2-qdrant.test.mjs scripts/smoke-memory-v2-external.test.mjs scripts/mock-memory-v2-services.test.mjs", "test:episodic-memory": "node --test episodic-memory.test.mjs direct-chat-service.test.mjs chat-intent-router.test.mjs", "test:deep-search": "node --test deep-search.test.mjs mindsearch.test.mjs", "test:image-review": "node --test mindspace-image-review.test.mjs mindspace-image-generation.test.mjs", diff --git a/scripts/run-memind-tests.mjs b/scripts/run-memind-tests.mjs index 07e448b..4b5796a 100644 --- a/scripts/run-memind-tests.mjs +++ b/scripts/run-memind-tests.mjs @@ -143,6 +143,23 @@ const SCOPE_RULES = [ 'plaza-ops.test.mjs', ], }, + { + id: 'health', + patterns: [/^health-/i, /health-assistant/i, /templates\/health-p0/i], + tests: [ + 'health-channel-state.test.mjs', + 'health-intent-rules.test.mjs', + 'health-extraction.test.mjs', + 'health-observation-validate.test.mjs', + 'health-baseline-maturity.test.mjs', + 'health-publish-guard.test.mjs', + 'health-p0-experiment.test.mjs', + 'mindspace.test.mjs', + 'mindspace-publications.test.mjs', + 'chat-skills.test.mjs', + 'skills-registry.test.mjs', + ], + }, { id: 'page-data', patterns: [/page-data/i, /page-access-policy/i, /user-data-space-service/i, /mindspace-sandbox-mcp/i], diff --git a/skills-registry.mjs b/skills-registry.mjs index 52e0cd0..e646827 100644 --- a/skills-registry.mjs +++ b/skills-registry.mjs @@ -36,6 +36,7 @@ export const DEFAULT_USER_SKILLS = { 'long-image-download': true, [IMAGE_GENERATION_SKILL_NAME]: true, [PAGE_DATA_COLLECT_SKILL_NAME]: true, + 'health-assistant': false, [AIDER_DEVELOPMENT_SKILL_NAME]: false, [PUBLISH_SKILL_NAME]: false, 'page-template-travel': false, diff --git a/skills-registry.test.mjs b/skills-registry.test.mjs index 4596c16..ead1789 100644 --- a/skills-registry.test.mjs +++ b/skills-registry.test.mjs @@ -17,7 +17,7 @@ test('lists static-page-publish in platform catalog', () => { const catalog = listPlatformSkillCatalog(h5Root); assert.ok(catalog.some((item) => item.name === 'static-page-publish')); assert.ok(catalog.some((item) => item.name === 'page-data-collect')); - assert.ok(catalog.some((item) => item.name === 'schedule-assistant')); + assert.ok(catalog.some((item) => item.name === 'health-assistant')); assert.ok(catalog.some((item) => item.name === 'scheduled-task-automation')); assert.ok(catalog.some((item) => item.name === 'service-integration-smoke')); assert.ok(catalog.some((item) => item.name === 'product-campaign-page')); @@ -76,7 +76,7 @@ test('DEFAULT_USER_SKILLS enables common platform skills', () => { assert.equal(DEFAULT_USER_SKILLS['product-campaign-page'], true); assert.equal(DEFAULT_USER_SKILLS['long-image-download'], true); assert.equal(DEFAULT_USER_SKILLS['image-generation'], true); - assert.equal(DEFAULT_USER_SKILLS['page-data-collect'], true); + assert.equal(DEFAULT_USER_SKILLS['health-assistant'], false); assert.equal(DEFAULT_USER_SKILLS['static-page-publish'], false); assert.equal(DEFAULT_USER_SKILLS['page-template-travel'], false); assert.equal(DEFAULT_USER_SKILLS['page-template-campaign'], false); diff --git a/skills/health-assistant/SKILL.md b/skills/health-assistant/SKILL.md new file mode 100644 index 0000000..c59afa7 --- /dev/null +++ b/skills/health-assistant/SKILL.md @@ -0,0 +1,23 @@ +--- +name: health-assistant +description: 本人健康助手锁定通道。只处理健康录入、评估与档案,禁止在普通聊天落库健康数据。 +--- + +# 健康助手 + +仅在用户已进入健康通道,或消息 metadata 已 pin `health-assistant` 时使用。 + +## 硬约束 + +- 监测对象只能是账号本人 +- 不得把他人血压/症状写入健康观测 +- 拍照/语音识别结果必须确认后才能保存 +- 意图不明时给出选项:1 健康录入 2 健康评估 3 上传报告 4 健康档案 +- 禁止诊断口吻;只解释变化并建议复测或就医 +- 健康区页面禁止 `public` 发布 + +## 不要 + +- 不要在普通聊天会话写入 Health Observation +- 不要绕过确认卡直接落库 +- 不要调用非白名单工具发布公开页 diff --git a/src/components/MindSpaceView.tsx b/src/components/MindSpaceView.tsx index 6a68c68..3720ab1 100644 --- a/src/components/MindSpaceView.tsx +++ b/src/components/MindSpaceView.tsx @@ -73,6 +73,7 @@ const CATEGORY_DESCRIPTIONS: Record = { oa: '上传文档、表格和资料,让 AI 生成报告与页面', private: '已停用', public: '管理可公开使用的页面、图片和静态资源', + health: '加密健康档案,仅本人或口令访问', draft: '继续编辑尚未发布或等待安全检查的页面', archive: '保存不再活跃但仍需要保留的内容', }; @@ -80,6 +81,7 @@ const CATEGORY_DESCRIPTIONS: Record = { const CATEGORY_ACTIONS: Partial> = { oa: '进入工作区', public: '查看发布', + health: '查看健康档案', draft: '继续创作', }; @@ -1283,7 +1285,7 @@ export function MindSpaceView({ return; } setAssets(await listMindSpaceAssets(category.code)); - if (['draft', 'oa', 'public'].includes(category.code)) { + if (['draft', 'oa', 'public', 'health'].includes(category.code)) { const { items } = await listMindSpacePages({ categoryCode: category.code, limit: 100 }); setPages(items); if (category.code === 'draft') { @@ -3186,7 +3188,7 @@ export function MindSpaceView({
{space.categories - .filter((category) => ['oa', 'public', 'archive'].includes(category.code)) + .filter((category) => ['oa', 'public', 'health', 'archive'].includes(category.code)) .map((category) => (
+ + + + + MeMind Health P0 研究者后台 + + + +

P0 实验记录

+

仅研究者使用。口令访问,禁止公开。

+
+ + + +
+

+
+ + + + diff --git a/templates/health-p0-log.html b/templates/health-p0-log.html new file mode 100644 index 0000000..1179e4f --- /dev/null +++ b/templates/health-p0-log.html @@ -0,0 +1,138 @@ + + + + + + MeMind Health P0 每日记录 + + + +

每日健康记录

+

晨间血压必填。请先静坐 5 分钟,固定同一侧手臂,连测 2 次后记录第 2 次。

+
+ + + + + + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ 晚间血压 / 睡眠 / 体重 / 扰动(可选) + + +
+
+ + +
+
+ + +
+
+ + +
+
+ + + + + + + + + + +
+ +
+

+ + + +