跳转到内容
快速开始

② 标准化

健康指标

② 标准化:离线解析器、概念图、300 条的指标 registry、入库时的单位换算,以及解析器覆盖率的测量方式。

indicator 是某个测量量的名字,比如 heartRatesbloodGlucosesdailyTotalSteps。每一条 reading 都存在这个键下面,一个问题也只能靠它抓住数据,所以 Garmin 手表、Oura 戒指和一张扫描的化验单,最终都要在这个名字上达成一致。这一页讲的就是这种一致是怎么安排出来的,以及安排不出来时会怎样:一份文件里叫 空腹血糖、另一份里叫 Fasting Glucose (FPG) 的报告行,说的仍然是同一个测量,而任何 registry 都不可能把两种写法都收进去。

有两套机制分别应对这两种情形,从一开始就该把它们分清楚。

指标名要能被查询,前提是同一项检查的不同写法最终落到同一个身份上。LDL cholesterol低密度脂蛋白胆固醇LDL-C 指的是同一件事,而任何一份固定名单都不可能穷举所有写法。引擎因此用两套机制分别应对:

机制应对什么
registry引擎自己定义的名字:设备数据流、派生聚合
解析器外部写下的名字:报告里的一行、外文化验单上的字

解析器可以单独使用:pip install mirobody 之后,resolve("血红蛋白").loinc 不需要数据库、密钥或网络。见引擎即库

随包分发的术语资源如下,通过 Git LFS 获取:

资源规模
概念图节点(其中 440,961 个携带跨词表边)745,620
跨词表边(LOINC ↔ SNOMED CT ↔ RxNorm)22,044,110
sibling 组(595,746 个节点参与)199,959
多语言别名(中文 22,578 · 日本語 16,809 · ru · es · fr · ko · de)49,253
UCUM 单位族约 310
registry 指标300 条,13 个类别

词表按行对齐;行数不一致时加载器会中止,而不是对齐一半。

指标名解析不出来、或解析到了错误的项目,都欢迎提 issue 或 PR。词表的打磨是这个项目最需要外部参与的部分:一条别名映射加一个测试用例,就是一份完整的贡献。见贡献指南

mirobody/test_engine_coverage.py 用体检常见套餐给离线解析器打分:血脂、血常规、代谢、肝功、甲功、激素、肿瘤标志物、尿常规、体征,样本按报告实际打印的写法书写,涵盖英文、中文与日文。

Terminal window
pytest mirobody/test_engine_coverage.py -s
# offline resolver coverage: 116/116 = 100%

评分依据是临床正确性,而不是解析成功率:

  • 血红蛋白 解析成 HbA1c 的编码算作失败,不计部分分。
  • 血圧 是套餐名而非单项观测,正确结果为空,而不是套餐所含的某一项。

StandardIndicator 是一个枚举,300 个成员,每个成员带一份 IndicatorInfo,声明它的类别、标准单位、数据类型、中英文名与可用的聚合方式。分布为 167 汇总 / 111 序列 / 22 混合,归入 13 个类别(生命体征、身体成分、活动、代谢、睡眠、运动表现、医疗、设备特定、营养摄入、生活方式、健康、心理、生殖)。

使用时需要知道的是三条约定:

  • 名字是 lowerCamelCase 且常为复数heartRatesbloodGlucoses),因为它命名的是一串读数而不是一条。派生聚合遵循 daily{Method}{Indicator},因此 dailyAvgHeartRates 是派生量、heartRates 是原始量。
  • 认不出的名字原样通过normalize_indicator_name() 只做大小写无关的规范化,不认识的名字不会被拒绝;自由文本指标正是靠这一点走完写入路径,再由解析器处理。
  • 每个指标都有目标单位。查询未登记指标的标准单位会抛 ValueError,因为没有目标单位的换算是错误,而不是可以兜底的情况。

一个值只在写入时换算一次,之后每个读取方都可以假定它就是该指标的标准单位。换算按「指标特化规则 → 通用换算表 → 原样保留」的顺序尝试。

需要在使用时知道的是最后一步的行为:没有规则命中时,值和它原本的单位一起原样存下,而不是给值改一个标签。 因此一条读数不会静默出错:一个 mg/dL 的值不会落在声称 mmol/L 的行里。调用方看到的是未换算的单位,可以据此处理。

有几类换算本质上不是单位换算而是临床估算(例如 PaO2SpO2%),也有几类因摩尔质量不同而无法通用(葡萄糖与各项胆固醇的 mg/dLmmol/L 系数并不相同),它们都以指标特化规则的形式存在。

引擎里还有另一套彼此无关的单位层,它不换算数值,只把自由文本的单位串解析成规范 UCUM,并给出对应的 LOINC PROPERTY 家族。要回答的是另一个问题:一份文档上写着「毫摩尔每升」,那到底是什么单位。

