跳转到内容
快速开始

② 标准化

数据流

健康数据进入 Mirobody 的三条接入路径、落到的两张表、之上的定时聚合与洞察,以及 agent 读回数据用的那一个工具。

健康数据经三条接入路径进入引擎:由 Mirobody 主动拉取、或由厂商 webhook 推送的 provider;客户端在设备本地读出、再成批上传的样本(Apple Health 是主用例);以及你上传、由 LLM 阅读的文档。三者几乎处处不同,唯一相同的是终点:每一条 reading 最终都落进同一批 PostgreSQL 表,按用户和 indicator 名索引。之后一趟定时任务把原始 reading 汇成日汇总,第二趟再从中挖掘洞察;到了对话时,agent 只用一个工具把它们读回来。

路径 1 —— provider 拉取与 webhook

Section titled “路径 1 —— provider 拉取与 webhook”

有服务端 API 的数据源按计划轮询;能主动推的数据源不去碰它,等它自己的 webhook。走哪条路由由 provider 自己声明:需要轮询的会在启动时被排上一个定时任务,只走推送的什么都不排。轮询间隔与分布式锁时长按 provider 分别配置,默认是每小时一次、锁 30 分钟,按 provider 的具体值见 Pulse Provider 体系

Webhook 从 POST /api/v1/pulse/{platform}/webhook(或 /{platform}/{provider}/webhook)进来。拉取与 webhook 最终汇到同一个归一写入,而且拉取不经过一次 HTTP 往返 —— 它在进程内直接把数据交进去。

轮询时,provider 取出每个已连接用户的凭据,逐个用户向厂商取数,丢掉判定为重复的部分,剩下的交给写入。

