- 29 Jul, 2026 12 commits
-
-
luoqi committed
-
冲突解法:两处 ensurePatientStub 之后的 stats.patientStubsCreated++ 保 main 侧 —— 那是 host-id-format-defense 加的空壳计数(回执要透出空壳患者数,否则宿主看不出 自己推早了);本分支只是没有这行,不是要删它。
luoqi committed -
事故(2026-07-28 19:19,测试服务器):FRIDAY 推 med_emr_info 时 patient_id 变成了 浮点字符串 "676600.0"(典型 pandas 症状:列含空值 → int64 提升 float64 → 导出带 .0)。 PAC 原样收下,患者索引查 "676600.0" 命中不了真档 "676600" → ensurePatientStub 建出无名幽灵患者。3 个幽灵档吸走 4224 条事务,其中一个带着 2246 条诊断进了召回池、 算了画像、生成「应治未治」计划排队等客服打电话 —— 而推送回执是干净的 failed=0, 宿主和我们都看不出来,直到有人在池子里看见一个没名字的患者。 两层防御: 1) 归一层去尾巴(field-mapper.normalizeCanonical) `Id` 后缀的 canonical 字段,值形如 "676600.0" / "676600.00" → 截成 "676600"。 用后缀约定而非逐资源枚举,新增 canonical 字段自动纳入。只处理**整数值**的浮点 写法,不动 "676600.5" —— 那是另一种问题,应该显形而不是被悄悄改写。 放在 normalizeCanonical 意味着 cold-import / push / pull / reparse 行为一致。 2) 回执透出 patientStubsCreated(通用信号) 空壳 >0 本身不是错(「推送顺序无要求」正是靠它兜底),但**持续 >0 说明患者号 对不上** —— id 格式漂移只是其中一种表现形式,主档漏推同样会触发。宿主据此自检, 不必等人肉在召回池里发现幽灵。同时 logger.warn 提示常见成因。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
luoqi committed -
同一情况三条路给了三个答案: processSubject → ensurePatientStub(),照常落库 processPatientRelations → `if (!patientId) continue`,静默丢 processPatientReturnVisits → `if (!patientId) continue`,静默丢 那句 continue 的注释写着「非 active client,无处挂靠」,但 cold-import 的 cohort 路径 经 injectPatientFilter 对每张表都按患者 id 过滤、patients 又先跑,它几乎永不触发; 真正踩中的是 push —— 宿主先推 customer_referee_circle(07-27)、后推 customer_basic_info(07-28~29),10169 条关系边只落 257 条(2.5%), 且不计 failed、不进回执,宿主看到的是 accepted=N / failed=0,完全无从察觉。 契约文档承诺「推送顺序无要求」,兑现它靠的就是空壳兜底(pull 侧的等价物是 ClickHouseSourceService 的「反向拉主档」)。空壳只建**本人**这一侧;关系的对方 (relatedPatientId 可空)不建,靠 upsert 的 `update: { relatedPatientId }` 在对方入库后重推时回填 —— 那段回填代码本来就在。 加源码闸 ingest-patient-stub-consistency.spec.ts 锁住「三条路对齐」, 将来加第四个 process* 方法不至于再漏。已验证该闸对修复前的代码报 processPatientRelations / processPatientReturnVisits 各 1 处静默丢弃、 且两者都缺 ensurePatientStub。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>luoqi committed -
ColdImportService 是 Nest 单例,却把 source_unit 解析器存成实例字段,每次摄入开头 覆写、在长 async 循环里读。原注释「cold-import 按 host 串行,实例字段安全」在 push (webhook,按请求并发)接入后失效: friday identity_namespace_field = tenant_id jvs-dw identity_namespace_field = brand 两者并发摄入互相覆写 resolver → 对方的行解析不出命名空间列 → source_unit='' → 患者索引 (source_unit, external_id) 未命中 → 建出空命名空间的重复患者主档。 测试服务器实测(2026-07-29 查证): - friday 首推 07-23 16:24:26,jvs-dw 首个空品牌患者 16:24:31(5 秒内); 此前 jvs-dw 自 06-28 起 39 万患者零个空品牌。 - jvs-dw 空品牌患者 51063 条,其中 51037(99.95%)与真主档同 external_id; friday 空品牌 498 条,496 条落在 jvs-dw pull 窗口内,354 条是重复档。 改法照抄 tenantResolver 一贯的纪律:局部 const 构建 + 逐层传参。涉及 4 个入口 (reparse / ingestRawTables / importDirectory / importPatient)与 5 个读取方法 (processPatients / processPatientRelations / processPatientReturnVisits / processSubject / hydratePushLookupTables)。纯管道改造,无行为变更。 并加源码闸测试 ingest-resolver-no-instance-state.spec.ts:这类 bug 单跑任何一条 路径都正确,只有并发交错才炸,单测抓不到,只能在源码层禁止 `this.*Resolver =`。 (已验证该闸对修复前的代码报 4 处赋值 / 9 处读取。) 注:线上已污染的 5.1 万条空命名空间主档需另行归并,不在本次范围。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>luoqi committed -
事故(2026-07-28 19:19,测试服务器):FRIDAY 推 med_emr_info 时 patient_id 变成了 浮点字符串 "676600.0"(典型 pandas 症状:列含空值 → int64 提升 float64 → 导出带 .0)。 PAC 原样收下,患者索引查 "676600.0" 命中不了真档 "676600" → ensurePatientStub 建出无名幽灵患者。3 个幽灵档吸走 4224 条事务,其中一个带着 2246 条诊断进了召回池、 算了画像、生成「应治未治」计划排队等客服打电话 —— 而推送回执是干净的 failed=0, 宿主和我们都看不出来,直到有人在池子里看见一个没名字的患者。 两层防御: 1) 归一层去尾巴(field-mapper.normalizeCanonical) `Id` 后缀的 canonical 字段,值形如 "676600.0" / "676600.00" → 截成 "676600"。 用后缀约定而非逐资源枚举,新增 canonical 字段自动纳入。只处理**整数值**的浮点 写法,不动 "676600.5" —— 那是另一种问题,应该显形而不是被悄悄改写。 放在 normalizeCanonical 意味着 cold-import / push / pull / reparse 行为一致。 2) 回执透出 patientStubsCreated(通用信号) 空壳 >0 本身不是错(「推送顺序无要求」正是靠它兜底),但**持续 >0 说明患者号 对不上** —— id 格式漂移只是其中一种表现形式,主档漏推同样会触发。宿主据此自检, 不必等人肉在召回池里发现幽灵。同时 logger.warn 提示常见成因。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
luoqi committed -
两个 P0(2026-07-29 查验 FRIDAY 测试环境推送时定位): 1. source_unit 解析器挂在 Nest 单例的实例字段上,push(并发)与 pull 互相覆写 → 建出空命名空间的重复患者主档(jvs-dw 51063 条、friday 498 条)。 2. 关系边/回访在本人未入库时静默丢弃,不计 failed 不进回执 → 宿主先推 customer_referee_circle 时 10169 条边只落 257 条(2.5%)。 部署后需让 FRIDAY 重推 customer_referee_circle 补齐关系边; 线上已有的空命名空间重复主档另行归并,不在本次范围。luoqi committed -
同一情况三条路给了三个答案: processSubject → ensurePatientStub(),照常落库 processPatientRelations → `if (!patientId) continue`,静默丢 processPatientReturnVisits → `if (!patientId) continue`,静默丢 那句 continue 的注释写着「非 active client,无处挂靠」,但 cold-import 的 cohort 路径 经 injectPatientFilter 对每张表都按患者 id 过滤、patients 又先跑,它几乎永不触发; 真正踩中的是 push —— 宿主先推 customer_referee_circle(07-27)、后推 customer_basic_info(07-28~29),10169 条关系边只落 257 条(2.5%), 且不计 failed、不进回执,宿主看到的是 accepted=N / failed=0,完全无从察觉。 契约文档承诺「推送顺序无要求」,兑现它靠的就是空壳兜底(pull 侧的等价物是 ClickHouseSourceService 的「反向拉主档」)。空壳只建**本人**这一侧;关系的对方 (relatedPatientId 可空)不建,靠 upsert 的 `update: { relatedPatientId }` 在对方入库后重推时回填 —— 那段回填代码本来就在。 加源码闸 ingest-patient-stub-consistency.spec.ts 锁住「三条路对齐」, 将来加第四个 process* 方法不至于再漏。已验证该闸对修复前的代码报 processPatientRelations / processPatientReturnVisits 各 1 处静默丢弃、 且两者都缺 ensurePatientStub。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>luoqi committed -
ColdImportService 是 Nest 单例,却把 source_unit 解析器存成实例字段,每次摄入开头 覆写、在长 async 循环里读。原注释「cold-import 按 host 串行,实例字段安全」在 push (webhook,按请求并发)接入后失效: friday identity_namespace_field = tenant_id jvs-dw identity_namespace_field = brand 两者并发摄入互相覆写 resolver → 对方的行解析不出命名空间列 → source_unit='' → 患者索引 (source_unit, external_id) 未命中 → 建出空命名空间的重复患者主档。 测试服务器实测(2026-07-29 查证): - friday 首推 07-23 16:24:26,jvs-dw 首个空品牌患者 16:24:31(5 秒内); 此前 jvs-dw 自 06-28 起 39 万患者零个空品牌。 - jvs-dw 空品牌患者 51063 条,其中 51037(99.95%)与真主档同 external_id; friday 空品牌 498 条,496 条落在 jvs-dw pull 窗口内,354 条是重复档。 改法照抄 tenantResolver 一贯的纪律:局部 const 构建 + 逐层传参。涉及 4 个入口 (reparse / ingestRawTables / importDirectory / importPatient)与 5 个读取方法 (processPatients / processPatientRelations / processPatientReturnVisits / processSubject / hydratePushLookupTables)。纯管道改造,无行为变更。 并加源码闸测试 ingest-resolver-no-instance-state.spec.ts:这类 bug 单跑任何一条 路径都正确,只有并发交错才炸,单测抓不到,只能在源码层禁止 `this.*Resolver =`。 (已验证该闸对修复前的代码报 4 处赋值 / 9 处读取。) 注:线上已污染的 5.1 万条空命名空间主档需另行归并,不在本次范围。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>luoqi committed -
luoqi committed
-
luoqi committed
-
2026-07-29 线上:FRIDAY 推 patient_settlement 反复 SocketTimeoutException。 查下来是一条链,三处都得补。 【事故链】 ① manifest 的退费识别只认两种表达:status=4、status=3 且 receivable_this<0。 FRIDAY 还有第三种 —— 应收不动、只把钱退回去(status=3 / 应收 0 或 6000 / 实收 -500~-6000), 测试服 57,939 条结算里 4 行。它们漏进 payment_rows → payment_record 的 amount_cents<0 撞 zod nonnegative。 ② 当时 FactWriter.bulkWrite 是**整批 zod 校验、一条不合格整批抛错**,ParserPipeline 捕获后降级 per-entry —— 而 per-entry 是**每 fact 一个 $transaction**。 单批 200 行:bulkWrite 3 秒 → 逐行事务 63.5 秒(实测 199 次事务累计 79 秒; 对照组:空事务 2.7ms、那条 findFirst 4.1ms —— 数据库不慢,慢在往返次数)。 ③ 63.5s 超过宿主 60s 读超时,对方收不到响应就重推同一批;而 PAC 侧回执是 status=success / failed=0,对方也看不出是自己哪一行的问题。 实测从 07-28 20:00 到 07-29 09:00,同一批 200 行推了 13 小时,一条没进。 【改动】 - data/friday/manifest.yaml: · payment_rows 加 net_receipts_this>=0(负实收不算消费) · 新增 _refund_neg_receipt(status∈{1,3} 且 应收>=0 且 实收<0)并入 refund_full_rows。 与既有两条零重叠(全库无 status=4、无应收<0),refund 的 amount 本就取 net_receipts_this。 - fact-writer.service.ts:bulkWrite 由「整批抛」改为「隔离违规条目」, 返回 { results, rejected };新增 FactReject / BulkWriteOutcome。 违规条目只带定位信息(type / subjectId / transactionId / 字段级 issues), **不带 content 原文** —— 回执会进对方日志,患者姓名金额不该顺着外流。 - parser-pipeline.service.ts:消费 rejected 计入 factsFailed + factRejects; 外层 catch 从此只兜真正的批级故障(DB 异常/事务超时),不再因一行脏数据降级。 - cold-import.service.ts / push.schema.ts / push-receiver.service.ts: factsFailed + factRejects 一路透到 push 回执(封顶 10 条,同种错整批同因)。 - channel-push.mdx §9.1:补 factsFailed / factRejects 字段说明与实例, 讲清 failed(行没进) vs factsFailed(行进了但派生不出事实)的区别。 【验证】新增 3 个回归用例锁住隔离语义(好行照常写 / 全违规不抛错 / 正常路径不受影响, 并断言回执不含 content 原文);650 tests / 41 suites 全过;typecheck 通过。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>luoqi committed
-
- 28 Jul, 2026 13 commits
-
-
luoqi committed
-
luoqi committed
-
接入文档 channel-push.mdx §7.3 承诺「推送顺序无要求;业务数据先于患者主档到达时, PAC 先建档、主档到达后补全同一人」。实际做不到:push 是**单表**请求, ingestRawTables 只把被推那张表放进 tables const tables = { [opts.source]: opts.rows }; for (const tf of transforms) if (tf.input && !tables[tf.input]) tables[tf.input] = []; 只兜底了 input,没兜底 lookup 的 from。于是 transform-engine 拿不到 from 表, warn 一句就当空表、select 字段全填 null —— 换什么推送顺序都没用,单表请求里 from 表永远不在场。 2026-07-28 FRIDAY 开发推测试数据时踩到: customer_referee_circle 的规则要 lookup customer_basic_info 取**对方性别**, 用来把关系码 3(本人是对方子女)拆成 father / mother。拿不到性别 → rel_sex_key="3|" → patient_relation.yaml 里码 3 唯独没配缺性别兜底 → 掉 _default: other。 实测(测试服): friday 257 条关系边:father=0 mother=0 other=211 jvs-dw 76,999 条(走 cold-import,from 表齐全):father=6,837 mother=10,715(占 23%) 连带 family_structure 的「多代之家」系统性漏判 —— friday multigen 只剩 2 个, 且全靠 grandparent 撑,该识别成多代之家的都被压成 single。 改动: - transforms.schema.ts:lookup 新增可选 push_fallback { entity, select{column,map} }。 不配 = 保持旧行为,存量 host 零影响。 - push-lookup-fallback.ts(新):按 input 行的 left_key 去 PAC patients 捞实体, 拼成"长得像 from 表"的合成行 { [right_key]: external_id, ...select }。 · source_unit 消歧:external_id 在集团型宿主跨品牌撞号(FRIDAY 22 个 source_unit, 实测 310 个 id 撞号)→ 捞取按 input 行解析出的 source_unit 圈定;仍落到多个品牌 则**整条跳过**填 null 走 _default,不硬猜。 · map 把 PAC 归一值反写回宿主原编码(male→'1'),保证 push 与 cold-import 产出 **同一个** rel_sex_key —— 否则两条通道各配一套 enum_mapping,迟早漂移。 · map 未命中 / 空串性别 一律当"取不到",不把 PAC 归一值漏给只认宿主编码的下游。 · IN 列表按 5000 分批(bind-var 32767 上限,存量补摄踩过)。 - cold-import.service.ts:transform 前调 hydratePushLookupTables。 只在 tables[from] 缺席或为空时接管 → cold-import / reparse 不受影响。 - friday manifest:给那条 lookup 配上 push_fallback。 残留(有意为之):对方**从未进过 PAC** 时仍取不到性别,落 other。这是"不知道"而不是 "猜错",且存量首推收尾跑一次 reparse 即可补齐(重放时库已全)。 验证:新增 7 个用例连着 runLookup 一起断言最终值(中间对了下游错了没意义); 647 tests / 41 suites 全过;service typecheck 通过。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>luoqi committed -
luoqi committed
-
luoqi committed
-
现象:测试服 deploy 挂在 migrate 步,pac-service 起不来,任何人往测试服部署 都会挂在同一处(跟部署内容无关)。 ERROR: CREATE INDEX CONCURRENTLY cannot run inside a transaction block (25001) 根因是我在 20260728020000 里写了 14 条 CREATE INDEX CONCURRENTLY。 Prisma 把整份 migration.sql 用**一次 simple query** 发给 Postgres,而 Postgres 只对 「一个查询串里多条语句」隐式开事务块 —— 分界线是**语句条数,不是 Prisma 版本**: 20260701060607 / 20260727070000 / 20260727110000 各 1 条语句 → 无隐式事务 → 跑得通 20260728020000 14 条语句 → 隐式事务 → 必挂 而 20260727070000 里那句「Prisma 6.19 已不成立(指事务包裹)」是**错的归因** —— 单条语句侥幸跑通被总结成了「新版没有事务包裹」,我照抄进 20260728020000,于是踩爆。 失败后果比失败本身更重:_prisma_migrations 留下一条 finished_at / rolled_back_at 全空、 applied_steps_count=0 的记录,Prisma 从此拒绝执行**任何**后续迁移(P3018), 整条部署流水线被堵死。同一颗雷已随 6333137a 合进 main,生产下次部署会撞一模一样的错。 改动: - 20260728020000:14 条改为**普通** CREATE INDEX IF NOT EXISTS(可在事务里跑)。 大表环境靠「部署前手工 CONCURRENTLY 先建」保证不锁表(两台机器 2026-07-28 已建完, 故本迁移在两台上都是 IF NOT EXISTS 空跑);从零建库时表为空,普通 CREATE INDEX 瞬间完成。 文件头补上完整复盘,明写"别再改回 CONCURRENTLY"。 - 20260727070000 / 20260727110000:订正那两处错误归因,改成"因为本文件只有 1 条语句"。 验证: - 把修好的整份文件用 `psql -1 -v ON_ERROR_STOP=1`(显式单事务,正是 Prisma 那种场景) 在测试服跑通,14 条全 skip,无 25001。 - 测试服已 `prisma migrate resolve --rolled-back 20260728020000_...` 解除 P3018 阻塞, 14 条索引仍在(pg_indexes 计数 14),库结构未被破坏。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
luoqi committed -
luoqi committed
-
背景:生产此前**完全统计不了 PV/UV** —— 前端无任何分析埋点(gtag/umami/plausible 全无),服务端无 request log,生产 ECS 上更没有 nginx(pac.friday.tech 走独立网关 lb1.friday.tech,访问日志不在我们这台机器上)。Sentry 只有 10% tracing 采样, 做 UV 会系统性偏低。 范围限定在 plan 详情页(切合 plan_event_logs 表名):plan_id / patient_id 本就是 该表的必填列,浏览事件天然填得上,不必为它放宽约束或另立新表。 三种入口按用户口径**全收**:直接进/刷新 · 患者列表 rail 切换 · /plans 落地自动跳转。 (自动跳转确实是系统行为,但业务侧确认要收 —— 它也代表这条 plan 被展示过。)
⭐ 唯一的雷:byHuman 不是"是不是人做的",是**口径开关** HUMAN_TOUCH_EVENTS 靠 filter(byHuman) 派生。view 按字面语义该写 true,但那会让 「客服处理过哪些患者」静默膨胀成"看一眼也算处理"——刚交付的 65 人统计与整张 客服明细 Excel 全部失真,**而且不会有任何编译错误或类型报错**。 故 view 写 byHuman:false,并把该字段注释从"是否人工动作"改写成明确的口径定义; spec 里用**精确相等**(非 arrayContaining)锁死 HUMAN_TOUCH_EVENTS 的四个成员, 以后新增事件必须显式决策要不要计入,改错即红。 改动: - enums: PlanEventType.VIEW + PLAN_EVENT_META 项(group 联合类型扩 'view') - controller: POST /pac/v1/plans/:id/view。**刻意不加认领闸**(区别于 recall-feedback)—— 浏览发生在认领之前,池子里任何一条都能点开;加闸则埋点只剩已认领的,PV/UV 直接废掉。 plan 不存在静默返回 not_found:埋点是旁路,不能因它失败而影响页面。 - 前端: 埋点挂在 use-plan-aggregate 现成的去重守卫里 —— 该守卫原为防 StrictMode 双调, 其语义恰好等于"每进入一次详情页";refresh() 直接调 load() 走不到这里,故手动刷新不重复计。 失败 .catch(()=>{}) 吞掉。 验证(本地起真实前后端 + 浏览器实操): /plans 自动跳转 → 记 1 条 ✓ 手动点刷新 → 仍 1 条(不重复计)✓ 重新进入页面 → 累计 2 条 ✓ 未认领查看 → 能记 ✓ plan 不存在 → not_found 不报错 ✓ 无 token → 401 ✓⭐ 只有 view 事件的患者,在「处理过」口径下计 0(全事件口径会误计 2)✓ 640 tests passed,service/web typecheck 通过。 无 DB 迁移(event 是既有 text 列,加的是取值不是列)。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>luoqi committed -
luoqi committed
-
luoqi committed
-
生产现象:召回池按「禁忌:手术禁忌」圈人,前端等 20+ 秒 GET /pac/v1/plans?view=pool&personaTags=contraindication:surgery 服务端复现:count 23,379ms + findMany 22,208ms(两句并发,墙钟 ≈23s)。 **不是禁忌特有** —— 测试服同口径按 rfm:重要价值 更慢,57s。14 个圈人维度全踩同一个坑, 禁忌只是这次先点到的那个。 根因:Prisma 把画像筛选编译成两层 EXISTS,最内层 persona_features.key = 'contraindication' AND (data #> '{domains}') @> '"surgery"' 而 persona_features 只有单列 key 索引,JSON 那半截没有索引可下推。 生产 EXPLAIN (ANALYZE, BUFFERS) 实测(7,657,944 行 / 5.4GB,堆 3.4GB): Index Scan using persona_features_key_idx actual time=3567..3954ms Index Cond: (key = 'contraindication') Filter: ((data #> '{domains}') @> '"surgery"') rows=2,156 Rows Removed by Filter: 188,054 ← 命中率 1.1% Buffers: shared read=162,830 ← 约 1.3GB 随机堆读 即为了捞 2,156 行,把该维度 190,210 行的 jsonb 全从磁盘搬了一遍。维度越大越惨: rfm / gender / lifecycle_stage 各 869,043 行,一次筛人搬 869k 行 data。 改动:每个可筛维度一条偏索引(WHERE key='<维度>'),表达式与 Prisma 生成的完全一致。 - 数组维度 7 条(array_contains → @>)→ GIN jsonb_path_ops - 标量维度 7 条(equals → =) → btree (表达式, persona_id) 第二列不是为了排序,是为了 index-only scan:内层 EXISTS 只 SELECT persona_id, 索引自带就不回堆。低选择性维度非它不可 —— 测试服实测 rfm 走上 btree 后 Bitmap Index Scan 只花 217ms,回堆 143,187 个块却花了 103s(work_mem 不够还退化成 lossy,Rows Removed by Index Recheck 1,564,553)。 Prisma schema 表达不了表达式/偏索引,只能落迁移 SQL;本地 migrate dev 会把它们报成 drift, 别按提示 reset(同类先例 20260727070000 / 20260727110000)。 persona-tag-filters.ts 加纪律注释:往 PERSONA_TAG_FILTER_DIMS 加维度必须同步补索引, 漏建就是 20 秒起步 —— 这次禁忌踩的正是这个。 上线(两台均已 CONCURRENTLY 手工建完,不锁表,业务无感;迁移全 IF NOT EXISTS 故 deploy 空跑): 生产 14 条 / 88s / 220MB,pg_index 无 invalid contraindication:surgery 23,379ms → 84ms(278×,1,347 单) contraindication:implant 842ms(25,900 单) rfm:important_value 1,558ms(61,300 单) treatment_history:implant_history 854ms(17,179 单) gender:female 2-4s (105,110 单) 测试服 14 条 / 238s / 195MB(盘慢,看倍数) rfm:important_value 57,299ms → 8,576ms contraindication:surgery 4,210ms → 344ms 剩下的耗时性质已经变了:不再是 JSON 过滤,而是结果集本身大(count(*) 要数完十万级 plan)。 gender 这类宽维度哪天嫌慢,方向是给 count 做近似/缓存,不是继续加索引。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>luoqi committed -
luoqi committed
-
线上(测试机)现象:FRIDAY 推 med_emr_info 每小时整点重试,连挂 25 次, 对面收到 http 500 body={"code":90000,"msg":"request entity too large"}。 根因两层: 1. main.ts 从未显式设 body limit → 吃 body-parser 出厂默认 100KB。 实测卡点 102,381B 过 / 103,405B 挂,正好 100KiB。 网关 client_max_body_size 是 50M,不是它拦的(对面收到的是 PAC 的 JSON 封装)。 2. body-parser 的 PayloadTooLargeError 是裸 Error(非 HttpException), 掉进过滤器的 500/90000 分支 → 对面按文档 §9.1 把 9xxxx 当"PAC 内部错误 → 退避重试",于是整点重试永远重试不好,也看不出是自己包太大。 为什么病历先踩到:测试机实测 friday 已入库 payload 单行大小 —— emr 均 1453B / 峰值 2218B,diagnosis 均 1647B(带主诉/检查/处置/医嘱自由文本) appointment、encounter 均 405B 接入文档 §10 只约束"≤500 条/请求",没约束字节:500 条病历 ≈ 710KB,超默认值 7 倍; 连 405B 的预约行 500 条(≈198KB)也超 —— 雷对所有表都埋着,病历只是先踩到。 改动: - main.ts: useBodyParser 显式设 10MB(json + urlencoded)。取 10MB 是因为它给 500 条病历留了十几倍余量,又远低于网关 50M —— 不把拦截点推到网关(那层返 HTML 413, 对面更难排查)。rawBody 仍由 create 选项保留,不影响 HMAC 验签。 - all-exceptions.filter.ts: 识别 PayloadTooLargeError(type=entity.too.large, 辅以 413 兜底)→ 归 10002 + HTTP 200,回执带上限与建议批量,且不上报 Sentry (对面发包过大不是 PAC 故障)。口径对齐文档 §9.1「字段校验失败/批量超限 → 修正后重发」。 - channel-push.mdx §10: 补字节上限,并给按表的分批建议(病历 50-100 行 / 结构化 500 行)。 验证(本地起真实服务,真 HMAC 签名): 146B / 300KB / 2MB / 8MB → 全部通过验签进入摄入流水线(证明 rawBody 未被破坏) 11MB → 10002 + HTTP 200,回执可指导动作 636 tests passed,service/web typecheck 通过。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>luoqi committed
-
- 27 Jul, 2026 15 commits
-
-
默认返回提示 JSON(不抛),避免公网裸 500;验收 Sentry 后端捕获用,关 flag 即停。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
luoqi committed -
merge: fix/k00-focus-category-and-ortho-mapping → main(K00 诊断词细分 + 低龄种植降位 + 二期正畸误吞 + reparse工具 + 历史联系摘要不判到诊) # Conflicts: # apps/pac-service/prisma/schema.prisma
luoqi committed -
luoqi committed
-
luoqi committed
-
四个提交: 469ef2c9 fix(ai): 历史联系摘要只讲回访事实,不再对到诊/失联下结论 6e7d1f60 feat(reparse): 加 --patients-file(按受影响患者收窄,实测最高 250 倍) 1c537ccb perf(reparse): patient_transactions 补 (host_id, subject_type, patient_id) 复合索引 cdad7db0 fix(plan): K00 按诊断词细分类目 + 低龄种植降位(≤18)+「二期」正畸误吞 + 早矫措辞 两条索引迁移(20260727070000 sync_log_id / 20260727110000 host+subject+patient)用 CONCURRENTLY,部署前先手动建(见各迁移注释),避免 pac-service 等 pac-migrate 白等停机。 # Conflicts: # apps/pac-service/prisma/schema.prisma
luoqi committed -
分支主题(K00 focus category + 正畸映射)的一批年龄/诊断适配。核心是把召回「目标·X」 标签和话术的类目排序,从"只看 K 码 + 年龄"细到"看诊断原文词",纠三类线上误标。 ── ① K00 大口袋按诊断词定方向(refineCategoriesForDiagnosis)── K00「牙发育和萌出障碍」混着语义完全不同的病:乳牙滞留(要拔)/ 乳牙早失(管间隙)/ 先天缺失(修复)/ 釉质发育不全(冠贴面)/ 萌出障碍(导萌),却共用固定序 [surgical,...] → focusCategory 恒为 surgical,给「乳牙早失」也打「目标·外科」(没牙可拔)。线上约 2800 条。 诊断码不动(还是 K00),只在同码内把最贴切类目提到首位。scenario 投影 name_zh、 signals 存 dxNameZh 快照,前端 ReasonLine 用同一函数复算,保证「目标·X」与本行首项一致。 ── ② 低龄种植降位,边界与全仓另三处种植年龄闸统一为 ≤18 ── recommendedCategoriesForAge 新增:age ≤ IMPLANT_LAST_AGE(18)时 implant 挪末位(只挪不删)。
⭐ 边界取 ≤18 而非 <18 —— 差一岁会自相矛盾:contraindication.feature.ts 是 age<=18 打 「种植禁忌」、potential-treatment 是 age>18 才产「潜在种植」。2026-07-27 生产实测: 恰好 18 岁有 1,299 条 plan、140 条 focusCategory=implant,若按 <18 判会在同一屏出现 「目标·种植」+ 画像「种植禁忌」。话术侧(fact-block / stable prompt)同步 ≤18。 ── ③ planned 规则裸「二期」不再误吞正畸 ── 「正畸二期矫治」被 implant 规则的裸「二期」吞成种植(线上 陈芃霖 BJ0A094257)。 actual 侧的修法是收紧成「种植二期」,但 planned 侧种植二期普遍不带"种植" (二期取模/二期手术/二期修复,prod 900+ 条),收紧会让它们掉进 _default 被丢。 改用位次:裸「二期」从 implant 主规则拆出、降到全表最末兜底 —— 带正畸词的先被 orthodontic 收走,其余纯「二期」仍落 implant,与修复前逐条一致。 ── ④ 替牙期正畸措辞说「早期矫治」── treatmentCategoryNameZhFor:age ≤ EARLY_ORTHO_MAX_AGE(12)时 orthodontic 显示「早期矫治」 (替牙期做的是一期干预,不是排齐恒牙列)。只改措辞不改类目,排除闸不受影响。 reason-signals schema 补 dxNameZh(nullable,旧 plan 无 → 回落原顺序,无回归)。 删 tests/tmp-jieya.spec.ts(纯 console.log 探针,无断言,不进仓库)。 测试 546 passed,两个 app tsc 干净。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>luoqi committed -
reparse 分批取源行那句: SELECT id, raw_payload, payload_hash FROM patient_transactions WHERE host_id = $1 AND subject_type IN ('emr','diagnosis') AND patient_id IN (3000 个) 没这条索引时 planner 只能走 (patient_id, occurred_at),把这 3000 个患者的**全部** transaction 连 raw_payload 一起捞进堆,再在堆上过滤 host_id / subject_type。 2026-07-27 测试服 EXPLAIN ANALYZE 实测(12,519,060 行 / 33GB): Bitmap Heap Scan 10,385 ms rows=14,578,Rows Removed by Filter: 39,305 ← 读进来 73% 是白读 Buffers: read=15,616(约 122MB 磁盘读) 全量 87 批,单这一句约占 15 分钟。本地建索引后 planner 已改走 Index Scan using patient_transactions_host_id_subject_type_patient_id_idx。⚠ ️ 收益如实说:每批总耗时 76–180s,这句只占 6–14%,对全程约 4%,**不是提速主手段**。 reparse 真正的杠杆是上一个提交的 --patients-file(按受影响患者收窄,实测最高 250 倍)。 本索引胜在一次性代价、之后每次 reparse / 每个 host 都受益。 体积:(uuid,text,uuid) × 千万行,测试服估 ~600MB、生产(2407 万行 / 64GB)估 ~1.2GB。 测试服建前 24G 空闲够用;生产上线前需先确认磁盘余量。 上线方式同 20260727070000:CONCURRENTLY(不锁写),推荐部署前手动先建 —— deploy 时 pac-service 要等 pac-migrate 退出才启动,几分钟索引构建 = 白等的停机; 手动建好后本迁移 IF NOT EXISTS 空跑。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>luoqi committed -
全量 reparse 的时间几乎全花在"证明没变"上。2026-07-27 测试服实测: 全量范围 260,434 患者 / 87 批 / ~4.5 小时 改 clinical-signals 字典 → 真正受影响 ~40,000 患者(15%) → 6.5 倍 改 planned「二期」规则 → 真正受影响 1,017 患者(0.4%) → 250 倍(2 分钟) 已有 --patient=<uuid>,... 但几千个 uuid 塞不进命令行(ARG_MAX),缺的就是文件入口。 用法:先用 SQL 圈出受影响患者,dump 成一行一个 id,再喂进来。 psql -tAc "SELECT DISTINCT patient_id FROM patient_facts WHERE type='treatment_record' AND kind='planned' AND content->>'subtype' LIKE '%二期%'" > /tmp/ids.txt pnpm reparse -- --host=jvs-dw --subject-type=treatment --patients-file=/tmp/ids.txt 文件格式刻意宽容(操作者多半是 psql 直接倒出来的):uuid 与 externalId 混写、 # 整行/行尾注释、空行、行尾逗号分号、CRLF 全收;但不做猜测性纠错 —— 认不出的 原样送去库里查,查不到就 WARN 报出前 5 个,不静默丢(否则"为什么少跑了几百人"是无头案)。⭐ 空结果必须硬失败,这是本次最要紧的一条: 服务侧 `if (!scopePatientIds?.length)` 把空数组当"不限定" —— 一个路径写错的收窄命令会静默变成 4.5 小时全量,且日志上完全看不出异常。 现在解析为 0 / 一个都对不上库,都直接报错退出。 ── 顺带修一个我自己踩出来的隐患 ── CLI 末尾是 `void main()`(全仓 CLI 通例),我最初把纯函数留在 CLI 里、测试直接 import, 结果测试跑起来对本地库真跑了一次 reparse(115 行 Nest 日志)。两道修: ① 纯函数拆到 src/cli/reparse-patients-file.ts,测试只碰这里 ② CLI 末尾加 `if (require.main === module)` 闸 —— 以后任何 import 都不会再误启动 实测(本地 friday): 空文件 → reparse 失败:解析后为 0 个患者(拒绝退化成全量) 混写清单 → 读到 4 token(uuid 1 / externalId 3 → 认领 2),WARN 1 个查不到, 最终限定 3 患者,ColdImportService 确认「范围 3 患者」 测试 547 passed(新增 13 例)。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>luoqi committed -
生产实例(陈芃霖 BJ0A094257): 摘要「上次**有效到诊**为2023年8月,已超一年未复诊」 实际 接诊记录末次 2025-12-20,画像也写着「末诊207天」—— 差 2 年 4 个月 同句另两处错:「已超一年未复诊」实为 7 个月;「近3个月涂氟邀约」那条回访实为 226 天前。 源头是这条回访记录: 2023-08-15 | 常规回访 | 已回访 | 已完成 | 内容:复查 | 结果:8月已就诊 诊所客服 2023 年写的一句备注,被模型升格成了患者当前的到诊状态。「有效到诊」这个词 源数据里没有,是模型自己造的。 ── 根因 ── 本 AiCall 的输入**只有回访记录,没有任何到诊数据**(见 input.types)。模型只能: ① 从回访备注文字里猜到诊 —— 「8月已就诊」被当成事实 ② 用"没有回访记录"推"没来过" —— 但回访是诊所主动打电话,患者自己来院不产生回访任务 两条路都错。而旧 prompt 不但没拦,关键要求 4 明写「优先突出…长期未到诊」, 示例里还有「此前半年 5 次回访无到诊」在**示范这个错法**。 ── 生产规模 ── 795 条 recall_history 摘要,162 条断言"失联/沉睡/未到诊", 其中 88 条(54%)患者实际 180 天内来过,18 条 90 天内来过。 最离谱一条:「自2018年至今无到诊或有效联系,属长期失联状态,建议优先核实联系方式」 —— 该患者 2026-06-24 刚到诊,上个月的事。客服照这个打电话会很尴尬。 ── 改法(按业务口径「这里只摘要回访事实」)── 不给它喂到诊数据,而是划清职责:到诊状态有专门的地方出(画像卡「末诊 N 天」按真实接诊算)。 1) prompt 加【职责边界】硬约束:输入里没有到诊数据,禁止判断是否到诊/失联/沉睡; 结果字段里的"已就诊"只能当那次回访的记录引述,不得当成到诊状态; 明确掐断"没有回访 ≠ 没来过"这条推理链 2) 修掉关键要求 4 和示例里示范错法的句子,新增反例段(把三条真实事故句列为 ✗) 3) 补程序算好的 daysAgo —— 相对时间不让模型自己减日期 4) 版本 @2026-07-26-f → @2026-07-27-g ── 这是同一个错误第三次以不同形式出现 ── 07-24 是"未来 vs 过去"(未来排程被说成漏做),今天是"回访 vs 到诊"和"距今多久"。 当初立的纪律是「能程序算的事实全算好、LLM 只润色」,但只补了一个轴, 于是同类错误换个轴又来。本次把职责边界和 daysAgo 都用测试锁住(新增 8 例)。 注:已生成的 795 条摘要是缓存,需另行重刷才会用新 prompt; 本次先删了陈芃霖那一条供业务观察重新生成效果。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>luoqi committed -
FRIDAY 对接增量 push 报 `SocketTimeoutException: Read timed out`,今天在测试服 按他们的路径(公网域名 + HMAC)模拟推 2 行患者主档定位到: 单次请求 74s,74s **全部**耗在这一条 SQL 上 (逐 6s 采 pg_stat_activity,同一条查询年龄从 6s 一路涨到 74s): SELECT id, patient_id, tenant_id FROM patient_transactions WHERE sync_log_id = $1 AND patient_id IS NOT NULL 来源 cold-import.service.ts `touchedRows`(push / cold-import / 增量共用这条路径)。 EXPLAIN:Parallel Seq Scan (cost=0.00..2150645.78) 外键约束不会自动建索引,这句从上线起就在全表扫: 测试服 patient_transactions 12,519,060 行 / 33 GB 生产 同表 24,074,031 行 / 64 GB ← 生产更慢,FRIDAY 真上生产会更糟 本地建索引后 plan 变 Index Scan(cost=0.29..4.31)。 ── 顺带更正一处仓库里的错误注释 ── 20260701060607 那份迁移写着「CONCURRENTLY 不能跑在事务里,Prisma migrate 对整份 migration.sql 有事务包裹,故不能靠 migrate deploy 直接跑」。**当前 Prisma 6.19 已不成立** —— 本地实测 migrate deploy 直接把 CONCURRENTLY 跑通、索引建出、plan 生效。 新迁移里写清了真实情况,并保留「推荐手动先建」的建议,但理由改成正确的那个: deploy 时 pac-service 要等 pac-migrate 退出才启动,33/64GB 建索引几分钟 = 白等的停机。 push 侧本身是好的:同一次模拟 accepted=2 / failed=0 / mappingMisses=0, canonical 映射(externalId/name/gender/birthDate/medicalRecordNumber/phone)全对。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>luoqi committed -
四个 commit: 99e01d42 禁忌能力落地 —— 摄入层抽 clinical_signals 写回源 fact,画像层裁决 51581f58 筛选改按治疗域四类;风险位露出真实原因 f85b7680 画像依据行改显命中信号,不再露 encounter 内部 ID 1171fb42 核查项不进画像;风险区先隐藏;挂号/接诊/接待依据行同样去 ID 上线还需两步(缺第二步则 7 万年龄型禁忌筛不到 —— 本地已实测): 1) pnpm reparse -- --host=jvs-dw --subject-type=emr,diagnosis 2) pnpm recompute-persona -- --host=jvs-dw --concurrency=8 --force (--force 必须带:水位闸只认'事实变了',本次变的是特征输出结构, 本地实测不带 --force 会被 noop 掉 13260/13268) 召回闸门未接,按预留处理。
luoqi committed -
业务口径三处调整: ── ① check 档(待核查)不进画像 ── 「有糖尿病史但控制状态未知」对客服不构成可执行信息,摆出来只会让人把「问一句」 当成「不能治」,是净噪音。现在 diabetes / hypertension / osteoporosis / smoking 不再产任何画像输出(本地实测禁忌特征 1910 → 1840,少的 70 个正是只有核查项的患者)。
⚠ ️ 信号本身**照常抽取并存在 fact 的 content.clinical_signals 里**,一条不丢。 这是「只讲事实」与「只说有用的话」的分工:事实层求全,画像层求准。 顺带改掉复评期的处理:超期不再降级为核查项(那会让绝对禁忌凭空消失),而是 **仍算禁忌**,只在依据行注明「记录较早,建议确认近况」—— 抗凝药可能已停,但漏挡 比多挡危险得多。副作用:正畸禁忌从 0 回到 13 人(之前被复评期降级掉了)。 ── ② 关键事实的风险区先隐藏 ── 画像标签卡的摘要已会提到禁忌,同屏再来一条红块重复。禁忌照常在画像 chip + 详情抽屉露出。代码保留并加 SHOW_RISK_NOTES 开关 —— 这个位置将来要接投诉 / 退费纠纷 / 医疗争议(业务已提出设想,现无数据),届时改回 true 即可。 ── ③ 依据行不再显示宿主内部 ID ── 接着上一个 commit 的 emr_record,把剩下三类一并修掉 —— 它们的 f.title 都是 `<中文> <externalId>`: 接诊 16432552 → 洁牙 / 正畸复查(取 content.chief_complaint) 挂号 <id> → 科室 · X医生 前台接待 <id> → 前台接待 生产实测 encounter_record 93.4%(2,004,383/2,146,887)带主诉,这条修复对绝大多数 依据行是实质改善,不是换个无用字符串。 测试 550 passed。 注:sync-lock-reap 那条在 37 suite 并发时偶发失败,单跑(改动前后均)通过 —— 是 它自身的时间竞态 flake,与本次改动无关,未处理。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>luoqi committed -
业务反馈禁忌标签的依据行看不懂: 2022-03-26 病历 关联接诊:15514 ← 内部 encounter id,对客服毫无意义 根因在 fact-label.ts:97 —— 对 emr_record 取 f.summary,而注释写着 「summary 是命中的那段」。这个注释是错的:emr.parser.ts 实际写进 summary 的是 `关联接诊:${encounterExternalId}`。依据区从上线起显示的就是个内部 ID。 现在按信息量三级降级: ① content.clinical_signals 里的阳性信号 label(这条病历到底命中了什么) → 「局麻药物过敏」,悬停出医生原话「自述普鲁卡因、利多卡因过敏」 ② 病历里真正有内容的 SOAP 段落(既往史/主诉/全身情况/医嘱)前 40 字 ③ 才退到 title 任何情况下都不再出现 `关联接诊:<id>`。 FactLabel 加可选 hover 字段:列表行窄、原话长(「有放射治疗史,三周前进行 颈淋巴结清扫(舌癌淋巴结转移)」),truncate 后必须能悬停看全。 标签是 PAC 的归纳,原话才是事实本身 —— 两者都要够得着。 顺带修好的不止禁忌:治疗敏感 / 不可等候等所有以病历为证据的画像标签, 依据行同样从内部 ID 变成可读内容。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>luoqi committed -
业务反馈两条,都是我做偏了: ── ① 筛选按四类,不按病因 ── 原来列的是 12 个病因(抗凝药/放疗史/抗骨吸收药…),那是算法口径。 客服圈人时想的是「哪些人不能做种植」,不是「哪些人在吃抗骨吸收药」—— 把内部实现摊给业务看,面板还长。改成 data.domains:手术/种植/麻醉/正畸禁忌。 病因照常在关键事实卡与画像详情里露出(带医生原话);要按病因查是风险排查场景, 需要时另加维度,别混进圈人面板。 不列「拍片禁忌」:唯一来源是妊娠,而妊娠同时禁种植和手术,勾那两项已覆盖。 ── ② 风险位直接露出真实原因 ── 原来只显示「手术禁忌 / 种植禁忌」,凭什么要 hover 才知道 —— 这正是我自己在设计里 写的「客服不知道凭什么就会当噪音忽略」,结果自己没落实。现在:⚠ 手术禁忌 / 种植禁忌 放疗史 · 2022-12-02 病因+日期直接成行,全文原话仍留 hover(一行放不下「有放射治疗史,三周前进行 颈淋巴结清扫(舌癌淋巴结转移)」)。 同时把这块抽成通用 RiskNote 形态 —— 业务提到这个位置以后要放投诉之类, 现在没数据不预建,但形状留好:再来一个来源就是往 notes 里 push 一条。 ──⭐ 顺带发现一个部署坑(本地实测) ── reparse 只重算**事实变了**的患者。年龄型禁忌那批人事实没动 → 画像没重算 → data.domains 还是 null → 新筛选完全筛不到他们(生产是 7 万人)。 本地实测:普通 recompute-persona 被水位闸 noop=13260/13268 全跳过; 加 --force 后 success=1804 refreshed=9536,domains 才落库,筛选立刻从 10,458 人收敛到 3 人(= 库里那 3 个局麻药物过敏患者,逐个核对一致)。 所以上线顺序必须是:reparse → recompute-persona **--force**。 --force 的注释里本就记着同类事故(「reparse 后的全量重算被这道闸 noop 掉 325,979 人」),我差点重蹈。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>luoqi committed
-