1. 28 Jul, 2026 3 commits
    • perf(persona): 画像圈人筛选补 14 条偏索引 —— 生产实测 23.4s → 84ms · aef7f5b6
      生产现象:召回池按「禁忌:手术禁忌」圈人,前端等 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
    • fix(push): 请求体上限显式设为 10MB —— 修 FRIDAY 增量 push 病历批次必挂 500 · 2e315869
      线上(测试机)现象: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
  2. 27 Jul, 2026 16 commits
    • chore(debug): 加 Sentry 验收端点 /health/sentry-test(PAC_SENTRY_DEBUG=1 才抛) · 6f7d980f
      默认返回提示 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 诊断词细分 + 低龄种植降位 +… · c1b468be
      merge: fix/k00-focus-category-and-ortho-mapping → main(K00 诊断词细分 + 低龄种植降位 + 二期正畸误吞 + reparse工具 + 历史联系摘要不判到诊)
      
      # Conflicts:
      #	apps/pac-service/prisma/schema.prisma
      luoqi committed
    • fix(plan): K00 按诊断词细分类目 + 低龄种植降位 + 「二期」正畸误吞 + 早矫措辞 · cdad7db0
      分支主题(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
    • perf(reparse): patient_transactions 补 (host_id, subject_type, patient_id) 复合索引 · 1c537ccb
      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
    • feat(reparse): 加 --patients-file —— 按受影响患者收窄,实测最高 250 倍 · 6e7d1f60
      全量 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
    • fix(ai): 历史联系摘要只讲回访事实,不再对到诊/失联下结论 · 469ef2c9
      生产实例(陈芃霖 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
    • perf(sync): patient_transactions 补 sync_log_id 索引 —— push 全表扫 74s 的根因 · 89dac080
      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
    • fix(persona): 核查项不进画像 + 风险区先隐藏 + 依据行不再露内部 ID · 1171fb42
      业务口径三处调整:
      
      ── ① 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
    • fix(web): 画像「依据」里的病历不再显示内部 ID,改显命中的临床信号 · f85b7680
      业务反馈禁忌标签的依据行看不懂:
          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
    • fix(persona): 禁忌筛选改按治疗域四类 + 风险位直接露出真实原因 · 51581f58
      业务反馈两条,都是我做偏了:
      
      ── ① 筛选按四类,不按病因 ──
      原来列的是 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
    • feat(persona): 禁忌能力落地 —— 病历自由文本抽临床信号 + 三档裁决 · 99e01d42
      业务方细则(种植/正畸/麻醉/手术禁忌)按「病因建模、按治疗域映射」实现。
      原来只有一条「年龄≤18 = 种植禁忌」的规则,而且跟召回侧的年龄闸重复,
      evidence.factIds 是空数组 —— 客服看到标签不知道凭什么,只会当噪音。
      
      ── 分层 ──
        摄入层  抽取(这句话提到抗凝药吗)  per-row、确定性、可重放  ← ParserPipeline hook
           ↓    写回**源 fact 自己的** content.clinical_signals
        衍生层  裁决(他现在是不是手术禁忌)  跨记录、跨时间          ← persona 特征
      
      不新建衍生 fact 类型 —— 遵守 FactType 的设计纪律「fact 只装源事件,衍生信息留在源
      fact 的 content JSONB 里」。信号跟着源病历生命周期走(病历订正→重放→自动重抽),
      不必额外维护一致性;召回闸门将来也只需一行 content->'clinical_signals' @> ...。
      
      ── 三档严重度(收束点)──
        absolute 绝对禁忌 → 打标签,将来接召回闸门挡的是这一档
        relative 相对禁忌 → 打标签 + 话术警示,不挡召回(由医生按身体条件评估)
        check    核查项   → 只提示「预约前确认X」,不叫禁忌
      
      细则里三条**刻意降级为 check**:糖尿病要 HbA1c(生产全量仅 10 条)、高血压要收缩压
      (几乎无人记录)、TMJ 要急性期(病历不记)。控制达标的糖尿病是可以种植的,硬判禁忌
      医学上就是错的 —— 判断权还给医生,PAC 只把事实摆出来。
      
      ── 时效(「禁忌≠终身禁忌」)──
      妊娠 280 天 / 哺乳 180 天(产后半年)/ 放疗·抗骨吸收药终身 / 抗凝药等设复评期
      (超期降级为 check 而非消失 ——「消失」会让客服以为患者从没有过这个病史)。
      到期日在抽取时按源病历 occurredAt 算好写进 signal,画像 TS 与召回 SQL 各自只做一次
      比较,时效规则不分叉。nextBoundaryAt 报出最近到期日 → 到期当晚自动重算。
      
      ── 否定作用域:全模块最要紧的一条 ──
      「患者否认有高血压、冠心病等心血管疾病;糖尿病、痛风等代谢性疾病。」
      分号后的糖尿病仍在否定作用域内。生产 88.9 万条既往史实测:按分号切 → 阳性 1,328 人;
      按句切 → 141 人,差 9.4 倍。这条错了整个模块就是负资产。
      再断言(有/患/服用)只有隔了并列符才翻回阳性,否则「否认有」这个常见搭配会被整片判反。
      「患者否认/自述有…」(生产 116 条,医生没删模板斜杠)→ negated=null,入库供人工复核但不产禁忌。
      
      ── 生产实测(702 万行文本全量跑过)──
      命中任一信号 0.75%(→ 首次 reparse 的 fact 版本波规模可控)
      阳性率:抗凝 94.5% / 心脏植入物 95.2% / 哺乳 92.1% / 放疗 91.0% / 重度牙周炎 99.9%
      上线后:3,790 人带禁忌标签,10,823 人仅带待核查(另有年龄禁忌 7 万人不变)
      全量跑还揪出两类假阳性并已修:「备孕期」⊂「孕期」(78 行)、「妊娠期龈炎」是牙龈炎
      诊断名不是妊娠状态(58 行)—— 中文无词边界,加了 not_any 反向清单。
      
      ── 前端 ──
      关键事实卡新增安全提示栏( + rose),禁忌与待核查分行显示,hover 出医生原话 + 记录日期。
      不混进标签云:那里禁忌跟急迫等级同色同排平铺,客服扫不出来。
      
      召回闸门本次**不接**,按预留处理。字典改动复用既有 reparse,不另造 CLI:
        pnpm reparse -- --host=jvs-dw --subject-type=emr,diagnosis --dry-run
      
      测试 545 passed(新增 44 抽取 + 18 裁决,用例全部抄自生产真实文本)
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • fix: 自带 PG 的 /dev/shm 提到 1g + 画像版本号并发竞态(测试服全量回填暴露的两个问题) · 2a02d9d1
      昨夜测试服跑优化后的全量回填(35.7 万患者,2.2 小时跑完、2,694 人/分)暴露两个**既有**问题,
      都不是优化本身造成的。
      
      ## ① /dev/shm 64MB → plan 全量重算当场崩
      
        ERROR: could not resize shared memory segment "/PostgreSQL.xxx" to 12615680 bytes:
               No space left on device
      
      Docker 给 /dev/shm 的默认只有 64MB(inspect 确认 ShmSize=67108864),而 PG 的并行查询
      worker 用共享内存段交换中间结果。召回的 selectHits 把 **10 个子场景 Promise.all 并行跑**,
      每个都是 2500 万 patient_facts 上带 LATERAL 的重查询、各自还起 parallel worker,
      并发的段远超 64MB。
      
      崩在 selectHits → runSubScenario,那处 Promise.all 本次一个字没动 —— 与 worker pool 无关。
      compose 里给 postgres 加 `shm_size: 1g`(PG 容器常规配法)。
      ️ 生产用阿里云托管 RDS、不启本服务,不受此问题影响,也不受本改动影响。
      
      ## ② CLI 与 stale-scan cron 抢版本号 → Unique constraint
      
      全量回填跨过了 02:00 的 `PAC_STALE_SCAN_CRON`,两个**独立进程**同时给同一批患者建新版本:
      各自在事务外读到同一个 latest.version、算出同一个 nextVersion →
      `Unique constraint failed on the fields: (patient_id, version)`。
      实测那一轮 CLI 挂 688 个、**定时任务自己挂 1,604 个**(它伤得更重)。
      
      BullMQ processor 内部按 patient_id 串行,挡得住自己人,挡不住外面另起的 CLI 进程。
      上一轮旧代码没撞,只是因为它在 2 点前就被我杀了 —— 运气,不是设计。
      
      修法:两条写路径进事务先拿**按患者的 advisory 事务锁**
      (`pg_advisory_xact_lock(hashtext(patientId))`,随事务自动释放、跨进程有效);
      建版本那支再**在锁内重读**最高版本 —— 迟到者看到已经变大的版本号,顺序叠上去。
      就地刷新那支也拿同一把锁,否则可能写到刚被并发写者 supersede 的版本上。
      
      ## 验证
      
      本地真库对照:取同 300 个患者、删掉其画像(逼两个进程都算出 version=1)、两进程并发 --force
      
        旧代码   A: success 18  failed 216  |  B: success 282 failed 18   → **234 次 unique 冲突**
        新代码   A: success 186 failed 0    |  B: success 300 failed 0    → **0 次冲突**
      
      498 单测通过(36 suites),tsc 干净。persona-watermark.spec 新增 4 例锁住:
      建版本前必拿锁 / 版本号锁内重读 / 就地刷新那支也拿锁 / noop 不白拿锁。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • perf(plan): 召回批量写也去掉批次栅栏 —— 与画像同一处改法 · b9765cd8
      plan-engine.runAllForHost 的 upsert 段用的是同一种写法:
        for (i += N) await Promise.all(entries.slice(i, i+N).map(...))
      每批都要等本批**最慢**那个,快的 worker 干等着。
      
      同一处改法已在 recompute-persona 上实测(测试服 35.7 万患者,concurrency=8):
        并发效率 44% → 94%,吞吐 1,454 → 2,694 人/分
      plan 侧单患者耗时的离散度只会更大(upsertPlan 内 reason 数量差异明显),浪费同理。
      
      改:换成 common/run-pool.ts 的连续调度 worker pool(N 个 worker 各自取下一个)。
      并发上限、错误处理、计数口径都不变 —— 计数仍靠 JS 单线程 await 恢复后同步自增。
      
      顺带:recompute-plans.cli 在 NestFactory 之前把 PAC_DB_CONCURRENCY 对齐到
      PAC_PLAN_BATCH_CONCURRENCY(默认 8)。同 recompute-persona 的理由:PrismaService 在容器
      初始化时就定池,不设的话是 Prisma 默认「核数×2+1」(生产 4 核 = 9),把 batch 并发调到
      9 以上会被卡住。按默认 8 跑时 9 条刚好够,本项属于**解锁更高并发**,当下不产生收益。
      
      ## 验证
      
      - 494 单测通过(36 suites),tsc 干净
      - 本地 friday host 全量重算(10,458 plan):15.94s,plansCreated=0,
        跑前跑后 followup_plans 指纹均为 3d5225bf6c1160226739385c6f8e7d8b —— **行为中性**
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • docs(perf): 更正 ② 连接池那条的证据 —— 我把测试服的观测当成了池上限 · d90665ec
      原注释写「生产实测 --concurrency=8 时进程恰好只有 9 条连接」,不准确。核对两台机器的
      .env 之后:
      
        测试服 47.251.104.47  URL 显式 `connection_limit=30` → withCohortDerivedPool 提前返回,
                              池=30。**我观测到的那 9 条其实是 8 个 worker + 1 空闲,不是池上限。**
        生产   47.99.62.30    URL 没写 connection_limit → Prisma 默认 核数×2+1,4 核机 = 9
      
      所以:
        · ② 这个缺陷本身成立(函数只认 PAC_COHORT_CONCURRENCY,CLI 旋钮不驱动池)
        · 但它是**潜在**的,只在没显式配 limit 的机器上咬人
        · 且按计划的 --concurrency=8 跑生产时 9 条刚好够 —— **本修复对今晚的回填零收益**,
          价值只在解锁更高并发
      
      代码/测试注释同步改对,别让后人照着一个错的观测去推断。实现与单测未动(行为本来就是对的)。
      luoqi committed
  3. 26 Jul, 2026 18 commits
    • perf(persona): 重算吞吐四处优化 —— worker pool + 连接池联动 + 少两次往返 · e5122154
      生产/测试服实测基线(测试服 12 万患者样本,concurrency=8):
        单患者 p50 139ms / p90 230ms / p99 386ms / max 75,750ms,均值 165ms
        理论上限 8÷0.165 = 2,909 人/分,实际 ~1,600 → **效率只有 55%**
      
      ## ① 批次栅栏 → worker pool(src/common/run-pool.ts)
      
      原写法 `for (i += N) await Promise.all(slice)` 是**栅栏**:每批都等本批最慢那个,
      快的 worker 干等。缺口正好是 E[N 次抽样最大值](≈p90)与均值之比。
      极端例子:那个 75.7 秒的患者把同批 7 个 worker 一起冻了 75 秒。
      改成 N 个 worker 各自从共享游标取下一个,谁空谁取 —— 慢任务只拖住自己那条线。
      
      ## ② --concurrency 根本没放大连接池(真 bug)
      
      `withCohortDerivedPool` 只认 `PAC_COHORT_CONCURRENCY`(**摄入**的旋钮),
      于是 `recompute-persona --concurrency=N` 的旋钮**静默失效**:实测跑 --concurrency=8 时
      进程恰好只有 9 条连接(= 4核×2+1 的 Prisma 默认),超过 9 的 worker 全卡在池上排队。
      
      ️ 这个 bug **只在小核机器上咬人**,所以一直没被发现:服务器 4 核 → 默认池 9;
         开发机 16 核 → 默认池 33,本地压根复现不出来。
      
      改:池改读通用的 `PAC_DB_CONCURRENCY`,由各 CLI 在 NestFactory **之前**按自己的
      --concurrency 设进来(PrismaService 在容器初始化时就定池,晚了没用);
      `PAC_COHORT_CONCURRENCY` 保留为向后兼容别名,两者取大。
      
      ## ③ 同一张表查两遍 → 一次取,内存分两份
      
      persona.service 对 patient_facts 每患者查两次(同患者、同索引、两次往返):
        ① status IN (active, fulfilled)                    → factsByType
        ② type=appointment_record AND status != superseded → appointmentsAll
      ② 是 ① 在 appointment 这类上的放宽,故取 `status != 'superseded'` 一把捞,内存切两份,
      语义逐字节等价。
      
      ## ⑤ 审计日志 2 写 → 1 写
      
      原 create(status='running') + update(终态)。startedAt 可显式赋值,故耗时统计一字不差。
      代价:进程被硬杀(OOM/SIGKILL)时不留痕 —— 评估可接受:try/catch 里的异常仍写 'failed' 行;
      唯一丢的是"算到一半进程没了",而那种情况原本留下的是一条**永远卡在 running** 的行(没人清理)。
      读侧确认无依赖(daily-health-report 只用 findFirst 最近一条 + 按 status 的 groupBy)。
      全量回填还顺带少一半日志表写入(35.6 万 → 17.8 万行/轮)。
      
      往返预算:12.7 → 10.7(-16%)。
      
      ## 本地实测(13,268 患者,--force,concurrency=8,同为「全 unchanged」稳态)
      
        旧代码  93.50s
        优化后  76.89s      → 1.22×,省 17.8%
      
      ️ 本地测不出 ① 的真实收益:本机 avg 35.9ms / p90 49ms / **max 436ms**,
         而测试服 avg 165ms / p90 230ms / **max 75,750ms**(174× 均值)。本地没有那种能冻住
         整批的长尾,且延迟低到瓶颈更像是 JS 单线程 CPU 而非 DB 往返。
         所以 1.22× 主要是 ③⑤ 的往返削减(机器无关),① 的收益要到服务器上才显现 ——
         **具体多少不预估,等在测试服实跑对照。**
      
      ## 正确性
      
      指纹法:优化后跑完 → 切回旧代码再跑 → 旧代码对全部 13,268 人判 `unchanged`,
      persona_features 指纹 4de1dcd0ddc6a2c99e222fd43e0755de 不变。
      即**旧算法认为新代码的产出与它自己会算出来的逐字节相同**。
      
      ## 测试
      
      494 单测通过(36 suites),service tsc 干净。新增 tests/persona-recompute-perf.spec.ts 12 例:
        runPool — 不丢项 / 并发上限严格且用满 / 慢任务不冻住其他 worker / 边界 / 错误上抛
        连接池 — PAC_DB_CONCURRENCY 生效 / COHORT 向后兼容 / 取大 / 封顶 40 / =1 不动 URL /
                已有 connection_limit 不覆盖 / 非标准 URL 不抛
      
      ## 未做(单独排)
      
      ④ gapSelector 每患者平均 2.66 条 SQL(实测分布 0 组 3.4% / 1 组 19.5% / 2 组 25.7% /
        3 组 24.7% / 4+ 26.7%,max 9)。UNION ALL 合一能再省 ~1.7 次往返,但动的是**召回共享**
        的 gap 真理源,风险最高,要配专门的对账测试。
      
      另注:plan-engine.runAllForHost 有一模一样的批次栅栏写法(PAC_PLAN_BATCH_CONCURRENCY),
      同样的 5 行改法适用,本次未动。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • fix(deploy): 机器形态自检 —— 该托管却漏 COMPOSE_MANAGED=1 直接退出 · 0165bc11
      ## 事故(2026-07-26,生产 friday / 47.99.62.30)
      
      在生产机漏了 `COMPOSE_MANAGED=1` 跑 deploy-prod.sh。不叠加 managed override 时,
      docker-compose.prod.yml 把 DATABASE_URL 硬编码成 `postgres:5432`(容器内自带 PG),于是:
      
        22:35:33  起了 pac-postgres-1 / pac-redis-1 两个空容器(volume 同刻创建)
        22:35~36  pac-migrate 对着这个**全新空库**跑完 29 条迁移,建出整套 schema
                  ← 在这里被打断
        22:38:29  正确那次(COMPOSE_MANAGED=1)把两条新迁移写进托管库
      
      托管生产库**未被写坏**,逐项核对:patients 425,874 / facts 25,022,720 /
      在池 236,553 / assigned 36 / personas 409,368 / plan_executions 7 —— 与事故前一致
      (patients、facts 的 +2/+206 是正常增量摄入)。空库那侧唯一有行的表是 _prisma_migrations(29 行)。
      
      ## 真正的后遗症(已清理)
      
      机器上留下一个「schema 齐全的空库」。危害不在占资源,在于:
      **下次再漏 COMPOSE_MANAGED,service 会顺利连上它、health 200、脚本三项验证全绿,
      生产静默服务空库且零告警。** 原本"空 PG 没 schema 会崩"这张天然安全网就此失效。
      
      已在生产执行:docker rm -f pac-postgres-1 pac-redis-1
                    docker volume rm pac_postgres_data pac_redis_data
      清理前核过:两 volume 无其他容器挂载 / pac-service 连的是 RDS+托管 Redis /
      pac-web 无 DB 环境变量 / 本地库业务表全 0 / 全机再无其他 volume。清理后 health+web+私网 200。
      
      ## 防线
      
      不依赖人记得加环境变量,而是问机器自己 —— 读 `.env` 的 DATABASE_URL 指向哪儿:
        · postgres / localhost / 127.0.0.1 → 自带 DB 形态,放行(测试机)
        · 其他主机(RDS 等)               → 托管形态,没设 COMPOSE_MANAGED=1 就 die
      
      闸放在 main() 最前,早于任何 git/docker 动作 —— 拦下时机器状态零变更。
      报错只打主机名不打整串 URL(不泄露 .env 口令)。
      
      ## 验证
      
      - 新增 tests/deploy-managed-guard.spec.ts 5 例,真起 bash 跑脚本:
        托管+漏设→退出且给出正确命令 / 报错不含口令 / 托管+设了→放行 /
        自带 DB→放行(不误伤测试机) / localhost 与 127.0.0.1 同样放行
      - 482 单测通过(35 suites),service tsc 干净
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • merge: feat/persona-explainability → main(画像可解释性 + 历史联系最近一次 + ⑤g 未来回访排除闸) · 13733519
      已在 test(a0d20f82)部署验证。三块内容:
      
      1. 画像可解释性 —— 水位对齐 fact 层 + 写入侧节流;到期改档精确调度(nextBoundaryAt);
         详情页说明收回 @pac/types 单一真理源 + 打通证据链 + 分组定序 + 圈人标签 hint
      2. 历史联系「最近一次」—— 程序判定 isLatestPast,摘要开头必须交代;prompt 加数据边界
         硬约束(源里只有标签没有原话,没记的不许补);抽屉标最近一次/未到期;
         修 fallback 拿 items[0] 当最近一次的老 bug;两处 orderBy 改 nulls:'last'
      3. ⑤g 未来回访排除闸 —— 诊所已排未来回访则本轮不召,排到哪天挡到哪天,到期自愈
      
      ️ 部署后必须做的两件事(见两个 migration 文件内注释):
        a. patient_facts 复合索引已在 migration 内,大表建议先 CONCURRENTLY 预建
        b. 画像回填 **--force 必须加**:next_boundary_at 为 NULL 语义是「不再改档」,
           水位闸认为这些版本新鲜、永远不会重算,不会自愈
        c. ⑤g 需 plan 重算才整体生效(生产预估挡下 30,426 条在池 plan / 12.9%)
      
      本地 477 单测 / service+web tsc 干净;test 服已验闸的两个方向。
      luoqi committed
    • feat(recall): ⑤g 排除闸 —— 诊所已排未来回访则本轮不召 · 5ed2e888
      业务可用性测试 Issue「超百天自动加急覆盖回访计划」:
        [被测者]「超过百天他就会给你加急自动升了一个紧急,而且他设置过的回访的话他实际是不记得」
      
      ## 先纠两个事实
      
      1. 阈值是 **90 天**不是 100 天(urgency_level:>90 紧急 / 30-90 高 / <30 中),
         而且它不只是标签 —— 急迫性 ×0.4 是综合分权重最大的一块。
      2. 「覆盖了回访计划」不准确:PAC **从没看过** task_date,不是覆盖是没接。
         (回访表本身早就进召回了 —— gapWhere 里的外院已治疗闸读 rv.result;
          只是一直没用 task_date 这一列。)
      
      ## 口径:排到哪天挡到哪天
      
        AND NOT EXISTS (SELECT 1 FROM patient_return_visits rv
                        WHERE rv.patient_id = p.id AND rv.task_date > now()::date)
      
      跟 ⑤b(未来预约)同一个道理,注释原话就够用:「召回目的 = 让客服建预约。
      患者已经有未来预约 → 客服不需要再 push」。回访是同构的,只是接触形式换成电话/微信。
      
      三个设计选择,都是被生产数据推翻假设之后定的:
      
      · **不设窗口上限。** 原本想拍 N=60 天,查完发现理由不成立:在池患者的未来排程
        99.65% 在一年内(2026 年 25,105 / 2027 年 5,271 / 2028 年以后共 50 条,最远 2033-11-12)。
        固定窗口反而会在第 N+1 天把"诊所排了 N+30 天回访"的人放回池,制造重复触达。
        那 50 条脏数据不值得引一个新常量 —— 真要防该在摄入侧校 task_date 范围。
      
      · **不为"排了不做"加兜底。** 全库到期任务执行率 74.3%(118.1 万 / 159.1 万),
        确实有 1/4 烂尾。但闸的条件是 task_date > today,任务日一过闸自动失效、患者当天回池 ——
        SQL 每轮重算就是自愈机制,不需要解除逻辑。
      
      · **放 scenario 不放 gapWhere。** 这是本次唯一的架构判断:
          gapWhere(召回 + 画像共享)= 【临床事实】层 —— 已治疗/患者拒绝/外院已做 → 缺口真没了
          scenario WHERE(仅召回)   = 【运营时机】层 —— 缺口还在,只是现在不该打电话(⑤b/⑤f)
        未来回访显然是后者:排了个电话不代表那颗缺牙补上了。放进 gapWhere 会让画像因为
        "排了个回访"就说这人没有潜在治疗 —— 那才是口径分叉。
      
      ## 生产影响(上线前实测)
      
        在池 plan            236,508
        ⑤g 将挡下             30,426  (12.9%)
          其中 urgent         17,263  ← 正是一线抱怨的"明明排了回访还催我"
        未来排程到期分布      ≤30天 8,724 / 31-90天 8,502 / 91-180天 9,407 / >180天 3,793
      
      被挡住的 plan 会被 supersede(0 命中 → closeStaleActivePlan),到期是新版本 —— 已确认
      可接受:数据都有记录,跟踪得到;分配逻辑将来单独做。contactAttempts 归零暂不处理。
      
      ## 顺带修一处过期注释
      
      plan-engine.service.ts:70 还写着「关闭 active(非 assigned)plan / assigned 不动」,
      但代码从批量路径修复那次起就已含 assigned(:145 条件是 active OR assigned,
      :261 注释也写明是有意改的)。注释改成与代码一致。
      
      ## 本地验证
      
      - 477 单测通过(新增 8 例:闸形状 / 只认未到期 / 患者级 / 无窗口上限 /
        ⑤b⑤f 未被替换 / gapWhere 不含未来 task_date / gapWhere 仍保留外院闸 / 画像 SQL 不含本闸)
        测试用递归摊平 Prisma.Sql 的方式断言 —— 嵌套片段在 tagged template 里只是绑定参数,
        不摊开断言会假通过(第一版就踩了这个,gapWhere 内容根本没被检查到)。
      - service / web tsc 干净
      - 真库跑引擎:
          吕学文 1921711(有 2026-10-09 未来回访)→ plansClosed=1,plan 转 superseded 出池
          李石明 1873810(无未来回访)            → 不受影响,仍 active
          把 2026-10-09 改成昨天再跑 → plansCreated=1,v2 active 回池(自愈方向验证),随后还原
      
      Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
      luoqi committed
    • feat(recall): 历史联系摘要突出「最近一次」+ 抽屉标注最近一次/未到期 · f634f274
      业务可用性测试 Issue 2「历史联系记录缺具体内容」(患者余同学 BJ0L028292):
        [被测者]「说了常规的提醒没有特殊的」
        [主持人]「那就是暂时没有办法详细的看到具体一次沟通什么内容」
      
      ## 先查源:PAC 没有藏数据,源侧确实只有标签
      
      DW 直查 fact_returnvisit_out(customer_id=1683102 brand=瑞尔),该患者 5 条:
      
        2026-05-20 常规回访 未回访/未完成 内容「复查洁牙」  结果 null
        2026-02-25 常规回访 未回访/未完成 内容「方案跟进」  结果 null
        2026-02-23 常规回访 已回访/已预约 内容 null        结果「复查」
        2025-11-16 常规回访 未回访/未完成 内容「半年复查」  结果 null
        2025-08-17 常规回访 已回访/已完成 内容 null        结果「复查」
      
      PAC 库里 5 条一条不差,7 个业务字段全部已在抽屉展示 —— **没有少展示任何东西**。
      
      ️ 排查中的一个坑,顺手确认了不是 bug:DW 直查会返回 15 条,多出的 10 条 brand=瑞泰。
         patient_id 跨品牌复用(374 万 id 中 196 万重号),1683102 在瑞尔=余继来(9岁,
         BJ0L028292)、在瑞泰=宋可超(33岁,QD0D020738),是两个人。摄入按 (patient_id, brand)
         复合键切分,各归各家,正确。
      
      全库口径(生产 165.2 万条回访):
        follow_content 填充 51.0%(均 12.4 字)· return_visit_result 填充 67.4%(均 9.1 字)
        取值 TOP:「种植潜客」8.6万「提醒洗牙」3.9万「无不适」8.2万「已约」3.5万「未接」1.2万
      
      即业务想要的三样东西,源里的实际情况是:
        · 沟通结论 / 患者态度 → **只有粗粒度标签**(无不适/已约/未接/挂断/拒接),没有原话
        · 下次建议联系时间   → **完全没有**(个别混在 result 自由文本里,如「7月份再联系」)
        · 逐字沟通内容       → **没有**;dw_group 全库只有 fact_returnvisit_out 一张联系表
      
      → 结论:摘要说「常规的提醒」是**如实转述**,不是 PAC 概括丢了信息。PAC 不造数据。
      
      ## 再改:业务关心「最近一次」这条建议成立,采纳
      
      列表按日期倒序时头部常是**还没到日子的排程**,客服最想知道的"上次聊到哪儿了"
      反而被埋在中间 —— 这是真问题,跟"源里没内容"是两回事。
      
      摘要侧(promptVersion → @2026-07-26-f):
        · orchestrator 程序判定 isLatestPast(第一条有日期且已到期的),同 isFuture 的纪律
          —— 哪条是"最近一次"是排序问题,不交给 LLM 比日期
        · prompt 渲染【最近一次·已发生】标记 + 要求摘要开头交代它(时间+事由+结果)
        · 新增「数据边界」硬约束段:明写源里只有标签、没有原话和下次时间,
          没记的一个字都不许补,禁止输出"患者表示…"这类原话式表述
        · 最近一次那条正文预算 40 → 160 字(源里偶有几百字的方案沟通记录,
          一刀切 40 字会把唯一一条有信息量的记录砍没)
        · 它没记结果时显式渲染「结果:未记录」—— 不写模型会拿别条的结果脑补这条
      
      抽屉侧:最近一次加 teal 角标 + 底色,未到期的加「未到期」灰标;
      最近一次没结果时明写「结果:宿主未记录」(空着会被当成 PAC 没展示,正是这次的误会来源)。
      
      顺带两个同源 bug:
        · fallback()(LLM 失败时给客服看的兜底句)还在拿 items[0] 当"最近一次",
          等于把 2026-07 那次未来排程事故在兜底路径上原样复刻 → 改走 isLatestPast
        · returnVisits 的 orderBy taskDate desc 在 Postgres 默认 NULLS FIRST,
          无日期的记录会顶到列表最前冒充"最近一次",还吃掉 take 名额 → nulls:'last'
          (摘要 take:12 与聚合 take:100 两处都改,口径一致)
      
      ## 本地验证
      - 469 单测通过(本文件 13 → 24,新增「最近一次」13 例,含余同学生产原始形态回放)
      - service / web tsc 干净
      - 吕学文(BJ0U017795,4 条历史 + 1 条 2026-10-09 未来排程)清缓存连跑 3 次稳定:
          「最近一次4月4日治疗后回访已完成;此前种植咨询未回访,已排10月9日复查洁牙邀约。」
        李石明(BJ0U017487,最近一次无结果)同样 3 次稳定:
          「最近一次4月种植咨询回访未执行、未留结果;此前3次常规回访均已约到诊复查…」
        —— 开头讲最近一次、没记结果如实说、未来排程仍说"已排"不说"漏做",三个方向都对
      - 抽屉截图确认:2026-10-09 带「未到期」灰标,2026-04-04 带「最近一次」角标+底色
      
      ## 待定(本次不做,需业务决定)
      - DW 有两列我们没摄:task_director(回访负责人姓名,「上次是谁跟的」)、
        appointment_id(该次回访是否真开出预约 —— 比 result 文本硬的结果证据)。
        加这两列要 migration + 165 万行 reparse,先记着。
      - fact_complain_out(8.15 万条投诉)整张表未摄入,是目前唯一没用的患者态度硬信号。
      
      Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
      luoqi committed
    • fix(build): .swcrc 的 exclude 是正则,必须锚定 · eb4d01c6
      跟刚 revert 的 hover 改动无关,单独留下 —— 这是个静默的构建坑,任何人加文件都可能撞上。
      
      原写 ["node_modules", "dist"],看着像目录名,swc 实际当**正则**匹配整个文件路径。
      于是文件名里含 "dist" 的源文件被直接跳过:271 个源文件只编译出 270 个,swc 不报错
      (照样打印 "Successfully compiled: 270 files"),直到进程启动才 MODULE_NOT_FOUND。
      同类地雷:distinct / distance / district / redistribute…
      
      更难查的是两边表现不一致 —— 只在 swc 路径(dev / --builder swc)复现,
      nest build 走 tsc 一切正常。
      
      锚定成 ["^node_modules/", "^dist/"],并加断言:exclude 必须以 ^ 开头,
      且遍历 src 下全部 .ts 确认一个都没被误伤。
      
      456 tests green。
      
      Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
      luoqi committed
    • Revert "优先级 hover 可解释性" 三个 commit · 0eddcffa
      Revert a4b75100 + 22b2c95f + 4477a8c2。
      
      理由:改完比原来更不清晰。截图实例 —— 价值性 9 分、意愿度 8.0 分,右边却写着
      「画像未标潜在治疗」「画像未标价值分群」:分数明明算出来了,解释说没数据,自相矛盾。
      根因是 breakdown 里那几个展示字段只在 plan 重算后才有,存量 / 其他 host 的 plan 一律落到
      "未标"分支 —— 而"未标"这个措辞本身也把「后端没透出」说成了「画像没打标」。
      
      绕了一圈说明这块的设计还没想清楚,不该边改边试。退回原状,择期重做。
      
      保留(与本次 UI 无关,单独重提):.swcrc exclude 正则未锚定的构建坑。
      
      ## 重做时手上已有的事实(都已核过,别再查一遍)
      
      · 「看不出高低」是真问题:生产 active+assigned 23.6 万,priority_score
        p50=31 / p80=56 / p90=66 / p95=76 / p99=82。9.78 分是前 0.05%,而客服无从判断。
      · 三维输入里 urgencyLevel / rfmSegment / lifecycleStage / treatmentHistory /
        referralChampion / specialAttention 来自画像;consultIntentMatch 来自
        consultation_record;primaryCode / toothCount 来自本条召回的 gap。
      · consultIntentMatch 基本不可用:237 万条 consultation_record 只有 9.7% 解析出非空
        intent_categories,其余 214.8 万条源端 intent/content 全为 null(DW 没给,非解析 bug)。
        intentBehavior=2 的 22 万患者里真的没咨询过的只有 230 人(0.1%)。
      · 存量对不齐(与本次无关,仍在):按年龄排治疗只落到召回目标,没落到画像 ——
        classifyGapToLabel 里 K08 仍只卡 age>18,生产 2,539 条 plan 目标写「活动义齿为主」
        而画像·潜在治疗仍标「潜在种植」。改它会同时改变「潜在种植」的圈人口径,需业务定。
      
      Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
      luoqi committed
    • fix(priority): 三维只念画像标签,不从别处补第二套口径 · a4b75100
      三维的输入本来就取自画像(急迫等级 / 价值分群 / 生命周期 / 潜在治疗,见 scorer 入参),
      但我为了"讲得细"又从 reason.signals 和 consultation_record 各推了一段话 —— 那是第二套口径,
      和「画像标签」抽屉必然对不齐。两处已经翻车:
      
        · 从 signals.triggers 反推出「种植 · 缺 6 颗」。同一屏上 79 岁患者的召回目标写的是
          「活动义齿为主」,画像自己写的是「潜在种植」—— 三处三个说法。
        · 把 intentBehavior=2 说成「没主动问过」(上个 commit 已修措辞,这次直接移除):
          它来自 consultation_record,根本不是画像标签。
      
      改成照抄画像:
      
          急迫性 10 × 0.4     急迫等级 紧急
          价值性 10 × 0.3     潜在治疗 潜在种植 / 潜在牙周
          意愿度 2.63 × 0.3   价值分群 重要挽留 · 生命周期 流失客
      
      与该患者「画像标签」抽屉里的取值逐字一致。画像没标就说「画像未标 X」,不编。
      
      为此 scenario 的 persona 上下文多取一个 potential_treatment,由 scorer 原样透传进
      breakdown(仅展示、不参与打分)—— 前端拿画像的原文,而不是自己拿 K 码 + 牙位再算一遍。
      中文名一律走 personaTagLabel(圈人字典),本文件不留映射表。hover 不再需要 signals 入参。
      
      守卫测试相应收紧:三维文案里出现「缺 N 颗」「咨询」等非画像来源的词就红。
      
      ️ 顺带查出一处**存量对不齐**(本次未改,需业务定):按年龄排治疗只落到了召回目标,
      没落到画像 —— classifyGapToLabel 里 K08 仍只卡 age>18。生产 2,539 条 plan 目标写着
      「活动义齿为主」而画像·潜在治疗仍标「潜在种植」。改它会同时改变「潜在种植」的圈人口径。
      
      481 tests green · pac-web build 通过 · 本地真实数据逐条比对过画像抽屉。
      
      Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
      luoqi committed
    • fix(priority): 「没主动问过」是错的 —— 查不到 ≠ 患者没做过 · 22b2c95f
      上一版把 intentBehavior=2 写成「没主动问过」。生产核对下来这话 99.9% 的情况是错的,
      22 万个 2 分患者拆开是:
      
          真的从没咨询过              230 人   0.1%
          咨询过但意向没解析出来   176,578 人    80%
          咨询过、问的是别的项目    43,911 人    20%
      
      而且 80% 那批不是解析 bug —— 237 万条 consultation_record 里 214.8 万条源端
      intent/content **全是 null**(DW 没给),再改 parser 也挖不出来。
      
      客服是照着这句开口的(「您之前没问过种植吧?」),说错等于当场翻车。这跟我在画像那边
      批评过的「权益身份承诺了数据里没有的保司和日期」是同一个错误。
      
      改为只陈述查到了什么,不替患者下结论:
          8 分 → 「问过这个项目」   (意向命中本次治疗类别,确凿)
          2 分 → 「没查到咨询意向」 (我们没查到,不是他没问)
      
      顺带在 scorer 记下这条数据现实:调权重的人要知道 2 分不代表患者被动,这一维现状更接近
      "有意向信号则加分"而非"无意向则扣分",把 2 当负面证据会系统性压低大批患者。
      
      新增 tests/priority-explain-wording.spec.ts 守住红线:低分档文案里不许出现
      没主动问过 / 没问过 / 未咨询 / 被动 这类断言患者行为的措辞。
      (测试放 pac-service 是因为 pac-web 没有测试环境,而该模块是纯函数零 React 依赖。)
      
      482 tests green · pac-web build 通过。
      
      Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
      luoqi committed
    • feat(priority): 优先级 hover 讲清「为什么这个分」和「这分算高还是低」 · 4477a8c2
      ## 问题
      
      hover 把三维拆解列得很细,但两个关键问题都没答:
      
      1. **看不出高低**。只给「9.78 / 10」,客服会以为满分 10、9.78 只是"还行"。
         生产实测分布完全不是这样(active+assigned 23.6 万):p50=31 · p90=66 · p99=82
         —— 97.8 分其实是**前 0.05%**。反过来本地库最高才 31 分,那 31 就是该先打的。
         没有参照系,这个数字读不出任何决策含义。
      2. **解释是口径定义,不是这个患者**。右列写的是「病情多急(末诊/超期)」
         「治疗类型 + 牙数预估收入」—— 换谁看都一样,看完仍不知道他为什么是 10 分。
      
      ## 改法
      
      **池内位置**:新增 personas 之外的 PriorityDistributionService —— 租户级整数分直方图,
      缓存 30 分钟 + single-flight,算分位纯内存(列表一页只查一次分布,不是每行一次)。
      池 <50 返回 null 不显示(样本不足给分位会误导)。查询失败降级 null,不拖垮列表。
      
      **逐患者解释**:文案抽到 priority-explain.ts,能从已有数据推的就不另存 ——
      急迫档位 ← urgency 分值;治疗类型/牙数 ← reason.signals.triggers/toothPosition;
      主诉命中 ← intentBehavior 二值。只有 RFM 分群与生命周期反推不出(8 分既可能是重要保持
      也可能是重要发展),由 scorer 在 breakdown 里带上。code→中文复用圈人字典
      (新增 personaTagLabel),不另抄映射表。
      
      实际效果(本地真实数据):
      
          优先级 3.12 / 10   召回池里最该先打的一批(前 1%)
          急迫性 10 × 0.4    紧急 · 90 天以上没来
          价值性 10 × 0.3    种植 · 缺 6 颗以上
          意愿度 2.63 × 0.3  重要挽留 · 没主动问过 · 流失客
          × 新鲜度 0.40      诊断偏老,已按时间降权
          × 置信度 1.00      来自医生诊断
      
      两个 10 分却只有 3.12,原因一眼可见:意愿度低 + 诊断偏老降权 —— 这是旧版讲不出来的。
      
      ## 连带修的两个坑
      
      **① breakdown 进不去存量 plan。** reasonNeedsRefresh 只比 signals/evidence,所以
      「算法给 breakdown 加字段」和「急迫档位跨了 90 天线」都刷不到存量行(和 2026-07 高龄缺牙
      同一形态)。把 breakdown 纳入判据,同样剔掉随时间漂移的 freshness / raw —— 否则每日重算
      全量重写。本地 17,298 / 17,304 条就地回填,**0 个新版本**。
      
      **② .swcrc 的 exclude 是正则,不是目录名。** 原写 ["node_modules", "dist"],
      而 priority-distribution.service.ts 文件名含 "dist" → 被静默排除,271 个源文件只编译 270 个,
      swc 不报错("Successfully compiled: 270 files"),直到启动才 MODULE_NOT_FOUND。
      锚定成 ["^node_modules/", "^dist/"],并加断言防回归 —— 这个坑只在 swc 构建路径复现,
      nest build(tsc)一切正常,极难排查。
      
      471 tests green · pac-web build 通过。
      
      Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
      luoqi committed
    • fix(persona-ui): 说明卡去掉底部注解行 · 3a85d4b1
      「已经在别处做掉的,这里看不到」「拔牙、补牙、根管等基础项目不计入」这类是在交代
      算法边界,客服不需要知道,占位还把卡片拉长。note 从 display 契约里整体删除(16 处)。
      
      三条确实影响判断的信息没丢,并进它该在的位置:
        · 价值分群 —— 「重要 = 消费排同院前 40%」原本藏在注解里,而它正是这 8 个取值的
          第一分野。改成第一条对照项,顺带把副标题写成「按消费高低 × 来得勤不勤 / 最近来没来」
        · 权益身份 —— 副标题补「(不代表现在仍有效)」,免得客服当成当前在保
        · 禁忌标签 —— 副标题补「· 目前只判年龄」,保住"没标记 ≠ 没禁忌"这个边界
      折扣锚点的时效提醒也不靠注解 —— 卡片上本来就有超 3 年才出现的 ️ 行,比常驻一句更准。
      
      顺带核对了时间偏好的时区(生产 120 万条预约):
        · 北京时区小时分布是干净的 9-17 营业钟形,12 点午休凹陷 —— +8 漏加或重复加都会把峰值
          推到 1-9 或 17-1
        · 星期分布周六 21.1% / 周日 19.3%(合计 40.4%),周一最低 9.8%,符合"休息日看牙"
        · 抽样对账:画像存的 recordCount=67 / weekendPct=15 与 SQL 现场复算一致
        · planned_for 120 万条 0 个 null → `?? occurredAt` 兜底(钟点不可靠)在生产从不触发
      结论:换算无误。硬编 +8 对中国安全(1991 年后无夏令时),多宿主时区仍是既有 follow-up。
      
      454 tests green · pac-web build 通过。
      
      Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
      luoqi committed
    • fix(persona-ui): 说明卡恢复「取值 + 短说明」两列结构 · 7e3a4f67
      上一版为了"精简"把自明标签的 rules 清成空数组,结果卡片退化成三段平铺文字 ——
      没有层次,也没有取值之间的对照,取值多的标签(权益身份 5 类、治疗史 4 类)尤其难读。
      
      精简该精简的是 body 字数,不是砍结构。16 个标签统一回「加粗取值 + 一句短说明」:
      客服先扫左边那列找到自己要的取值,再读右边一句。措辞仍是上一版改过的客服版,
      口径订正(权益身份 5 类、转介绍达人门槛、急迫档位)都保留。
      
      `rules[].label` 由可选改必填;CI 断言相应收紧:至少一条、每条都得有 label。
      
      454 tests green · pac-web build 通过 · 本地逐个 hover 核对过。
      
      Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
      luoqi committed
    • fix(persona-ui): 说明写给客服不写给程序员 + 依据日期不折行 + 圈人标签给解释 · 5b72d147
      ## ① 依据行的日期折行了
      
      定宽 68px 装不下 `2026-03-27`(11px 字号),每条依据的日期都断成两行。
      改为按内容撑开 + nowrap;tabular-nums 保证各行等宽,不定宽也自然对齐。
      
      ## ② hover 说明太啰嗦,而且是写给程序员的
      
      16 个标签的说明全部重写。原则:客服扫三秒能用,取值本身说得清就不再复述一遍。
      典型对比(潜在治疗):
      
        改前 8 行 —— 副标题 + 3 条把 chip 上已有的词再列一遍 + 一句
          「口径与召回完全一致(就是召回在挖的机会),只是不受冷静期等时间限制」
        改后 3 行 —— 副标题「诊断或建议过、但还没做的项目」+ 取值 + 「已经在别处做掉的,这里看不到」
      
      rules 现在允许为空(性别 / 治疗史 / 权益身份 / 治疗敏感 / 潜在治疗等 6 个都清空了),
      note 只留会改变客服**做法**的那句。新增一条 CI 断言拦实现术语(fact / 字段 / K0 / data. …),
      免得下次又写成实现说明。
      
      ## ③ 圈人面板的标签用户看不懂
      
      「重要保持」vs「重要挽留」差在哪?「待激活」vs「沉睡客」几个月分界?这些名字是算法口径的
      产物,客服只能凭感觉勾。给 PERSONA_TAG_FILTER_DIMS 的选项补 hint(64 项),内容是**判定条件**
      的大白话,如「重要挽留 · 消费高,但很久没来了」「待激活 · 半年到一年半没来」。
      
      展示方式**没用浮层**:这些选项是拿来横向比较的,浮层恰好盖住正要比的那几个(实测会遮住
      2 个 chip)。改成面板底部一行定高说明,悬停即换,不遮挡、无 z-index 问题、扫读时一直在。
      
      454 tests green · pac-web build 通过 · 三处均在本地真实数据上人工验过。
      
      Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
      luoqi committed
    • feat(persona): 到期改档精确调度 —— 时间跨档也能刷新,但不靠全量扫 · d7dc50eb
      ## 问题
      
      水位闸只在**事实变了**时放行重算。这是对的 —— 没有它 40 万患者天天全量重算。
      但有一类变化不伴随任何事实变动,时间自己走了就会改档:
      
        急迫等级跨 30/90 天线 · 生命周期跨 180/540/730 · RFM 的 R 跨 540/730/1095/1460
        年龄跨段 · 种植年龄禁忌满 19 岁解除 · 时间偏好/特别关注的滑动窗口把最老记录挤出去
      
      这类患者永远等不到重算。年龄段和年龄禁忌最极端 —— 它们**完全不依赖 fact**,
      一个 54 岁患者会一直显示「中老年」,直到他碰巧有新事实进来。而年龄还喂着按龄排治疗。
      
      ## 解法:算出确切到期时刻,而不是每天盲扫
      
      FeatureExtractor 新增可选 `nextBoundaryAt(ctx)`,报出"纯时间流逝会让我改档的最近时刻";
      PersonaService 取全体最小值存 personas.next_boundary_at;夜扫加一条 UNION 分支
      只捞 `next_boundary_at <= now()`,走 (superseded_at, next_boundary_at) 索引。
      isFresh 同步加这一维 —— 否则夜扫捞出来入队也会被水位当场 noop 掉,白捞。
      
      八个特征声明了边界(rfm / lifecycle_stage / urgency_level / age_bracket /
      contraindication / potential_treatment / time_preference / special_attention),
      日期算术统一走 features/time-boundary.ts 的四个原语,不手搓。
      
      顺带把 rfm / lifecycle_stage / urgency_level 各抄一遍的「到诊」口径收进 visit-facts.ts
      —— 三个标签的 description 都印着"就诊 N 次 / 末诊 N 天",口径对不上客服当场就能发现。
      
      ## 本地实测(friday host,13,268 患者)
      
        --force 回填   success=0 · refreshed=885 · unchanged=12,383 → 全量跑几乎不产新版本
        边界分布       10,808 有到期时刻 / 2,463 已过全部档界(NULL)
        每日到期量     5~30 条(外推生产 40 万约 150~900/天,而不是 40 万)
        端到端         人为把某条置为已过期 → 夜扫 SQL 捞到 → 不带 --force 重算 noop=0(闸放行)
                       → 内容真没变故判 unchanged 不写 → 边界推进到 2029-06-06,不会重复捞
      
      ## 一并清理(逻辑清晰向)
      
      · persona-display.ts 删掉 `[enum] 文本` 前缀解析与 PERSONA_STATUS_ZH —— 服务的四个特征
        (treatment_chain_status / recall_risk / value / do_not_contact_status)W7 已摘除,
        现存 16 个 extractor 没有一个产这种前缀,抽屉里那个 tag 恒为 null。
      · PERSONA_FEATURE_META 从 33 个键收到 16 个(= 在跑的全集)。原来混着已摘除的和只存在于
        路线图上的,"看着 30 个标签实际只出 16 个"。枚举保留路线图,展示元数据只登记会上屏的。
      · tone 重排:红色只留给警示(急迫/特别关注/禁忌/治疗敏感)。原来「转介绍达人」「折扣锚点」
        这类正面或中性信息也是红的,和「禁忌」同色,客服扫一眼分不出轻重。两条 CI 断言守住。
      · mock 画像换成在跑的特征 —— 原来摆着六个系统根本不产出的标签,拿它当参照会看错。
      · 折扣锚点超过 3 年在详情页给过时提醒(生产上见过 2018 年的锚点被原样展示)。
      
      453 tests green · pac-web build 通过。
      
      ## 部署
      
      两个迁移的回填是同一次运行,且**必须带 --force**:
      next_boundary_at 的 NULL 语义是"不再改档",不会被判 stale,不强制跑就永远算不出来。
      
        pnpm recompute-persona:prod -- --host=jvs-dw --concurrency=8 --force
      
      Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
      luoqi committed
    • feat(persona): 详情页说明收回单一真理源 + 打通证据链 + 分组定序 · e874e74b
      页面上「对画像的解释」的四个毛病,一次修掉。
      
      ## ① 说明写错了 —— 前端自己硬编了一份算法字典
      
      persona-feature-hover.tsx 里的 ALGORITHMS 与后端口径各写各的,已经漂:
        · 权益身份只列「商保客户 / 医保结算」2 类,extractor 实际产 5 类;还承诺
          「显示保司 + 最近日期」—— description/data 里压根没有这两样
        · 转介绍达人写「社交型 = 推外人 ≥3 人」,真实口径是「推荐 ≥3 人**且**带来成交,
          直系亲属关系 <3 才归社交型」
        · 急迫等级缺档位说明;年龄段 labelValues 写 0-3 / 46-55,与自家 algorithm 的
          0-2 / 46-54 自相矛盾
      客服是照着这段话跟患者讲的,写错比不写更糟。
      
      改为收进 PERSONA_FEATURE_SPECS[key].display(与 algorithm 口径贴脸放,前端只渲染)。
      不新开接口 —— packages/types 本来就是前后端共享的。
      新增 tests/persona-spec-drift.spec.ts:registry  注册表  圈人字典三方必须对齐,
      每个标签必须有 display —— 光收拢不设闸,过两个月照样漂。
      
      ## ② 只给结论不给出处
      
      后端一直存着 evidence.factIds(生产上 rfm + lifecycle 两个特征就占 1 GB),
      但 adapt-data 把它写死成 `evidence: []`。现在 /full 透出 evidenceFactIds,
      详情页反查同一响应的 facts 渲染「依据 · N 条」(默认 4 行,可展开)。
      本地实测证据 id 100% 可解析。factLabel 从 tooth-timeline 抽成共享模块并补齐
      结算/预约/就诊/病历等类型 —— 否则证据行会把 `payment_record` 这种表名甩给客服。
      
      ## ③ 顺序是随机的
      
      抽屉排序取 `orderBy createdAt`,而同一版 feature 行是一条 createMany 写进去的、
      createdAt 完全相同 → 排序实际未定义,生产上呈现为英文 key 字母序:
      「急迫等级 = 紧急」排最后一个,「性别」「获客渠道」排最前。
      现按用途分三组(跟进要点 / 价值与阶段 / 基础属性),组与组内序都定义在标签卡里。
      标签云用同一套序。
      
      ## ④ `?` 够不着
      
      原来是 16×16 的 <span>,无 tabindex 无 role、纯 hover —— 键盘、读屏、平板触屏都点不到
      (可访问性树里一个都读不出来)。改成 24×24 button,focus 即可唤出说明卡,
      aria-label 带标签名。
      
      顺带:「更新于」改用 refreshedAt(就地刷新不升版本,computedAt 会停在旧值,
      拿它显示会让客服以为数据比实际更旧);extractor 清单从 module/registry 两处收成一处。
      
      ## 验证
      
      432 tests green · pac-web build 通过 · 本地真实数据人工验:
      分组与依据渲染正确、hover 内容来自 spec、`?` 可聚焦且 focus 能唤出对应说明卡。
      
      Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
      luoqi committed
    • fix(persona): 画像水位对齐到 fact 层 + 写入侧节流 · 889dd6f0
      ## 事故
      
      画像算的是 patient_facts,stale 判定却只看 patient_transactions 的 event_seq。
      reparse 重写事实但不产新事务,于是 2026-07-21「amount_cents 应收→实付」reparse
      之后紧跟的全量重算被水位闸整批 noop 掉 —— 生产日志:
      
          manual:cli  noop=325,979  success=79,010
      
      后果(生产 3000 抽样):reparse 前算的画像 96% 累计消费与实时事实不符,中位高估 91%。
      页面上同屏两个数打架 —— 关键事实 ¥154,350(实时)vs 画像 ¥195,750(旧口径)。
      而且不只是显示错:monetaryCents 要跟实时算出的 M 分位阈值比大小 → mScore 系统性
      偏高 → RFM 分群整体偏「重要」→ 直接抬高召回优先级排序。
      
      ## 修法
      
      水位对齐:新增 personas.fact_watermark = max(patient_facts.updated_at),
      与 event_watermark 取「任一落后即重算」。存量行为 NULL → 判 stale,自然回填。
      stale-scan 同步加这一维。
      
      闸放宽必然带来「算了但没变」,所以补一道**写入侧的闸**(persona-diff.ts):
        unchanged 一字未变     → 不写 feature 行,只推水位
        refreshed 只有时间派生值变 → 就地刷新,不升版本(记 refreshedAt)
        semantic  真变了       → 升版本
      判据比 data(剔易变键)+ evidence.factIds,不解析 description —— 同 reason-refresh 思路,
      canonical 序列化抽成 common/canonical-json.ts 两边共用(jsonb 不保留键序)。
      
      顺带:闸里查过的水位不再在正算路径重查(原来 transaction / persona 各查两遍)。
      
      ## 本地验证(friday host,13,268 患者)
      
        首轮回填  noop=0(旧代码这里会大批 noop)· success=48 · refreshed=11,155 · unchanged=2,065
                  → 写侧节流把 13,268 次升版本压到 48 次
        再次运行  noop=13,268 全零其余,142s → 29s,幂等
        事故复现  只改 fact 不动 transaction → 触发重算,¥24,994 订正为 ¥15,996,v1 留痕
      
      ## 部署注意
      
      patient_facts 370 万行,新索引请先手动 CREATE INDEX CONCURRENTLY 预建(迁移里是
      IF NOT EXISTS,预建后即 no-op);上线后跑一次全量 recompute-persona 补 fact_watermark。
      
      Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
      luoqi committed
    • merge: fix/idle-probe-false-alarm → main(增量空转探针误报修复) · 13737a51
      - 探针只在 status=success 的空轮上跑(失败轮误报修复)
      - 探针反查带业务过滤,与真实增量拉取同一套 WHERE(退费单过度计数修复)
      
      生产同样受此误报影响;本次仅合入,暂不部署生产。
      luoqi committed
    • fix(sync): 增量空转探针不再误报 —— 失败轮守卫 + 探针带业务过滤 · 0ffb6e07
      测试服务器收到 CRITICAL「增量空转:PAC 拉到 0 行,但 DW 有新数据」。排查 sync_logs:
      
        07-25 16:15 UTC  failed  fetched=0  err="fatal: unexpected end of file"
        07-26 00:15 UTC  success fetched=239959 tx=103652   ← 下一轮全额补回
      
      即:一次瞬时 ClickHouse 连接断开导致该轮失败,游标未推进,下一轮把积压全额补回
      (tx=10万+,远高于平时 ~1万),**无数据丢失**。告警是误报,两处 bug 叠加:
      
      ## ① 探针在失败轮上也跑(主因)
      空转告警条件只看 `tx+dup=0`,没看 status。失败轮 tx=dup=0 → 走进探针 →
      把瞬时网络失败误诊成「游标格式失效/空转」。失败轮本就经日报失败计数 + error_message
      暴露,不该再被探针误标。
      修:加 `status===SUCCESS` 守卫,只在**成功空轮**上探。
      
      ## ② 探针反查不带业务过滤(放大误报)
      探针反查只拼 `cursor > val`,而真实增量拉取还叠加业务过滤(结算正表
      `is_refund=0 AND settlement_status=1`、退费表 `is_refund=1 OR settlement_status=4`、
      结算方式 `settlement_status='1'`)。于是"游标之后、但会被业务条件过滤掉"的退费行
      被探针数进去 → 真实拉取正确地不拉,探针却告警。
      告警数据自证:settlement 正/退表各 5788 完全相等,正是这批全为退费单的铁证。
      修:探针复用 extractBusinessFilters,与真实拉取同一套 WHERE。
      
      ## 验证
      - 388 单测通过(新增 6 例:各表业务过滤保留/剔除、括号 OR 不被 AND 拆开)
      - 失败原因「unexpected end of file」是瞬时网络错误,已自愈,无需数据补摄
      
      Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
      luoqi committed
  4. 24 Jul, 2026 3 commits
    • fix(plan): unchanged 分支支持 reason 就地刷新 —— 修存量 plan 的 signals 不生效 · 7808f53c
      ## 问题
      
      引擎变化判定只比 `(scenario, subKey)` 集合;集合没变就走 unchanged 分支,
      **plan_reasons 一个字都不改**。于是"只改 signals 内容"的算法升级对存量 plan
      永远不生效 —— 生产 3,170 条高龄缺牙即使上线了按年龄排治疗(signals 新增
      focusCategory / patientAge),仍显示「目标·种植」。
      
      原先只能删库重算(丢认领、丢版本号、丢触达计数)或写一次性 SQL 回填(逻辑重复、
      易与引擎口径漂移)。
      
      ## 做法
      
      unchanged 分支新增就地刷新:**不升版本、不动认领/状态/抑制窗/触达计数**。
      同时补 plan.goal 的比较(goal 是静态文案,算法改措辞要能落到存量)。
      
      刻意不动的字段:closedReason / closedAt(关闭状态)、source / sourceActorId /
      campaignId(来源溯源)、lifecycle;已关闭的 reason 行直接跳过。
      
      ##  关键:只在语义变化时才写
      
      reason 文案与 signals 都内嵌天数(`${days} 天前` / `signals.daysSince`),天天变。
      无条件刷新 = 每日重算把全部 plan_reasons 重写一遍(生产 23 万+ plan),纯废写 + WAL 膨胀。
      
      判据(reason-refresh.ts):比 **signals(去掉 daysSince)+ evidence.factIds(排序后)**,
      **不解析文案** —— reason 文案完全由 signals 派生(诊断名←triggers、牙位←toothPosition、
      治疗类目←expectedCategories+patientAge),所以比 signals 既充分又不依赖文案格式。
      
      踩到并修掉的坑:**Postgres jsonb 不保留键序**(按键长+字节序重排),直接 JSON.stringify
      比较会让库里读出的和新算出的永远不等 → 每次都判"变了"。本地实测连跑三次每次都刷新。
      改用递归排序键的规范化序列化后,第二三次不再写。
      
      ## 验证
      - 382 单测通过(新增 16 例 reason-refresh 单元 + 2 例批量路径集成)
      - 本地端到端(90 岁吕学文,人为退回存量旧状态 + 认领 + 触达 2 次):
        第 1 次重算 → 「reason 就地刷新 3 条」,signals 补上 focusCategory/patientAge、
        文案变「未启动活动义齿 / 种植」、goal 换高龄版;
        version 仍为 1、status=assigned、assignee=u-tester、contactAttempts=2 全部未动;
        第 2、3 次重算 → 0 条刷新(幂等,不产生废写)
      
      Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
      luoqi committed
    • docs(extraction): 修正冷静期字典 + 补认领取数口径(plan_event_logs) · e8e121c5
      - 关闭原因冷静期:inaccurate / treated 由 14 天改永久
      - 品牌患者数更新:瑞尔 351,024 / 瑞泰 64,883(瑞泰必须排除)
      - 新增「三之二、认领怎么捞」:快照与账本的分工、plan_event_logs 字段字典、
        三条常用查询(处理过哪些患者 / 接手后放弃 / 认领时长分布)
      - 表关系图补 plan_event_logs
      - 坑表补两条:assignee_user_id 是快照、按 plan_id 聚合会因升版本重复计数
      
      SQL 均已在生产库执行验证。
      
      Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
      luoqi committed
    • docs(ops): 回滚必须带 --no-pull + 补 DEPLOY_BRANCH 用法 · d9fb836e
      排查分支状态时发现文档与脚本长期不一致:main 上文档早已写「部署脚本不写死分支」,
      而 main 的脚本仍是 `git pull --ff-only origin main`(该改动只在 test 上)。
      本次已 cherry-pick 脚本改动到 main,文档主体因此自然对齐,只补两处:
      
      1. ️ 回滚命令必须带 --no-pull —— 原文档写的
         `git checkout <commit> && bash deploy/deploy-prod.sh`
         在旧脚本下会紧接着 pull 回分支最新,**把刚回滚的 commit 又拉走且静默无提示**;
         新脚本在 detached HEAD 下 fetch 会失败退出,不再假成功,但正确用法仍是 --no-pull。
         并提醒回滚后切回 main,否则下次部署仍在 detached HEAD。
      
      2. 补 DEPLOY_BRANCH 用法 —— 脚本一直支持,文档从没提过。
      
      Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
      luoqi committed