设备本地的健康数据库没有可供拉取的服务端 API,所以由客户端在设备本地读出样本,再成批 POST 上来。Apple Health 是这条通道的主用例,但它并不是 Apple 专用的:类型词表用的是 Flutter health 插件那套跨平台名字(同一批名字在 iOS HealthKit 与 Android Health Connect 上都成立),里面还专门列着体脂秤的十二个字段。三个端点接收它们,且 /apple/*/api/v1/pulse/apple/* 两套前缀都会响应,因为上传端可能指向其中任意一套:

端点请求体作用
POST /apple/health{ request_id, metaInfo, healthData[] }主导入通道。接受 Content-Encoding: gzip(是 gzip 压缩的 JSON body,不是 Apple 的导出压缩包)。
POST /apple/statistics{ metaInfo, statistics[] }客户端预先算好的 sum / average / minimum / maximum / mostRecent,按分组直接写进 th_series_data
POST /apple/cda{ request_id, metaInfo, cdaData[] }CDA(Clinical Document Architecture)文档。

样本按类型映射到标准指标名。以下两个行为是静默发生的,写客户端时需要知道:

  • 类型不在映射表里的样本会被丢弃,同时打一条警告,写明类型、UUID 和值。不会有任何数据被写入猜测出来的名字下。
  • 睡眠分期样本会被复制出第二条总睡眠时长记录,于是总睡眠成了一条一等公民 reading,不必让每个读取方各算一遍。

写入成功后会立即触发一次增量聚合,让客户端不必等定时任务就能看到新汇总;这一步出错只记日志,因为定时任务本身就是兜底。

上传一份化验单不是数据源接入,而是让 LLM 读一份文档:PDF 或图片先转成文本,文本连同一份 JSON schema 交给模型,模型返回的指标再写成 reading。抽取的机制见文件处理,这里要说的是它落在哪。

抽出来的每个指标直接写进 th_series_datasource_table 置为 'th_files'source_table_id 带上 file key:一条 reading 正是靠这个才能追回原始页面。单位与参考区间不进独立列,而是以 JSON 写入加密的 comment。随后 worker 会把新出现的指标名物化并补上 embedding,所以语义检索在写入完成之后才具备。

路径 1 与路径 2 最终汇到同一条归一写入。它在写库之前对每条记录做五件事:

  1. 把 indicator 名规范化 对 registry 做大小写无关匹配,于是 bloodglucoses 变成 bloodGlucoses。认不出来的名字原样透传,而不是丢掉。
  2. 换算单位 把值换算到该指标的标准单位。没有换算规则时,原值**和**原单位一起保留,绝不悄悄改标签。
  3. 校验取值范围 按指标的取值范围校验归一后的值。不通过并不删行:这一行的 task_id 被置为 filtered_out_of_range,于是读取端可以隐藏它,而审计仍然找得到。
  4. 定下墙上时钟 取记录自带的时区,否则取用户设置的时区,否则 UTC。汇总行会做换算,让 th_series_data.start_time 是用户本地的墙上时间而不是 UTC。
  5. 按数据类型分流 汇总型指标写一条汇总行,序列型指标写一条序列行,两者兼具的指标两条都写。

最后这一步是分叉而不是二选一:一个指标可以既是汇总指标又是序列指标,此时同一条读数会写进两张表。

存什么唯一键删除
series_data原始带时间戳的 reading——分钟级的流(user_id, indicator, source, time)物理删除。没有 deleted 列。
th_series_data汇总型与混合型 reading,以及聚合和文件抽取产出的一切(user_id, indicator, start_time, end_time)软删除,置 deleted = 1

路径 1 与路径 2 的这两处写入都是 upsert,所以重放同一批数据是空操作,不会产生重复;路径 3 走的是「冲突即跳过」,重传同一份文件既不会产生重复,也不会更新已有行。th_series_data 另外带着 fhir_id(从编码 registry 查出来,见健康指标)、以 JSON 存单位的 fhir_mapping_info,以及一个在数据库里加密后写入的 comment

还有第三种模式,用于 upsert 表达不了的情形:客户端重新上传一段修正过的窗口。批次的 metaInfo.taskId 形如 repair-<uuid> 且带了 windowFrom / windowTo 时,引擎会清扫窗口内没有被这批数据重新确认的行 —— series_data 里硬删,th_series_data 里软删。同一次修复中较早批次写下的行受保护,所以拆成多批上传的修复能收敛,不会自己吃自己。窗口不完整时跳过清扫,只让 upsert 生效。

「我上个月睡得怎么样」这种问题要的不是原始 reading。一趟定时任务每几分钟增量刷新一次日汇总:它记着上次处理到哪,只聚合这之后变动过的部分,因此新写入的数据很快就能按天查到(没有游标时冷启动回退到最近 24 小时)。

规则不是一张手工维护的清单,而是从 registry 生成的:每个声明了聚合方法的序列型指标,按方法各生成一条规则,目标名叫 daily{Method}{Indicator},于是带 ['avg', 'max', 'min']heartRates 会产出 dailyAvgHeartRatesdailyMaxHeartRatesdailyMinHeartRates。加一个聚合量,等于给某个指标定义加一个方法。支持的方法有 avgmaxmintotalcountlastfirststddevvariancemedianp95

有一条规则打破了日界,而且必须打破。睡眠塞不进 00:00–24:00,所以睡眠类指标按用户自己时区里的 18:00 到 18:00 窗口分组:1 号 23:00 睡下、2 号 07:00 起床的一夜算作同一天,不会被切成两半。分组与查询用的是同一套口径。

需要多个数据源才能算出的派生指标走第二趟更慢的任务(每 6 小时)。另有一个带上限的回填接口 POST /api/v1/manage/aggregate/recalculate-range:不传 user_id 时上限 30 天,因为全用户范围的开销会随活跃用户数线性增长。

洞察引擎每 6 小时运行一次。它逐用户算出基线,构建一份「哪些指标类别的数据够密」的画像,再挑出这份画像支撑得起的 recipe,然后只运行那些。每个 recipe 都声明自己需要哪些指标类别、要多少天的数据密度与重叠,以及报一次之后隔多久才能再报,所以一个只有两周心率数据、没有血糖数据的用户,根本不会运行到血糖那条。

内置六个 recipe:

Recipe类别必需密度冷却
multi_signal_deterioration异常heartRate14 天,重叠 10 天3 天
single_sustained_anomaly异常——(七个可选任取)14 天7 天
long_term_trend趋势——21 天14 天
recovery_trend恢复heartRate14 天7 天
weekday_weekend_pattern模式steps21 天30 天
glucose_control异常bloodGlucose14 天7 天

结果会落库,并由 GET /api/v1/pulse/user/insights 暴露,反馈走 POST /api/v1/pulse/user/insights/{insight_id}/feedback。冷却时间的作用就是避免同一条观察每六小时重复上报。

在一次对话里,agent 不写 SQL。它拿到的是一个工具 query_health_indicators,检索、读取与聚合被压进同一次调用:

  1. keywords 走语义匹配 先做 embedding,再做限定在该用户记录范围内的向量召回
  2. indicators 走精确匹配 上一次调用返回的那些名字,所以第二个问题不必重运行一遍检索
  3. 读数在同一个响应里回来 每个指标各取最新若干行,已软删除的行跳过
  4. aggregate 让趋势类问题在服务端算完 用统计量或 day/week/month 时间桶,而不是把原始读数拉给模型自己去加
  5. 什么都没匹配上时返回 catalog 也就是这个用户实际有什么,让下一次调用从现实里挑,而不是重新猜关键词

来自文件的行会在结果里带回一个 file_key,好让 agent 打开原始文档。每条结果还带着它的 system / code 身份:编码相同就是同一项检查,不管名字怎么写 —— 一条 Garmin 读数和一行化验结果,正是靠这个才能放在一起比较。

这个工具以注入的调用方身份运行,拿不到用户就拒绝执行,于是每一次健康数据读取在构造上就限定在了用户范围内,不必靠人记得写 WHERE

Mirobody Cloud数据形态组织写入,自部署按机制组织。两侧是两套代码,读数落在的也不是同一套列,因此按下表对照,不要假定同名:

Cloud /v1自部署引擎中的对应物
POST /v1/data(已获得的结构化读数)上文的路径 1 与路径 2。Cloud 不代管设备 OAuth,因此 provider 平台那一半在 Cloud 上没有对等物:厂商授权与 webhook 管道留在你自己的产品里,获得样本之后再写入 /v1/data
POST /v1/files路径 3 的文件解析。
POST /v1/standardize库接口的 parse_file()resolve(),或 CLI mirobody parse / mirobody resolve,见引擎即库
仅 Cloud 具备retention 四档留存、Subject(user)多租户隔离、单次 500 条上限、sourceapi / extract / upload / consolidation
仅自部署具备series_data 中的分钟级原始流、日聚合 daily{Method}{Indicator}、六条洞察 recipe、修复窗口。

字段名同样是两套:Cloud 的一行读数带 parsed_valueparsed_unitloinc_codecanonical_namefhir_resource_id;自部署引擎这一侧是换算到标准单位,加上 fhir_idfhir_mapping_info

采集这一块的源码在 mirobody/pulse/