1. 28 Aug, 2026 7 commits
  2. 27 Aug, 2026 13 commits
  3. 26 Aug, 2026 4 commits
    • docs(召回): 补第 2 例 王丽芳 TS0M013040 —— 残根被推荐种植,已改为拔牙 · 377d4547
      新增「处理」列区分已修复与待定。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • docs(召回): 新建误召个案表 —— 首条 宋志宏 TS0M001982 · da7fc018
      一患者一行,后续个案追加。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • fix(生产): 主进程堆上限 4G→8G —— 每 4 小时崩一次,三天没跑成过一次全量 plans · 470a44d9
      【症状】容器 exitCode=139 自动重启,08-23~08-26 共 **11 次**,间隔约 4 小时。
        页面无感(自动重启 + health 200),所以一直没人发现。
      
      【根因】每轮增量同步结束会在**主进程内**跑一次 runAllForHost 全量召回。补摄把患者
        灌到 111 万、召回池 54 万之后,selectHits 一次出 **96 万条命中**,连同 prefetch 的
        latest/snoozed/persona 全在堆里 —— 而主进程启动命令
        `node --enable-source-maps dist/main.js` **没带 max-old-space-size**,
        取 Node 默认 **4144 MB**,装不下:
          Mark-Compact 3985.6 → 3953.5 MB (mu = 0.008)   ← GC 已经回收不动
          FATAL ERROR: Reached heap limit — JavaScript heap out of memory
      
      【代价】那三天 `Result(ruier-grp)` 在日志里出现 **0 次** —— 一次完整的全量 plans
        都没跑成过。每轮崩在 selectHits 之后、写库之前:
           增量导入成功落库    persona 重算成功落库    plans 整轮作废
        数据不丢,丢的是"召回池的定时刷新"。另外每次崩溃留一个僵尸 sync 锁(重启后被回收),
        且会带走同容器里正在跑的 docker exec —— bj04 第一次尝试就是这么死的(白跑 1h27m,
        靠 batch.sh 的 3 次重试救回)。
      
      【为什么现在才发现】补摄期间增量被 sync 并发锁挡下(00:15/08:15/10:15 全是 skip),
        不跑全量 plans 就不崩 —— 实测连续 12 小时零崩溃。补摄一停,下一轮就会再崩。
      
      ️ 排查时别往"容器内存不够"上找:容器**没有内存上限**(HostConfig.Memory=0),
        宿主 15.36G 一直有 7-11G 空闲。撑爆的是 V8 自己的堆,与宿主余量无关。
      ️ 同一容器里 CLI 那条路(cold-import / recompute-plans)一直是 8192,
        **两条路配置不一致**,崩的一直是没配的这条。这次补齐。
      ️ 宿主内存账写进注释了:8G(服务) + 8G(CLI) = 16G > 15.36G。实测两者不会同时顶到
        上限(max-old-space 是天花板不是预留;实测峰值 服务 4G / CLI 4.9G),但
         别手工并发跑全量 `recompute-plans`(它不走 sync 锁)。
      
      ️ **这是止血不是根治**:池子还在涨,8G 迟早也会到顶。根治是「增量之后别跑全量」——
        recompute-plans 已经有 --clinics 收窄能力,同样的思路搬到
        SyncIncrementalSchedulerService 那一处即可。另开分支做。
      
      改动只有一行环境变量(docker-compose.prod.yml 的 pac-service),不动代码不动数据。
      YAML 已校验;managed overlay 只合并 DATABASE_URL/REDIS_URL(非 !override),不影响本项。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
  4. 23 Aug, 2026 4 commits
    • fix(宿主跳转): 按子串换,别按域名标签精确匹配 —— arrail-test 漏了 · 8827ac0b
      2026-08-23 测试环境实测:瑞泰患者点跳转,还是去了 arrail。
      
      根因:上一版把 hostname 拆成标签、找**等于** `arrail` 的那一段。而测试环境的域名是
      `arrail-test.5i5ya.com`,标签是 `arrail-test` —— 精确匹配匹配不上,一个字都没换。
      各环境的域名前缀会带后缀(test / uat / …),枚举不完。
      
      ⇒ 认这个**词**本身,不认它周围是什么:hostname 里出现的别的品牌片段整词替换
         (arrail-test → rytime-test)。
      
      ️ 仍然只换 hostname, 不碰路径和查询串(`?from=arrail` 是宿主自己的业务参数)。
      ️ 解不出 URL(相对路径 / 哨兵值 'postMessage')仍原样返回。
      
      判据本身没问题 —— 同一时刻查过那个患者:sourceUnit='瑞泰',取值是对的,
      只是替换规则太严。补回归用例(带环境后缀的域名),前端 15 条全绿。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • fix(宿主跳转): 瑞泰患者跳瑞尔门户 —— 打开时把 arrail 换成 rytime · eb5d8b9f
      jvs-dw 底下两个品牌各有一套门户:瑞尔 arrail.5i5ya.com / 瑞泰 rytime.5i5ya.com,
      两边**只差域名这一段**,路径参数完全一样。而 host.actionUrls 是 host 级配置、只有一套
      (现在配的是瑞尔那套)。于是瑞泰患者点「查看档案 / 病历 / 新建预约 / 回访」,
      会被带到瑞尔门户 —— 那边查不到这个人。
      
      ⇒ 打开的那一刻按**患者所属品牌**把域名换掉(lib/host-brand-url.ts)。
      
      【为什么按患者判、不按登录人判】
      登录人的品牌前端**拿不到**:/auth/session 下发的 sourceUnits 对诊所级用户是哨兵
      `__pac_deny_no_brand__`(那条路按 clinicIds 过滤,不走品牌维)。2026-08-23 拿生产
      三家诊所的 token 实测,全是这个值 —— 判不出品牌。而患者的 sourceUnit 是实打实的品牌名。
      语义上也是患者对:这些链接要打开的就是**那个患者**在宿主里的页面。
      
      【改了什么】
      - 后端 plan-aggregate:患者 + **关系人**都下发 sourceUnit
        (关系人用他自己的, 不拿本人的顶替 —— 亲属跨品牌时会跳错门户)
      - 新增 lib/host-brand-url.ts:applyBrandHost(url, sourceUnit)
      - resolveActionUrl / openHostAction 加可选的品牌参数;plan-detail-app 六个调用点全传上
      
      【两个容易漏的点】
      - 🔴 **HOST_ORIGIN 也必须换** —— 它是 postMessage 的 targetOrigin。PAC 嵌在 rytime 里
        而 targetOrigin 写着 arrail,浏览器**静默丢弃**这条消息:按钮点了没反应,控制台连个错都没有。
      - ️ **只换域名,不碰路径和查询串**:`?from=arrail` 是宿主自己的业务参数,替换它属于越界。
        URL 解不出来(相对路径 / 哨兵值 'postMessage')原样返回 —— 这条补丁不许把能用的链接变没。
      
      【纪律】 别把它做成通用的"多品牌路由"。它成立的前提是"两个门户只差一个域名片段"。
      真要支持多品牌,正解是 actionUrls 按品牌分组配置(host 管理页加一层),那时删掉这个文件。
      
      【验证】
      - 新增 host-brand-url.test.ts 14 条:替换 / 幂等 / 只换域名不碰路径查询串 / 哨兵值与
        相对路径原样 / 未知品牌与空值不动 / **每个调用点都带上了品牌参数**(源码断言,
        防的是"新加调用点时忘了传" —— 参数可选,漏了 tsc 不报)
      - 本地实测 /plans/:id/full:瑞泰患者 sourceUnit='瑞泰'、瑞尔患者 ='瑞尔'
      - 两端 tsc + 后端 1443 用例 + 前端 46 用例全绿
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
  5. 22 Aug, 2026 5 commits
    • docs(口径): 钉住预约成功率的分母 —— 不筛结果项,未接通也算他打过 · e46bd0dd
      产品确认(2026-08-22):分母判据只有一条——**他有没有真打**。
      所以「没进展」那一桶(未接通 / 待跟进 / 需要找医生 / 已发短信 / 改约)
      照样进分母:打了没人接,那也是打了。
      
      ️ 未接通往往占分母的大头,所以这个数读的是「触达 × 转化」合起来的效率,
       不是"聊上了的人里谈成几成"。 别为了好看把 keep 组从分母里摘掉——
      那会变成另一个指标,而名字没变。
      
      唯一不在分母里的是「没有结果」:那些单一条执行记录都没有,天然不在
      plan_executions 里,不需要额外排除。
      
      代码一行没动,只是把这条决定写进 schema 注释 + 加一条回归(分母 SQL 里
      出现 outcome 就红)。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • refine(主管工作台): 约上→预约上、完成→近N天已处置、成功率只出一个值 · 6d74e284
      主管走查第二轮:
      
      ① 「还没收尾」说明补「(含已经超期的)」
         到期不再自动回池(回收器 2026-08-19 删了),过了时限的单就压在客服手上,
         批次靠 inHand>0 留在这一组。不写明的话主管会以为过了时限的都沉到历史里了,
         而那恰恰是最该动手的一批。判据一个字没动。
      
      ② 「约上」→「预约上」
         「约上」仍有歧义:约好了既可以是约了预约、也可以是约了下次再联系,
         而这两列分的正是这件事。
      
      ③ 「完成」→「近 N 天已处置」
         主管问「完成和已处置是同一个意思吗」—— 是。同一个判据(真的落了通话结果),
         差的只是范围(本批 vs 近 N 天),而同一屏上叫了两个词。
         而且「完成」听起来像"做成了",是这套口径一直在躲的读法。
         ⇒ 统一叫「已处置」,并带上窗口前缀(预约成功率同)——
         不带的话切了 7天/30天,数字变了却看不出为什么。
      
      ④ 预约成功率只出一个百分比
         下面那行 rateNote(退回 N · 没动 M)删掉。
          released / idle 没有消失,仍在响应里 —— 只是不该挤在这一列下面。
      
      顺带拆掉一颗雷:AgentWorkloadRow.handled(= done + released)删了。
      它和界面上的「已处置」(只算写了结果的)是两个意思,同一个词两个含义 ——
      主管问的正是这个。服务端留局部量 touched 算「没动」,语义不变。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • feat(主管工作台): 「约上」拆成约上/约下次,退回率换成预约成功率,「还在跑的」改名 · 0d7c6a53
      四处口径/措辞,主管实测提的:
      
      ① 「还在跑的」→「还没收尾」
         被读成"分配还没完成",像有个后台任务在跑。它说的是**批次**的生命周期。
         ️ 没叫「时限内」:判据有两支,另一支(还有单压在客服手上)在时限过了
         之后仍然成立,叫「时限内」会让过了期、单还压着的批次莫名待在这一组。
         判据一个字没动。
      
      ② 「约上」收窄成**只数转化新预约**,约定下次回访单列「约下次」
         两者原来都算 close 组、合成一个数,靠小字解释「含约定下次回访」。
         而两件事差着一整个台阶 —— 前者患者进了预约表、有个日子会来;
         后者只是客服答应"那天再联系您",患者什么都没答应,单子还挂在他手上
         (drivesStatus 一个 completed 一个 keep,状态机上就是两回事)。
         主管看到「约上 6」会当成 6 个人要来了。
      
      ③ 抽屉「通话成效」四个桶 → 五个
         第一格「成功」拆成「约上 / 约下次」。要靠小字解释的数,本身就该拆开摆。
         五个桶仍然穷尽(= 条数),回归里锁着。
      
      ④ 「退回率」→「预约成功率」
         分子 = 真的约上的( 不含约定下次回访 —— 算进来的话,一个只会往后拖的人
         能拖出一条漂亮的成功率);
         分母 = 「完成」他真打过的( 不是"已处置" —— 退回的单他压根没打,
         摆进分母等于因为他退单而扣他的成功率)。
          退回和没动没有消失,降级成后面那行小字:两者仍是主管调分配的输入(T7)。
      
      - schema: brief/detail 加 bookedNext,outcomes 加 appointed/scheduledNext
        (success 保留为两者合计, 但界面不再单独摆它),workload row 加 booked
      - SQL 由「close 组 IN (...)」改成按 outcome 逐项 FILTER,DISTINCT ON 不动
      - 助手解读规范同步(guides.ts),旧那句「outcomes.success 含约定下次回访」删掉
      - 新增 booked-vs-scheduled-next.spec.ts(21 条)
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • fix(助手): 「这批都给某某」改不动确认单 —— 三层各错一处,且都不报错 · 245f0b01
      本批 5 人,主管说「这5人都分配给韩维」。界面回「已把整批铺给 1 位客服()」,
      括号是空的:一条都没动,而助手接着说「这版已经改成 5 人都归韩维」。
      
      三层:
      
      ① schema 判断本身就是错的
         `batch + assign` 被 refine 判非法。而 batch 是这句话唯一自然的表达,
         韩维就印在卡片下面那行「本批未分到」里。
         改判:batch + balance 合法;只有 batch + owner 不行 ——
         专属关系只有「待分配」那份数据带着(pending[].ownerUserId),
         已排好的条目卡片上查不到它归谁专属。
      
      ② 那条 refine 一次都没跑过(根因,不是 ① )
         两个改单工具的入参用的是**手写的 JSON Schema**(给模型看的那份),
         zod 那份从没进过运行时。两份 schema 各写各的,
         写在没跑的那份里的约束只是注释。
         ⇒ 加 `checkEditOps`:推给界面之前按 zod 校一遍,校不过整组退回给模型,
         并说清哪一条错在哪(只说「参数错误」它会原样再发一遍)。
         确认单、调整单同一条路。
      
      ③ 界面把 batch 解成空名单
         `return { planIds: [], label: '整批' }`,注释说"只有 set_expiry /
         set_benefit 走得到这里" —— 而那两件在上面就短路了(走批次级控件),
         真正走到这一行的恰恰是 assign / remove。
         改:整批 = 生效条目 + 还留在待分配里的,与顶栏「共 N 人」同一口径。
         另加一道总闸:选中 0 人就到此为止 —— 下面每个分支都会 push 一句
         "已如何如何",一条没动而主管读到的是成功。
      
      顺带(同一类):owner 分不下去的两种原因分开报。
      「卡片没带这条的专属关系」被报成「查不到在岗的专属客服」,
      那是一句关于数据的断言,而卡片没有资格下这个断言。
      
      - 整批移出仍然拦掉,但界面要吭声, 不许静默什么都不做
      - 工具描述补 batch 那一格该怎么填;ASSISTANT_PROMPT_VERSION → 2026-08-22-a
      - 新增 sheet-edit-batch-assign.spec.ts(14 条),改判 mcp-clinic-scope 两条旧断言
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
  6. 21 Aug, 2026 7 commits
    • merge: perf/batch-unnest → main · 0e32600a
      luoqi committed
    • feat(埋点): user_activities —— 一张通用行为表(PV/UV + 漏斗 + 登录审计) · 1eddc1b6
      主管工作台此前没有任何使用数据可查,而 plan_event_logs 的 plan_id 是 NOT NULL,
      工作台没有「单」这个实体可挂 —— 这是「没有表可以落」的由来。
      
      不为工作台单独建表,建一张通用的:后面再有页面要看数据,加两个枚举值即可,不用迁移。
      
      【一张表回答三类问题】
        ① 谁在用    —— 按 (day, surface) 聚合;PV=条数,UV=distinct user_id
        ② 卡在哪一步 —— 同 surface 下 action 逐级衰减(打开 → 点格子 → 出确认单 → 确认)
        ③ 登录审计   —— surface='auth' 的行。PAC 此前**不记录登录**(没有 session/login 表,
           role 只活在 JWT 里),「谁何时以什么角色进来的」事后无从查证,这次补上。
      
      【几条纪律,都写进了 docs/design/user-activities.md】
      -  身份不由客户端给:userId/role/orgScope 一律从 JWT 取。上报体是 strictObject,
        前端多传一个 userId 被 Zod 直接拒(已实测:code=10002 unrecognized_keys)。
      -  页面 PV 不许塞进 plan_event_logs:那是「单的归属账本」,放空 plan_id 会让所有
        按 plan 聚合的查询悄悄多算,且不报错。
      -  surface/action 在库里是 text 不是 PG enum:加埋点不该要迁移,且改枚举不会让
        历史行变非法。role 同理存快照文本 → 读取侧 roles 是 string[] 而非 UserRole[],
        否则会出现「写得进读不出」。
      - ️ 埋点是旁路:前端不 await 不抛(微批 400ms + pagehide 冲刷),后端写失败只落日志。
        埋点挂了不能让主管点不了确认。
      
      【已埋 6 个点】
        auth/token_exchange      宿主后端换票(无 Origin —— 服务器间调用)
        auth/code_exchange       浏览器换 code(有 Origin = 从哪个宿主系统进来);只记首次
        supervisor_workbench/open            权限判定之后 —— 被弹走的客服不算一次访问
        supervisor_workbench/matrix_cell     {treatment,temperature,count}; 不记 rect(屏幕坐标换分辨率就没意义)
        supervisor_workbench/proposal_shown  按 requestId 去重,只在 active 记
        supervisor_workbench/assign_confirm  服务端返回之后 —— 点了但失败不算确认
      
      【本地实测(pac-service:3101 + pac-web:3100 + 真浏览器)】
        - 正常上报 accepted=2;伪造 userId 被拒 10002;无 token 拦在 10106(库里无脏行)
        - 真走一次换票+换 code:两条 auth 行落库,Origin 只出现在 code_exchange 上(符合预期)
        - 浏览器打开工作台 + 点格子:open/matrix_cell 落库,payload 正确
        - stats 端点:PV/UV 正确;leader 调被 platform:manage 拦下(10107)
      
      【过程中修掉的三个自己的坑】
        - track 路径漏了 /pac/v1 前缀(api-client 用 new URL(path,base) 不自动补)→ 请求打到
          /activities,envelope 里其实是 404 但 HTTP 仍 200(项目约定),网络面板一看才发现
        - open 记了两条:React 18 StrictMode 双挂载 + canDispatch 翻转 → 加 ref 守卫
        - session_id 恒空:access token 没有 jti(只有 refresh token 有)→ 改用 sub:iat,
          同一次登录内所有行为共享,漏斗能串起来
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • perf(分配): 落库改 unnest 数组 —— 甩掉 PG bind 上限那堵墙,上限 500 → 20000 · aabd8ae9
      生产实遇(杭州大厦 2026-08-20):23 位客服按默认值 23×15×3=1035,提案出了 537 条,
      主管点确认只回一句「请求字段校验失败」,在卡片上改什么都没用。
      
      根因不是业务,是**写法**:落库那条 UPDATE 用 `VALUES (…),(…),…`,每行 9 个 bind,
      而 PG 单条语句上限 32,767 → 32767/9 ≈ 3,640 行就是硬墙。测试机实测 N=4000 直接报
      `too many bind variables in prepared statement, expected maximum of 32767, received 36000`。
      
      ── 改法 ──────────────────────────────────────────────────
      每列打成一个数组,`unnest($1::uuid[], $2::text[], …)` —— **不管多少行永远 9 个 bind**。
      墙没了,而且顺带更快(PG 不用再解析几千个占位符)。
      
      测试机实测(事务内跑完回滚,一行数据没改):
        N=500    VALUES 444ms → unnest  74ms
        N=2000   VALUES 458ms → unnest 220ms
        N=4000   VALUES 炸  → unnest 433ms
      忠实形状(13 列全写 + 同事务账本写入),线性到 5 万条:
        500→0.25s  2千→0.6s  5千→1.4s  1万→2.7s  2万→5.6s  5万→13.6s(≈0.27ms/条)
      
      ── 事务保持不变 ──────────────────────────────────────────
      · 仍然是**一个事务**,全成或全不成 ——  没有拆成多批提交
      · 并发安全的那三个 where 守卫(status='active' / assignee IS NULL / superseded_at IS NULL)
        和 RETURNING 一字未动 —— 抢先认领的行照旧落不上、照旧出现在 skipped 里
      · 超时改成**随条数放宽**:30 秒打底 + 每条 3ms,上限 180 秒。
        一刀切 30 秒的话小批次白留 100 倍余量、大批次又不够。 不设无限:事务开着就持有行锁。
      
      ── 一起改的 ──────────────────────────────────────────────
      · 调整在手的三条写路径(改派/改时限/移出)同样换 unnest
      · 那条 `fp.id IN (Prisma.join(...))` 换 `= ANY($1::uuid[])`(3 万个 id:521ms → 219ms)
      · 报错文案说清他能动哪个旋钮 —— 他手上只有时限和每天几通,没有"本批人数"那个输入框
      
      ️ 上限没敢一次拉满。再往上调之前要先量这三样(注释里列了):事务外那几个
         Prisma `id:{in:[]}` 读(~32,000 个 bind 就炸)、推给浏览器的 6.3MB 载荷、卡片渲染 2 万行。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed
    • feat(recompute-plans): 加 --clinics 子集重算 —— 补摄后不必再全 host 全扫 · d5dce069
      按诊所补摄的三步里,前两步(cold-import / recompute-persona)都能用 --clinics 收窄,
      只有 plans 是全量:每批 patientsHit 26-29 万、耗时 1h50m,其中约 1h43m 花在 selectHits
      全表扫。今晚四批补摄,plans 一项就吃掉约 7 小时,而 90%+ 的扫描结果是 unchanged。
      
      本次给 recompute-plans 加 --clinics=<orgId,...>,口径与 cold-import / recompute-persona
      完全一致(patient_transactions.clinic_id 反查患者),把 selectHits 收窄到子集。
      
      ️ 关键点:收窄命中集**必须同步收窄关闭步骤**。runAllForHost 第 3 步会扫全租户的
      active+assigned,凡患者不在本轮命中集就判定"信号消失"关掉,挂在客服名下的还各记一条
      auto_release。只收窄 selectHits 不收窄关闭 = 一条命令清空整个召回池。故 runAllForHost
      新增 patientIds 入参时,两处一起收窄;关闭侧用内存 Set 过滤而非 SQL in(),避开 PG
      32767 bind 上限(子集动辄数万)。
      
      ️ SQL 形式踩坑:子集过滤写 `IN (SELECT … unnest(array))` 而非 `= ANY(array)`。
      结果等价但代价差一个数量级 —— 后者让规划器转去嵌套循环,而本 SQL 带 gap lateral join
      每行代价很高。本地实测(30000 患者库 / 子集 5825 人 / 11 个子场景):
        = ANY(array)          53.6s  ← 比全量 42.3s 还慢,perio_no_srp 一个就 35.5s
        IN (SELECT unnest())  10.2s  ← 命中数逐个相同,快 4.2 倍
      细节写进 treatment-initiation-recall.scenario.ts 注释,改那行前先按同口径量一遍。
      
      本地验证(jvs-dw 30000 患者 / 18591 active + 283 assigned):
        - 子集跑(5825 人):patientsHit 2552,plansClosed 0,duration 9.8s
          范围外 active 16317 / assigned 278 —— 跑前跑后逐字相同,一条没动
          (没有护栏的话这 16595 条会被全部关掉,plansClosed=0 就是护栏生效的证据)
        - 全量跑:patientsHit 19147,日志标 (全量),行为与改动前一致,无回归
        - 重复跑幂等:第二次 created=0 superseded=0 closed=0
      
      ️ --clinics 只用于「补摄后立刻补计划」,**不能替代日常全量**:召回是时间驱动的,
      数据一个字没变、沉默时长跨过阈值也该出计划,收窄就等于其余诊所当天不评估。
      定时全量照跑,这个参数只省掉补摄后的那一次全扫。
      
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
      luoqi committed