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:
john
2026-09-02 09:35:55 +08:00
parent d2d463b1db
commit f96fdcb3b9
34 changed files with 3140 additions and 9 deletions
+1
View File
@@ -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 = [
+14
View File
@@ -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);
+10
View File
@@ -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
+573
View File
@@ -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 D8D14 偏离标记
每天对晨间收缩压做双重判定:
```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` — 建表与页面绑定规范
+26
View File
@@ -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;
}
+23
View File
@@ -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);
});
+225
View File
@@ -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' }],
};
}
+55
View File
@@ -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);
});
+57
View File
@@ -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,
};
}
+48
View File
@@ -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']);
});
+8
View File
@@ -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());
}
+114
View File
@@ -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 =
'你想记录健康数据吗?健康数据需要在健康助手通道里录入,这样才能保证记录准确并纳入你的健康基线分析。';
+26
View File
@@ -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);
});
+75
View File
@@ -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}`;
}
+27
View File
@@ -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');
});
+118
View File
@@ -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 };
}
+55
View File
@@ -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);
});
+77
View File
@@ -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 };
}
+72
View File
@@ -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);
});
+49
View File
@@ -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 };
}
+22
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
File diff suppressed because one or more lines are too long
+17
View File
@@ -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],
+1
View File
@@ -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,
+2 -2
View File
@@ -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);
+23
View File
@@ -0,0 +1,23 @@
---
name: health-assistant
description: 本人健康助手锁定通道。只处理健康录入、评估与档案,禁止在普通聊天落库健康数据。
---
# 健康助手
仅在用户已进入健康通道,或消息 metadata 已 pin `health-assistant` 时使用。
## 硬约束
- 监测对象只能是账号本人
- 不得把他人血压/症状写入健康观测
- 拍照/语音识别结果必须确认后才能保存
- 意图不明时给出选项:1 健康录入 2 健康评估 3 上传报告 4 健康档案
- 禁止诊断口吻;只解释变化并建议复测或就医
- 健康区页面禁止 `public` 发布
## 不要
- 不要在普通聊天会话写入 Health Observation
- 不要绕过确认卡直接落库
- 不要调用非白名单工具发布公开页
+4 -2
View File
@@ -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}`}
+11
View File
@@ -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
View File
@@ -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;
+50
View File
@@ -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>
+138
View File
@@ -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>