normalize_unit("MG/DL")
"mg/dL"
normalize_unit("毫摩尔每升")
"mmol/L"
parse_value_unit("90次每分钟")
ParsedQuantity("", 90.0, "/min")

同一族内的单位可以互相换算,跨族换算是范畴错误。这一层通过 normalize_unit 工具对外暴露,见内置工具

真实存在的是一份编码映射:启动时把编码表读进内存,写入路径上靠这份缓存回答「这个名字对应哪个 fhir_id」,不再碰数据库。

有两个配置键管着它:FHIR_TABLE_AUTO_R 必须是 "true" 这份映射才会加载 —— 否则 fhir_id 那一列会一直空着;FHIR_TABLE_AUTO_W 打开自动登记,此时没见过的名字会在稍后的聚合中被补登进编码表。Mirobody 自己的指标登记在 THETA 这个词表名下,与各标准词表并列。

文件接入路径产出的是没人登记过的指标名:化验单上那一行叫什么就是什么,用什么语言印的就是什么语言。写入时强行把它们归入 registry 就等于猜,而猜错了不可逆,所以它们被原样存下,留到读取时再解析。

worker 会持续给这些名字补编码,从最便宜、最有把握的判据开始:先用 registry 做确定性匹配;再看这个名字历史上已映射的行是否全都指向同一个编码;最后才用 ≥99% 的历史多数值。真正一名多义的名字(比如散布在各个身体部位上的「疼痛」)永远到不了 99%,会被刻意留成未映射 —— 它们靠向量检索找回,为此每个未映射的名字都会拿到一条 embedding。

两种 provider 的 embedding 都是 1024 维gemini 请求 output_dimensionality: 1024qwentext-embedding-v4dimensions: 1024。一个键 EMBEDDING_PROVIDER 在两者之间选,列名也跟着 provider 走,所以换 provider 意味着要重算一遍 embedding,而不只是翻个开关。

一次检索分四步:

  1. 做 embedding。 对关键词本身,以及关键词多于一个时它们的拼接分别做 embedding,于是 “MCHC” 和 “Mean Corpuscular Hemoglobin Concentration” 既能分别命中、也能合起来命中。
  2. 向量召回。 在已编码概念与该用户的自由文本指标上各做一次向量召回,两次都按用户限定,用户没有 reading 的指标不会被召回。
  3. 图扩展。 沿概念图向外走一步:跨词表(SNOMED CT ↔ LOINC ↔ RxNorm)以及同一词表内的近邻(共享成分的 LOINC 码、同一 ATC 子组的药物)。于是命中了某个概念某种写法的查询,也能找到用户归在相关编码下的记录。
  4. 合并与阈值。 已编码与自由文本的命中合到一起,自由文本命中按一个下限过滤(0.6 与最弱的已编码得分中较高的那个),再按得分排序。

概念图与索引是构建产物而非源码,由 Git LFS 跟踪。部署时需要知道两件事:

  • 离线解析器的词表随包分发,体积小,所以 resolve() 不需要任何外部依赖。
  • embedding 索引不随包分发(约 1.4 GB)。容器部署把它挂载进来,用 FHIR_INDICATORS_DIR 指向该目录。没有它时,语义检索回退到数据库里的 pgvector,词法解析器不受影响。
端点返回
GET /api/v1/pulse/theta/indicators调用方可用的指标。
GET /api/v1/health-indicators某个用户自己的读数,附抽屉里那个原始文件链接;POST /api/v1/health-indicators/reading 改一条或删一条,仅所有者可用。
GET /api/v1/manage/pulse/indicators整份 registry 按类别分组。
GET /api/v1/manage/pulse/units标准单位集合与换算表。
GET /api/v1/manage/pulse/indicators-and-units上面两者合在一个响应里。
GET /api/v1/manage/pulse/user-indicators某个用户实际有 reading 的那些指标。
GET /api/v1/manage/pulse/std-indicators/status · POST …/trigger标准指标同步的状态,以及手动触发一次。

/api/v1/manage/* 这几项由一个管理密钥把守,而不是用户的 bearer token:它们是运维侧的端点,不属于面向 App 的接口面。

在一次对话里,agent 用的不是上面任何一个,它获得的是单个 query_health_indicators 工具,说明见内置工具。那个工具对缩写要求同时给出缩写和全称(["MCHC", "Mean Corpuscular Hemoglobin Concentration"]),因为光一个缩写放到一个以全称写成的语料上做 embedding,效果很差。什么都没匹配上时,它返回的是这个用户的 catalog 而不是一个空列表,好让模型从已有的条目里挑,而不是重新猜关键词。

标准化这一块的源码在 mirobody/indicator/