feat(health): add P0 health channel kernel and encrypted health zone.
Lock health recording behind confirm-only state machines, reject public publishes for the health category, and ship the 14-day experiment Page Data templates with tests. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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 = [
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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'));
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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` — 建表与页面绑定规范
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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' }],
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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']);
|
||||
});
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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 =
|
||||
'你想记录健康数据吗?健康数据需要在健康助手通道里录入,这样才能保证记录准确并纳入你的健康基线分析。';
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
+9
-1
@@ -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
|
||||
|
||||
+2
-2
@@ -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'/);
|
||||
});
|
||||
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
@@ -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],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
---
|
||||
name: health-assistant
|
||||
description: 本人健康助手锁定通道。只处理健康录入、评估与档案,禁止在普通聊天落库健康数据。
|
||||
---
|
||||
|
||||
# 健康助手
|
||||
|
||||
仅在用户已进入健康通道,或消息 metadata 已 pin `health-assistant` 时使用。
|
||||
|
||||
## 硬约束
|
||||
|
||||
- 监测对象只能是账号本人
|
||||
- 不得把他人血压/症状写入健康观测
|
||||
- 拍照/语音识别结果必须确认后才能保存
|
||||
- 意图不明时给出选项:1 健康录入 2 健康评估 3 上传报告 4 健康档案
|
||||
- 禁止诊断口吻;只解释变化并建议复测或就医
|
||||
- 健康区页面禁止 `public` 发布
|
||||
|
||||
## 不要
|
||||
|
||||
- 不要在普通聊天会话写入 Health Observation
|
||||
- 不要绕过确认卡直接落库
|
||||
- 不要调用非白名单工具发布公开页
|
||||
@@ -73,6 +73,7 @@ const CATEGORY_DESCRIPTIONS: Record<MindSpaceCategory['code'], string> = {
|
||||
oa: '上传文档、表格和资料,让 AI 生成报告与页面',
|
||||
private: '已停用',
|
||||
public: '管理可公开使用的页面、图片和静态资源',
|
||||
health: '加密健康档案,仅本人或口令访问',
|
||||
draft: '继续编辑尚未发布或等待安全检查的页面',
|
||||
archive: '保存不再活跃但仍需要保留的内容',
|
||||
};
|
||||
@@ -80,6 +81,7 @@ const CATEGORY_DESCRIPTIONS: Record<MindSpaceCategory['code'], string> = {
|
||||
const CATEGORY_ACTIONS: Partial<Record<MindSpaceCategory['code'], string>> = {
|
||||
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({
|
||||
<div className="mindspace-category-layout">
|
||||
<div className="mindspace-grid-zones">
|
||||
{space.categories
|
||||
.filter((category) => ['oa', 'public', 'archive'].includes(category.code))
|
||||
.filter((category) => ['oa', 'public', 'health', 'archive'].includes(category.code))
|
||||
.map((category) => (
|
||||
<article
|
||||
className={`mindspace-card mindspace-card-compact mindspace-card-${category.code}`}
|
||||
|
||||
@@ -67,6 +67,17 @@ export const PREVIEW_SPACE: MindSpace = {
|
||||
sortOrder: 3,
|
||||
itemCount: 3,
|
||||
},
|
||||
{
|
||||
id: 'cat-health',
|
||||
code: 'health',
|
||||
name: '健康档案区',
|
||||
visibilityPolicy: 'private',
|
||||
aiAccessPolicy: 'explicit_asset_grant',
|
||||
publishPolicy: 'password_required',
|
||||
isSystem: true,
|
||||
sortOrder: 30,
|
||||
itemCount: 0,
|
||||
},
|
||||
{
|
||||
id: 'cat-draft',
|
||||
code: 'draft',
|
||||
|
||||
+1
-1
@@ -424,7 +424,7 @@ export type MindSpaceQuota = {
|
||||
|
||||
export type MindSpaceCategory = {
|
||||
id: string;
|
||||
code: 'oa' | 'private' | 'public' | 'draft' | 'archive';
|
||||
code: 'oa' | 'private' | 'public' | 'health' | 'draft' | 'archive';
|
||||
name: string;
|
||||
visibilityPolicy: string;
|
||||
aiAccessPolicy: string;
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>MeMind Health P0 研究者后台</title>
|
||||
<style>
|
||||
body { font-family: sans-serif; margin: 1.5rem; color: #111827; }
|
||||
table { border-collapse: collapse; width: 100%; font-size: 0.85rem; }
|
||||
th, td { border: 1px solid #d1d5db; padding: 0.35rem 0.5rem; text-align: left; }
|
||||
input { padding: 0.4rem; }
|
||||
button { margin-left: 0.5rem; padding: 0.4rem 0.8rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>P0 实验记录</h1>
|
||||
<p>仅研究者使用。口令访问,禁止公开。</p>
|
||||
<form id="auth-form">
|
||||
<label for="password">口令</label>
|
||||
<input id="password" name="password" type="password" minlength="6" required />
|
||||
<button type="submit">查看</button>
|
||||
</form>
|
||||
<p id="status"></p>
|
||||
<div id="table-wrap"></div>
|
||||
<script src="/assets/page-data-client.js"></script>
|
||||
<script>
|
||||
const form = document.getElementById('auth-form');
|
||||
const statusEl = document.getElementById('status');
|
||||
const tableWrap = document.getElementById('table-wrap');
|
||||
form.addEventListener('submit', async (event) => {
|
||||
event.preventDefault();
|
||||
const password = document.getElementById('password').value;
|
||||
const client = MindSpacePageData.createClient({ apiBase: '/api' });
|
||||
try {
|
||||
await client.authenticate(password);
|
||||
const { rows } = await client.listRows('health_p0_daily_log', { limit: 200 });
|
||||
statusEl.textContent = `共 ${rows.length} 条`;
|
||||
const keys = rows[0] ? Object.keys(rows[0]) : [];
|
||||
tableWrap.innerHTML = rows.length
|
||||
? `<table><thead><tr>${keys.map((key) => `<th>${key}</th>`).join('')}</tr></thead><tbody>${
|
||||
rows.map((row) => `<tr>${keys.map((key) => `<td>${row[key] ?? ''}</td>`).join('')}</tr>`).join('')
|
||||
}</tbody></table>`
|
||||
: '<p>暂无数据</p>';
|
||||
} catch (error) {
|
||||
statusEl.textContent = error instanceof Error ? error.message : '读取失败';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,138 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>MeMind Health P0 每日记录</title>
|
||||
<style>
|
||||
body { font-family: sans-serif; max-width: 40rem; margin: 1.5rem auto; padding: 0 1rem; color: #1f2937; }
|
||||
label { display: block; margin: 0.6rem 0 0.2rem; font-weight: 600; }
|
||||
input, select, textarea { width: 100%; box-sizing: border-box; padding: 0.5rem; }
|
||||
.row { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 0.5rem; }
|
||||
details { margin-top: 1rem; }
|
||||
button { margin-top: 1rem; padding: 0.7rem 1.2rem; }
|
||||
.status { margin-top: 1rem; min-height: 1.5rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>每日健康记录</h1>
|
||||
<p>晨间血压必填。请先静坐 5 分钟,固定同一侧手臂,连测 2 次后记录第 2 次。</p>
|
||||
<form id="log-form">
|
||||
<label for="participant_code">编号</label>
|
||||
<input id="participant_code" name="participant_code" required />
|
||||
<label for="log_date">日期</label>
|
||||
<input id="log_date" name="log_date" type="date" required />
|
||||
<label for="am_measured_at">晨测时刻</label>
|
||||
<input id="am_measured_at" name="am_measured_at" placeholder="07:20" required />
|
||||
<div class="row">
|
||||
<div>
|
||||
<label for="am_systolic">收缩压</label>
|
||||
<input id="am_systolic" name="am_systolic" type="number" inputmode="numeric" required />
|
||||
</div>
|
||||
<div>
|
||||
<label for="am_diastolic">舒张压</label>
|
||||
<input id="am_diastolic" name="am_diastolic" type="number" inputmode="numeric" required />
|
||||
</div>
|
||||
<div>
|
||||
<label for="am_pulse">脉搏</label>
|
||||
<input id="am_pulse" name="am_pulse" type="number" inputmode="numeric" />
|
||||
</div>
|
||||
</div>
|
||||
<label for="symptoms">症状</label>
|
||||
<select id="symptoms" name="symptoms">
|
||||
<option value="none" selected>无</option>
|
||||
<option value="dizziness">头晕</option>
|
||||
<option value="headache">头痛</option>
|
||||
<option value="chest_tightness">胸闷</option>
|
||||
<option value="palpitation">心慌心悸</option>
|
||||
<option value="fatigue">乏力</option>
|
||||
<option value="shortness_of_breath">气短</option>
|
||||
<option value="leg_edema">下肢水肿</option>
|
||||
<option value="insomnia">失眠</option>
|
||||
<option value="other">其他</option>
|
||||
</select>
|
||||
<details>
|
||||
<summary>晚间血压 / 睡眠 / 体重 / 扰动(可选)</summary>
|
||||
<label for="pm_measured_at">晚测时刻</label>
|
||||
<input id="pm_measured_at" name="pm_measured_at" />
|
||||
<div class="row">
|
||||
<div>
|
||||
<label for="pm_systolic">晚收缩压</label>
|
||||
<input id="pm_systolic" name="pm_systolic" type="number" inputmode="numeric" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="pm_diastolic">晚舒张压</label>
|
||||
<input id="pm_diastolic" name="pm_diastolic" type="number" inputmode="numeric" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="pm_pulse">晚脉搏</label>
|
||||
<input id="pm_pulse" name="pm_pulse" type="number" inputmode="numeric" />
|
||||
</div>
|
||||
</div>
|
||||
<label for="sleep_minutes">睡眠分钟</label>
|
||||
<input id="sleep_minutes" name="sleep_minutes" type="number" inputmode="numeric" />
|
||||
<label for="weight_kg">体重 kg</label>
|
||||
<input id="weight_kg" name="weight_kg" type="number" step="0.1" inputmode="decimal" />
|
||||
<label for="alcohol">昨日饮酒</label>
|
||||
<select id="alcohol" name="alcohol">
|
||||
<option value="none">无</option>
|
||||
<option value="light">少量</option>
|
||||
<option value="heavy">较多</option>
|
||||
</select>
|
||||
<label for="stress">情绪压力</label>
|
||||
<select id="stress" name="stress">
|
||||
<option value="low">低</option>
|
||||
<option value="mid">中</option>
|
||||
<option value="high">高</option>
|
||||
</select>
|
||||
<label for="other_note">备注</label>
|
||||
<textarea id="other_note" name="other_note" rows="2"></textarea>
|
||||
</details>
|
||||
<button type="submit">保存今日记录</button>
|
||||
</form>
|
||||
<p id="status" class="status"></p>
|
||||
<script src="/assets/page-data-client.js"></script>
|
||||
<script>
|
||||
const form = document.getElementById('log-form');
|
||||
const statusEl = document.getElementById('status');
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const preset = params.get('p');
|
||||
if (preset) document.getElementById('participant_code').value = preset;
|
||||
const today = new Date();
|
||||
const iso = today.toISOString().slice(0, 10);
|
||||
document.getElementById('log_date').value = iso;
|
||||
|
||||
form.addEventListener('submit', async (event) => {
|
||||
event.preventDefault();
|
||||
const data = Object.fromEntries(new FormData(form).entries());
|
||||
const client = MindSpacePageData.createClient({ apiBase: '/api' });
|
||||
statusEl.textContent = '正在保存…';
|
||||
try {
|
||||
await client.insertRow('health_p0_daily_log', {
|
||||
participant_code: data.participant_code,
|
||||
log_date: data.log_date,
|
||||
am_measured_at: data.am_measured_at || null,
|
||||
am_systolic: data.am_systolic ? Number(data.am_systolic) : null,
|
||||
am_diastolic: data.am_diastolic ? Number(data.am_diastolic) : null,
|
||||
am_pulse: data.am_pulse ? Number(data.am_pulse) : null,
|
||||
pm_measured_at: data.pm_measured_at || null,
|
||||
pm_systolic: data.pm_systolic ? Number(data.pm_systolic) : null,
|
||||
pm_diastolic: data.pm_diastolic ? Number(data.pm_diastolic) : null,
|
||||
pm_pulse: data.pm_pulse ? Number(data.pm_pulse) : null,
|
||||
sleep_minutes: data.sleep_minutes ? Number(data.sleep_minutes) : null,
|
||||
weight_kg: data.weight_kg ? Number(data.weight_kg) : null,
|
||||
symptoms: JSON.stringify([{ code: data.symptoms || 'none', severity: data.symptoms === 'none' ? null : 1 }]),
|
||||
alcohol: data.alcohol || 'none',
|
||||
stress: data.stress || 'low',
|
||||
other_note: data.other_note || null,
|
||||
entry_channel: 'h5_form',
|
||||
photo_sent: false,
|
||||
});
|
||||
statusEl.textContent = '已记录。请继续按规程测量,不要依赖本页做医疗判断。';
|
||||
} catch (error) {
|
||||
statusEl.textContent = error instanceof Error ? error.message : '保存失败';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user