Commit a0d20f82 by luoqi

merge: feat/persona-explainability → test(画像可解释性 + 历史联系最近一次 + ⑤g 未来回访排除闸)

三块内容,都在这个分支上做完并本地验过:

1. 画像可解释性(889dd6f03a85d4b1)
   · 水位对齐 fact 层 + 写入侧节流(修 ¥154,350 vs ¥195,750 那个漏算)
   · 到期改档精确调度(nextBoundaryAt),时间跨档能刷新但不靠全量扫
   · 详情页说明收回 @pac/types 单一真理源 + 打通证据链 + 分组定序 + 圈人标签 hint

2. 历史联系「最近一次」(f634f274)
   · orchestrator 程序判定 isLatestPast,摘要开头必须交代最近一次
   · prompt 加数据边界硬约束:源里只有标签、没有原话,没记的不许补
   · 抽屉标「最近一次」/「未到期」;修 fallback 拿 items[0] 当最近一次的老 bug
   · 两处 orderBy 改 nulls:'last'(Postgres DESC 默认 NULLS FIRST 会让无日期记录冒充最新)

3. ⑤g 未来回访排除闸(5ed2e888)
   · 诊所已排未来回访 → 本轮不召,排到哪天挡到哪天,到期自愈
   · 生产将挡下 30,426 条在池 plan(12.9%),其中 urgent 17,263

另含 eb4d01c6:.swcrc 的 exclude 是正则必须锚定(文件名含 dist 会被静默跳过)。

本地:477 单测通过 / service+web tsc 干净 / 真库跑引擎验过闸的两个方向。
parents 596adcdc 5ed2e888
Pipeline #3443 failed in 0 seconds
......@@ -21,5 +21,5 @@
"module": {
"type": "commonjs"
},
"exclude": ["node_modules", "dist"]
"exclude": ["^node_modules/", "^dist/"]
}
-- 画像水位对齐到 fact 层 + 就地刷新时刻。
--
-- 背景:画像读 patient_facts,但 stale 判定只看 event_watermark(patient_transactions)。
-- reparse 重写事实、不产新事务 → 2026-07-21「amount_cents 应收→实付」reparse 之后的全量重算
-- 被水位闸 noop 掉 325,979 人(生产日志),74% 存量画像的累计消费永久停在旧口径。
ALTER TABLE "personas"
ADD COLUMN "fact_watermark" TIMESTAMPTZ(3),
ADD COLUMN "refreshed_at" TIMESTAMPTZ(3);
-- fact 水位查询(每次重算前 MAX(updated_at) per patient)的支撑索引:倒序扫首行即出,index-only。
-- 没它就得沿 (patient_id, status) 扫完该患者全部 fact 再回堆取 updated_at —— 闸本身变成开销。
-- IF NOT EXISTS:prod 先手动 CREATE INDEX CONCURRENTLY 预建(patient_facts 370 万行,免锁写),
-- 再 deploy 时本句即 no-op。预建命令:
-- CREATE INDEX CONCURRENTLY IF NOT EXISTS "patient_facts_patient_id_updated_at_idx"
-- ON "patient_facts"("patient_id", "updated_at");
CREATE INDEX IF NOT EXISTS "patient_facts_patient_id_updated_at_idx"
ON "patient_facts"("patient_id", "updated_at");
-- ⚠️ 部署后需一次性全量重算把存量 fact_watermark 补上(NULL 一律判 stale):
-- pnpm recompute-persona:prod -- --host=jvs-dw --concurrency=8 --force
-- --force 必带:同批上线的 next_boundary_at 以 NULL 表示"不再改档",不会自己判 stale,
-- 只有强制跑一遍才写得上(见 20260726064759_persona_next_boundary)。
-- 不补 fact_watermark 也能自愈 —— 每晚 stale-scan 会按 LIMIT 速率回填,只是慢;
-- next_boundary_at 则**不会**自愈,必须这一次 --force。
-- 「到期改档」调度锚点。
--
-- 水位闸只在事实变了时放行重算,但有一类变化不伴随任何事实变动 —— 时间自己走了:
-- 急迫等级跨 30/90 天线、生命周期跨 180/540/730、RFM 的 R 跨 540/730/1095/1460、
-- 年龄跨段、种植年龄禁忌满 19 岁解除、滑动窗口把最老记录挤出去。
-- 这类患者旧实现里永远不会被重算。靠"每天全量扫"等于把闸拆了,所以存确切到期时刻,
-- 夜扫只捞 next_boundary_at <= now() 的那一小撮(每天几百到几千,不是 40 万)。
ALTER TABLE "personas" ADD COLUMN "next_boundary_at" TIMESTAMPTZ(3);
-- 夜扫分支的支撑索引。personas 约 50 万行,建索引很快,无需 CONCURRENTLY 预建。
CREATE INDEX IF NOT EXISTS "personas_superseded_at_next_boundary_at_idx"
ON "personas"("superseded_at", "next_boundary_at");
-- ⚠️ 存量行 next_boundary_at 为 NULL,而 NULL 的语义是"不再改档"—— **不会自愈**:
-- 水位闸认为这些版本新鲜,永远不会重算,也就永远算不出边界。必须部署后强制跑一次:
-- pnpm recompute-persona:prod -- --host=jvs-dw --concurrency=8 --force
-- 与 fact_watermark 的回填是同一次运行。写侧节流保证这次全量跑基本不产新版本
-- (本地 13,268 患者实测:success=0 / refreshed=885 / unchanged=12,383)。
......@@ -659,6 +659,10 @@ model PatientFact {
/// 原仅 (patient_id, status) type 走内存 Filter,facts/患者 多时 heap fetch 超线性膨胀(13万患者全量重算瓶颈)
/// 实测本地 selectHits 7.3s2.0s(~3.6×),prod 收益更大。
@@index([patientId, type, status])
/// 画像水位:max(updated_at) per patient(PersonaService.readWatermarks 每次重算前必查)
/// 倒序扫首行即可命中 index-only,不回堆 —— 没这条要按 (patientId,status) 扫完该患者全部
/// fact 再取 heap 上的 updated_at,重患者(千级 fact)会把"闸"本身变成开销。
@@index([patientId, updatedAt])
/// 按患者拉 actual 时间轴
@@index([patientId, occurredAt])
/// 按患者查 planned 列表(待办 / 即将复查)
......@@ -695,10 +699,29 @@ model Persona {
computedAt DateTime @default(now()) @map("computed_at") @db.Timestamptz(3)
/// 被新版本取代的时间;null 表示当前版本
supersededAt DateTime? @map("superseded_at") @db.Timestamptz(3)
/// 本次重算时已响应到的 transaction.event_seq;判断 persona 是否 stale 用。
/// Persona 实际读 patient_facts 当前 active 版本算 features;
/// 水位是触发 / stale 用的,不是消费 transaction
/// 就地刷新时刻(只有易变数值变 —— 天数/年龄/滑窗聚合 —— 时不升版本,原地改 feature )
/// null = 从未就地刷新过。UI "更新于" refreshedAt ?? computedAt;
/// computedAt 保持"本版本创建时刻"不动 —— 覆盖率日报靠它反推历史快照,前移会算错昨日覆盖。
refreshedAt DateTime? @map("refreshed_at") @db.Timestamptz(3)
/// 本次重算时已响应到的 transaction.event_seq
/// ⚠️ 单靠它判 stale 是错的( factWatermark)—— 保留为辅助水位。
eventWatermark BigInt? @map("event_watermark")
/// 本次重算所见 max(patient_facts.updated_at) —— **画像真正的输入水位**
///
/// 画像读的是 patient_facts,但历史上只用 eventWatermark(transaction) stale
/// reparse 只重写 fact、不产新 transaction,于是 2026-07-21 那次
/// amount_cents 应收→实付」reparse 之后的全量重算被水位闸 noop 325,979 (生产实测),
/// 74% 存量画像的累计消费永久停在旧口径(中位高估 91%),还会污染 RFM M 分位打分。
/// null = 存量老版本(没记过 fact 水位) 判定视为 stale,下次必重算一遍补上。
factWatermark DateTime? @map("fact_watermark") @db.Timestamptz(3)
/// **纯时间流逝**会让本版本改档的最近时刻(全体 extractor nextBoundaryAt 取最小)
///
/// 两个水位只在「事实变了」时放行重算,但有一类变化不伴随任何事实变动 —— 时间自己走了:
/// 急迫等级跨 30/90 天线、生命周期跨 180/540/730RFM R 540/730/1095/1460
/// 年龄跨段、种植年龄禁忌满 19 岁解除、滑动窗口把最老记录挤出去。
/// "每天全量扫"等于把闸拆了;这里存确切到期时刻,夜扫只捞 <= now() 的那一小撮。
/// null = 再等下去也不会变(如已过全部档界)
nextBoundaryAt DateTime? @map("next_boundary_at") @db.Timestamptz(3)
host Host @relation(fields: [hostId], references: [id])
/// Restrict:画像版本流是审计 + 召回合规依据,不随 patient 物删消失
......@@ -709,6 +732,9 @@ model Persona {
@@unique([patientId, version])
@@index([hostId, tenantId])
@@index([patientId, supersededAt])
/// 夜扫「到期改档」分支:WHERE superseded_at IS NULL AND next_boundary_at <= now()
/// 到期的每天只有几百到几千条,索引扫比全表快几个量级。
@@index([supersededAt, nextBoundaryAt])
@@map("personas")
}
......
......@@ -114,8 +114,10 @@ async function bootstrap() {
`(concurrency=${args.concurrency}${args.shard ? `, shard=${args.shard.i}/${args.shard.k}` : ''}) ...`,
);
// noop = 水位闸命中(自上次重算无新事件,沿用旧 persona),是正常结果,不并入 failed。
let ok = 0, partial = 0, noop = 0, failed = 0, done = 0;
// 五档结果(见 PersonaService.recompute):noop=闸拦下没算 / unchanged=算了内容没变 /
// refreshed=只有天数等易变值变(就地刷新不升版本) / ok+partial=升了版本。都不是错误。
// 看 unchanged 占比就知道闸放宽后有多少是白算的 —— 调水位口径的依据。
let ok = 0, partial = 0, noop = 0, unchanged = 0, refreshed = 0, failed = 0, done = 0;
const verbose = total <= 500; // 小批逐条日志;大批只打进度,免刷屏
const runOne = async (p: { id: string; externalId: string; name: string | null }) => {
try {
......@@ -128,6 +130,8 @@ async function bootstrap() {
if (r.status === 'success') ok++;
else if (r.status === 'partial') partial++;
else if (r.status === 'noop') noop++;
else if (r.status === 'unchanged') unchanged++;
else if (r.status === 'refreshed') refreshed++;
else failed++;
} catch (err) {
failed++;
......@@ -143,7 +147,10 @@ async function bootstrap() {
}
logger.log('─────────────────────────────────');
logger.log(`Done: success=${ok} partial=${partial} noop=${noop} failed=${failed}`);
logger.log(
`Done: success=${ok} partial=${partial} refreshed=${refreshed} ` +
`unchanged=${unchanged} noop=${noop} failed=${failed}`,
);
} catch (err) {
new Logger('recompute-persona').error(err instanceof Error ? err.stack : String(err));
process.exitCode = 2;
......
/**
* 规范化 JSON 序列化 —— **递归按键名排序**后再 stringify。
*
* ⚠️ 凡是「把库里读出来的 jsonb 和代码里新算出来的对象比一比,判断变没变」的场景都必须用它:
* Postgres `jsonb` **不保留键序**(按键长度 + 字节序重排存储),而代码构造对象的顺序是源码顺序。
* 直接 `JSON.stringify` 比较,两边键序必然不同 → 每次都判"变了" → 每轮重算全量重写。
* (plan_reasons 实测:不加这层,连跑三次每次都刷新。)
*
* 现有消费方:
* - plan/engine/reason-refresh.ts 召回原因就地刷新判定
* - persona/persona-diff.ts 画像写入判定
*/
export function canonicalJson(v: unknown): string {
return JSON.stringify(normalize(v));
}
/** 递归:数组保序(顺序有语义),对象按键名排序,undefined 值丢弃(对齐 JSON.stringify 语义)。 */
function normalize(x: unknown): unknown {
if (Array.isArray(x)) return x.map(normalize);
if (x !== null && typeof x === 'object') {
const o = x as Record<string, unknown>;
return Object.keys(o)
.sort()
.reduce<Record<string, unknown>>((acc, k) => {
if (o[k] !== undefined) acc[k] = normalize(o[k]);
return acc;
}, {});
}
return x;
}
......@@ -22,13 +22,22 @@ const safetyRules: ReadonlyArray<SafetyRule<DraftRecallSummaryOutput>> = [
},
];
/** LLM 失败 / safety 拒收时:用记录拼一句最朴素的概述。 */
/**
* LLM 失败 / safety 拒收时:用记录拼一句最朴素的概述。
*
* ⭐「最近一次」必须取 isLatestPast 那条,**不能**取 items[0] —— items 按日期倒序,
* 头部很可能是尚未到期的未来排程,拿它当"最近一次"就是 2026-07 那次生产事故的原样复刻
* (兜底文案同样会展示给客服)。全是未来排程时不提"最近一次"。
*/
function fallback(input: DraftRecallSummaryInput): DraftRecallSummaryOutput {
const n = input.items.length;
if (n === 0) return { summary: '暂无历史联系记录。' };
const top = input.items[0]!;
const top2 = [top.type, top.status].filter(Boolean).join(' · ') || '回访';
return { summary: `共 ${n} 条历史联系,最近一次:${top.taskDate ?? '—'} ${top2}` };
const latest = input.items.find((it) => it.isLatestPast);
if (!latest) return { summary: `共 ${n} 条历史联系,均为尚未到期的排程。` };
const what = [latest.type, latest.status].filter(Boolean).join(' · ') || '回访';
const outcome = latest.result?.trim() || latest.followContent?.trim();
const tail = outcome ? `,${outcome.slice(0, 20)}` : ',未留结果';
return { summary: `共 ${n} 条历史联系,最近一次:${latest.taskDate ?? '—'} ${what}${tail}` };
}
@Injectable()
......
......@@ -27,6 +27,16 @@ export interface DraftRecallSummaryInput {
* 934 条已生成摘要里 196 条最新回访是未来排程、其中 158 条带"未执行/补做"措辞。
*/
isFuture: boolean;
/**
* ⭐ 该条是否为**最近一次真正发生过的联系**(有日期、且已到期的第一条)。
* 全列表里**至多一条**为 true;全是未来排程 / 全无日期时一条都没有。
* 同 isFuture 一样由 orchestrator 程序判定 —— 「哪条最新」是排序问题不是语言问题。
*
* 为什么要单独标:业务用户看历史联系时第一诉求就是"上次联系聊了什么"
* (2026-07 可用性测试原话:"暂时没有办法详细的看到具体一次沟通什么内容"),
* 摘要必须先回答这一条,再讲整体趋势。
*/
isLatestPast: boolean;
treatmentItems: string | null; // 关联治疗项(如 种植)
followContent: string | null; // 回访内容(术后复查 / 洁牙提醒 / 活动邀约…)
result: string | null; // 回访结果
......@@ -34,6 +44,6 @@ export interface DraftRecallSummaryInput {
}
export interface DraftRecallSummaryOutput {
/** 一句话历史联系摘要(中文,≤60 字) */
/** 一句话历史联系摘要(中文,≤70 字;先说最近一次,再说整体) */
summary: string;
}
......@@ -5,40 +5,54 @@ import type { DraftRecallSummaryInput } from './input.types';
* 2026-06-16-d 初版(仅 taskDate/type/status/内容/结果)
* 2026-07-24-e ⭐ 加今日锚点 + 程序判定的「未来排程」标记 + taskStatus。
* 修生产事故:未来排程被写成"最新一次未执行,需优先补做"。
* 2026-07-26-f ⭐ 摘要先说「最近一次」(程序标注),再讲整体;并明写"源系统只记事由/
* 结果标签、没有逐字沟通内容",没记就说没记,禁止补写沟通细节。
* 来源:业务可用性测试「暂时没有办法详细的看到具体一次沟通什么内容」。
*/
export const DRAFT_RECALL_SUMMARY_PROMPT_VERSION = 'draft_recall_summary@2026-07-24-e';
export const DRAFT_RECALL_SUMMARY_PROMPT_VERSION = 'draft_recall_summary@2026-07-26-f';
export const DRAFT_RECALL_SUMMARY_SYSTEM = `你是牙科诊所客服主管。下面是一个患者的历史联系/回访记录(结构化、看着累)。请提炼成**重点**,让接手客服**一眼抓住关键**,而不是逐条复述。
# 关键要求
1. 输出中文,不要换行/列表/Markdown。
2. **是"重点提炼"不是"总结罗列"**:抓最值得注意的那一点——比如"近期联系密但未到诊""多次未接通""有种植意向待跟进""术后回访已完成、可推新项目"。**不要**把所有回访类型都列出来。
3. 优先突出**对下一步有用的信号**(还没接通 / 已表达意向 / 长期未到诊 / 刚成交别打扰 等)。
4. 客观,**禁止**承诺疗效、编造、给医疗建议。
5. 严格按 JSON schema 输出,只有一个 key:summary。
1. 输出中文,不要换行/列表/Markdown,总长 ≤70 字。
2. **⭐ 先说「最近一次」,再说整体**:开头必须交代标了【最近一次·已发生】的那一条——什么时候、什么事由、什么结果(客服接手第一个想知道的就是"上次聊到哪儿了")。其余记录压成半句趋势即可。没有任何【最近一次·已发生】条目时(全是未来排程/无日期),直接讲整体,不要硬凑。
3. **是"重点提炼"不是"总结罗列"**:整体那半句抓最值得注意的一点——比如"近期联系密但未到诊""多次未接通""有种植意向待跟进"。**不要**把所有回访类型都列出来。
4. 优先突出**对下一步有用的信号**(还没接通 / 已表达意向 / 长期未到诊 / 刚成交别打扰 等)。
5. 客观,**禁止**承诺疗效、编造、给医疗建议。
6. 严格按 JSON schema 输出,只有一个 key:summary。
# ⭐ 数据边界(硬约束,违反即错)
源系统的回访记录**只有事由标签和结果标签**(如"复查洁牙""方案跟进""无不适""未接""已约"),
**没有逐字沟通内容、没有患者原话、没有下次建议联系时间**。
- 只能复述记录里**写着的**字;记录里没有的,一个字都不要补。
- 某条只有事由没有结果(常见)→ 如实说"未留结果 / 结果未记录",**不要**推断当时聊了什么、患者什么态度。
- **禁止**输出"患者表示…""沟通中提到…"这类原话式表述,除非结果字段里原文就这么写。
# ⭐ 时间纪律(硬约束,违反即错)
记录里**包含诊所已排好、但还没到日子的未来任务**,这类条目已用【未来排程·尚未到期】标出。
- 【未来排程·尚未到期】的条目**绝不能**说成"未执行 / 未回访 / 漏做 / 缺失 / 需补做 / 需优先跟进该次"——
它只是**还没到时间**,不是任何人的疏漏。需要提及时只能说"已排 X 月 X 日复查洁牙"这类中性表述。
- 只有标了【已过期·未执行】的条目才算真正漏做。
- "最近一次""最新一次"这类说法**只能指已过期的条目**,不要用未来排程当"最近一次"。
- 判断哪条是未来、哪条已过期**已经替你算好了**,直接用标记,不要自己比日期。
- "最近一次""最新一次"这类说法**只能指标了【最近一次·已发生】的那一条**,不要用未来排程当"最近一次"。
- 判断哪条是未来、哪条已过期、哪条是最近一次**已经替你算好了**,直接用标记,不要自己比日期。
# 示例(风格参考,不要照抄)
- "近 3 次活动邀约均未接通,建议换企微触达。"
- "术后回访已完成,有种植意向待跟进。"
- "半年内 5 次回访无到诊,流失风险高。"
- "4 月术后回访已完成,10 月 9 日已排复查洁牙,暂无需额外动作。" ← 未来排程的正确写法`;
- "最近一次 4 月术后回访已完成、无不适;近 3 次活动邀约均未接通,建议换企微触达。"
- "最近一次 2 月复查已约到诊;此前半年 5 次回访无到诊,流失风险仍高。"
- "最近一次 5 月复查洁牙提醒未执行、未留结果;近一年 3 次回访均未跟进到位。" ← 只有事由没结果的正确写法
- "最近一次 4 月术后回访已完成,10 月 9 日已排复查洁牙,暂无需额外动作。" ← 未来排程的正确写法`;
export function buildDraftRecallSummaryPrompt(input: DraftRecallSummaryInput): string {
const lines =
input.items.length > 0
? input.items
.map((it, i) => {
// 未来 / 过期 由 orchestrator 程序判定后传入(见 input.types.isFuture 注释),
// 这里只负责渲染成模型看得懂的显式标记 —— LLM 不做日期比较。
const when = it.isFuture ? '【未来排程·尚未到期】' : null;
// 未来 / 过期 / 最近一次 由 orchestrator 程序判定后传入(见 input.types 注释),
// 这里只负责渲染成模型看得懂的显式标记 —— LLM 不做日期比较、不做排序。
const when = it.isFuture ? '【未来排程·尚未到期】' : it.isLatestPast ? '【最近一次·已发生】' : null;
// 最近一次要展开讲,给它更长的正文预算;其余条目只是背景趋势,40 字够了。
// (源里偶有几百字的方案沟通记录,一刀切 40 字会把唯一一条有信息量的记录砍没)
const budget = it.isLatestPast ? 160 : 40;
const parts = [
it.taskDate ?? '—',
when,
......@@ -47,8 +61,10 @@ export function buildDraftRecallSummaryPrompt(input: DraftRecallSummaryInput): s
// 「已预约」「创建新回访」跟「未完成」语义差很远,漏了会被当成没做
it.taskStatus ? `任务:${it.taskStatus}` : null,
it.treatmentItems ? `项目:${it.treatmentItems}` : null,
it.followContent ? `内容:${it.followContent.slice(0, 40)}` : null,
it.result ? `结果:${it.result.slice(0, 40)}` : null,
it.followContent ? `内容:${it.followContent.slice(0, budget)}` : null,
it.result ? `结果:${it.result.slice(0, budget)}` : null,
// ⭐ 显式说"这条没记结果",否则模型倾向于用别条的结果脑补这条
it.isLatestPast && !it.result ? '结果:未记录' : null,
].filter(Boolean);
return `${i + 1}. ${parts.join(' · ')}`;
})
......@@ -59,11 +75,16 @@ export function buildDraftRecallSummaryPrompt(input: DraftRecallSummaryInput): s
const futureNote = futureCount
? `\n⚠️ 其中 ${futureCount} 条是**尚未到期的未来排程**(已标注),不得表述为未执行/漏做/需补做。`
: '';
const latestNote = input.items.some((it) => it.isLatestPast)
? '\n⭐ 标了【最近一次·已发生】的那条要放在摘要开头讲清楚(时间 + 事由 + 结果);它没记结果就说未留结果,不要脑补。'
: input.items.length > 0
? '\n⭐ 本列表**没有已发生的回访**(全是未来排程 / 无日期),不要说"最近一次…",直接讲整体。'
: '';
return `今天:${input.today}
患者:${input.patientNameMasked}
历史联系 / 回访记录(时间倒序,共 ${input.items.length} 条):
${lines}${futureNote}
${lines}${futureNote}${latestNote}
请概括以上历史联系的重点。`;
}
......@@ -57,7 +57,9 @@ export class RecallSummaryOrchestrator {
// 2) 取历史联系(诊所回访 patient_return_visits,patient 级,最近 12 条)
const visits = await this.prisma.patientReturnVisit.findMany({
where: { hostId: scope.hostId, tenantId: scope.tenantId, patientId: plan.patientId },
orderBy: { taskDate: 'desc' },
// nulls:'last' 是刻意的 —— Postgres 的 DESC 默认 NULLS FIRST,无日期的记录会占住
// 列表头部(既冒充"最近一次",又吃掉 take:12 的名额)。无日期的当最老处理。
orderBy: { taskDate: { sort: 'desc', nulls: 'last' } },
take: 12,
select: {
taskDate: true, type: true, status: true, taskStatus: true,
......@@ -76,23 +78,30 @@ export class RecallSummaryOrchestrator {
// 口径:按**日期**比(taskDate 是 @db.Date,无时分秒),taskDate > today 即未到期。
const today = new Date().toISOString().slice(0, 10);
const dated = visits.map((v) => ({
...v,
taskDate: v.taskDate ? v.taskDate.toISOString().slice(0, 10) : null,
}));
// ⭐「最近一次真正发生过的联系」同样程序判定:列表已按日期倒序,
// 第一条**有日期且已到期**的就是它(至多一条;全是未来排程/全无日期 → 一条都不标)。
// 业务第一诉求是"上次联系聊到哪儿了",摘要必须先答这条 —— 见 input.types.isLatestPast。
const latestPastIndex = dated.findIndex((v) => v.taskDate != null && v.taskDate <= today);
const input: DraftRecallSummaryInput = {
patientNameMasked: maskName(patient?.name ?? null) ?? '该患者',
today,
items: visits.map((v) => {
const taskDate = v.taskDate ? v.taskDate.toISOString().slice(0, 10) : null;
return {
taskDate,
items: dated.map((v, i) => ({
taskDate: v.taskDate,
type: v.type,
status: v.status,
taskStatus: v.taskStatus,
// 无日期 → 不算未来(宁可当普通历史,也不要给出"已排期"的错误暗示)
isFuture: taskDate != null && taskDate > today,
isFuture: v.taskDate != null && v.taskDate > today,
isLatestPast: i === latestPastIndex,
treatmentItems: v.treatmentItems,
followContent: v.followContent,
result: v.result,
};
}),
})),
};
// 3) 跑 AiCall → upsert
......
......@@ -5,6 +5,7 @@ import type {
FeatureExtractorContext,
PersonaFeatureDraft,
} from './feature.interface';
import { nextAgeBoundary } from './time-boundary';
import { ageYearsAt } from './potential-treatment.feature';
/**
......@@ -65,4 +66,18 @@ export class AgeBracketFeatureExtractor implements FeatureExtractor {
evidence: { factIds: [] }, // 证据=patient.birthDate(主档,非 fact)
};
}
/**
* 下一次跨档 = 满下一个档位下界的那个生日。
*
* ⚠️ 本特征**完全不依赖 fact**,所以没有这个边界它就永远不会被重算 —— 一个 54 岁患者
* 会一直显示「中老年」直到他碰巧有新事实进来。年龄还喂着按龄排治疗(>70 优先活动义齿)。
*/
nextBoundaryAt(ctx: FeatureExtractorContext): Date | null {
return nextAgeBoundary(
ctx.patient.birthDate,
ctx.now,
AgeBracketFeatureExtractor.BRACKETS.map((b) => b.min),
);
}
}
......@@ -6,6 +6,7 @@ import type {
PersonaFeatureDraft,
} from './feature.interface';
import { ageYearsAt } from './potential-treatment.feature';
import { nextAgeBoundary } from './time-boundary';
/**
* contraindication 禁忌标签(D.2.4)— 规则层,snapshot · 多标签
......@@ -56,4 +57,15 @@ export class ContraindicationFeatureExtractor implements FeatureExtractor {
evidence: { factIds: [] }, // v1 证据=patient.birthDate(年龄禁忌,非 fact)
};
}
/**
* 种植年龄禁忌满 19 岁自动解除 —— 到期日已写在 data.detail[].validUntil,这里报同一个时刻。
* 本特征不依赖任何 fact:没有边界,一个 18 岁患者的「种植禁忌」会挂到他下次恰好有新事实为止。
*/
nextBoundaryAt(ctx: FeatureExtractorContext): Date | null {
return nextAgeBoundary(ctx.patient.birthDate, ctx.now, [
ContraindicationFeatureExtractor.IMPLANT_MIN_AGE,
]);
}
}
......@@ -87,4 +87,15 @@ export interface PersonaFeatureDraft {
export interface FeatureExtractor {
readonly key: PersonaFeatureKey;
extract(ctx: FeatureExtractorContext): PersonaFeatureDraft | null;
/**
* 可选:**纯时间流逝**会让本特征改档的最近时刻(未来时刻;不会再变 → null)。
*
* 水位闸只在事实变了时放行重算,所以"只是时间过去了"导致的改档(急迫跨 90 天线、
* 年龄跨段、滑窗把最老记录挤出去…)本来永远不会被触发。各特征在这里报出自己的到期时刻,
* PersonaService 取全体最小值存 personas.next_boundary_at,每晚只捞到期的那一小撮重算。
*
* 不实现 = 该特征不随时间自行改变(性别 / 获客渠道 / 治疗史 …)。
* 用 time-boundary.ts 里的原语拼,别手搓日期算术。
*/
nextBoundaryAt?(ctx: FeatureExtractorContext): Date | null;
}
......@@ -26,6 +26,30 @@ import { UrgencyLevelFeatureExtractor } from './urgency-level.feature';
* 合规闸召回读 patient_profiles 原始列(不依赖本特征);已故/投诉信号后续补(投诉数据缺)。
* 单个 extractor 抛错不影响其余 — PersonaService 整体记 partial。
*/
/**
* 全部 extractor 的 DI provider 清单 —— PersonaModule 与测试共用这一份。
* (原先 module.providers 与本文件构造参数各写一遍,加特征时容易只改一边。)
* extractor 都是无依赖纯函数类,所以测试里 `providers: [FeatureRegistry, ...此清单]` 即可跑起来。
*/
export const FEATURE_EXTRACTOR_PROVIDERS = [
RfmFeatureExtractor,
AgeBracketFeatureExtractor,
GenderFeatureExtractor,
AcquisitionChannelFeatureExtractor,
FamilyStructureFeatureExtractor,
ReferralChampionFeatureExtractor,
LifecycleStageFeatureExtractor,
TreatmentHistoryFeatureExtractor,
TimePreferenceFeatureExtractor,
DiscountAnchorFeatureExtractor,
SpecialAttentionFeatureExtractor,
TreatmentSensitivityFeatureExtractor,
ContraindicationFeatureExtractor,
PotentialTreatmentFeatureExtractor,
UrgencyLevelFeatureExtractor,
EntitlementStatusFeatureExtractor,
];
@Injectable()
export class FeatureRegistry {
readonly extractors: FeatureExtractor[];
......
import { Injectable } from '@nestjs/common';
import { PersonaFeatureKey, FactType, FactKind } from '@pac/types';
import { PersonaFeatureKey, FactType } from '@pac/types';
import type {
ActiveFact,
FeatureExtractor,
FeatureExtractorContext,
PersonaFeatureDraft,
} from './feature.interface';
import { visitFactsOf, visitSpanOf } from './visit-facts';
import { earliestFuture, nextDayBoundary } from './time-boundary';
/**
* lifecycle_stage 生命周期(B.1.4)— 规则层,snapshot
......@@ -56,23 +58,12 @@ export class LifecycleStageFeatureExtractor implements FeatureExtractor {
const netThis =
this.sumCents(pays, 1, yearAgo) + this.sumCents(recharges, 1, yearAgo) - this.sumCents(refunds, 1, yearAgo);
// 就诊:去重天数 + 首/末
const visitFacts: ActiveFact[] = [
...get(FactType.ENCOUNTER_RECORD),
...get(FactType.TREATMENT_RECORD).filter((f) => f.kind === FactKind.ACTUAL),
...get(FactType.VISIT_REGISTRATION_RECORD),
];
const days = new Set<string>();
let first: number | null = null;
let last: number | null = null;
for (const f of visitFacts) {
if (!f.occurredAt) continue;
const t = f.occurredAt.getTime();
days.add(f.occurredAt.toISOString().slice(0, 10));
if (first === null || t < first) first = t;
if (last === null || t > last) last = t;
}
const totalVisits = days.size;
// 就诊:去重天数 + 首/末(口径见 visit-facts.ts,与 rfm / urgency_level 同源)
const visitFacts = visitFactsOf(ctx);
const span = visitSpanOf(visitFacts);
const first = span.first?.getTime() ?? null;
const last = span.last?.getTime() ?? null;
const totalVisits = span.dayCount;
const hasAppt = get(FactType.APPOINTMENT_RECORD).length > 0;
// 咨询记录(consultation_record,W7末摄入)— 潜客判定的另一触点(spec:零就诊 + 有预约/咨询)
const hasConsult = get(FactType.CONSULTATION_RECORD).length > 0;
......@@ -147,4 +138,19 @@ export class LifecycleStageFeatureExtractor implements FeatureExtractor {
},
};
}
/**
* 分期的天数档界:末诊 180 / 540 / 730(待激活 → 沉睡 → 流失),首诊 180(新客窗口到期)。
*
* 未覆盖:近 1 年消费 vs 历史年均(成长客 ↔ 成熟客)是连续量,没有确切跨越时刻 ——
* 那条边界只能等下一笔消费事实(fact 水位会管)。这里只报能算准的。
*/
nextBoundaryAt(ctx: FeatureExtractorContext): Date | null {
const { first, last } = visitSpanOf(visitFactsOf(ctx));
return earliestFuture(
ctx.now,
nextDayBoundary(last, ctx.now, [180, 540, 730]),
nextDayBoundary(first, ctx.now, [180]),
);
}
}
......@@ -6,6 +6,7 @@ import type {
PersonaFeatureDraft,
} from './feature.interface';
import type { PotentialGap } from '../../clinical-gap/potential-treatment.selector';
import { nextAgeBoundary } from './time-boundary';
/**
* potential_treatment 潜在治疗(C.1.1 转化机会)— 规则层,snapshot · 多标签
......@@ -29,6 +30,13 @@ import type { PotentialGap } from '../../clinical-gap/potential-treatment.select
*/
const EXTRACTION_NAME_KW = ['残根', '残冠', '无法保留', '不能保留'];
/**
* classifyGapToLabel 里的年龄闸(满这些岁数时,同一批 gap 的标签映射会变):
* 3 → 够岁数进早矫;13 → 早矫转正畸;19 → 够岁数进种植(K08 要 >18);41 → 超龄退出正畸。
* 与下方 switch 的边界一一对应,改一处要改两处 —— 见 tests/persona-time-boundary.spec.ts 的对账用例。
*/
const AGE_GATES = [3, 13, 19, 41] as const;
/// 周岁(单一源,potential_treatment / urgency_level 共用)
export function ageYearsAt(birth: Date | null, now: Date): number | null {
if (!birth) return null;
......@@ -127,4 +135,14 @@ export class PotentialTreatmentFeatureExtractor implements FeatureExtractor {
evidence: { factIds: [...factIds] },
};
}
/**
* 标签映射里有年龄闸(见 classifyGapToLabel):种植要 >18、早矫 3-12、正畸 >12 且 ≤40。
* 跨过这些生日,同一批 gap 会映射出不同标签 —— 事实没变但标签要变。
*/
nextBoundaryAt(ctx: FeatureExtractorContext): Date | null {
if (!(ctx.potentialGaps ?? []).length) return null; // 无 gap → 本特征不产出,也无从改档
return nextAgeBoundary(ctx.patient.birthDate, ctx.now, AGE_GATES);
}
}
......@@ -6,11 +6,12 @@ import {
lookupDxTreatment,
} from '@pac/types';
import type {
ActiveFact,
FeatureExtractor,
FeatureExtractorContext,
PersonaFeatureDraft,
} from './feature.interface';
import { visitFactsOf, visitSpanOf } from './visit-facts';
import { nextDayBoundary } from './time-boundary';
/**
* rfm 价值分群(RFM 八象限 + 生命周期)— 统计层
......@@ -115,22 +116,9 @@ export class RfmFeatureExtractor implements FeatureExtractor {
const valueTier = RfmFeatureExtractor.VALUE_TIERS.find((t) => monetaryCents >= t.cents)?.tier ?? 0;
const mScore = RfmFeatureExtractor.mScore(monetaryCents, ctx.populationStats?.monetaryQuantiles ?? []);
// ── R / F:就诊(encounter / actual treatment / 挂号)──
const visitFacts: ActiveFact[] = [
...get(FactType.ENCOUNTER_RECORD),
...get(FactType.TREATMENT_RECORD).filter((f) => f.kind === FactKind.ACTUAL),
...get(FactType.VISIT_REGISTRATION_RECORD),
];
const visitDays = new Set<string>();
let lastVisit: Date | null = null;
let firstVisit: Date | null = null;
for (const f of visitFacts) {
if (!f.occurredAt) continue;
visitDays.add(f.occurredAt.toISOString().slice(0, 10));
if (!lastVisit || f.occurredAt > lastVisit) lastVisit = f.occurredAt;
if (!firstVisit || f.occurredAt < firstVisit) firstVisit = f.occurredAt;
}
const freqCount = visitDays.size; // lifetime 去重就诊天数 = visit_times
// ── R / F:就诊(口径见 visit-facts.ts,与 lifecycle_stage / urgency_level 同源)──
const visitFacts = visitFactsOf(ctx);
const { first: firstVisit, last: lastVisit, dayCount: freqCount } = visitSpanOf(visitFacts);
const recencyDays = lastVisit ? Math.floor((now.getTime() - lastVisit.getTime()) / DAY) : 9999;
const firstDays = firstVisit ? Math.floor((now.getTime() - firstVisit.getTime()) / DAY) : 9999;
......@@ -198,4 +186,13 @@ export class RfmFeatureExtractor implements FeatureExtractor {
evidence: { factIds: [...new Set([...moneyFactIds, ...visitFacts.map((f) => f.id), ...clinicalFactIds])] },
};
}
/**
* R 分完全由「距末诊天数」决定,档界在 540/730/1095/1460 —— 跨线就换档、换档可能换象限。
* F/M 只随事实变(有 fact 水位管),这里不用管。
*/
nextBoundaryAt(ctx: FeatureExtractorContext): Date | null {
const { last } = visitSpanOf(visitFactsOf(ctx));
return nextDayBoundary(last, ctx.now, [540, 730, 1095, 1460]);
}
}
......@@ -6,6 +6,7 @@ import type {
FeatureExtractorContext,
PersonaFeatureDraft,
} from './feature.interface';
import { nextWindowExpiry } from './time-boundary';
/**
* special_attention 特别关注(D.2.3)— 规则层,snapshot · 多标签
......@@ -115,4 +116,18 @@ export class SpecialAttentionFeatureExtractor implements FeatureExtractor {
evidence: { factIds: [...evidenceIds] },
};
}
/**
* 近 1 年滑动窗口:窗内最早那条预约滑出去时,履约率 / 迟到率与"≥3 次"门槛都会变
* —— 「屡次爽约」「经常迟到」可能因此消失。免打扰 / 不可等候不随时间变,不影响。
*/
nextBoundaryAt(ctx: FeatureExtractorContext): Date | null {
const appts = (ctx.appointmentsAll ?? []) as ActiveFact[];
return nextWindowExpiry(
appts.map((f) => f.plannedFor),
365,
ctx.now,
);
}
}
/**
* 「下一次会因为时间流逝而改档」的时刻 —— 各 extractor 的 nextBoundaryAt 用这几个原语拼。
*
* ── 解决什么问题 ──
* 水位闸只在**事实变了**时才放行重算(见 PersonaService.isFresh),这是对的:没有它,
* 40 万患者每天全量重算纯属烧钱。但有一类变化不伴随任何事实变动 —— 时间本身走了:
* · 急迫等级跨 30 / 90 天线 · 生命周期跨 180 / 540 / 730 天线
* · RFM 的 R 分跨 540/730/1095/1460 天线 · 年龄跨段、种植年龄禁忌满 19 岁解除
* · 时间偏好 / 特别关注的滑动窗口把最老那条记录挤出去
* 这类患者在旧实现里永远不会被重算,画像就一直停在旧档。
*
* ── 为什么不靠"每天扫一遍" ──
* 那等于把闸拆了。正确做法是**算出确切的到期时刻**存进 personas.next_boundary_at,
* 每晚只捞 `next_boundary_at <= now()` 那一小撮 —— 精确、可索引,量级是每天几百到几千,
* 而不是 40 万。
*
* 约定:只返回**未来**时刻;已经全部过去 → null(表示"再等下去也不会变了")。
*/
const DAY_MS = 86400_000;
/** 取最早的那个未来时刻;没有未来项 → null */
export function earliestFuture(now: Date, ...candidates: Array<Date | null | undefined>): Date | null {
let best: Date | null = null;
for (const c of candidates) {
if (!c || c.getTime() <= now.getTime()) continue;
if (!best || c.getTime() < best.getTime()) best = c;
}
return best;
}
/**
* 患者满 `ages` 中下一个年龄的那天(即生日)。
*
* 与 ageYearsAt 同语义:生日当天算作已满。2/29 出生者用 setFullYear 会滚到 3/1,
* 早一天触发重算 —— 宁可早一天,不可漏。
*/
export function nextAgeBoundary(
birth: Date | null,
now: Date,
ages: readonly number[],
): Date | null {
if (!birth) return null;
const sorted = [...ages].sort((a, b) => a - b);
for (const age of sorted) {
const at = new Date(birth);
at.setFullYear(at.getFullYear() + age);
if (at.getTime() > now.getTime()) return at;
}
return null; // 全部年龄档都已跨过
}
/** anchor 之后 `dayThresholds` 天中,第一个还在未来的时刻 */
export function nextDayBoundary(
anchor: Date | null,
now: Date,
dayThresholds: readonly number[],
): Date | null {
if (!anchor) return null;
const sorted = [...dayThresholds].sort((a, b) => a - b);
for (const d of sorted) {
const at = new Date(anchor.getTime() + d * DAY_MS);
if (at.getTime() > now.getTime()) return at;
}
return null; // 最大阈值也已跨过 → 再等也不改档
}
/**
* 滑动窗口口径(近 N 天)下,**窗内最早那条记录滑出窗口**的时刻。
*
* 一旦它滑出,窗内统计量(计数 / 占比)就会变 —— 可能让标签出现或消失。
* 窗内无记录 → null(没东西可过期)。
*/
export function nextWindowExpiry(
times: Array<Date | null | undefined>,
windowDays: number,
now: Date,
): Date | null {
const windowMs = windowDays * DAY_MS;
const since = now.getTime() - windowMs;
let oldest: number | null = null;
for (const t of times) {
if (!t) continue;
const ms = t.getTime();
if (ms < since || ms > now.getTime()) continue; // 窗外 / 未来记录不参与
if (oldest === null || ms < oldest) oldest = ms;
}
return oldest === null ? null : new Date(oldest + windowMs);
}
......@@ -6,6 +6,7 @@ import type {
FeatureExtractorContext,
PersonaFeatureDraft,
} from './feature.interface';
import { nextWindowExpiry } from './time-boundary';
/**
* time_preference 时间偏好(D.1.2)— 规则层,snapshot · 多标签(允许并列)
......@@ -95,4 +96,17 @@ export class TimePreferenceFeatureExtractor implements FeatureExtractor {
evidence: { factIds },
};
}
/**
* 近 2 年滑动窗口:窗内最早那条预约滑出去时,占比(甚至"记录数 ≥2"这个门槛)就会变。
*/
nextBoundaryAt(ctx: FeatureExtractorContext): Date | null {
const appts = (ctx.factsByType.get(FactType.APPOINTMENT_RECORD) ?? []) as ActiveFact[];
return nextWindowExpiry(
appts.map((f) => f.plannedFor ?? f.occurredAt),
730,
ctx.now,
);
}
}
import { Injectable } from '@nestjs/common';
import { PersonaFeatureKey, FactType, FactKind } from '@pac/types';
import { PersonaFeatureKey } from '@pac/types';
import type {
ActiveFact,
FeatureExtractor,
FeatureExtractorContext,
PersonaFeatureDraft,
} from './feature.interface';
import { ageYearsAt, classifyGapToLabel } from './potential-treatment.feature';
import { visitFactsOf, visitSpanOf } from './visit-facts';
import { nextDayBoundary } from './time-boundary';
/**
* urgency_level 急迫等级(C.2.1)— 规则层,snapshot · 单一标签(取最大)
......@@ -45,23 +46,9 @@ export class UrgencyLevelFeatureExtractor implements FeatureExtractor {
if (labelKeys.size === 0) return null; // 无潜在待转(8 标签)→ 此路径无急迫
const gaps = ctx.potentialGaps ?? [];
// 末诊(末次就诊)— 口径同 lifecycle_stage
const get = (t: string) => ctx.factsByType.get(t) ?? [];
const visitFacts: ActiveFact[] = [
...get(FactType.ENCOUNTER_RECORD),
...get(FactType.TREATMENT_RECORD).filter((f) => f.kind === FactKind.ACTUAL),
...get(FactType.VISIT_REGISTRATION_RECORD),
];
let last: number | null = null;
let lastFactId: string | null = null;
for (const f of visitFacts) {
if (!f.occurredAt) continue;
const t = f.occurredAt.getTime();
if (last === null || t > last) {
last = t;
lastFactId = f.id;
}
}
// 末诊(末次就诊)— 口径见 visit-facts.ts,与 rfm / lifecycle_stage 同源
const { last: lastVisit, lastFactId } = visitSpanOf(visitFactsOf(ctx));
const last = lastVisit?.getTime() ?? null;
if (lastFactId) factIds.add(lastFactId);
const now = ctx.now.getTime();
// 无就诊记录(罕见,有诊断必有诊)→ 回退用 gap 最早诊断天数
......@@ -92,4 +79,14 @@ export class UrgencyLevelFeatureExtractor implements FeatureExtractor {
evidence: { factIds: [...factIds] },
};
}
/**
* 档界只看「距末诊天数」:30(中→高)、90(高→紧急)。跨过 90 之后不再变 → null。
* 注:无潜在待转时本特征不产出,但边界照报无妨 —— 重算一次发现还是不产出,写入侧判 unchanged。
*/
nextBoundaryAt(ctx: FeatureExtractorContext): Date | null {
const { last } = visitSpanOf(visitFactsOf(ctx));
return nextDayBoundary(last, ctx.now, [30, 90]);
}
}
import { FactKind, FactType } from '@pac/types';
import type { ActiveFact, FeatureExtractorContext } from './feature.interface';
/**
* 「到诊」的统一口径 —— rfm / lifecycle_stage / urgency_level 三个特征共用。
*
* 三处原本各自抄了一遍同样的三行(encounter + actual treatment + 挂号),口径一致纯靠自觉;
* 现在收成一处 —— 改口径只改这里,三个标签不会各走各的(它们的 description 都印着"就诊 N 次 /
* 末诊 N 天",对不上会被客服当场发现)。
*/
export function visitFactsOf(ctx: FeatureExtractorContext): ActiveFact[] {
const get = (t: string) => ctx.factsByType.get(t) ?? [];
return [
...get(FactType.ENCOUNTER_RECORD),
...get(FactType.TREATMENT_RECORD).filter((f) => f.kind === FactKind.ACTUAL),
...get(FactType.VISIT_REGISTRATION_RECORD),
];
}
export interface VisitSpan {
/** 首诊 / 末诊时刻(无到诊记录 → null) */
first: Date | null;
last: Date | null;
/** 末诊那条 fact 的 id(证据链用) */
lastFactId: string | null;
/** 去重就诊**天数** = 业务口径的 visit_times(同日多条治疗算一次) */
dayCount: number;
}
export function visitSpanOf(facts: ActiveFact[]): VisitSpan {
const days = new Set<string>();
let first: Date | null = null;
let last: Date | null = null;
let lastFactId: string | null = null;
for (const f of facts) {
if (!f.occurredAt) continue;
days.add(f.occurredAt.toISOString().slice(0, 10));
if (!first || f.occurredAt < first) first = f.occurredAt;
if (!last || f.occurredAt > last) {
last = f.occurredAt;
lastFactId = f.id;
}
}
return { first, last, lastFactId, dayCount: days.size };
}
import { canonicalJson } from '../../common/canonical-json';
import type { PersonaFeatureDraft } from './features/feature.interface';
/**
* 画像写入判定 —— 「算完之后要不要写、写成新版本还是就地刷新」。
*
* ── 为什么需要 ──
* 原逻辑是「闸没拦住 → 无条件写新版本 + 全套 feature 行」。闸(水位)因此被迫收得很紧,
* 而收紧的代价是 **2026-07-21 reparse 后全量重算被 noop 掉 325,979 人**(生产实测):
* reparse 只改 patient_facts、不动 patient_transactions,而水位盯的是 transaction。
* 结果 74% 的存量画像把「累计消费」永久停在旧口径(应收),中位高估 91%,
* 且这个数会去和实时算出的 M 分位阈值比大小 → mScore 系统性偏高 → 召回优先级被抬。
*
* 修法是把水位对齐到 fact 层(见 PersonaService.readWatermarks)。闸放宽之后,
* 「算了但内容没变」的情况会变多 —— 本模块就是那道**写入侧的闸**:内容没变就不写,
* 只有易变数值变了就地刷新,真变了才升版本。省下的是版本行 + 3.1 GB persona_features 的增长。
*
* ── 判据:比 data(去掉易变键)+ evidence.factIds,**不比 description** ──
* description 完全由 data 派生(`${zh} · 末诊${lastDays}天 · 就诊${totalVisits}次`),
* 所以比 data 就够;不解析文案 —— 将来改文案模板不会引发误判。同 reason-refresh 的思路。
*/
export type PersonaWriteVerdict =
/** 内容一字未变 → 不写 feature 行,只推水位 */
| 'unchanged'
/** 只有易变数值变(纯时间流逝导致)→ 就地刷新 description/data,不升版本 */
| 'volatile'
/** 语义变了(标签/取值/证据集)→ 升版本,supersede 旧版 */
| 'semantic';
/** 库里读出来的 persona_features 行(比较用的最小投影) */
export interface StoredPersonaFeature {
key: string;
description: string;
score: number | null;
data: unknown;
evidence: unknown;
}
/**
* 易变键 —— **判据:拿同一批事实、换个时刻重算,值就会变**。
*
* 这类键的变化不代表患者变了,只代表"时间过去了",不该升版本。分三类:
* ① 纯时钟:距今天数 / 周岁
* ② 时间做分母:年均就诊次数
* ③ 滑动窗口聚合:近 1 年消费额、近 1 年履约/迟到率、近 2 年预约时段占比
*
* ⚠️ 加新的时间派生 data 键时必须登记到这里,否则每次重算都会白升一个版本。
* 对应回归见 tests/persona-diff.spec.ts「易变键清单」。
*
* ⚠️ 反过来也要小心:**不要**把 monetaryCents / totalVisits / types 这类"事实变才变"的键
* 塞进来 —— 那正是 E 事故要修的东西,漏判就等于修了个寂寞。
*/
export const VOLATILE_DATA_KEYS: ReadonlySet<string> = new Set([
// ① 纯时钟
'recencyDays', // rfm / lifecycle_stage:距末诊天数
'firstSeenDays', // rfm / lifecycle_stage:距首诊天数
'lastVisitDays', // urgency_level:距末诊天数
'daysSince', // potential_treatment.detail[]:诊断距今天数
'ageYears', // age_bracket:周岁(跨档会改 bracket,那才是语义变化)
// ② 时间做分母
'visitsPerYear', // lifecycle_stage:年均就诊
// ③ 滑动窗口聚合
'netThisCents', // lifecycle_stage:近 1 年净消费
'fulfillRate', // special_attention:近 1 年履约率
'apptDecided', // special_attention:近 1 年已决预约数
'lateRate', // special_attention:近 1 年迟到率
'arrivalRecs', // special_attention:近 1 年到店记录数
'recordCount', // time_preference:近 2 年预约数
'weekdayPct', // time_preference:近 2 年工作日占比
'weekendPct',
'morningPct',
'afternoonPct',
'eveningPct',
]);
/** 递归剔除易变键(嵌套对象 / 数组元素同样处理,如 potential_treatment.detail[].daysSince) */
function stripVolatile(v: unknown): unknown {
if (Array.isArray(v)) return v.map(stripVolatile);
if (v !== null && typeof v === 'object') {
const o = v as Record<string, unknown>;
const out: Record<string, unknown> = {};
for (const k of Object.keys(o)) {
if (VOLATILE_DATA_KEYS.has(k)) continue;
out[k] = stripVolatile(o[k]);
}
return out;
}
return v;
}
/** evidence.factIds 排序后取出;批次间顺序会抖,不排序会误判成变化 */
function factIdsOf(evidence: unknown): string[] {
if (evidence == null || typeof evidence !== 'object') return [];
const ids = (evidence as { factIds?: unknown }).factIds;
return Array.isArray(ids) ? [...ids].map(String).sort() : [];
}
/** 语义指纹:key + score + data(去易变)+ 证据集。description 不参与(派生物)。 */
function semanticKey(f: StoredPersonaFeature | PersonaFeatureDraft): string {
return canonicalJson({
key: f.key,
score: f.score ?? null,
data: stripVolatile(f.data ?? null),
factIds: factIdsOf(f.evidence),
});
}
/** 全量指纹:语义指纹 + 易变值 + 文案。用来区分"完全没变"和"只有天数变了"。 */
function fullKey(f: StoredPersonaFeature | PersonaFeatureDraft): string {
return canonicalJson({
key: f.key,
description: f.description,
score: f.score ?? null,
data: f.data ?? null,
factIds: factIdsOf(f.evidence),
});
}
/** 按 key 排序后逐条拼 —— 特征集顺序在库里/代码里不一致,不排序会误判 */
function fingerprint(
features: ReadonlyArray<StoredPersonaFeature | PersonaFeatureDraft>,
of: (f: StoredPersonaFeature | PersonaFeatureDraft) => string,
): string {
return features
.map(of)
.sort()
.join('\n');
}
/**
* 该患者这次算出来的画像,相对当前 active 版本要怎么落库。
*
* @param current 当前 active persona 的 feature 行;null / 空数组 = 没有当前版本 → 必写(semantic)
* @param next 本次算出的 drafts
*/
export function personaWriteVerdict(
current: readonly StoredPersonaFeature[] | null,
next: readonly PersonaFeatureDraft[],
): PersonaWriteVerdict {
// 首次(无 active 版本)必写。注意:current 是空数组但 persona 行存在的情况也走这里 ——
// 只有"上一版一个特征都没产出"才会这样,极罕见,多写一版无害。
if (!current || current.length === 0) return 'semantic';
if (next.length === 0) return 'semantic'; // 从有到无(全部降级)也是语义变化
if (fingerprint(current, semanticKey) !== fingerprint(next, semanticKey)) return 'semantic';
if (fingerprint(current, fullKey) !== fingerprint(next, fullKey)) return 'volatile';
return 'unchanged';
}
......@@ -2,23 +2,7 @@ import { Module } from '@nestjs/common';
import { PersonaController } from './persona.controller';
import { PersonaService } from './persona.service';
import { PersonaCoverageMonitorService } from './persona-coverage-monitor.service';
import { FeatureRegistry } from './features/feature.registry';
import { EntitlementStatusFeatureExtractor } from './features/entitlement-status.feature';
import { RfmFeatureExtractor } from './features/rfm.feature';
import { AgeBracketFeatureExtractor } from './features/age-bracket.feature';
import { GenderFeatureExtractor } from './features/gender.feature';
import { AcquisitionChannelFeatureExtractor } from './features/acquisition-channel.feature';
import { FamilyStructureFeatureExtractor } from './features/family-structure.feature';
import { ReferralChampionFeatureExtractor } from './features/referral-champion.feature';
import { LifecycleStageFeatureExtractor } from './features/lifecycle-stage.feature';
import { TreatmentHistoryFeatureExtractor } from './features/treatment-history.feature';
import { TimePreferenceFeatureExtractor } from './features/time-preference.feature';
import { DiscountAnchorFeatureExtractor } from './features/discount-anchor.feature';
import { SpecialAttentionFeatureExtractor } from './features/special-attention.feature';
import { TreatmentSensitivityFeatureExtractor } from './features/treatment-sensitivity.feature';
import { ContraindicationFeatureExtractor } from './features/contraindication.feature';
import { PotentialTreatmentFeatureExtractor } from './features/potential-treatment.feature';
import { UrgencyLevelFeatureExtractor } from './features/urgency-level.feature';
import { FeatureRegistry, FEATURE_EXTRACTOR_PROVIDERS } from './features/feature.registry';
import { ClinicalGapModule } from '../clinical-gap/clinical-gap.module';
@Module({
......@@ -28,23 +12,8 @@ import { ClinicalGapModule } from '../clinical-gap/clinical-gap.module';
PersonaService,
PersonaCoverageMonitorService,
FeatureRegistry,
// W7:rfm 统一了旧 value/recall_risk/treatment_chain_status,三个旧 extractor 已摘除。
RfmFeatureExtractor,
AgeBracketFeatureExtractor,
GenderFeatureExtractor,
AcquisitionChannelFeatureExtractor,
FamilyStructureFeatureExtractor,
ReferralChampionFeatureExtractor,
LifecycleStageFeatureExtractor,
TreatmentHistoryFeatureExtractor,
TimePreferenceFeatureExtractor,
DiscountAnchorFeatureExtractor,
SpecialAttentionFeatureExtractor,
TreatmentSensitivityFeatureExtractor,
ContraindicationFeatureExtractor,
PotentialTreatmentFeatureExtractor,
UrgencyLevelFeatureExtractor,
EntitlementStatusFeatureExtractor,
// extractor 清单收在 feature.registry.ts(与 FeatureRegistry 构造参数同一份,免两边漏改)
...FEATURE_EXTRACTOR_PROVIDERS,
],
exports: [PersonaService],
})
......
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import type { Prisma } from '@prisma/client';
// Prisma 作为值用(Prisma.DbNull:就地刷新时把 data 显式写回 SQL NULL)
import { Prisma } from '@prisma/client';
import { PrismaService } from '../../prisma/prisma.service';
import type { TenantScopeContext } from '../../common/decorators/tenant-scope.decorator';
import { FeatureRegistry } from './features/feature.registry';
import { PotentialTreatmentSelector } from '../clinical-gap/potential-treatment.selector';
import { personaWriteVerdict, type StoredPersonaFeature } from './persona-diff';
import type {
ActiveFact,
FeatureExtractorContext,
PersonaFeatureDraft,
} from './features/feature.interface';
/** 画像输入水位:transaction(辅助)+ fact(主)。见 readWatermarks / isFresh。 */
interface Watermarks {
eventSeq: bigint | null;
factAt: Date | null;
}
/**
* PersonaService — 第二层画像引擎
*
......@@ -86,9 +94,75 @@ export class PersonaService {
}
/**
* 重算单个 patient 的 Persona,产新版本并 supersede 旧 active。
* 读该患者的两个输入水位 —— 一条 SQL 两个子查询,省一次往返。
*
* fact 侧刻意只按 patient_id 过滤:patient_id 全局唯一,加 host/tenant 反而让 planner
* 用不上 (patient_id, updated_at) 索引(倒序扫首行即出,不回堆)。
*/
private async readWatermarks(patient: {
id: string;
hostId: string;
tenantId: string;
}): Promise<Watermarks> {
const rows = await this.prisma.$queryRaw<
Array<{ event_seq: bigint | null; fact_at: Date | null }>
>`
SELECT
(SELECT MAX(event_seq) FROM patient_transactions
WHERE host_id = ${patient.hostId}::uuid AND tenant_id = ${patient.tenantId}
AND patient_id = ${patient.id}::uuid) AS event_seq,
(SELECT MAX(updated_at) FROM patient_facts
WHERE patient_id = ${patient.id}::uuid) AS fact_at`;
return { eventSeq: rows[0]?.event_seq ?? null, factAt: rows[0]?.fact_at ?? null };
}
/**
* 当前 active 版本是否还"新鲜"(= 可以 noop)。
*
* ⚠️ **fact 水位是主,transaction 水位是辅**。历史上只判 transaction,而画像读的是
* patient_facts —— reparse 重写事实但不产新事务,于是 2026-07-21 那次
* 「amount_cents 应收→实付」reparse 后的全量重算被这道闸 noop 掉 325,979 人(生产日志),
* 74% 存量画像的累计消费永久停在旧口径。fact 水位就是补这个洞。
*
* factWatermark 为 null = 存量老版本没记过 → 一律判 stale,靠下一次重算补上(自然回填)。
* 两个水位都为 null = 该患者既无事务也无事实 → 没有输入可变,判新鲜(baseline 已写过)。
*/
private isFresh(
active: {
eventWatermark: bigint | null;
factWatermark: Date | null;
nextBoundaryAt: Date | null;
},
wm: Watermarks,
now: Date,
): boolean {
if (wm.factAt !== null) {
if (active.factWatermark === null) return false;
if (wm.factAt.getTime() > active.factWatermark.getTime()) return false;
}
if (wm.eventSeq !== null) {
if (active.eventWatermark === null) return false;
if (wm.eventSeq > active.eventWatermark) return false;
}
// 到期改档:事实一个字没变,但时间跨过了某条档界(急迫 30/90 天、年龄跨段、滑窗过期…)。
// 没这一条,夜扫把到期患者捞出来入队,也会被上面两个水位当场 noop 掉 —— 白捞。
if (active.nextBoundaryAt !== null && active.nextBoundaryAt.getTime() <= now.getTime()) {
return false;
}
return true;
}
/**
* 重算单个 patient 的 Persona。
*
* 结果分五档(status):
* noop 闸拦下,压根没算(输入水位没动)
* unchanged 算了,内容一字未变 → 只推水位,不写 feature 行
* refreshed 只有易变数值变(天数/年龄/滑窗)→ 就地刷新,**不升版本**
* success 语义变了 → 产新版本并 supersede 旧 active(partial = 有 extractor 抛错)
* failed 全挂
*
* @returns 新写的 Persona id;无 fact 数据时仍写一版(各 extractor 自行降级)
* @returns 生效的 Persona id(unchanged/refreshed 时是原 active 的 id)
*/
async recompute(input: {
patientId: string;
......@@ -107,29 +181,27 @@ export class PersonaService {
throw new NotFoundException(`Patient ${input.patientId} not found`);
}
// ─── 水位幂等(spec: 第二层 · 关键纪律)───
// 如果当前 active Persona 的 eventWatermark ≥ 最新 transaction 的 eventSeq,
// 表示这个 patient 自上次 recompute 后没有新事件 → 直接 noop,省一次全量提取。
// 注:txn 没有 → 不算 noop(允许"无事实"的 patient 也跑一次写 baseline persona);
// persona 没有 → 不算 noop(首次必跑)。
const latestTxnForGate = await this.prisma.patientTransaction.findFirst({
where: { hostId: patient.hostId, tenantId: patient.tenantId, patientId: patient.id },
orderBy: { eventSeq: 'desc' },
select: { eventSeq: true },
});
const currentPersona = await this.prisma.persona.findFirst({
where: { patientId: patient.id, supersededAt: null },
select: { id: true, version: true, eventWatermark: true },
// ─── 水位幂等闸(读侧省算)───
// 自上次重算以来输入没变 → 直接 noop,省掉后面 ~6 次查询 + 16 个 extractor。
// 水位有两个,fact 是主、transaction 是辅 —— 只盯 transaction 是历史事故根因,见 isFresh。
const wm = await this.readWatermarks(patient);
// 取最高版本行:正常流程下 supersede 与建新版同事务发生,所以它就是 active 版。
// (旧代码这里查两次 —— 闸一次 where supersededAt:null、写入前又一次 orderBy version —— 已合并。)
const latest = await this.prisma.persona.findFirst({
where: { patientId: patient.id },
orderBy: { version: 'desc' },
select: {
id: true,
version: true,
supersededAt: true,
eventWatermark: true,
factWatermark: true,
nextBoundaryAt: true,
},
});
const active = latest && !latest.supersededAt ? latest : null;
if (
!input.force &&
currentPersona &&
latestTxnForGate &&
currentPersona.eventWatermark !== null &&
latestTxnForGate.eventSeq <= currentPersona.eventWatermark
) {
if (!input.force && active && this.isFresh(active, wm, now)) {
// 落一条 noop 日志便于审计 — 区别于实际算了一遍
await this.prisma.personaRecomputeLog.create({
data: {
......@@ -138,18 +210,18 @@ export class PersonaService {
patientId: patient.id,
triggeredBy: input.source,
status: 'noop',
producedPersonaId: currentPersona.id,
producedPersonaId: active.id,
featureCount: 0,
ruleCount: 0,
agentCount: 0,
fallbackCount: 0,
eventWatermark: latestTxnForGate.eventSeq,
eventWatermark: wm.eventSeq,
endedAt: new Date(),
},
});
return {
personaId: currentPersona.id,
version: currentPersona.version,
personaId: active.id,
version: active.version,
featureCount: 0,
ruleCount: 0,
failed: 0,
......@@ -238,12 +310,8 @@ export class PersonaService {
})
: [];
const latestTxn = await this.prisma.patientTransaction.findFirst({
where: { hostId: patient.hostId, tenantId: patient.tenantId, patientId: patient.id },
orderBy: { eventSeq: 'desc' },
select: { eventSeq: true },
});
const eventWatermark = latestTxn?.eventSeq ?? null;
// 水位在闸那里已经读过一次(wm),不再重查 —— 旧代码这条 findFirst 与闸里那条完全相同。
const eventWatermark = wm.eventSeq;
const ctx: FeatureExtractorContext = {
patient: {
......@@ -285,6 +353,9 @@ export class PersonaService {
const drafts: PersonaFeatureDraft[] = [];
let ruleCount = 0;
let failed = 0;
// 「到期改档」锚点:各特征报出自己因纯时间流逝而改档的最近时刻,取全体最小值。
// 没有它,不依赖 fact 的特征(年龄段 / 年龄禁忌)永远等不到重算。
let nextBoundaryAt: Date | null = null;
for (const ex of this.registry.extractors) {
try {
const d = ex.extract(ctx);
......@@ -292,6 +363,8 @@ export class PersonaService {
drafts.push(d);
ruleCount++;
}
const b = ex.nextBoundaryAt?.(ctx) ?? null;
if (b && (!nextBoundaryAt || b < nextBoundaryAt)) nextBoundaryAt = b;
} catch (err) {
failed++;
this.logger.error(
......@@ -300,16 +373,59 @@ export class PersonaService {
}
}
const latest = await this.prisma.persona.findFirst({
where: { patientId: patient.id },
orderBy: { version: 'desc' },
// ─── 写入判定(写侧省写)───
// 闸放宽到 fact 水位之后,「算了但内容没变」会变多(reparse 常常只动了不进画像的字段)。
// 这里决定:不写 / 就地刷新 / 升版本。三种都要推水位,否则闸永远开着、下次还白算一遍。
const currentFeatures = active
? ((await this.prisma.personaFeature.findMany({
where: { personaId: active.id },
select: { key: true, description: true, score: true, data: true, evidence: true },
})) as StoredPersonaFeature[])
: null;
const verdict = personaWriteVerdict(currentFeatures, drafts);
const watermarks = { eventWatermark, factWatermark: wm.factAt, nextBoundaryAt };
let personaId: string;
let version: number;
let status: string;
if (verdict !== 'semantic' && active) {
// 内容没变 / 只有天数漂移 → 不产新版本,认领与召回引用的 personaId 保持不变。
personaId = active.id;
version = active.version;
status = verdict === 'unchanged' ? 'unchanged' : 'refreshed';
await this.prisma.$transaction(
async (tx) => {
if (verdict === 'volatile') {
// key 集合与语义指纹都已确认相同 → 按 (personaId,key) 逐条就地改,保住行 id。
for (const d of drafts) {
await tx.personaFeature.update({
where: { personaId_key: { personaId: active.id, key: d.key } },
data: {
description: d.description,
score: d.score ?? null,
data:
d.data == null ? Prisma.DbNull : (d.data as unknown as Prisma.InputJsonValue),
evidence: d.evidence as unknown as Prisma.InputJsonValue,
},
});
}
}
await tx.persona.update({
where: { id: active.id },
// computedAt 不动(覆盖率日报靠它反推历史快照);就地刷新记 refreshedAt。
data: { ...watermarks, ...(verdict === 'volatile' ? { refreshedAt: new Date() } : {}) },
});
},
{ maxWait: 30000, timeout: 60000 },
);
} else {
const nextVersion = (latest?.version ?? 0) + 1;
const created = await this.prisma.$transaction(async (tx) => {
if (latest && !latest.supersededAt) {
const created = await this.prisma.$transaction(
async (tx) => {
if (active) {
await tx.persona.update({
where: { id: latest.id },
where: { id: active.id },
data: { supersededAt: new Date() },
});
}
......@@ -320,7 +436,7 @@ export class PersonaService {
patientId: patient.id,
version: nextVersion,
source: input.source as unknown as string,
eventWatermark,
...watermarks,
features: {
createMany: {
data: drafts.map((d) => ({
......@@ -337,14 +453,19 @@ export class PersonaService {
},
},
});
}, { maxWait: 30000, timeout: 60000 });
},
{ maxWait: 30000, timeout: 60000 },
);
personaId = created.id;
version = nextVersion;
status = failed === 0 ? 'success' : drafts.length > 0 ? 'partial' : 'failed';
}
const status = failed === 0 ? 'success' : drafts.length > 0 ? 'partial' : 'failed';
await this.prisma.personaRecomputeLog.update({
where: { id: log.id },
data: {
status,
producedPersonaId: created.id,
producedPersonaId: personaId,
featureCount: drafts.length,
ruleCount,
agentCount: 0,
......@@ -355,8 +476,8 @@ export class PersonaService {
});
return {
personaId: created.id,
version: nextVersion,
personaId,
version,
featureCount: drafts.length,
ruleCount,
failed,
......@@ -399,6 +520,7 @@ export class PersonaService {
version: number;
source: string;
computedAt: Date;
refreshedAt: Date | null;
supersededAt: Date | null;
eventWatermark: bigint | null;
features: Array<{
......@@ -420,6 +542,9 @@ export class PersonaService {
version: p.version,
triggeredBy: p.source,
computedAt: p.computedAt.toISOString(),
// 就地刷新过就报刷新时刻 —— UI 的"更新于 N 天前"要反映内容真实新鲜度,
// 而 computedAt 是版本创建时刻(覆盖率日报靠它反推历史快照,不能动)。
refreshedAt: (p.refreshedAt ?? p.computedAt).toISOString(),
supersededAt: p.supersededAt?.toISOString() ?? null,
eventWatermark: p.eventWatermark != null ? p.eventWatermark.toString() : null,
features: p.features.map((f) => ({
......
......@@ -63,7 +63,9 @@ export class PlanAggregateService {
include: { relatedPatient: { select: { name: true, phone: true } } },
},
// 诊所回访记录(展示用,按时间倒序,封顶 100;每患者 p99=19,够用)
returnVisits: { orderBy: { taskDate: 'desc' }, take: 100 },
// nulls:'last' —— Postgres 的 DESC 默认 NULLS FIRST,无日期的记录会顶到列表最前,
// 被前端当成「最近一次」;无日期的当最老处理,跟摘要侧口径保持一致。
returnVisits: { orderBy: { taskDate: { sort: 'desc', nulls: 'last' } }, take: 100 },
},
});
if (!patient) throw new NotFoundException(`Patient ${plan.patientId} not found`);
......@@ -535,12 +537,22 @@ function serializePersona(persona: {
id: string;
version: number;
computedAt: Date;
features: Array<{ id: string; key: string; description: string; score: number | null; data: unknown }>;
refreshedAt: Date | null;
features: Array<{
id: string;
key: string;
description: string;
score: number | null;
data: unknown;
evidence: unknown;
}>;
}) {
return {
id: persona.id,
version: persona.version,
computedAt: persona.computedAt.toISOString(),
/// 内容真实新鲜度:就地刷新过就报刷新时刻(computedAt 是版本创建时刻,覆盖率日报靠它反推快照)
refreshedAt: (persona.refreshedAt ?? persona.computedAt).toISOString(),
features: persona.features.map((f) => ({
id: f.id,
key: f.key,
......@@ -548,10 +560,21 @@ function serializePersona(persona: {
score: f.score,
/// 结构化 payload(如 entitlement_status 的 {commercialInsured, commercialInsurers, medicalInsured...})
data: f.data ?? null,
/// 证据 fact id —— 详情页「依据」区反查同一响应里的 facts 渲染。
/// 之前这里没透出,前端 evidence 恒为空数组:库里存着的证据链(rfm/lifecycle 两个
/// 特征就占 1 GB)一条都没被用上,客服只能看到结论看不到出处。
evidenceFactIds: extractFactIds(f.evidence),
})),
};
}
/// evidence 形状由各 extractor 自管({factIds, agentInvocationIds?}),这里只取 factIds 且防脏
function extractFactIds(evidence: unknown): string[] {
if (!evidence || typeof evidence !== 'object') return [];
const ids = (evidence as { factIds?: unknown }).factIds;
return Array.isArray(ids) ? ids.filter((x): x is string => typeof x === 'string') : [];
}
/**
* W4 新加:PlanScript serializer + markdown → sections 反 parse。
*
......
......@@ -67,8 +67,10 @@ export class PlanEngineService {
}
// ⭐ 缺口1 修复:本轮 0 命中 = 该患者已无任何活信号(治完疗/诊断消失/被排除闸过滤)。
// 旧版直接 return → 患者遗留的 active plan 永远残留在召回池(治完疗该结案却一直 active)。
// 现在主动 supersede 关闭它那条 active(非 assigned)plan,让它退出召回池。
// - assigned 不动(客服正在跟进,关掉会打断)
// 现在主动 supersede 关闭它最新那条在池(active / assigned)plan,让它退出召回池。
// - assigned **一起关**(见 closeStaleActivePlan 与批量路径注释):信号都没了,
// 留在客服手上只会让他按已失效的理由去跟进。此前注释写"assigned 不动"是过期的,
// 代码从批量路径修复那次起就已含 assigned。
// - superseded/completed/abandoned 已是终态,无需处理
if (hits.length === 0) {
const closed = await this.closeStaleActivePlan(scope, input.patientId);
......
......@@ -19,30 +19,7 @@
* 据此判定既充分,又不依赖文案格式(将来 scenario 改文案模板不会引发误刷)。
*/
/**
* 规范化序列化:**递归按键名排序**后再 stringify。
*
* ⚠️ 必须这么做 —— Postgres `jsonb` **不保留键序**(按键长度+字节序重排存储),
* 而代码里构造 signals 的顺序是源码顺序。直接 JSON.stringify 比较,库里读出来的
* 和新算出来的键序必然不同 → 每次都判"变了" → 每日重算把全部 plan_reasons 重写一遍,
* 恰好是本模块要避免的事(本地实测:不加这层,连跑三次每次都刷新)。
*/
function canonical(v: unknown): string {
const norm = (x: unknown): unknown => {
if (Array.isArray(x)) return x.map(norm);
if (x !== null && typeof x === 'object') {
const o = x as Record<string, unknown>;
return Object.keys(o)
.sort()
.reduce<Record<string, unknown>>((acc, k) => {
if (o[k] !== undefined) acc[k] = norm(o[k]);
return acc;
}, {});
}
return x;
};
return JSON.stringify(norm(v));
}
import { canonicalJson as canonical } from '../../../common/canonical-json';
/** 稳定投影:剔除随时间必变、不代表语义变化的字段 */
function stableSignals(signals: unknown): unknown {
......
......@@ -321,6 +321,7 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
// ║ ┌─ ⑤a 排除闸:NOT EXISTS 同类 actual 治疗(牙位级 W4 末升级)─┐ ║
// ║ ┌─ ⑤c 排除:同牙位拔除(W4 末加,临床:拔了就终结)────────────┐ ║
// ║ ┌─ ⑤b 排除:患者已有未来预约 ────────────────────────────────┐ ║
// ║ ┌─ ⑤g 排除:诊所已排未来回访(2026-07 加)───────────────────┐ ║
// ║ │ tx.type = 'treatment_record' AND tx.kind = 'actual' │ ║
// ║ │ tx.status IN ('active','fulfilled') (完成的 actual 是 fulfilled)│ ║
// ║ │ tx.content->>'category' = ANY(resolverCats) │ ║
......@@ -395,7 +396,7 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
-- 数据验证 → 全口场景 resolvedTeeth(看治疗)已覆盖在治患者(K05 挡669/K07 挡73,⑤d 独占仅0~1
-- 且那1个无近期预约=stalled本就该召);牙位场景 ⑤d 按科目(非牙位)误排"同科目别牙在治、这颗没治"
-- (沈静芳 34;43 修复 被 25;26 修复预约连带排除)。⑤d 既冗余又有害 → 去掉,全口/牙位统一靠
-- resolvedTeeth(治疗) + ⑤b(未来预约) + ⑤f(近期到诊)。"已进入链"的细粒度留 W5+ 治疗链内召回。
-- resolvedTeeth(治疗) + ⑤b(未来预约) + ⑤f(近期到诊) + ⑤g(未来回访)。"已进入链"的细粒度留 W5+ 治疗链内召回。
-- (⑤e 同牙位替代治疗 种植/冠桥 已折进上面 resolved_teeth 的 implant/prosthodontic 分支 — 诊断后做了的从 remaining 减掉)
AND NOT EXISTS ( -- ⑤f 就诊冷静:近 N 天到过诊 → 别催刚来过的人(防打扰)
-- 患者级、与具体信号无关:最近一次到诊(encounter/emr)在 N 天内 → 本轮不召。
......@@ -407,6 +408,24 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
AND vis.occurred_at IS NOT NULL
AND vis.occurred_at > ${this.daysAgo(scope.now, POST_VISIT_COOLDOWN_DAYS)}::timestamptz
)
AND NOT EXISTS ( -- ⑤g 排除:诊所已排未来回访
-- 同 ⑤b 的道理:召回的产物就是"让客服去联系一次";诊所已经排好了这次联系,
-- PAC 再产一条就是重复触达(一线反馈里 duplicate_contact 本就是既有反馈项)。
-- 患者级、不看牙位/类目 —— 回访任务本身就是患者级的,宿主不按牙位排。
--
-- 到期自愈:条件是 task_date > 今天,任务日一过本闸自动失效,下一轮照常召回。
-- 所以"排了不做"(全库到期任务执行率 74.3%)不需要额外兜底 —— 烂尾的到点就放行。
-- 也不设窗口上限:在池患者的未来排程 99.65% 在一年内(2028 年以后仅 50 条),
-- 拍一个 N 天窗口反而会在第 N+1 天把"诊所排了 N+30 天回访"的人放回池,制造重复触达。
--
-- ⚠️ 位置刻意放在这里(scenario)而不是 gapWhere:gapWhere 是召回与画像**共享**的
-- 【临床事实】层(已治疗/患者拒绝/外院已做 → 缺口真的没了);本闸是【运营时机】层
-- (缺口还在,只是现在不该打电话),同 ⑤b/⑤f。放进 gapWhere 会让画像因为"排了个回访"
-- 就说这人没有潜在治疗 —— 那是两套口径。
SELECT 1 FROM patient_return_visits rv
WHERE rv.patient_id = p.id
AND rv.task_date > ${scope.now}::date
)
`;
// ⭐ 同 patient 同 sub_scenario 的多 sig 按 tooth-overlap 合并(union-find)
......
......@@ -43,9 +43,12 @@ export class PersonaRecomputeProcessor extends WorkerHost {
`persona=${r.personaId ?? 'noop'} v${r.version} features=${r.featureCount} duration=${r.durationMs}ms`,
);
// 链式触发:只在画像确有更新时(success / partial)才下推 plan 重算
// noop = 水位幂等(没新事件)→ plan 也不会变,省一次
// failed = persona 写库挂了 → 不传播,等重试 / 死信
// 链式触发:只在画像**语义**确有更新时(success / partial)才下推 plan 重算。
// noop 水位幂等,压根没算 → plan 也不会变
// unchanged 算了但内容一字未变 → 同上
// refreshed 只有天数/年龄这类易变值变了 → 召回打分读的是 rfm.segment / urgency.level /
// lifecycle.stage 这些**分档**,分档没变就不必重排(变了会判成 semantic)
// failed persona 写库挂了 → 不传播,等重试 / 死信
let planEnqueued = false;
if (r.status === 'success' || r.status === 'partial') {
try {
......@@ -64,7 +67,9 @@ export class PersonaRecomputeProcessor extends WorkerHost {
}
return {
ok: r.status === 'success' || r.status === 'noop',
// ok = 干净完成。新增的 unchanged / refreshed 与 noop 同档(都属"无需升版本"的正常结果);
// partial 仍不算 ok(有 extractor 抛错),沿用原语义。
ok: r.status === 'success' || r.status === 'noop' || r.status === 'unchanged' || r.status === 'refreshed',
personaId: r.personaId ?? null,
status: r.status,
planEnqueued,
......
......@@ -11,8 +11,17 @@ import { QueueProducer } from './queue-producer.service';
* - 应用错误期间漏入队的(部署 / panic 错过 enqueue)
* - cold-import 进来但没触发 enqueue 的(脚本路径)
*
* 判定:max(patient_transactions.event_seq) > personas.event_watermark (或 persona 不存在)
* → 这个 patient 的画像落后,enqueue persona-recompute(BullMQ jobId 自动去重)
* 判定(任一命中即 stale,或 persona 根本不存在)→ enqueue persona-recompute(jobId 去重):
* - max(patient_transactions.event_seq) > personas.event_watermark 事务水位(辅)
* - max(patient_facts.updated_at) > personas.fact_watermark 事实水位(主)
* - personas.next_boundary_at <= now() 到期改档
*
* ⚠️ 后两条都是 2026-07 补的,各修一个洞:
* · fact 水位 —— 画像读 patient_facts,原来只判 transaction,reparse(重写事实、不产事务)
* 后的重算被全部 noop 掉。存量 fact_watermark 为 NULL → 本扫描持续判 stale,自然回填。
* · 到期改档 —— 有一类变化不伴随任何事实变动(时间跨过档界)。存量 next_boundary_at 为
* NULL,而 NULL 的语义是"不再改档",**不会自愈**,必须部署后强制回填一次:
* `pnpm recompute-persona -- --host=<h> --concurrency=N --force`。
*
* 安全:扫描走 GROUP BY + HAVING,不锁表。同一 patient 即便已在主路径队列里,
* jobId 去重保证不会重复算。
......@@ -72,30 +81,57 @@ export class StaleScanService {
}
/**
* 找出"画像 stale"的患者:
* - 有 transaction 但没 persona(从来没算过)
* - 有 persona 但 watermark < latest event_seq(漏了更新)
* 找出"画像 stale"的患者,两类各走一条子查询(UNION):
*
* ① 输入变了 —— 有 transaction 但没 persona(从来没算过)/ event 水位落后 /
* **fact 水位落后**(reparse、事实层订正这类不产新事务的变更)。
* ② 到期改档 —— 事实一个字没变,但时间跨过了某条档界(急迫 30/90 天、生命周期
* 180/540/730、RFM 的 R、年龄跨段、滑动窗口过期)。这类患者只能靠 next_boundary_at
* 捞:它是**确切时刻**,所以每晚只命中几百到几千条,走 (superseded_at, next_boundary_at)
* 索引,而不是把 40 万人全重算一遍 —— 那等于把水位闸拆了。
*
* 用一句 raw SQL 兼顾两种情况;返回 (hostId, tenantId, patientId) 三元组。
* 返回 (hostId, tenantId, patientId) 三元组。
*/
private async findStalePatients(): Promise<
Array<{ hostId: string; tenantId: string; patientId: string }>
> {
// Postgres:LEFT JOIN active persona,WHERE 取 watermark IS NULL 或 max_seq > watermark
const rows = await this.prisma.$queryRaw<
Array<{ host_id: string; tenant_id: string; patient_id: string }>
>`
SELECT t.host_id, t.tenant_id, t.patient_id
FROM (
WITH tx AS (
SELECT host_id, tenant_id, patient_id, MAX(event_seq) AS max_seq
FROM patient_transactions
WHERE patient_id IS NOT NULL
GROUP BY host_id, tenant_id, patient_id
) t
), fx AS (
-- (patient_id, updated_at) 索引上的 index-only 聚合,不回堆
SELECT patient_id, MAX(updated_at) AS max_fact_at
FROM patient_facts
GROUP BY patient_id
)
(
-- ① 输入变了
SELECT tx.host_id, tx.tenant_id, tx.patient_id
FROM tx
LEFT JOIN personas p
ON p.patient_id = t.patient_id
ON p.patient_id = tx.patient_id
AND p.superseded_at IS NULL
WHERE p.event_watermark IS NULL OR p.event_watermark < t.max_seq
LEFT JOIN fx ON fx.patient_id = tx.patient_id
WHERE p.id IS NULL
OR p.event_watermark IS NULL
OR p.event_watermark < tx.max_seq
OR (fx.max_fact_at IS NOT NULL
AND (p.fact_watermark IS NULL OR p.fact_watermark < fx.max_fact_at))
)
UNION
(
-- ② 到期改档(走 (superseded_at, next_boundary_at) 索引,每晚只有一小撮)
SELECT host_id, tenant_id, patient_id
FROM personas
WHERE superseded_at IS NULL
AND next_boundary_at IS NOT NULL
AND next_boundary_at <= now()
)
LIMIT 5000
`;
return rows.map((r) => ({
......
import {
personaWriteVerdict,
VOLATILE_DATA_KEYS,
type StoredPersonaFeature,
} from '../src/modules/persona/persona-diff';
import type { PersonaFeatureDraft } from '../src/modules/persona/features/feature.interface';
/**
* 画像写入判定回归。
*
* 背景(生产事故):画像读 patient_facts,stale 判定却只看 patient_transactions 的水位。
* 2026-07-21「amount_cents 应收→实付」reparse 只改事实、不产事务 → 之后的全量重算
* 被水位闸 noop 掉 325,979 人,74% 存量画像的累计消费永久停在旧口径(中位高估 91%),
* 而这个数会去和实时算的 M 分位阈值比大小 → mScore 系统性偏高 → 召回优先级被抬。
*
* 修法是把水位对齐到 fact 层。闸放宽之后「算了但没变」变多,本模块是**写入侧的闸**:
* unchanged 一字未变 → 不写 feature 行
* volatile 只有时间派生值变 → 就地刷新,不升版本
* semantic 真变了 → 升版本
*
* 本文件同时守住两条红线:
* ① 不能把"事实变才变"的键(monetaryCents 等)误判成易变 —— 那就等于没修事故
* ② 不能把纯时间漂移judg成语义变化 —— 那会让每次重算都白升一版
*/
const draft = (over: Partial<PersonaFeatureDraft> = {}): PersonaFeatureDraft =>
({
key: 'rfm',
description: '重要价值 · 距上次 140 天 · 就诊20次 · 累计¥154,350',
score: null,
data: {
segment: 'important_value',
rScore: 5,
fScore: 5,
mScore: 5,
recencyDays: 140,
firstSeenDays: 3000,
freqCount: 20,
monetaryCents: 15435000,
valueTier: 4,
riskScore: 2,
hasTreatmentGap: true,
},
evidence: { factIds: ['f1', 'f2'] },
...over,
}) as PersonaFeatureDraft;
/** draft → 库里那行的形状(字段同名,类型放宽) */
const stored = (d: PersonaFeatureDraft): StoredPersonaFeature => ({
key: d.key,
description: d.description,
score: d.score ?? null,
data: d.data ?? null,
evidence: d.evidence,
});
const verdict = (cur: PersonaFeatureDraft[], next: PersonaFeatureDraft[]) =>
personaWriteVerdict(cur.map(stored), next);
describe('personaWriteVerdict — 三档判定', () => {
test('⭐ 完全相同 → unchanged(不写任何 feature 行)', () => {
expect(verdict([draft()], [draft()])).toBe('unchanged');
});
test('⭐ 只有天数漂移(recencyDays 140→151)→ volatile,**不升版本**', () => {
const next = draft({
description: '重要价值 · 距上次 151 天 · 就诊20次 · 累计¥154,350',
data: { ...(draft().data as object), recencyDays: 151 },
});
expect(verdict([draft()], [next])).toBe('volatile');
});
test('⭐⭐ 累计消费变了(应收→实付,正是事故本体)→ semantic,必须升版本', () => {
const next = draft({
description: '重要价值 · 距上次 140 天 · 就诊20次 · 累计¥195,750',
data: { ...(draft().data as object), monetaryCents: 19575000 },
});
expect(verdict([draft()], [next])).toBe('semantic');
});
test('⭐ 分群变了(mScore 掉档导致 segment 变)→ semantic', () => {
const next = draft({
data: { ...(draft().data as object), segment: 'general_value', mScore: 3 },
});
expect(verdict([draft()], [next])).toBe('semantic');
});
test('首次(无当前版本)→ semantic', () => {
expect(personaWriteVerdict(null, [draft()])).toBe('semantic');
expect(personaWriteVerdict([], [draft()])).toBe('semantic');
});
test('特征全没了(从有到无)→ semantic', () => {
expect(verdict([draft()], [])).toBe('semantic');
});
test('新增一个特征 → semantic', () => {
const extra = draft({ key: 'age_bracket', data: { bracket: 'senior', label: '老年', ageYears: 58 } });
expect(verdict([draft()], [draft(), extra])).toBe('semantic');
});
test('掉了一个特征(降级为不打标签)→ semantic', () => {
const extra = draft({ key: 'age_bracket', data: { bracket: 'senior', label: '老年', ageYears: 58 } });
expect(verdict([draft(), extra], [draft()])).toBe('semantic');
});
test('特征顺序不同 → 不算变化(库里/代码里顺序本就不保证)', () => {
const a = draft({ key: 'age_bracket', data: { bracket: 'senior' } });
expect(verdict([draft(), a], [a, draft()])).toBe('unchanged');
});
});
describe('易变键清单 —— 时间派生值不该升版本', () => {
// 判据:拿同一批事实、换个时刻重算,值就会变
test.each([
['recencyDays', 'rfm / lifecycle_stage 距末诊天数'],
['firstSeenDays', 'rfm / lifecycle_stage 距首诊天数'],
['lastVisitDays', 'urgency_level 距末诊天数'],
['ageYears', 'age_bracket 周岁'],
['visitsPerYear', 'lifecycle_stage 年均就诊(时间做分母)'],
['netThisCents', 'lifecycle_stage 近 1 年净消费(滑窗)'],
['fulfillRate', 'special_attention 近 1 年履约率(滑窗)'],
['recordCount', 'time_preference 近 2 年预约数(滑窗)'],
])('%s 变化 → volatile(%s)', (key) => {
const base = draft({ data: { segment: 'important_value', [key]: 1 } });
const next = draft({ data: { segment: 'important_value', [key]: 999 }, description: '改了' });
expect(verdict([base], [next])).toBe('volatile');
});
test('⭐ 嵌套里的 daysSince 也要剔(potential_treatment.detail[])', () => {
const mk = (d: number) =>
draft({
key: 'potential_treatment',
description: '潜在种植 / 潜在修复',
data: {
types: ['implant', 'restoration'],
labels: ['潜在种植', '潜在修复'],
detail: [{ key: 'implant', teeth: ['16'], daysSince: d, confidence: 1 }],
},
});
expect(verdict([mk(140)], [mk(151)])).toBe('volatile');
});
test('⭐ 红线:牙位变了(detail[].teeth)→ semantic,不能被 detail 的剔除逻辑连坐', () => {
const mk = (teeth: string[]) =>
draft({
key: 'potential_treatment',
data: { types: ['implant'], detail: [{ key: 'implant', teeth, daysSince: 140 }] },
});
expect(verdict([mk(['16'])], [mk(['16', '17'])])).toBe('semantic');
});
test('⭐ 红线:清单里不能混进"事实变才变"的键', () => {
for (const k of ['monetaryCents', 'netTotalCents', 'totalVisits', 'freqCount', 'types', 'segment', 'stage', 'level', 'bracket']) {
expect(VOLATILE_DATA_KEYS.has(k)).toBe(false);
}
});
});
describe('evidence 维度', () => {
test('证据集变了(命中新 fact)→ semantic', () => {
const next = draft({ evidence: { factIds: ['f1', 'f2', 'f3'] } });
expect(verdict([draft()], [next])).toBe('semantic');
});
test('⭐ factIds 只是顺序不同 → 不算变化(批次间顺序会抖)', () => {
const next = draft({ evidence: { factIds: ['f2', 'f1'] } });
expect(verdict([draft()], [next])).toBe('unchanged');
});
test('evidence 形状异常不炸', () => {
const a = draft({ evidence: null as never });
const b = draft({ evidence: {} as never });
expect(verdict([a], [b])).toBe('unchanged');
});
});
describe('⭐ 红线:jsonb 键序不能被当成变化', () => {
// Postgres jsonb 不保留键序 —— 不规范化的话每次重算都判"变了",全量重写(3.1 GB 表)
test('顶层键序不同 → unchanged', () => {
const asStored = stored(draft());
asStored.data = {
hasTreatmentGap: true, monetaryCents: 15435000, valueTier: 4, segment: 'important_value',
riskScore: 2, freqCount: 20, firstSeenDays: 3000, recencyDays: 140,
mScore: 5, fScore: 5, rScore: 5,
};
expect(personaWriteVerdict([asStored], [draft()])).toBe('unchanged');
});
test('嵌套对象键序不同 → unchanged', () => {
const mk = (o: Record<string, unknown>) =>
draft({ key: 'potential_treatment', data: { detail: [o] } });
const a = mk({ key: 'implant', teeth: ['16'], confidence: 1 });
const b = mk({ confidence: 1, teeth: ['16'], key: 'implant' });
expect(verdict([a], [b])).toBe('unchanged');
});
test('数组顺序仍算变化(types 首项 = 主推,有序)', () => {
const a = draft({ key: 'treatment_history', data: { types: ['implant_history', 'perio_history'] } });
const b = draft({ key: 'treatment_history', data: { types: ['perio_history', 'implant_history'] } });
expect(verdict([a], [b])).toBe('semantic');
});
});
describe('红线:判定不依赖 description 文案', () => {
test('⭐ data 相同、只有文案模板改了 → volatile(就地刷新),不升版本', () => {
const next = draft({ description: '【重要价值】140 天未到诊,累计 154,350 元' });
expect(verdict([draft()], [next])).toBe('volatile');
});
});
import { Test } from '@nestjs/testing';
import { PERSONA_FEATURE_SPECS, PERSONA_TAG_FILTER_DIMS, PERSONA_FEATURE_META } from '@pac/types';
import {
FeatureRegistry,
FEATURE_EXTRACTOR_PROVIDERS,
} from '../src/modules/persona/features/feature.registry';
/**
* 标签注册表防漂移。
*
* 由来:同一个「这个标签怎么算的」历史上有四份 —— PERSONA_FEATURE_SPECS.algorithm、
* 前端 persona-feature-hover.tsx 的 ALGORITHMS、extractor 顶部 JSDoc、extractor 代码。
* 前端那份靠人肉同步,已经漂了:
* · 权益身份 hover 只列 2 类,extractor 实际产 5 类,还承诺了数据里没有的「保司 + 日期」
* · 转介绍达人 hover 写「社交型 = 推外人 ≥3 人」,真实门槛是「推荐 ≥3 人且带来成交」
* 客服照着 hover 跟患者讲,说明写错比不写更糟。
*
* 现在说明收进标签卡(spec.display),前端只渲染不编内容。本文件守住"注册表 ↔ 实现"的一致:
* 光收拢不设闸,过两个月照样会漂。
*/
/** extractor 都是无依赖纯函数类 → 不用起 PersonaModule(那会拽进 Prisma / ClinicalGap) */
async function registryKeys(): Promise<string[]> {
const mod = await Test.createTestingModule({
providers: [FeatureRegistry, ...FEATURE_EXTRACTOR_PROVIDERS],
}).compile();
return mod.get(FeatureRegistry).extractors.map((e) => e.key as string);
}
describe('PERSONA_FEATURE_SPECS ↔ FeatureRegistry', () => {
test('⭐ 每个在跑的 extractor 都必须在注册表里登记(新特征上线不许漏登记)', async () => {
const missing = (await registryKeys()).filter((k) => !PERSONA_FEATURE_SPECS[k]);
expect(missing).toEqual([]);
});
test('⭐ 注册表里不留已摘除的标签(摘 extractor 时要一并清掉,否则覆盖率日报会永远报 0%)', async () => {
const live = new Set(await registryKeys());
const stale = Object.keys(PERSONA_FEATURE_SPECS).filter((k) => !live.has(k));
expect(stale).toEqual([]);
});
});
describe('spec.display —— 详情页说明', () => {
test('⭐ 副标题 + 至少一条取值条目,且每条都带加粗取值', () => {
// 结构即可读性:卡片靠「取值 → 说明」两列对照来扫。曾试过把自明的标签 rules 清空,
// 卡片退化成三段平铺文字,没有层次也无从对照 —— 要精简就精简 body,不是砍结构。
for (const [key, spec] of Object.entries(PERSONA_FEATURE_SPECS)) {
expect(spec.display?.subtitle?.length ?? 0).toBeGreaterThan(0);
expect(spec.display?.rules?.length ?? 0).toBeGreaterThan(0);
for (const r of spec.display.rules) {
expect(`${key} 缺 label`).toBe(r.label ? `${key} 缺 label` : '');
expect(r.body.length).toBeGreaterThan(0);
}
expect(`${key}:${spec.display.subtitle}`).not.toContain('undefined');
}
});
test('⭐ 说明是给客服看的,不许出现实现术语', () => {
// 这些词一旦出现,说明写的是"程序怎么实现的"而不是"这个标签什么意思"
const JARGON = ['extractor', 'fact', 'jsonb', '字段', '接口', '口径与召回完全一致', 'K0', 'data.'];
const offenders: string[] = [];
for (const [key, spec] of Object.entries(PERSONA_FEATURE_SPECS)) {
const text = [spec.display.subtitle,
...spec.display.rules.map((r) => `${r.label}${r.body}`)].join(' ');
for (const w of JARGON) if (text.includes(w)) offenders.push(`${key}: ${w}`);
}
expect(offenders).toEqual([]);
});
test('每个标签都有中文展示名(前端 hover 标题取它)', async () => {
for (const key of await registryKeys()) expect(PERSONA_FEATURE_META[key]).toBeDefined();
});
test('⭐ 展示元数据里不留已摘除 / 只在路线图上的 key —— 否则"看着 30 个标签实际只出 16 个"', () => {
const stale = Object.keys(PERSONA_FEATURE_META).filter((k) => !PERSONA_FEATURE_SPECS[k]);
expect(stale).toEqual([]);
});
test('⭐ 红色是稀缺资源:只有警示类标签能用 rose(客服要扫一眼分轻重)', () => {
const WARNING = new Set(['urgency_level', 'special_attention', 'contraindication', 'treatment_sensitivity']);
const wrong = Object.entries(PERSONA_FEATURE_META)
.filter(([k, m]) => (m.tone === 'rose') !== WARNING.has(k))
.map(([k]) => k);
expect(wrong).toEqual([]);
});
});
describe('PERSONA_TAG_FILTER_DIMS —— 圈人字典', () => {
test('⭐ 可筛维度的 key 必须是在跑的标签(筛一个不产出的标签 = 永远 0 结果)', async () => {
const live = new Set(await registryKeys());
const orphan = PERSONA_TAG_FILTER_DIMS.filter((d) => !live.has(d.key)).map((d) => d.key);
expect(orphan).toEqual([]);
});
test('圈人维度的取值不能为空', () => {
for (const d of PERSONA_TAG_FILTER_DIMS) {
expect(d.options.length).toBeGreaterThan(0);
for (const o of d.options) expect(o.value.length).toBeGreaterThan(0);
}
});
});
import { Test } from '@nestjs/testing';
import {
earliestFuture,
nextAgeBoundary,
nextDayBoundary,
nextWindowExpiry,
} from '../src/modules/persona/features/time-boundary';
import {
FeatureRegistry,
FEATURE_EXTRACTOR_PROVIDERS,
} from '../src/modules/persona/features/feature.registry';
import { classifyGapToLabel } from '../src/modules/persona/features/potential-treatment.feature';
import type { FeatureExtractorContext } from '../src/modules/persona/features/feature.interface';
import type { PotentialGap } from '../src/modules/clinical-gap/potential-treatment.selector';
/**
* 「到期改档」调度锚点回归。
*
* 为什么需要:水位闸只在**事实变了**时放行重算 —— 这是对的,没有它 40 万患者天天全量重算。
* 但有一类变化不伴随任何事实变动,时间自己走了就会改档:
* 急迫等级跨 30/90 天线 · 生命周期跨 180/540/730 · RFM 的 R 跨 540/730/1095/1460
* 年龄跨段 · 种植年龄禁忌满 19 岁解除 · 时间偏好/特别关注的滑动窗口把最老记录挤出去
* 这类患者旧实现里永远等不到重算(年龄段、年龄禁忌**完全不依赖 fact**,最极端)。
*
* 解法不是"每天全量扫"(那等于把闸拆了),而是让每个特征报出**确切的到期时刻**,
* 取最小值存 personas.next_boundary_at,夜扫只捞到期那一小撮。
*
* 本文件守两条:① 原语算得准;② 每个"会随时间改档"的特征都真的报了边界(漏报 = 静默不刷新)。
*/
const D = (s: string) => new Date(s);
const NOW = D('2026-07-26T00:00:00Z');
describe('时间边界原语', () => {
test('nextDayBoundary:取第一个还在未来的阈值', () => {
const last = D('2026-06-26T00:00:00Z'); // 30 天前
// 30 天线已过 → 落到 90 天线
expect(nextDayBoundary(last, NOW, [30, 90])?.toISOString()).toBe('2026-09-24T00:00:00.000Z');
});
test('⭐ nextDayBoundary:全部阈值都已跨过 → null(再等也不改档,别白捞)', () => {
const long = D('2020-01-01T00:00:00Z');
expect(nextDayBoundary(long, NOW, [30, 90])).toBeNull();
});
test('nextDayBoundary:无锚点(从没到诊)→ null', () => {
expect(nextDayBoundary(null, NOW, [30, 90])).toBeNull();
});
test('⭐ nextAgeBoundary:下一个档位下界对应的那个生日', () => {
const birth = D('1980-09-01T00:00:00Z'); // 2026-07-26 时 45 岁
// 年龄档下界 …31,46,55…;下一个是 46 → 2026-09-01 生日
expect(nextAgeBoundary(birth, NOW, [0, 3, 7, 12, 18, 26, 31, 46, 55])?.toISOString()).toBe(
'2026-09-01T00:00:00.000Z',
);
});
test('⭐ nextAgeBoundary:已过最高档(老年)→ null', () => {
const birth = D('1940-01-01T00:00:00Z'); // 86 岁
expect(nextAgeBoundary(birth, NOW, [0, 18, 55])).toBeNull();
});
test('nextAgeBoundary:无生日 → null', () => {
expect(nextAgeBoundary(null, NOW, [18])).toBeNull();
});
test('⭐ nextWindowExpiry:窗内最早那条滑出窗口的时刻', () => {
const times = [D('2025-01-10T00:00:00Z'), D('2026-05-01T00:00:00Z')];
// 近 730 天窗口 → 窗内最早是 2025-01-10,滑出 = +730 天
expect(nextWindowExpiry(times, 730, NOW)?.toISOString()).toBe('2027-01-10T00:00:00.000Z');
});
test('nextWindowExpiry:窗内无记录 → null(没东西可过期)', () => {
expect(nextWindowExpiry([D('2010-01-01T00:00:00Z')], 365, NOW)).toBeNull();
expect(nextWindowExpiry([null, undefined], 365, NOW)).toBeNull();
});
test('earliestFuture:忽略过去项,取最早的未来项', () => {
expect(
earliestFuture(NOW, D('2020-01-01Z'), D('2027-01-01T00:00:00Z'), D('2026-08-01T00:00:00Z'))?.toISOString(),
).toBe('2026-08-01T00:00:00.000Z');
expect(earliestFuture(NOW, null, D('2020-01-01Z'))).toBeNull();
});
});
// ── 特征级:确保"会随时间改档"的都报了边界 ──────────────────────────
function ctxOf(over: Partial<FeatureExtractorContext> = {}): FeatureExtractorContext {
return {
patient: {
id: 'p1', externalId: 'E1', name: null, phone: null, gender: 'F',
birthDate: D('1980-09-01T00:00:00Z'), active: true,
},
profile: null,
factsByType: new Map(),
relations: [],
appointmentsAll: [],
potentialGaps: [],
now: NOW,
populationStats: null,
...over,
} as FeatureExtractorContext;
}
const fact = (over: Record<string, unknown>) =>
({ id: 'f1', subjectId: 's1', type: 'encounter_record', kind: 'actual', status: 'active',
occurredAt: null, plannedFor: null, clinicId: null, content: {}, ...over }) as never;
async function extractors() {
const mod = await Test.createTestingModule({
providers: [FeatureRegistry, ...FEATURE_EXTRACTOR_PROVIDERS],
}).compile();
return mod.get(FeatureRegistry).extractors;
}
/** 会随纯时间流逝改档的特征 —— 少一个就意味着那个标签会静默停在旧档 */
const MUST_DECLARE_BOUNDARY = [
'rfm', // R 分档界
'lifecycle_stage', // 末诊/首诊天数档界
'urgency_level', // 30 / 90 天线
'age_bracket', // 年龄跨段(完全不依赖 fact)
'contraindication', // 满 19 岁解除(完全不依赖 fact)
'potential_treatment', // 标签映射里的年龄闸
'time_preference', // 近 2 年滑窗
'special_attention', // 近 1 年滑窗
];
describe('⭐ 红线:会随时间改档的特征必须声明 nextBoundaryAt', () => {
test('清单里的都实现了', async () => {
const byKey = new Map((await extractors()).map((e) => [e.key as string, e]));
const missing = MUST_DECLARE_BOUNDARY.filter((k) => typeof byKey.get(k)?.nextBoundaryAt !== 'function');
expect(missing).toEqual([]);
});
test('不随时间变的没多余声明(声明了就会被白捞重算)', async () => {
const still = ['gender', 'acquisition_channel', 'family_structure', 'referral_champion',
'treatment_history', 'entitlement_status', 'discount_anchor', 'treatment_sensitivity'];
const byKey = new Map((await extractors()).map((e) => [e.key as string, e]));
const extra = still.filter((k) => typeof byKey.get(k)?.nextBoundaryAt === 'function');
expect(extra).toEqual([]);
});
});
describe('特征报出的边界值', () => {
test('⭐ age_bracket:45 岁 → 下一个生日(满 46 进中老年)', async () => {
const ex = (await extractors()).find((e) => e.key === 'age_bracket')!;
expect(ex.nextBoundaryAt!(ctxOf())?.toISOString()).toBe('2026-09-01T00:00:00.000Z');
});
test('⭐ contraindication:未成年 → 满 19 岁那天;成年 → null', async () => {
const ex = (await extractors()).find((e) => e.key === 'contraindication')!;
const minor = ctxOf({ patient: { ...ctxOf().patient, birthDate: D('2010-03-05T00:00:00Z') } });
expect(ex.nextBoundaryAt!(minor)?.toISOString()).toBe('2029-03-05T00:00:00.000Z');
expect(ex.nextBoundaryAt!(ctxOf())).toBeNull(); // 45 岁
});
test('⭐ urgency_level:末诊 10 天前 → 30 天线', async () => {
const ex = (await extractors()).find((e) => e.key === 'urgency_level')!;
const ctx = ctxOf({
factsByType: new Map([['encounter_record', [fact({ occurredAt: D('2026-07-16T00:00:00Z') })]]]),
});
expect(ex.nextBoundaryAt!(ctx)?.toISOString()).toBe('2026-08-15T00:00:00.000Z');
});
test('lifecycle_stage:取末诊/首诊两组档界里最早的未来项', async () => {
const ex = (await extractors()).find((e) => e.key === 'lifecycle_stage')!;
const ctx = ctxOf({
factsByType: new Map([
['encounter_record', [
fact({ id: 'a', occurredAt: D('2026-03-01T00:00:00Z') }), // 首诊:+180 → 2026-08-28
fact({ id: 'b', occurredAt: D('2026-07-01T00:00:00Z') }), // 末诊:+180 → 2026-12-28
]],
]),
});
expect(ex.nextBoundaryAt!(ctx)?.toISOString()).toBe('2026-08-28T00:00:00.000Z');
});
test('⭐ potential_treatment:无 gap → null(本特征不产出,别白捞)', async () => {
const ex = (await extractors()).find((e) => e.key === 'potential_treatment')!;
expect(ex.nextBoundaryAt!(ctxOf())).toBeNull();
});
test('⭐ 年龄闸 AGE_GATES 与 classifyGapToLabel 的实际分界一致', () => {
// 用 K07(早矫 3-12 / 正畸 13-40)和 K08(种植 >18)逐岁探边界,
// 找出"标签发生变化"的年龄,必须与 nextAgeBoundary 用的闸值集合吻合。
const gap = (code: string) => ({ primaryCode: code, tooth: '16', daysSince: 1, confidence: 1,
signalType: 'diagnosis', factId: 'f', nameZh: '' }) as unknown as PotentialGap;
const changes: number[] = [];
for (let age = 1; age <= 60; age++) {
for (const code of ['K07', 'K08']) {
const prev = classifyGapToLabel(gap(code), age - 1)?.key ?? null;
const cur = classifyGapToLabel(gap(code), age)?.key ?? null;
if (prev !== cur) changes.push(age);
}
}
expect([...new Set(changes)].sort((a, b) => a - b)).toEqual([3, 13, 19, 41]);
});
});
import { Test } from '@nestjs/testing';
import { PersonaService } from '../src/modules/persona/persona.service';
import { PrismaService } from '../src/prisma/prisma.service';
import { FeatureRegistry } from '../src/modules/persona/features/feature.registry';
import { PotentialTreatmentSelector } from '../src/modules/clinical-gap/potential-treatment.selector';
import type {
FeatureExtractor,
PersonaFeatureDraft,
} from '../src/modules/persona/features/feature.interface';
/**
* 画像水位闸回归 —— 守住 2026-07 那次事故。
*
* 事故:画像算的是 patient_facts,stale 判定却只看 patient_transactions 的 event_seq。
* reparse(「amount_cents 应收→实付」)重写事实、**不产新事务**,于是紧随其后的全量重算
* 被这道闸 noop 掉 325,979 人(生产日志 manual:cli noop=325979 / success=79010),
* 74% 存量画像的累计消费永久停在旧口径,中位高估 91%;这个数还要跟实时算出的 M 分位阈值
* 比大小 → mScore 系统性偏高 → RFM 分群整体偏「重要」→ 直接抬高召回优先级。
*
* 所以本文件第一条测试就是「事务水位没动、事实水位动了,必须重算」。
*
* 另一半是闸放宽后的**写入侧节流**(见 persona-diff.spec.ts 判定本身):
* 算了不等于要写 —— unchanged 只推水位,refreshed 就地刷新,只有 semantic 才升版本。
*/
const PATIENT = {
id: 'pat-1',
hostId: 'h1',
tenantId: 't1',
externalId: 'E1',
name: '张三',
phone: null,
gender: 'F',
birthDate: new Date('1968-01-01'),
active: true,
profile: null,
};
const T0 = new Date('2026-07-15T00:00:00Z');
const T1 = new Date('2026-07-21T15:37:00Z'); // reparse 时刻
/** 只产一个特征的桩 extractor,输出由测试注入 */
function stubRegistry(draft: PersonaFeatureDraft | null): FeatureRegistry {
const ex: FeatureExtractor = { key: 'rfm' as never, extract: () => draft };
return { extractors: [ex] } as FeatureRegistry;
}
const RFM = (over: Partial<PersonaFeatureDraft> = {}): PersonaFeatureDraft =>
({
key: 'rfm',
description: '重要价值 · 距上次 140 天 · 累计¥154,350',
score: null,
data: { segment: 'important_value', recencyDays: 140, monetaryCents: 15435000 },
evidence: { factIds: ['f1'] },
...over,
}) as PersonaFeatureDraft;
interface Opts {
/** 库里 active persona 的水位 + 到期锚点;null = 从没算过 */
persona: {
eventWatermark: bigint | null;
factWatermark: Date | null;
nextBoundaryAt?: Date | null;
} | null;
/** 当前实时水位 */
now: { eventSeq: bigint | null; factAt: Date | null };
/** 当前 active 版本已存的 feature 行 */
storedFeatures?: Array<Record<string, unknown>>;
draft?: PersonaFeatureDraft | null;
}
function makeHarness(o: Opts) {
const calls = {
personaCreate: 0,
personaUpdate: [] as Record<string, unknown>[],
featureUpdate: [] as Record<string, unknown>[],
watermarkQueries: 0,
txnFindFirst: 0,
};
const tx = {
persona: {
update: jest.fn().mockImplementation(({ data }) => {
calls.personaUpdate.push(data);
return Promise.resolve({});
}),
create: jest.fn().mockImplementation(() => {
calls.personaCreate++;
return Promise.resolve({ id: 'persona-new' });
}),
},
personaFeature: {
update: jest.fn().mockImplementation(({ data }) => {
calls.featureUpdate.push(data);
return Promise.resolve({});
}),
},
};
const prisma = {
patient: { findUnique: jest.fn().mockResolvedValue(PATIENT) },
// readWatermarks(patient_facts / patient_transactions)与 M 分位查询共用 $queryRaw,靠 SQL 文本分流
$queryRaw: jest.fn().mockImplementation((strings: TemplateStringsArray) => {
const sql = strings.join('');
if (sql.includes('patient_facts') && sql.includes('MAX(updated_at)')) {
calls.watermarkQueries++;
return Promise.resolve([{ event_seq: o.now.eventSeq, fact_at: o.now.factAt }]);
}
return Promise.resolve([{ q: [] }]); // getMonetaryQuantiles
}),
persona: {
findFirst: jest.fn().mockResolvedValue(
o.persona
? { id: 'persona-old', version: 3, supersededAt: null, nextBoundaryAt: null, ...o.persona }
: null,
),
},
personaFeature: { findMany: jest.fn().mockResolvedValue(o.storedFeatures ?? []) },
personaRecomputeLog: {
create: jest.fn().mockResolvedValue({ id: 'log-1' }),
update: jest.fn().mockResolvedValue({}),
},
patientFact: { findMany: jest.fn().mockResolvedValue([]) },
patientRelation: { findMany: jest.fn().mockResolvedValue([]) },
patientTransaction: {
findFirst: jest.fn().mockImplementation(() => {
calls.txnFindFirst++;
return Promise.resolve(null);
}),
},
$transaction: jest.fn().mockImplementation(async (fn: (t: typeof tx) => Promise<unknown>) => fn(tx)),
};
return { prisma: prisma as unknown as PrismaService, calls };
}
async function build(o: Opts) {
const { prisma, calls } = makeHarness(o);
const mod = await Test.createTestingModule({
providers: [
PersonaService,
{ provide: PrismaService, useValue: prisma },
{ provide: FeatureRegistry, useValue: stubRegistry(o.draft === undefined ? RFM() : o.draft) },
{ provide: PotentialTreatmentSelector, useValue: { selectForPatient: jest.fn().mockResolvedValue([]) } },
],
}).compile();
return { svc: mod.get(PersonaService), calls, prisma };
}
const storedFrom = (d: PersonaFeatureDraft) => [
{ key: d.key, description: d.description, score: d.score ?? null, data: d.data, evidence: d.evidence },
];
describe('水位闸 —— 该不该算', () => {
test('⭐⭐ 事故回归:事务水位没动、**事实水位前进**(reparse)→ 必须重算,不能 noop', async () => {
const { svc } = await build({
persona: { eventWatermark: 100n, factWatermark: T0 },
now: { eventSeq: 100n, factAt: T1 }, // event 一样,fact 变新
});
const r = await svc.recompute({ patientId: 'pat-1', source: 'test' });
expect(r.status).not.toBe('noop');
});
test('两个水位都没动 → noop(闸的本职:少算)', async () => {
const { svc, calls } = await build({
persona: { eventWatermark: 100n, factWatermark: T1 },
now: { eventSeq: 100n, factAt: T1 },
});
const r = await svc.recompute({ patientId: 'pat-1', source: 'test' });
expect(r.status).toBe('noop');
expect(calls.personaCreate).toBe(0);
});
test('⭐ 存量版本 factWatermark=NULL(迁移前写的)→ 判 stale,借下次重算自然回填', async () => {
const { svc } = await build({
persona: { eventWatermark: 100n, factWatermark: null },
now: { eventSeq: 100n, factAt: T1 },
});
const r = await svc.recompute({ patientId: 'pat-1', source: 'test' });
expect(r.status).not.toBe('noop');
});
test('事务水位前进(正常增量)→ 重算', async () => {
const { svc } = await build({
persona: { eventWatermark: 100n, factWatermark: T1 },
now: { eventSeq: 101n, factAt: T1 },
});
expect((await svc.recompute({ patientId: 'pat-1', source: 'test' })).status).not.toBe('noop');
});
test('从没算过 → 必算', async () => {
const { svc } = await build({ persona: null, now: { eventSeq: 1n, factAt: T1 } });
expect((await svc.recompute({ patientId: 'pat-1', source: 'test' })).status).toBe('success');
});
test('force=true 直接穿闸', async () => {
const { svc } = await build({
persona: { eventWatermark: 100n, factWatermark: T1 },
now: { eventSeq: 100n, factAt: T1 },
storedFeatures: storedFrom(RFM()),
});
const r = await svc.recompute({ patientId: 'pat-1', source: 'test', force: true });
expect(r.status).toBe('unchanged'); // 算了,但内容确实没变
});
test('⭐ 到期改档:两个水位都没动,但 next_boundary_at 已过 → 必须重算', async () => {
// 事实一个字没变、时间跨过了档界(如急迫等级过 90 天线、年龄跨段)。
// 没有这一条,夜扫把到期患者捞出来入队也会被水位当场 noop 掉 —— 白捞。
const { svc } = await build({
persona: { eventWatermark: 100n, factWatermark: T1, nextBoundaryAt: new Date('2026-07-01T00:00:00Z') },
now: { eventSeq: 100n, factAt: T1 },
storedFeatures: storedFrom(RFM()),
});
const r = await svc.recompute({ patientId: 'pat-1', source: 'test', now: new Date('2026-07-26T00:00:00Z') });
expect(r.status).not.toBe('noop');
});
test('到期锚点还在未来 → 照常 noop', async () => {
const { svc } = await build({
persona: { eventWatermark: 100n, factWatermark: T1, nextBoundaryAt: new Date('2027-01-01T00:00:00Z') },
now: { eventSeq: 100n, factAt: T1 },
});
const r = await svc.recompute({ patientId: 'pat-1', source: 'test', now: new Date('2026-07-26T00:00:00Z') });
expect(r.status).toBe('noop');
});
test('该患者既无事务也无事实、且已有 baseline → noop(没有输入可变)', async () => {
const { svc } = await build({
persona: { eventWatermark: null, factWatermark: null },
now: { eventSeq: null, factAt: null },
});
expect((await svc.recompute({ patientId: 'pat-1', source: 'test' })).status).toBe('noop');
});
});
describe('写入侧节流 —— 算了不等于要写', () => {
test('⭐ 内容一字未变 → unchanged:不建新版本,只推水位', async () => {
const { svc, calls } = await build({
persona: { eventWatermark: 100n, factWatermark: T0 },
now: { eventSeq: 100n, factAt: T1 },
storedFeatures: storedFrom(RFM()),
});
const r = await svc.recompute({ patientId: 'pat-1', source: 'test' });
expect(r.status).toBe('unchanged');
expect(calls.personaCreate).toBe(0);
expect(calls.featureUpdate).toHaveLength(0);
// 水位必须推进,否则闸永远开着、下次还白算一遍
expect(calls.personaUpdate[0]).toMatchObject({ factWatermark: T1 });
expect(calls.personaUpdate[0]).not.toHaveProperty('refreshedAt');
});
test('⭐ 只有天数漂移 → refreshed:就地改 feature 行,**不升版本**,记 refreshedAt', async () => {
const { svc, calls } = await build({
persona: { eventWatermark: 100n, factWatermark: T0 },
now: { eventSeq: 100n, factAt: T1 },
storedFeatures: storedFrom(RFM()),
draft: RFM({
description: '重要价值 · 距上次 151 天 · 累计¥154,350',
data: { segment: 'important_value', recencyDays: 151, monetaryCents: 15435000 },
}),
});
const r = await svc.recompute({ patientId: 'pat-1', source: 'test' });
expect(r.status).toBe('refreshed');
expect(r.version).toBe(3); // 版本号不动
expect(calls.personaCreate).toBe(0);
expect(calls.featureUpdate).toHaveLength(1);
expect(calls.featureUpdate[0]).toMatchObject({ description: '重要价值 · 距上次 151 天 · 累计¥154,350' });
expect(calls.personaUpdate[0]).toHaveProperty('refreshedAt');
});
test('⭐⭐ 累计消费被 reparse 订正(应收→实付)→ semantic:升版本', async () => {
const { svc, calls } = await build({
persona: { eventWatermark: 100n, factWatermark: T0 },
now: { eventSeq: 100n, factAt: T1 },
storedFeatures: storedFrom(RFM({ data: { segment: 'important_value', recencyDays: 140, monetaryCents: 19575000 } })),
draft: RFM(), // 新算出来是实付 15435000
});
const r = await svc.recompute({ patientId: 'pat-1', source: 'test' });
expect(r.status).toBe('success');
expect(r.version).toBe(4);
expect(calls.personaCreate).toBe(1);
});
});
describe('查询瘦身', () => {
test('⭐ 两个水位一次查完,且正算路径不再重复查 transaction', async () => {
const { svc, calls } = await build({
persona: { eventWatermark: 100n, factWatermark: T0 },
now: { eventSeq: 100n, factAt: T1 },
storedFeatures: storedFrom(RFM()),
});
await svc.recompute({ patientId: 'pat-1', source: 'test' });
expect(calls.watermarkQueries).toBe(1);
expect(calls.txnFindFirst).toBe(0); // 旧实现这里会再查一次 latest event_seq
});
});
import { Prisma } from '@prisma/client';
import { lookupDxTreatment } from '@pac/types';
import { TreatmentInitiationRecallScenario } from '../src/modules/plan/engine/scenarios/treatment-initiation-recall.scenario';
import { PotentialTreatmentSelector } from '../src/modules/clinical-gap/potential-treatment.selector';
import {
buildGapCore,
GAP_FLAGS_BY_PRIMARY,
GAP_PRIMARY_GROUPS,
} from '../src/modules/clinical-gap/potential-treatment-gap.sql';
import type { PrismaService } from '../src/prisma/prisma.service';
/**
* ⑤g 排除闸:诊所已排未来回访 → 本轮不召。
*
* 由来(2026-07 业务可用性测试):
* 「超过百天他就会给你加急自动升了一个紧急,而且他设置过的回访的话他实际是不记得」
* 生产量级:在池 236,508 条 plan 里,30,426(12.9%)患者已有未到期回访排程;
* 其中急迫档 urgent 占 17,263 —— 正是一线说的"明明排了回访还催我"。
*
* 口径三条,本文件逐条锁住:
* ① 患者级 + 只认未到期(task_date > 今天)—— 到期自愈,不需要解除逻辑;
* "排了不做"(全库到期任务执行率 74.3%)靠这条自然放行,不另设兜底。
* ② 不设窗口上限 —— 在池未来排程 99.65% 在一年内,拍 N 天窗口反而会在第 N+1 天
* 把"诊所排了 N+30 天回访"的人放回池,制造重复触达。
* ③ ⭐ 必须在 scenario,**不能**进 gapWhere。
* gapWhere 是召回与画像共享的【临床事实】层(已治疗/患者拒绝/外院已做 → 缺口没了);
* 本闸是【运营时机】层(缺口还在,只是现在不该打电话),同 ⑤b 未来预约 / ⑤f 就诊冷静。
* 放错层会让画像因为"排了个回访"就说这人没有潜在治疗 —— 两套口径,正是要避免的分叉。
*/
/// 鸭子类型判 Prisma.Sql —— `Prisma.Sql` 在运行时不是可 instanceof 的构造器
type SqlLike = { strings: string[]; values: unknown[] };
const isSql = (v: unknown): v is SqlLike =>
!!v && typeof v === 'object' && Array.isArray((v as SqlLike).strings) && Array.isArray((v as SqlLike).values);
/// 把 Prisma.Sql 片段(含嵌套)摊平成可断言的文本。
/// 必须递归展开:嵌套片段(gapWhere / lateralJoin …)在外层 tagged template 里只是一个
/// 绑定参数,不摊开就看不见内容,断言会假通过。
function flatten(frag: Prisma.Sql | SqlLike | unknown): string {
if (!isSql(frag)) return ' ? ';
return frag.strings
.map((s, i) => s + (i < frag.values.length ? flatten(frag.values[i]) : ''))
.join('');
}
/// 捕获 $queryRaw 的完整 SQL 形状(静态段 + 递归摊平的嵌套片段;绑定值占位成 ` ? `)。
/// 返回 [] 让调用方走空结果短路,不需要再 mock 后续查询。
function makeSqlCapturingPrisma(): { prisma: PrismaService; sql: () => string } {
const captured: string[] = [];
const queryRaw = (strings: TemplateStringsArray, ...values: unknown[]) => {
const parts = Array.isArray(strings?.raw) ? strings.raw : [String(strings)];
captured.push(parts.map((s, i) => s + (i < values.length ? flatten(values[i]) : '')).join(''));
return Promise.resolve([]);
};
const prisma = { $queryRaw: queryRaw } as unknown as PrismaService;
return { prisma, sql: () => captured.join('\n/* --- next query --- */\n') };
}
describe('⑤g 未来回访排除闸 —— 召回 SQL', () => {
let sqlText: string;
/// ⑤g 闸自身的子查询文本(整条 SQL 里还有别的 rv.* 条件,断言必须收窄到闸内)
let gate: string;
beforeAll(async () => {
const { prisma, sql } = makeSqlCapturingPrisma();
const scenario = new TreatmentInitiationRecallScenario(prisma);
await scenario.selectHits({
hostId: '00000000-0000-0000-0000-000000000001',
tenantId: 't1',
now: new Date('2026-07-26T00:00:00Z'),
});
sqlText = sql();
// 同一条 SQL 里有两个 `SELECT 1 FROM patient_return_visits rv` 子查询:
// 外院已治疗闸(gapWhere,读 rv.result)和 ⑤g(读 rv.task_date 未来)。
// 取后者 —— 判据是它比的是 `::date`(跟今天比),外院那条比的是诊断日。
const subs = [...sqlText.matchAll(/SELECT 1 FROM patient_return_visits rv[\s\S]{0,240}?\)/g)].map((m) => m[0]);
gate = subs.find((s) => /task_date\s*>\s*\?\s*::date/.test(s)) ?? '';
expect(subs.length).toBeGreaterThanOrEqual(2); // 外院闸 + ⑤g 都在
expect(gate).not.toBe(''); // 抓不到 ⑤g 就别往下断言了,免得后面全部假通过
});
test('⭐ 闸存在,且是 NOT EXISTS 子查询(不是 JOIN,别把行数放大)', () => {
expect(sqlText).toContain('patient_return_visits');
expect(sqlText).toMatch(/NOT EXISTS \([\s\S]*?SELECT 1 FROM patient_return_visits rv/);
expect(sqlText).not.toMatch(/JOIN patient_return_visits/);
});
test('⭐ 只认未到期 —— 必须是 task_date > now::date,不能是 >= 或 <', () => {
// 绑定参数被占位成 ` ? `,故断言写成 `rv.task_date > ? ::date`。
// ⚠️ 只在 ⑤g 闸内断言:同一条 SQL 里外院闸也有 `rv.task_date >= 诊断日`,那是另一回事。
expect(gate).toMatch(/rv\.task_date\s*>\s*\?\s*::date/);
expect(gate).not.toMatch(/rv\.task_date\s*>=/);
expect(gate).not.toMatch(/rv\.task_date\s*<\s*\?/);
});
test('患者级:按 patient_id 关联,不掺牙位 / 类目条件', () => {
expect(gate).toContain('rv.patient_id = p.id');
expect(gate).not.toContain('tooth');
expect(gate).not.toContain('category');
});
test('⭐ 不设窗口上限(闸内只有一个 task_date 比较,没有 now + N 天的第二个条件)', () => {
expect(gate.match(/task_date/g)?.length).toBe(1);
expect(gate).not.toMatch(/interval|days'/i);
});
test('⑤b(未来预约)/ ⑤f(就诊冷静)仍在 —— ⑤g 是新增不是替换', () => {
expect(sqlText).toContain('appointment_record');
expect(sqlText).toMatch(/vis\.type IN \('encounter_record', 'emr_record'\)/);
});
});
describe('⭐ 分层纪律:⑤g 属于「运营时机」层,不得下沉到召回+画像共享的 gapWhere', () => {
const gap = buildGapCore({
rule: lookupDxTreatment('K08')!,
cfgFlags: GAP_FLAGS_BY_PRIMARY.K08 ?? {},
allCodes: [...GAP_PRIMARY_GROUPS.K08!.dxCodes, ...GAP_PRIMARY_GROUPS.K08!.recCodes],
resolverCats: ['implant', 'prosthodontic'],
});
const gapWhereText = flatten(gap.gapWhere);
test('⭐ gapWhere 里没有「与今天比的未来 task_date」—— 那是 ⑤g,不属于这一层', () => {
// ⚠️ 注意 gapWhere 里**本来就有** task_date:外院已治疗闸拿它跟**诊断日**比方向
// (rv.task_date >= COALESCE(sig.occurred_at, sig.planned_for)),那是临床事实判定。
// 要禁的是 ⑤g 那种跟**今天**比的未来判定(rv.task_date > $now::date)。
expect(gapWhereText).not.toMatch(/task_date\s*>\s*\?\s*::date/);
expect(gapWhereText.match(/task_date/g)?.length).toBe(1);
});
test('gapWhere 里**保留**外院已治疗闸 —— 它读 rv.result(临床事实),跟 ⑤g 分工不同', () => {
expect(gapWhereText).toContain('patient_return_visits');
expect(gapWhereText).toContain('rv.result');
expect(gapWhereText).toMatch(/rv\.task_date\s*>=\s*COALESCE\(sig\.occurred_at/);
});
test('画像 selector 跑出来的 SQL 不含未来回访闸', async () => {
const { prisma, sql } = makeSqlCapturingPrisma();
const selector = new PotentialTreatmentSelector(prisma);
await selector.selectForPatient({
hostId: '00000000-0000-0000-0000-000000000001',
tenantId: 't1',
patientId: '00000000-0000-0000-0000-0000000000aa',
now: new Date('2026-07-26T00:00:00Z'),
activeCodes: new Set(['K08', 'K07', 'K02']), // 不给码会被剪枝跳过,SQL 都不生成
});
const text = sql();
expect(text.length).toBeGreaterThan(0); // 确认真跑了 SQL,不是被剪枝跳过
// 外院闸的 task_date(跟诊断日比)允许出现;⑤g 那种跟今天比的未来判定不允许
expect(text).not.toMatch(/rv\.task_date\s*>\s*\?\s*::date/);
});
});
......@@ -4,6 +4,7 @@ import {
DRAFT_RECALL_SUMMARY_PROMPT_VERSION,
} from '../src/modules/ai/calls/draft-recall-summary/prompt';
import type { DraftRecallSummaryInput } from '../src/modules/ai/calls/draft-recall-summary/input.types';
import { DraftRecallSummaryCall } from '../src/modules/ai/calls/draft-recall-summary/call';
/**
* 历史联系摘要 —— 「未来排程 ≠ 漏做」回归。
......@@ -31,6 +32,7 @@ function item(over: Partial<DraftRecallSummaryInput['items'][0]> = {}) {
status: '已回访',
taskStatus: '已完成',
isFuture: false,
isLatestPast: false,
treatmentItems: null,
followContent: null,
result: '治疗后回访',
......@@ -114,6 +116,133 @@ describe('DRAFT_RECALL_SUMMARY_SYSTEM — 硬约束文本', () => {
test('promptVersion 已 bump(改了 prompt 必须换版本,否则缓存/评估串档)', () => {
expect(DRAFT_RECALL_SUMMARY_PROMPT_VERSION).not.toBe('draft_recall_summary@2026-06-16-d');
expect(DRAFT_RECALL_SUMMARY_PROMPT_VERSION).not.toBe('draft_recall_summary@2026-07-24-e');
expect(DRAFT_RECALL_SUMMARY_PROMPT_VERSION).toMatch(/^draft_recall_summary@\d{4}-\d{2}-\d{2}-[a-z]$/);
});
});
/**
* 「最近一次」突出 —— 2026-07 业务可用性测试回归。
*
* 原始反馈(患者余同学 BJ0L028292 详情页):
* [被测者]「说了常规的提醒没有特殊的」/[主持人]「那就是暂时没有办法详细的看到具体一次沟通什么内容」
*
* 生产核过的两件事,决定了这里怎么改:
* ① 源侧确实没有更多 —— DW 只有 fact_returnvisit_out 一张联系表,该患者 5 条全部
* 落库无丢失;follow_content/return_visit_result 都是**标签**(「复查洁牙」「方案跟进」
* 「无不适」「未接」),全库 165 万条平均 12.4 / 9.1 字。**没有**逐字沟通内容、
* 患者原话、下次建议联系时间这三样东西。→ PAC 不造数据,摘要维持"只讲记录里写着的"。
* ② 但「最近一次」值得单独拎出来 —— 列表按日期倒序时头部常是未到期排程,
* 客服最想知道的"上次聊到哪儿了"反而被埋在中间。
*
* 纪律不变:哪条是"最近一次"是**排序问题**,由 orchestrator 算好 isLatestPast 传入,
* 不让 LLM 自己比日期(同 isFuture)。
*/
describe('最近一次·已发生 —— 程序标注 + 摘要开头必须讲它', () => {
test('⭐ isLatestPast 条目带【最近一次·已发生】标记', () => {
const p = build([item({ taskDate: '2026-05-20', isLatestPast: true, followContent: '复查洁牙' })]);
expect(p).toContain('【最近一次·已发生】');
});
test('⭐ 未来排程即使排在最前也拿不到「最近一次」标记', () => {
const p = build([
item({ taskDate: '2026-10-09', isFuture: true, followContent: '复查洁牙' }),
item({ taskDate: '2026-05-20', isLatestPast: true, followContent: '方案跟进' }),
]);
const lines = p.split('\n');
expect(lines.find((l) => l.includes('2026-10-09'))).toContain('【未来排程·尚未到期】');
expect(lines.find((l) => l.includes('2026-10-09'))).not.toContain('【最近一次·已发生】');
expect(lines.find((l) => l.includes('2026-05-20'))).toContain('【最近一次·已发生】');
});
test('⭐ 有最近一次 → 追加"放开头讲清楚"的指令', () => {
const p = build([item({ isLatestPast: true })]);
expect(p).toContain('放在摘要开头讲清楚');
});
test('⭐ 全是未来排程 → 明确告知"没有已发生的回访",不许硬凑最近一次', () => {
const p = build([item({ taskDate: '2026-10-09', isFuture: true })]);
expect(p).toContain('没有已发生的回访');
expect(p).not.toContain('【最近一次·已发生】');
});
test('⭐ 最近一次没记结果 → 显式写「结果:未记录」(不写模型会拿别条的结果脑补)', () => {
const p = build([
item({ taskDate: '2026-05-20', status: '未回访', taskStatus: '未完成', isLatestPast: true, followContent: '复查洁牙', result: null }),
item({ taskDate: '2026-02-23', result: '复查' }),
]);
expect(p.split('\n').find((l) => l.includes('2026-05-20'))).toContain('结果:未记录');
});
test('非最近一次的空结果**不**补「未记录」(只在焦点那条上花笔墨)', () => {
const p = build([item({ taskDate: '2026-02-23', result: null, followContent: '半年复查' })]);
expect(p).not.toContain('结果:未记录');
});
test('⭐ 最近一次的正文预算更长 —— 源里几百字的方案沟通记录不能被 40 字砍没', () => {
const long = '一'.repeat(150);
expect(build([item({ isLatestPast: true, followContent: long })])).toContain('一'.repeat(150));
expect(build([item({ followContent: long })])).not.toContain('一'.repeat(41));
});
test('⭐ system 明写"源系统没有逐字沟通内容",禁止补写患者原话', () => {
expect(DRAFT_RECALL_SUMMARY_SYSTEM).toContain('没有逐字沟通内容');
expect(DRAFT_RECALL_SUMMARY_SYSTEM).toContain('患者表示');
expect(DRAFT_RECALL_SUMMARY_SYSTEM).toMatch(/先说「最近一次」/);
});
test('余同学 BJ0L028292 的生产原始形态 → 最近一次是 5-20 未回访、无结果', () => {
// 生产实测(2026-07-26 查 DW fact_returnvisit_out,customer_id=1683102 brand=瑞尔,共 5 条)
const p = buildDraftRecallSummaryPrompt({
patientNameMasked: '余*',
today: '2026-07-26',
items: [
item({ taskDate: '2026-05-20', status: '未回访', taskStatus: '未完成', isLatestPast: true, followContent: '复查洁牙', result: null }),
item({ taskDate: '2026-02-25', status: '未回访', taskStatus: '未完成', followContent: '方案跟进', result: null }),
item({ taskDate: '2026-02-23', status: '已回访', taskStatus: '已预约', followContent: null, result: '复查' }),
item({ taskDate: '2025-11-16', status: '未回访', taskStatus: '未完成', followContent: '半年复查', result: null }),
item({ taskDate: '2025-08-17', status: '已回访', taskStatus: '已完成', followContent: null, result: '复查' }),
],
});
expect(p).not.toContain('尚未到期的未来排程'); // 5 条全在过去
const latest = p.split('\n').find((l) => l.includes('2026-05-20'))!;
expect(latest).toContain('【最近一次·已发生】');
expect(latest).toContain('内容:复查洁牙');
expect(latest).toContain('结果:未记录');
});
});
/**
* 兜底文案(LLM 失败 / safety 拒收时展示给客服的那句)也走同一条时间纪律。
* 原实现直接拿 items[0] 当「最近一次」—— 而 items 按日期倒序,头部很可能是未到期排程,
* 等于把 2026-07 那次生产事故在兜底路径上原样复刻一遍。
*/
describe('DraftRecallSummaryCall.fallback — 兜底也不许把未来排程当最近一次', () => {
const call = new DraftRecallSummaryCall();
const run = (items: DraftRecallSummaryInput['items']) =>
call.fallback({ patientNameMasked: '余*', today: TODAY, items }).summary;
test('无记录 → 直说暂无', () => {
expect(run([])).toContain('暂无历史联系记录');
});
test('⭐ 头部是未来排程时,「最近一次」取的是 isLatestPast 那条,不是 items[0]', () => {
const s = run([
item({ taskDate: '2026-10-09', isFuture: true, result: null, followContent: '复查洁牙' }),
item({ taskDate: '2026-05-20', isLatestPast: true, result: '已约' }),
]);
expect(s).toContain('2026-05-20');
expect(s).not.toContain('2026-10-09');
});
test('⭐ 全是未来排程 → 不提「最近一次」', () => {
const s = run([item({ taskDate: '2026-10-09', isFuture: true })]);
expect(s).not.toContain('最近一次');
expect(s).toContain('尚未到期');
});
test('最近一次没结果 → 兜底也如实说未留结果,不留空', () => {
const s = run([item({ taskDate: '2026-05-20', isLatestPast: true, result: null, followContent: null })]);
expect(s).toContain('未留结果');
});
});
import { readdirSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
/**
* `.swcrc` 的 exclude 必须是**锚定**正则。
*
* 踩过的坑:原本写成 `["node_modules", "dist"]`,看着像目录名,实际 swc 当**正则**去匹配
* 整个文件路径 —— 于是新加的 `priority-distribution.service.ts` 因为文件名里含 "dist"
* 被静默跳过。271 个源文件只编译出 270 个,swc **不报任何错**
* ("Successfully compiled: 270 files"),直到进程启动才
* `Cannot find module './priority-distribution.service'`。
*
* 同类地雷:任何文件名撞上 dist / node_modules 子串的都会中招(distinct、distance、
* district、redistribute…)。而且只在 swc 构建路径(dev / `--builder swc`)复现,
* `nest build` 走 tsc 一切正常 —— 两边表现不一致,极难排查。
*/
describe('.swcrc exclude 必须锚定', () => {
const root = join(__dirname, '..');
const swcrc = JSON.parse(readFileSync(join(root, '.swcrc'), 'utf8')) as { exclude?: string[] };
const patterns = (swcrc.exclude ?? []).map((p) => new RegExp(p));
test('⭐ 每条 exclude 都以 ^ 开头(否则会误伤文件名含该子串的源文件)', () => {
const unanchored = (swcrc.exclude ?? []).filter((p) => !p.startsWith('^'));
expect(unanchored).toEqual([]);
});
test('⭐ 现有源文件一个都不会被 exclude 误伤', () => {
const files: string[] = [];
const walk = (rel: string) => {
for (const e of readdirSync(join(root, rel), { withFileTypes: true })) {
const p = `${rel}/${e.name}`;
if (e.isDirectory()) walk(p);
else if (e.name.endsWith('.ts')) files.push(p);
}
};
walk('src');
const hit = files.filter((f) => patterns.some((re) => re.test(f)));
expect(hit).toEqual([]);
});
});
......@@ -109,6 +109,9 @@ export function adaptData(real: PlanDetailData, dict?: TokenDictionary) {
id: real.persona?.id ?? '—',
version: real.persona?.version ?? 0,
computedAt: real.persona ? new Date(real.persona.computedAt) : now,
/// 内容真实新鲜度:只有天数漂移时后端就地刷新、不升版本,computedAt 会停在版本创建那刻。
/// 老响应没有这个字段 → 退回 computedAt。
refreshedAt: real.persona ? new Date(real.persona.refreshedAt ?? real.persona.computedAt) : now,
features: (real.persona?.features ?? []).map((f) => {
const meta = personaFeatureMeta(f.key);
return {
......@@ -116,7 +119,9 @@ export function adaptData(real: PlanDetailData, dict?: TokenDictionary) {
label: meta.label,
value: f.description,
tone: meta.tone,
evidence: [],
/// 证据 fact id —— 详情抽屉反查 facts 渲染「依据」。
/// 原来这里写死 [],库里存着的证据链一条都没被用上(客服只看得到结论看不到出处)。
evidence: f.evidenceFactIds ?? [],
data: f.data ?? null,
} as PersonaFeature;
}),
......
......@@ -2,13 +2,12 @@
import { type ReactNode } from 'react';
import { cn } from '@/lib/utils';
import { AIStamp, Chip, MD, tone } from './shared';
import { Chip } from './shared';
import { ChainDetailView } from './chain-viz';
import { EmrSoapView } from './emr-soap-view';
import { FactsTimeline } from './facts-timeline';
import { ToothTimeline } from './tooth-timeline';
import { cleanPersonaValue } from './persona-display';
import { PersonaFeatureHover } from './persona-feature-hover';
import { PersonaDetailList } from './persona-detail-list';
import type { Chain, PersonaFeature, PlanReason } from './mock-data';
import type { AdaptedFact } from './adapt-data';
import type { ReturnVisitItem } from './plan-detail-app';
......@@ -35,7 +34,7 @@ export function Drawer({
onClose: () => void;
kind: DrawerKind;
chains: Chain[];
persona: { computedAt: Date; features: PersonaFeature[] };
persona: { computedAt: Date; refreshedAt: Date; features: PersonaFeature[] };
summaries: {
medical_record: { content: string; generatedAt: Date };
treatment_chain: { content: string; generatedAt: Date };
......@@ -115,64 +114,10 @@ export function Drawer({
width = 'w-[560px]';
} else if (kind === 'persona') {
title = '画像标签';
subtitle = `更新于 ${fmtRel(persona.computedAt)} · ${persona.features.length} 项画像`;
body = (
<div className="space-y-3">
<AIStamp relative={fmtRel(persona.computedAt)} label="画像重算" />
{persona.features.map((f) => {
const T = tone(f.tone);
const { tag, text } = cleanPersonaValue(f.value);
// 多值特征(治疗史 / 潜在治疗 / 时间偏好…)后端给结构化 data.labels —— 详情里全部展开,
// 不像卡片那样截成 "+N"。单值特征回退到 cleanPersonaValue 的主描述文本。
const labels = (f.data as { labels?: unknown } | null | undefined)?.labels;
const valueLabels =
Array.isArray(labels) && labels.every((x) => typeof x === 'string')
? (labels as string[])
: null;
return (
<div key={f.key} className="relative rounded-md border border-slate-100 p-3 pr-8">
{/* 右上角 ? hover 看规则说明(算法口径) */}
<PersonaFeatureHover featureKey={f.key} value={f.value}>
<span
className="absolute top-2 right-2 inline-flex items-center justify-center w-4 h-4 rounded-full text-slate-400 cursor-help hover:text-slate-700 hover:bg-slate-100"
aria-label="查看规则说明"
>
<svg viewBox="0 0 24 24" className="w-3 h-3" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="10" />
<path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3M12 17h.01" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</span>
</PersonaFeatureHover>
{/* 属性名 */}
<div className={cn('inline-flex items-center gap-1.5 text-[11px] font-semibold', T.text)}>
<span className={cn('w-1.5 h-1.5 rounded-full', T.dot)} />
{f.label}
{tag && (
<span className="ml-1 px-1 py-0 rounded bg-slate-100 text-slate-600 text-[10.5px] font-normal">
{tag}
</span>
)}
</div>
{/* 取值条目 — 多值展开为 chip,单值显示文本 */}
{valueLabels && valueLabels.length > 0 ? (
<div className="mt-1.5 flex flex-wrap gap-1">
{valueLabels.map((l, i) => (
<span
key={i}
className="inline-flex items-center rounded bg-slate-50 px-1.5 py-0.5 text-[12px] font-medium text-slate-800 ring-1 ring-slate-100"
>
{l}
</span>
))}
</div>
) : (
text && <div className="text-[13px] font-medium text-slate-900 mt-1">{text}</div>
)}
</div>
);
})}
</div>
);
// 「更新于」用 refreshedAt(内容真实新鲜度):只有天数漂移时后端就地刷新、不升版本,
// computedAt 会停在版本创建那刻,拿它显示会让客服以为数据比实际更旧。
subtitle = `更新于 ${fmtRel(persona.refreshedAt)} · ${persona.features.length} 项画像`;
body = <PersonaDetailList features={persona.features} facts={facts} />;
}
return (
......@@ -364,20 +309,47 @@ function CBCTImageView() {
);
}
/** 历史联系(诊所回访)结构化列表 — 「历史联系」卡片「详情」抽屉内容。 */
/**
* 历史联系(诊所回访)结构化列表 — 「历史联系」卡片「详情」抽屉内容。
*
* 「最近一次」单独标出来:客服接手第一个问题就是"上次联系聊到哪儿了",而列表按日期倒序时
* 头部很可能是**还没到日子的排程**,肉眼分不出哪条才是真发生过的最后一次。
* 判定口径与后端摘要一致(taskDate ≤ 今天 的第一条),两处说法不会打架。
*
* ⚠️ 宿主回访表只有**事由标签 + 结果标签**,没有逐字沟通内容。空着不如明说"未留结果",
* 免得客服以为是 PAC 没展示(2026-07 可用性测试的原始反馈就是这个误会)。
*/
function ReturnVisitsList({ visits }: { visits: ReturnVisitItem[] }) {
if (visits.length === 0) {
return <div className="text-center py-12 text-sm text-slate-400">无历史联系记录</div>;
}
const today = new Date().toISOString().slice(0, 10);
const latestPast = visits.findIndex((v) => v.taskDate != null && v.taskDate <= today);
return (
<div className="space-y-2">
{visits.map((v, i) => {
const done = v.status === '已回访' || v.taskStatus === '已完成';
const future = v.taskDate != null && v.taskDate > today;
const latest = i === latestPast;
return (
<div key={i} className="flex items-start gap-2.5 text-[12.5px] py-1 border-b border-slate-50 last:border-0">
<div
key={i}
className={cn(
'flex items-start gap-2.5 text-[12.5px] py-1 border-b border-slate-50 last:border-0',
latest && 'bg-teal-50/50 -mx-2 px-2 rounded',
)}
>
<span className={cn('flex-none mt-[5px] w-1.5 h-1.5 rounded-full', done ? 'bg-teal-400' : 'bg-slate-300')} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5 flex-wrap">
{latest && (
<span className="flex-none rounded bg-teal-600 px-1 py-px text-[10px] font-medium text-white">
最近一次
</span>
)}
{future && (
<span className="flex-none rounded bg-slate-100 px-1 py-px text-[10px] text-slate-500">未到期</span>
)}
<span className="font-medium text-slate-700">{v.type ?? '回访'}</span>
{v.status && <span className="text-slate-400">· {v.status}</span>}
{v.taskDate && <span className="text-slate-400 tabular-nums">{v.taskDate}</span>}
......@@ -386,8 +358,10 @@ function ReturnVisitsList({ visits }: { visits: ReturnVisitItem[] }) {
{v.followContent && (
<div className="text-slate-600 leading-snug break-words mt-0.5">{v.followContent}</div>
)}
{v.result && (
{v.result ? (
<div className="text-slate-400 leading-snug break-words mt-0.5">结果:{v.result}</div>
) : (
latest && <div className="text-slate-400 leading-snug mt-0.5">结果:宿主未记录</div>
)}
</div>
</div>
......
import { diagnosisCodeNameZh, treatmentCategoryNameZh } from '@pac/types';
import type { AdaptedFact } from './adapt-data';
/**
* 一条 fact 的紧凑展示形态:徽标 + 一行文本。
*
* 原本只长在 tooth-timeline.tsx 里、只覆盖临床四类(诊断/治疗/建议/影像);
* 画像「依据」区要展示任意类型的证据 fact(结算、预约、就诊、病历都会出现),
* 落到 default 分支会直接把 `payment_record` 这种表名甩给客服。故抽出来共用并补齐常见类型。
*/
export interface FactLabel {
badge: string;
badgeTone: string;
text: string;
}
const MODALITY_ZH: Record<string, string> = {
pa: '根尖片',
bw: '咬翼片',
pano: '曲面断层',
cbct: 'CBCT',
intraoral_photo: '口内照片',
};
const yuan = (cents: unknown): string => {
const n = Number(cents ?? 0);
return Number.isFinite(n) ? ${Math.round(n / 100).toLocaleString('zh-CN')}` : '';
};
export function factLabel(f: AdaptedFact): FactLabel {
const c = (f.content ?? {}) as Record<string, unknown>;
switch (f.type) {
case 'diagnosis_record': {
const code = String(c.code ?? '');
const name = String(c.name_zh ?? c.name ?? '');
const std = code ? diagnosisCodeNameZh(code) : '';
const text = std && name && std !== name ? `${std} · ${name}` : std || name || '诊断';
return { badge: '诊断', badgeTone: 'bg-rose-50 text-rose-700', text };
}
case 'treatment_record': {
const cat = String(c.category ?? '');
const sub = String(c.subtype ?? '');
const catZh = cat ? treatmentCategoryNameZh(cat) : '';
const text = sub || catZh || '治疗';
const isPlanned = f.kind === 'planned';
return {
badge: isPlanned ? '计划' : catZh || '治疗',
badgeTone: isPlanned ? 'bg-slate-100 text-slate-500' : 'bg-teal-50 text-teal-700',
text,
};
}
case 'recommendation_record': {
const name = String(c.name ?? '');
const code = String(c.code ?? '');
return {
badge: '建议',
badgeTone: 'bg-amber-50 text-amber-700',
text: name || (code ? diagnosisCodeNameZh(code) : '') || '医生建议',
};
}
case 'image_record': {
const mod = String(c.modality ?? '');
return {
badge: '影像',
badgeTone: 'bg-slate-100 text-slate-500',
text: MODALITY_ZH[mod] ?? mod ?? '影像',
};
}
// ── 以下为画像证据链补齐(临床时间轴用不到,但画像标签大量引用)──
case 'payment_record': {
// 折扣锚点 / 权益身份的证据:金额 + 卡券或项目名才说明得了问题
const project = String(c.settlement_project ?? '').trim();
const card = String(c.card_name ?? c.card_type_name ?? c.insurance_name ?? '').trim();
const parts = [yuan(c.amount_cents), card, project].filter(Boolean);
return { badge: '结算', badgeTone: 'bg-indigo-50 text-indigo-700', text: parts.join(' · ') || '结算' };
}
case 'recharge_record':
return { badge: '充值', badgeTone: 'bg-indigo-50 text-indigo-700', text: yuan(c.amount_cents) || '充值' };
case 'refund_record':
return { badge: '退费', badgeTone: 'bg-rose-50 text-rose-700', text: yuan(c.amount_cents) || '退费' };
case 'appointment_record': {
// 特别关注(爽约/迟到)的证据:履约状态才是重点
const st = String(c.status ?? f.status ?? '');
const ST_ZH: Record<string, string> = {
no_show: '爽约',
cancelled: '已取消',
completed: '已到诊',
arrived: '已到诊',
booked: '已预约',
};
return { badge: '预约', badgeTone: 'bg-sky-50 text-sky-700', text: ST_ZH[st] ?? st ?? '预约' };
}
case 'encounter_record':
return { badge: '就诊', badgeTone: 'bg-teal-50 text-teal-700', text: f.title ?? '到诊' };
case 'visit_registration_record':
return { badge: '挂号', badgeTone: 'bg-teal-50 text-teal-700', text: f.title ?? '挂号' };
case 'emr_record':
// 治疗敏感 / 不可等候的证据来自病历自由文本 —— summary 是命中的那段
return { badge: '病历', badgeTone: 'bg-slate-100 text-slate-600', text: f.summary ?? f.title ?? '病历' };
case 'consultation_record':
return { badge: '咨询', badgeTone: 'bg-amber-50 text-amber-700', text: f.title ?? '咨询' };
default:
return { badge: '事实', badgeTone: 'bg-slate-100 text-slate-500', text: f.title ?? f.type };
}
}
......@@ -210,52 +210,63 @@ export type PersonaFeature = {
data?: unknown;
};
// mock 画像 —— 只用**在跑的**特征 key(与 PERSONA_FEATURE_SPECS 一致)。
// 原来这里摆着 value / treatment_chain_status / recall_risk / communication_preference /
// cooperation_attitude / doctor_relation 六个 —— 前三个 W7 已并入 rfm、后三个从未实现,
// 于是 mock 长期展示着系统根本不产出的标签,拿它当参照会看错。
export const mockPersona = {
id: 'prsn_v7',
version: 7,
computedAt: daysAgo(2),
refreshedAt: daysAgo(2),
features: [
{
key: PersonaFeatureKey.VALUE,
label: '患者价值',
value: 'VIP 金卡 · LTV ¥48,200',
tone: 'indigo',
evidence: ['fact_pay_2024_05', 'fact_pay_2024_07', 'fact_pay_2024_10'],
key: PersonaFeatureKey.URGENCY_LEVEL,
label: '急迫等级',
value: '紧急 · 末诊140天 · 2 类潜在治疗',
tone: 'rose',
evidence: ['fact_dx_2025_12', 'fact_visit_2026_04'],
data: { level: 'urgent', levelZh: '紧急', lastVisitDays: 140, pendingTypes: 2 },
},
{
key: PersonaFeatureKey.TREATMENT_CHAIN_STATUS,
label: '治疗链状态',
value: '2 条闭环/在管 · 1 条断口',
key: PersonaFeatureKey.POTENTIAL_TREATMENT,
label: '潜在治疗',
value: '潜在种植 / 潜在修复',
tone: 'amber',
evidence: ['fact_dx_2025_12', 'fact_tx_2024_07'],
evidence: ['fact_dx_2025_12'],
data: { types: ['implant', 'restoration'], labels: ['潜在种植', '潜在修复'] },
},
{
key: PersonaFeatureKey.RECALL_RISK,
label: '流失风险',
value: '低 — 牙周维护按期',
tone: 'emerald',
evidence: ['fact_visit_2026_04'],
key: PersonaFeatureKey.RFM,
label: '价值分群',
value: '重要价值 · 距上次 140 天 · 就诊20次 · 累计¥48,200',
tone: 'indigo',
evidence: ['fact_pay_2024_05', 'fact_pay_2024_07', 'fact_pay_2024_10'],
data: { segment: 'important_value', recencyDays: 140, freqCount: 20, monetaryCents: 4820000 },
},
{
key: PersonaFeatureKey.COMMUNICATION_PREFERENCE,
label: '沟通偏好',
value: '电话 · 工作日 19:00 后',
tone: 'sky',
evidence: ['fact_pref_2024'],
key: PersonaFeatureKey.LIFECYCLE_STAGE,
label: '生命周期',
value: '成熟客 · 末诊140天 · 就诊20次',
tone: 'teal',
evidence: ['fact_visit_2026_04'],
data: { stage: 'mature', label: '成熟客', recencyDays: 140, totalVisits: 20 },
},
{
key: PersonaFeatureKey.COOPERATION_ATTITUDE,
label: '配合态度',
value: '高 · 6 次复诊全到',
tone: 'emerald',
evidence: ['fact_visit_history'],
key: PersonaFeatureKey.TREATMENT_HISTORY,
label: '治疗史',
value: '种植史 / 牙周治疗史',
tone: 'amber',
evidence: ['fact_tx_2024_07'],
data: { types: ['implant_history', 'perio_history'], labels: ['种植史', '牙周治疗史'] },
},
{
key: PersonaFeatureKey.DOCTOR_RELATION,
label: '主治医患',
value: '李医生(种植)· 王医生(牙周)',
tone: 'slate',
evidence: ['fact_emr_2024'],
key: PersonaFeatureKey.AGE_BRACKET,
label: '年龄段',
value: '中年 · 42岁',
tone: 'sky',
evidence: [],
data: { bracket: 'middle_aged', label: '中年', ageYears: 42 },
},
] as PersonaFeature[],
};
......
'use client';
import * as React from 'react';
import {
PERSONA_FEATURE_GROUP_LABEL,
PERSONA_FEATURE_GROUP_ORDER,
personaFeatureGroup,
personaFeatureSortKey,
type PersonaFeatureGroup,
} from '@pac/types';
import { cn } from '@/lib/utils';
import { tone } from './shared';
import { PersonaFeatureHover } from './persona-feature-hover';
import { factLabel } from './fact-label';
import type { PersonaFeature } from './mock-data';
import type { AdaptedFact } from './adapt-data';
/**
* 画像标签详情列表(抽屉体)。
*
* 相对旧版改了三件事:
* ① **分组 + 定序**。原来 16 张卡等权重平铺,顺序来自 `orderBy createdAt`;而同一版的 feature
* 行是一条 createMany 写进去的、createdAt 完全相同 → 排序实际未定义,生产上呈现为英文 key
* 字母序:「急迫等级 = 紧急」排在最后一个,「性别」「获客渠道」排在最前。现按用途分三组。
* ② **给出依据**。后端一直存着 evidence.factIds(rfm + lifecycle 两个特征在生产就占 1 GB),
* 但前端 adapt-data 把它写死成空数组 —— 客服只看得到结论、看不到出处。现在反查同一份 facts
* 渲染最近几条,并给出总条数。
* ③ **`?` 改成真按钮**。原来是 16×16 的 `<span>`,无 tabindex 无 role、纯 hover —— 键盘、读屏、
* 平板触屏都够不着。现在是 button(focus 可触发 hover 卡),热区放大到 24×24。
*/
const MAX_EVIDENCE_ROWS = 4;
/**
* 折扣锚点超过这个年数就提示「已过时」。
*
* 生产上见过 2018 年的锚点(8 年前)照旧原样展示 —— 客服照着那个折扣去谈会出事。
* 阈值取 3 年:口腔项目定价与集团政策的调整周期大致在这个量级。
*/
const DISCOUNT_ANCHOR_STALE_YEARS = 3;
/** 折扣锚点的过时提醒;非折扣锚点或日期缺失 → null */
function staleAnchorHint(feature: PersonaFeature): string | null {
if (feature.key !== 'discount_anchor') return null;
const at = (feature.data as { anchorDate?: unknown } | null | undefined)?.anchorDate;
if (typeof at !== 'string') return null;
const years = (Date.now() - new Date(at).getTime()) / (365.25 * 86400_000);
if (!Number.isFinite(years) || years < DISCOUNT_ANCHOR_STALE_YEARS) return null;
return `该折扣发生在 ${Math.floor(years)} 年前,当时的定价与政策未必仍适用 —— 报价前请先与主管确认`;
}
export function PersonaDetailList({
features,
facts,
}: {
features: PersonaFeature[];
facts: AdaptedFact[];
}) {
const factById = React.useMemo(() => new Map(facts.map((f) => [f.id, f])), [facts]);
const grouped = React.useMemo(() => {
const sorted = [...features].sort((a, b) => {
const [ga, oa] = personaFeatureSortKey(a.key);
const [gb, ob] = personaFeatureSortKey(b.key);
return ga - gb || oa - ob || a.key.localeCompare(b.key);
});
const buckets = new Map<PersonaFeatureGroup | 'other', PersonaFeature[]>();
for (const f of sorted) {
const g = personaFeatureGroup(f.key) ?? 'other';
buckets.set(g, [...(buckets.get(g) ?? []), f]);
}
return buckets;
}, [features]);
const sections: Array<[string, PersonaFeature[]]> = [
...PERSONA_FEATURE_GROUP_ORDER.map(
(g) => [PERSONA_FEATURE_GROUP_LABEL[g], grouped.get(g) ?? []] as [string, PersonaFeature[]],
),
// 注册表里没登记的标签不吞掉,兜到最后一组(比"标签凭空消失"好排查)
['其他', grouped.get('other') ?? []],
];
return (
<div className="space-y-4">
{sections
.filter(([, items]) => items.length > 0)
.map(([label, items]) => (
<section key={label} className="space-y-2">
<h3 className="text-[10.5px] font-semibold tracking-wide text-slate-400">
{label}
<span className="ml-1.5 font-normal text-slate-300">{items.length}</span>
</h3>
{items.map((f) => (
<FeatureCard key={f.key} feature={f} factById={factById} />
))}
</section>
))}
</div>
);
}
function FeatureCard({
feature: f,
factById,
}: {
feature: PersonaFeature;
factById: Map<string, AdaptedFact>;
}) {
const [showAll, setShowAll] = React.useState(false);
const T = tone(f.tone);
const staleHint = staleAnchorHint(f);
// 多值特征(治疗史 / 潜在治疗 / 时间偏好…)后端给结构化 data.labels —— 详情里全部展开,
// 不像卡片那样截成 "+N"。单值特征直接显示后端那句完整 description。
const labels = (f.data as { labels?: unknown } | null | undefined)?.labels;
const valueLabels =
Array.isArray(labels) && labels.every((x) => typeof x === 'string') ? (labels as string[]) : null;
// 证据:只渲染能在本次响应的 facts 里找到的(跨版本的旧 fact 可能已被 supersede,查不到就不显)
const resolved = f.evidence
.map((id) => factById.get(id))
.filter((x): x is AdaptedFact => !!x)
.sort((a, b) => (b.occurredAt ?? '').localeCompare(a.occurredAt ?? ''));
const shown = showAll ? resolved : resolved.slice(0, MAX_EVIDENCE_ROWS);
return (
<div className="relative rounded-md border border-slate-100 p-3 pr-9">
{/* 右上角 ? —— 真 button:可 Tab 聚焦,focus 也能唤出说明卡(触屏/键盘可达) */}
<PersonaFeatureHover featureKey={f.key} value={f.value}>
<button
type="button"
className="absolute top-1.5 right-1.5 inline-flex h-6 w-6 items-center justify-center rounded-full text-slate-400 hover:bg-slate-100 hover:text-slate-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-teal-500"
aria-label={`${f.label} 怎么算的`}
>
<svg viewBox="0 0 24 24" className="h-3.5 w-3.5" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="10" />
<path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3M12 17h.01" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
</PersonaFeatureHover>
{/* 属性名 */}
<div className={cn('inline-flex items-center gap-1.5 text-[11px] font-semibold', T.text)}>
<span className={cn('h-1.5 w-1.5 rounded-full', T.dot)} />
{f.label}
</div>
{/* 取值条目 — 多值展开为 chip,单值显示文本 */}
{valueLabels && valueLabels.length > 0 ? (
<div className="mt-1.5 flex flex-wrap gap-1">
{valueLabels.map((l, i) => (
<span
key={i}
className="inline-flex items-center rounded bg-slate-50 px-1.5 py-0.5 text-[12px] font-medium text-slate-800 ring-1 ring-slate-100"
>
{l}
</span>
))}
</div>
) : (
f.value && <div className="mt-1 text-[13px] font-medium text-slate-900">{f.value}</div>
)}
{/* 过时提醒 —— 目前只有折扣锚点会触发(见 staleAnchorHint) */}
{staleHint && (
<p className="mt-1.5 flex gap-1 rounded bg-amber-50 px-1.5 py-1 text-[10.5px] leading-relaxed text-amber-800">
<span aria-hidden>⚠️</span>
<span>{staleHint}</span>
</p>
)}
{/* 依据 —— 这个标签是被哪些事实推出来的 */}
{resolved.length > 0 && (
<div className="mt-2 border-t border-slate-100 pt-2">
<div className="mb-1 flex items-baseline gap-2">
<span className="text-[10.5px] text-slate-400">依据 · {resolved.length}</span>
{resolved.length > MAX_EVIDENCE_ROWS && (
<button
type="button"
onClick={() => setShowAll((v) => !v)}
className="text-[10.5px] text-teal-700 hover:underline"
>
{showAll ? '收起' : `展开全部`}
</button>
)}
</div>
<ul className="space-y-0.5">
{shown.map((fact) => {
const { badge, badgeTone, text: label } = factLabel(fact);
return (
<li key={fact.id} className="flex items-baseline gap-1.5 text-[11px] leading-relaxed">
{/* 日期不折行 —— 定宽 68px 装不下 `2026-03-27`(11px 字号)会断成两行;
改为按内容撑开 + nowrap,tabular-nums 保证各行等宽自然对齐 */}
<span className="flex-none whitespace-nowrap tabular-nums text-slate-400">
{(fact.occurredAt ?? fact.plannedFor ?? '').slice(0, 10) || '—'}
</span>
<span className={cn('flex-none rounded px-1 text-[10px]', badgeTone)}>{badge}</span>
<span className="min-w-0 flex-1 truncate text-slate-700" title={label}>
{label}
</span>
</li>
);
})}
</ul>
</div>
)}
</div>
);
}
/**
* persona-display — persona feature.description 展示辅助
* persona feature.description 的展示辅助 —— 把后端那句自包含描述裁成不同密度的展示形态。
*
* 后端 persona feature description 故意带 `[英文 enum]` 前缀(给 LLM prompt 当
* machine-readable hint,例 `[gap] 治疗链有 3 处缺口`),前端展示要中文化或分离成 chip
* 后端每个特征产一句 description(如 `重要价值 · 距上次 140 天 · 就诊20次 · 累计¥154,350`),
* 详情抽屉整句显示,紧凑位(右侧 hint 列 / 标签云 chip)放不下,要短词
*
* 用法:
* const { tag, text } = cleanPersonaValue(feature.value);
* // tag 渲染成小灰 chip(可空);text 主描述
* 注:这里曾有一套 `[enum] 文本` 前缀的解析(`[gap] 治疗链有 3 处缺口` → chip「缺口」+ 正文),
* 服务的是 treatment_chain_status / recall_risk / value / do_not_contact_status 四个特征 ——
* W7 已把它们摘除(前三个并入 rfm,免打扰并入 special_attention)。现存 16 个 extractor
* 没有一个产 `[enum]` 前缀,那套解析与它的中文字典已随之删除。
*/
const PERSONA_STATUS_ZH: Record<string, string> = {
// treatment_chain_status
gap: '缺口',
ongoing: '在管',
closed: '闭环',
in_progress: '在管',
no_chain: '无链',
// recall_risk
high: '高',
medium: '中',
low: '低',
none: '无',
// do_not_contact_status
contactable: '可触达',
DNC: '拒不打扰',
// value
diamond: '钻卡',
gold: '金卡',
silver: '银卡',
normal: '普通',
new: '新客',
};
export function cleanPersonaValue(raw: string | null | undefined): {
tag: string | null;
text: string;
} {
if (!raw) return { tag: null, text: '—' };
const m = /^\s*\[([A-Za-z_]+)\]\s*(.*)$/.exec(raw);
if (!m) return { tag: null, text: raw };
const enumKey = m[1]!;
const text = m[2]!.trim();
const tag = PERSONA_STATUS_ZH[enumKey] ?? enumKey;
// 文本完全等同 tag 翻译时(例 "[contactable] 可触达") 只显示 tag,避免重复
if (text === PERSONA_STATUS_ZH[enumKey]) return { tag, text: '' };
return { tag, text };
}
/**
* 取 persona description 的"短标签" — 用于"关键事实"等紧凑位置(右侧 hint 列)
*
* 后端 description 常带详细括号(给客服详情看),紧凑位放不下,要短词:
* "新客/未消费(累计净消费 ¥58.00,含 ...)" → "新客"
* "钻卡(LTV ¥48,200,含 ...)" → "钻卡"
* "[gap] 治疗链有 3 处缺口 — K05..." → "缺口"(取 [enum] 翻译)
*
* 规则:
* ① 有 [enum] 前缀 → 用翻译后的 tag(简洁)
* ② 否则 → 截首段(到第一个 "/" 或 "(" 或 "(" 前)
* 短标签 —— 用于"关键事实"等紧凑位置。
* `新客/未消费(累计净消费 ¥58.00,含 ...)` → `新客`
* `重要价值 · 距上次 140 天 · 累计¥154,350` → `重要价值`
* 规则:截到第一个分隔符(`·` / `/` / 半角或全角括号)之前。
*/
export function shortPersonaValueLabel(raw: string | null | undefined): string {
if (!raw) return '—';
// ① [enum] 前缀模式 — 直接用 tag
const m = /^\s*\[([A-Za-z_]+)\]/.exec(raw);
if (m) return PERSONA_STATUS_ZH[m[1]!] ?? m[1]!;
// ② 截首段:遇到 · / 或 ( 或 ( 停(分隔符语义都是"主词 · 详情")
const stop = raw.search(/[·\/(()]/);
return (stop > 0 ? raw.slice(0, stop) : raw).trim();
}
/**
* 多值标签的紧凑显示(方案 A:首值 + "+N" 溢出)
* 多值标签的紧凑显示(首值 + `+N` 溢出)。
*
* 治疗史 / 权益 / 禁忌 / 潜在治疗 / 时间偏好 / 治疗敏感 / 特别关注 这 7 个特征
* 是"`/` 并列多值",description 会被 shortPersonaValueLabel 在 `/` 处误截只剩第一个、
* 且不提示还有更多。这里改读结构化 `data.labels`(后端这 7 个都带),chip 显示
* 第一个值 + `+N`(N=剩余个数),完整列表走 hover 卡片。单值特征 → { text, more:0 }
* 治疗史 / 权益 / 禁忌 / 潜在治疗 / 时间偏好 / 治疗敏感 / 特别关注 这 7 个特征是「`/` 并列多值」,
* 直接交给 shortPersonaValueLabel 会在第一个 `/` 处截断、只剩首值且不提示还有更多。
* 这里改读结构化 `data.labels`(这 7 个后端都带),显示首值 + `+N`,完整列表走 hover 卡片。
* 单值特征 → `{ text, more: 0 }`
*/
export function compactPersonaValue(
raw: string | null | undefined,
......
'use client';
import * as React from 'react';
import { PersonaFeatureKey } from '@pac/types';
import { personaFeatureDisplay, personaFeatureMeta } from '@pac/types';
import { HoverCard, HoverCardTrigger, HoverCardContent } from '@/components/ui/hover-card';
/**
* PersonaFeatureHover — 患者画像 4 个 feature 的算法说明 hover card
* PersonaFeatureHover — 画像标签的「怎么算的」说明卡
*
* 数据来源:apps/pac-service/src/modules/persona/features/*.feature.ts 顶部 JSDoc。
* 算法 doc 改动时同步这里(后续可考虑后端返回 description,前端零硬编码)。
* 内容来自 `@pac/types` 的 PERSONA_FEATURE_SPECS[key].display —— 与后端 extractor 的口径
* (同一份标签卡里的 algorithm 字段)贴脸放。**前端不再自己维护一份算法字典**:
* 原来这里硬编的 ALGORITHMS 已经和后端漂了 —— 权益身份只列 2 类(实际 5 类)、还承诺
* "显示保司 + 最近日期"(数据里根本没有);转介绍达人把门槛写成"推外人≥3人"(真实口径是
* 推荐≥3人**且**带来成交)。客服是照着这段话跟患者讲的,说明写错比不写更糟。
*
* 触发器(children)在 feature 卡片右上角放一个 ? 图标
* 触发器(children)由调用方给 —— 抽屉里是卡片右上角的 ? 按钮,标签云里是 chip 本身
*/
export function PersonaFeatureHover({
featureKey,
......@@ -18,12 +21,14 @@ export function PersonaFeatureHover({
children,
}: {
featureKey: string;
/// 该患者的实际取值(原生 title 内容并入 hovercard 顶部)
/// 该患者的实际取值(展示在说明卡顶部,先说"你是什么"再说"怎么算的")
value?: string;
children: React.ReactNode;
}) {
const meta = ALGORITHMS[featureKey];
if (!meta && !value) return <>{children}</>;
const display = personaFeatureDisplay(featureKey);
if (!display && !value) return <>{children}</>;
const title = personaFeatureMeta(featureKey).label;
return (
<HoverCard openDelay={150} closeDelay={80}>
{/* 直接把 children 作为 trigger,radix 锚定 children 本身的位置(避免多包一层 0 尺寸 span)*/}
......@@ -31,189 +36,34 @@ export function PersonaFeatureHover({
{/* z-[70]:抽屉面板是 z-[60],默认 hovercard z-50 会被抽屉盖住(详情抽屉里 ? hover 不显的根因) */}
<HoverCardContent align="end" sideOffset={6} className="z-[70] w-80 p-3 text-[11.5px]">
<div className="space-y-2">
<div className="flex items-baseline justify-between border-b border-slate-100 pb-1.5">
<span className="text-[13px] font-semibold text-slate-900">{meta?.title ?? featureKey}</span>
{meta?.subtitle && <span className="text-[10.5px] text-slate-500">{meta.subtitle}</span>}
<div className="flex items-baseline justify-between gap-2 border-b border-slate-100 pb-1.5">
<span className="text-[13px] font-semibold text-slate-900">{title}</span>
{display && (
<span className="text-right text-[10.5px] text-slate-500">{display.subtitle}</span>
)}
</div>
{/* 本患者实际取值(原 title 内容)*/}
{/* 本患者实际取值 */}
{value && (
<div className="rounded bg-teal-50 px-2 py-1.5 text-[11px] text-teal-800 leading-relaxed">
<div className="rounded bg-teal-50 px-2 py-1.5 text-[11px] leading-relaxed text-teal-800">
{value}
</div>
)}
{meta?.formula && (
<div className="rounded bg-slate-50 px-2 py-1.5 font-mono text-[10.5px] text-slate-700 leading-relaxed">
{meta.formula}
</div>
)}
{meta && (
<ul className="space-y-1 text-slate-600 leading-relaxed">
{meta.rules.map((r, i) => (
{display && (
<ul className="space-y-1 leading-relaxed text-slate-600">
{display.rules.map((r, i) => (
<li key={i} className="flex gap-2">
<span className="text-slate-400 flex-none">·</span>
<span className="flex-none text-slate-400">·</span>
{/* 两列对照:加粗取值 + 短说明。客服先扫左边找到自己要的那个取值,再读右边 */}
<span>
{r.label && <strong className="text-slate-800 mr-1">{r.label}</strong>}
<strong className="mr-1 text-slate-800">{r.label}</strong>
{r.body}
</span>
</li>
))}
</ul>
)}
{meta?.note && (
<p className="border-t border-slate-100 pt-1.5 text-[10.5px] text-slate-500 leading-relaxed">
{meta.note}
</p>
)}
</div>
</HoverCardContent>
</HoverCard>
);
}
interface AlgoMeta {
title: string;
subtitle: string;
formula?: string;
rules: { label?: string; body: string }[];
note?: string;
}
const ALGORITHMS: Record<string, AlgoMeta> = {
[PersonaFeatureKey.RFM]: {
title: '价值分群 (RFM)',
subtitle: '按最近就诊 · 频次 · 累计消费',
rules: [
{ label: '重要价值', body: '近期来过、高频、消费高' },
{ label: '重要保持/发展/挽留', body: '消费高,但最近度或频次偏弱' },
{ label: '一般价值/保持/发展', body: '消费偏低的对应象限' },
{ label: '低活跃', body: '很久没来,或各项都偏低' },
],
},
[PersonaFeatureKey.AGE_BRACKET]: {
title: '年龄段',
subtitle: '按生日当下计算',
rules: [
{ body: '婴幼儿 0-2 / 学龄前 3-6 / 替牙期 7-11 / 青少年 12-17' },
{ body: '青年 18-25 / 中青年 26-30 / 中年 31-45 / 中老年 46-54 / 老年 ≥55' },
],
},
[PersonaFeatureKey.GENDER]: {
title: '性别',
subtitle: '影响话术与项目推荐',
rules: [{ label: '男性 / 女性 / 未知', body: '按建档性别' }],
},
[PersonaFeatureKey.ACQUISITION_CHANNEL]: {
title: '获客渠道',
subtitle: '客户首次到诊的来源',
rules: [{ body: '按客户首次到诊的来源判定,确定后不再变' }],
},
[PersonaFeatureKey.FAMILY_STRUCTURE]: {
title: '家庭构成',
subtitle: '按直系亲属关系推断',
rules: [
{ label: '多代之家', body: '有长辈(父母 / 祖辈)' },
{ label: '多口之家', body: '有子女' },
{ label: '两口之家', body: '有配偶' },
{ label: '单身家庭', body: '仅有同辈 / 朋友等非直系' },
],
},
[PersonaFeatureKey.REFERRAL_CHAMPION]: {
title: '转介绍达人',
subtitle: '推荐过他人且带来成交',
rules: [
{ label: '家庭型', body: '带家人(直系 ≥3 人)' },
{ label: '社交型', body: '推外人(≥3 人)' },
],
},
[PersonaFeatureKey.LIFECYCLE_STAGE]: {
title: '生命周期',
subtitle: '按就诊时间 + 消费',
rules: [
{ label: '潜客', body: '尚未到诊,只约过 / 咨询过' },
{ label: '新客', body: '首诊半年内、就诊 ≤3 次' },
{ label: '成长客', body: '近一年消费明显上升' },
{ label: '成熟客', body: '到诊稳定、消费平稳' },
{ label: '待激活 / 沉睡 / 流失', body: '末次就诊越久,越靠后' },
],
},
[PersonaFeatureKey.TREATMENT_HISTORY]: {
title: '治疗史',
subtitle: '做过的核心治疗类型',
rules: [
{ label: '种植史', body: '种过牙' },
{ label: '正畸史', body: '做过矫正' },
{ label: '修复史', body: '冠桥 / 贴面 / 嵌体' },
{ label: '牙周治疗史', body: '洁牙 / 刮治 / 牙周序列' },
],
note: '基础治疗(拔牙 / 补牙 / 根管)不计入',
},
[PersonaFeatureKey.TIME_PREFERENCE]: {
title: '时间偏好',
subtitle: '习惯的就诊时段',
rules: [
{ label: '工作日 / 周末', body: '按近两年预约时间占比' },
{ label: '上午 / 下午 / 晚间', body: '同上,占比过半即标' },
],
},
[PersonaFeatureKey.DISCOUNT_ANCHOR]: {
title: '折扣锚点',
subtitle: '历史最深的一次折扣',
rules: [
{ body: '取真实治疗(原价 ≥¥500)上谈到的最低折扣 + 日期 / 项目' },
{ body: '免费洁牙 / 检查等促销不计入' },
],
note: '作谈优惠的价格底线参考',
},
[PersonaFeatureKey.SPECIAL_ATTENTION]: {
title: '特别关注',
subtitle: '排班 / 触达需注意',
rules: [
{ label: '屡次爽约', body: '近一年履约率偏低' },
{ label: '经常迟到', body: '多次迟到超 15 分钟' },
{ label: '免打扰', body: '已标记不打扰' },
{ label: '不可等候', body: '病历 / 备注提到赶时间、不能等' },
],
},
[PersonaFeatureKey.TREATMENT_SENSITIVITY]: {
title: '治疗敏感',
subtitle: '沟通需安抚的点',
rules: [
{ label: '看牙恐惧', body: '牙科恐惧 / 害怕看牙' },
{ label: '晕针 / 晕血', body: '晕针·怕打针 / 晕血·见血不适' },
{ label: '密闭恐惧', body: '幽闭 / 长时间张口不适' },
],
},
[PersonaFeatureKey.CONTRAINDICATION]: {
title: '禁忌标签',
subtitle: '治疗安全预警',
rules: [{ label: '种植禁忌', body: '未满 18 岁(骨骼未发育完全)' }],
note: '满 19 岁后自动解除',
},
[PersonaFeatureKey.URGENCY_LEVEL]: {
title: '急迫等级',
subtitle: '按末次就诊距今',
rules: [
{ label: '紧急', body: '有待治项且 90 天以上没来' },
{ label: '高', body: '30-90 天没来' },
{ label: '中', body: '近期来过或新发现' },
],
},
[PersonaFeatureKey.POTENTIAL_TREATMENT]: {
title: '潜在治疗',
subtitle: '诊断 / 建议了但还没做',
rules: [
{ label: '种植 / 修复 / 拔牙', body: '缺牙、需冠桥、残根需拔' },
{ label: '正畸 / 早矫', body: '按年龄分成人正畸 / 儿童早矫' },
{ label: '根管 / 牙周 / 补牙', body: '牙髓、牙周、龋齿' },
],
},
[PersonaFeatureKey.ENTITLEMENT_STATUS]: {
title: '权益身份',
subtitle: '保险结算历史',
rules: [
{ label: '商保客户', body: '用过商业保险结算(显示保司 + 最近日期)' },
{ label: '医保结算', body: '用过社保医保结算' },
],
note: '只表示曾用保险结算,不代表当前在保',
},
};
......@@ -44,6 +44,7 @@ import {
EXECUTION_OUTCOME_META,
RECALL_FEEDBACK_OPTIONS,
ABANDON_REASON_META,
personaFeatureSortKey,
type AbandonReason,
type ExecutionOutcome,
} from '@pac/types';
......@@ -2155,9 +2156,16 @@ function PersonaTagCloud({ features }: { features: typeof mockPersona.features }
if (!features.length) {
return <p className="text-[10.5px] text-slate-400">暂无画像标签(数据不足)</p>;
}
// 与详情抽屉同一套序(跟进要点 → 价值与阶段 → 基础属性)。
// 后端返回顺序不保证(同版 feature 行 createdAt 相同),不排就是随机的。
const ordered = [...features].sort((a, b) => {
const [ga, oa] = personaFeatureSortKey(a.key);
const [gb, ob] = personaFeatureSortKey(b.key);
return ga - gb || oa - ob || a.key.localeCompare(b.key);
});
return (
<div className="flex flex-wrap gap-1.5">
{features.map((f) => {
{ordered.map((f) => {
const T = tone(f.tone);
const { text: short, more } = compactPersonaValue(f.value, f.data);
return (
......
......@@ -130,13 +130,19 @@ export type PlanDetailData = {
persona: {
id: string;
version: number;
/// 本版本创建时刻(升版本才动)
computedAt: string;
/// 内容真实新鲜度:只有天数漂移时后端就地刷新、不升版本 → computedAt 会停在旧值。
/// UI 的「更新于」用这个。老响应可能没有 → 调用方回退 computedAt。
refreshedAt?: string;
features: Array<{
id: string;
key: string;
description: string;
score: number | null;
data: unknown;
/// 证据 fact id(反查同一响应的 facts 渲染「依据」);老响应没有 → 空
evidenceFactIds?: string[];
}>;
} | null;
chains: Array<{
......
'use client';
import {
diagnosisCodeNameZh,
treatmentCategoryNameZh,
lookupDxTreatment,
NO_RESTORATION_GAP_EXAM_PATTERNS,
} from '@pac/types';
import { lookupDxTreatment, NO_RESTORATION_GAP_EXAM_PATTERNS } from '@pac/types';
import { cn } from '@/lib/utils';
import type { AdaptedFact } from './adapt-data';
import { factLabel } from './fact-label';
/**
* ToothTimeline — 每颗牙(+ 全口治疗线)的事实时间轴
......@@ -204,54 +200,6 @@ function ToothFactRow({ fact }: { fact: AdaptedFact }) {
);
}
function factLabel(f: AdaptedFact): { badge: string; badgeTone: string; text: string } {
const c = (f.content ?? {}) as Record<string, unknown>;
switch (f.type) {
case 'diagnosis_record': {
const code = String(c.code ?? '');
const name = String(c.name_zh ?? c.name ?? '');
const std = code ? diagnosisCodeNameZh(code) : '';
const text = std && name && std !== name ? `${std} · ${name}` : std || name || '诊断';
return { badge: '诊断', badgeTone: 'bg-rose-50 text-rose-700', text };
}
case 'treatment_record': {
const cat = String(c.category ?? '');
const sub = String(c.subtype ?? '');
const catZh = cat ? treatmentCategoryNameZh(cat) : '';
const text = sub || catZh || '治疗';
const isPlanned = f.kind === 'planned';
return {
badge: isPlanned ? '计划' : catZh || '治疗',
badgeTone: isPlanned ? 'bg-slate-100 text-slate-500' : 'bg-teal-50 text-teal-700',
text: catZh && sub ? `${text}` : text,
};
}
case 'recommendation_record': {
const name = String(c.name ?? '');
const code = String(c.code ?? '');
return {
badge: '建议',
badgeTone: 'bg-amber-50 text-amber-700',
text: name || (code ? diagnosisCodeNameZh(code) : '') || '医生建议',
};
}
case 'image_record': {
const mod = String(c.modality ?? '');
const modZh = MODALITY_ZH[mod] ?? mod ?? '影像';
return { badge: '影像', badgeTone: 'bg-slate-100 text-slate-500', text: modZh };
}
default:
return { badge: '事实', badgeTone: 'bg-slate-100 text-slate-500', text: f.title ?? f.type };
}
}
const MODALITY_ZH: Record<string, string> = {
pa: '根尖片',
bw: '咬翼片',
pano: '曲面断层',
cbct: 'CBCT',
intraoral_photo: '口内照片',
};
/// 牙位归一(剥面/方位后缀,保留牙位 base);跟 facts-timeline 同义
function parseJsonArrayLoose(raw: unknown): Array<{ toothPosition?: string; message?: string }> {
......
......@@ -337,7 +337,7 @@ export function PatientPickerRail({
);
}
// ── 画像标签筛选 popover(4 维度分组多选)────────────────────
// ── 画像标签筛选 popover(维度分组多选)────────────────────
function PersonaTagFilter({
selected,
onToggle,
......@@ -347,6 +347,8 @@ function PersonaTagFilter({
onToggle: (kv: string) => void;
onClear: () => void;
}) {
/// 当前悬停选项的说明,渲染在面板底部固定行(见下方注释:为什么不用浮层)
const [hint, setHint] = useState<string | null>(null);
return (
<Popover>
<PopoverTrigger asChild>
......@@ -372,6 +374,9 @@ function PersonaTagFilter({
</button>
)}
</div>
{/* 悬停某个选项时在底部解释它。「重要保持」「待激活」这类名字是算法口径的产物,
光看名字猜不出边界(重要保持 vs 重要挽留差在哪?待激活 vs 沉睡客几个月分界?),
客服只能凭感觉勾。说明文案在 persona-tag-filters.ts 的 hint 字段。 */}
<div className="max-h-80 space-y-2 overflow-y-auto">
{PERSONA_TAG_FILTER_DIMS.map((dim) => (
<div key={dim.key}>
......@@ -387,6 +392,12 @@ function PersonaTagFilter({
key={kv}
type="button"
onClick={() => onToggle(kv)}
// 说明走面板底部的固定行,不用浮层:这些选项是用来**横向比较**的
// (重要保持 vs 重要挽留差在哪),浮层会盖住正要比的那几个。
onMouseEnter={() => o.hint && setHint(`${o.zh} · ${o.hint}`)}
onMouseLeave={() => setHint(null)}
onFocus={() => o.hint && setHint(`${o.zh} · ${o.hint}`)}
onBlur={() => setHint(null)}
className={cn(
'rounded-full border px-2 py-0.5 text-[11px] transition-colors',
on
......@@ -402,6 +413,14 @@ function PersonaTagFilter({
</div>
))}
</div>
{/* 定高:hover 进出不抖动 */}
<div className="mt-1 min-h-[30px] border-t border-slate-100 px-1 pt-1.5 text-[10.5px] leading-relaxed">
{hint ? (
<span className="text-slate-600">{hint}</span>
) : (
<span className="text-slate-300">悬停任一标签看它的含义</span>
)}
</div>
</PopoverContent>
</Popover>
);
......
......@@ -37,40 +37,43 @@ export const planScenarioLabel = (key: string): string =>
// PersonaFeatureKey 中文展示 + UI tone
// ─────────────────────────────────────────────────────────
/**
* 只登记**在跑的** 16 个标签 —— 与 PERSONA_FEATURE_SPECS 一一对应
* (CI 对账见 pac-service tests/persona-spec-drift.spec.ts)。
*
* 曾经把 PersonaFeatureKey 里全部枚举值(含 W7 摘除的 value / treatment_chain_status /
* recall_risk / do_not_contact_status,以及一批只存在于路线图上的候选 key)都列在这里,
* 结果是"看着有 30 个标签、实际只出 16 个"。枚举保留路线图,展示元数据只登记真会上屏的。
*
* tone 的语义(客服要能扫一眼分轻重,所以红色是稀缺资源):
* rose ⚠️ 警示 —— 看到要小心/要避让(禁忌、爽约、敏感、急迫)
* amber ⏳ 机会 —— 有事可做(潜在治疗、治疗史这类可切入点)
* indigo 💰 价值 —— 钱与权益
* teal 📈 阶段 —— 客户所处位置
* sky/slate 基础属性 —— 不需要判断,只是背景信息
*/
export const PERSONA_FEATURE_META: Record<string, { label: string; tone: Tone }> = {
[PersonaFeatureKey.RFM]: { label: '价值分群', tone: 'indigo' },
[PersonaFeatureKey.AGE_BRACKET]: { label: '年龄段', tone: 'sky' },
[PersonaFeatureKey.GENDER]: { label: '性别', tone: 'slate' },
[PersonaFeatureKey.ACQUISITION_CHANNEL]: { label: '获客渠道', tone: 'teal' },
[PersonaFeatureKey.FAMILY_STRUCTURE]: { label: '家庭构成', tone: 'amber' },
[PersonaFeatureKey.REFERRAL_CHAMPION]: { label: '转介绍达人', tone: 'rose' },
[PersonaFeatureKey.LIFECYCLE_STAGE]: { label: '生命周期', tone: 'teal' },
[PersonaFeatureKey.TREATMENT_HISTORY]: { label: '治疗史', tone: 'amber' },
[PersonaFeatureKey.TIME_PREFERENCE]: { label: '时间偏好', tone: 'sky' },
[PersonaFeatureKey.DISCOUNT_ANCHOR]: { label: '折扣锚点', tone: 'rose' },
// ⚠️ 警示
[PersonaFeatureKey.URGENCY_LEVEL]: { label: '急迫等级', tone: 'rose' },
[PersonaFeatureKey.SPECIAL_ATTENTION]: { label: '特别关注', tone: 'rose' },
[PersonaFeatureKey.TREATMENT_SENSITIVITY]: { label: '治疗敏感', tone: 'rose' },
[PersonaFeatureKey.CONTRAINDICATION]: { label: '禁忌标签', tone: 'rose' },
[PersonaFeatureKey.URGENCY_LEVEL]: { label: '急迫等级', tone: 'rose' },
[PersonaFeatureKey.VALUE]: { label: '患者价值', tone: 'indigo' },
[PersonaFeatureKey.TREATMENT_CHAIN_STATUS]: { label: '治疗链状态', tone: 'amber' },
[PersonaFeatureKey.RECALL_RISK]: { label: '流失风险', tone: 'emerald' },
[PersonaFeatureKey.DO_NOT_CONTACT_STATUS]: { label: '不打扰状态', tone: 'slate' },
[PersonaFeatureKey.ENTITLEMENT_STATUS]: { label: '权益身份', tone: 'indigo' },
[PersonaFeatureKey.INCOMPLETE_TREATMENT]: { label: '未完成治疗', tone: 'amber' },
[PersonaFeatureKey.RECOMMENDED_ROLE]: { label: '推荐角色', tone: 'teal' },
[PersonaFeatureKey.REFERRAL_RELATION]: { label: '转介绍关系', tone: 'sky' },
[PersonaFeatureKey.DOCTOR_RELATION]: { label: '主治医患', tone: 'slate' },
[PersonaFeatureKey.COMMUNICATION_PREFERENCE]: { label: '沟通偏好', tone: 'sky' },
[PersonaFeatureKey.CUSTOMER_EXPERIENCE_RISK]: { label: '客户体验风险', tone: 'rose' },
[PersonaFeatureKey.TREATMENT_INTENT]: { label: '治疗意向', tone: 'teal' },
[PersonaFeatureKey.FAMILY_SOCIAL_RELATION]: { label: '家庭/社交关系', tone: 'slate' },
[PersonaFeatureKey.ENGAGEMENT]: { label: '触达活跃度', tone: 'sky' },
[PersonaFeatureKey.PATIENT_SEGMENT]: { label: '人群细分', tone: 'slate' },
[PersonaFeatureKey.COMPLAINT_HISTORY]: { label: '投诉历史', tone: 'rose' },
[PersonaFeatureKey.TREATMENT_SENSITIVITY]: { label: '治疗敏感', tone: 'rose' },
// ⏳ 机会 / 可切入点
[PersonaFeatureKey.POTENTIAL_TREATMENT]: { label: '潜在治疗', tone: 'amber' },
[PersonaFeatureKey.COOPERATION_ATTITUDE]: { label: '配合态度', tone: 'emerald' },
[PersonaFeatureKey.PRIMARY_CONTACT_RELATION]: { label: '主要联系人', tone: 'slate' },
[PersonaFeatureKey.TREATMENT_HISTORY]: { label: '治疗史', tone: 'amber' },
// 💰 价值与权益(折扣锚点是价格参考,不是警示 —— 原来标红与"禁忌"同色,轻重不分)
[PersonaFeatureKey.RFM]: { label: '价值分群', tone: 'indigo' },
[PersonaFeatureKey.ENTITLEMENT_STATUS]: { label: '权益身份', tone: 'indigo' },
[PersonaFeatureKey.DISCOUNT_ANCHOR]: { label: '折扣锚点', tone: 'indigo' },
// 📈 阶段与关系(转介绍达人是正面信息,同理不该标红)
[PersonaFeatureKey.LIFECYCLE_STAGE]: { label: '生命周期', tone: 'teal' },
[PersonaFeatureKey.REFERRAL_CHAMPION]: { label: '转介绍达人', tone: 'teal' },
// 基础属性
[PersonaFeatureKey.TIME_PREFERENCE]: { label: '时间偏好', tone: 'sky' },
[PersonaFeatureKey.AGE_BRACKET]: { label: '年龄段', tone: 'sky' },
[PersonaFeatureKey.ACQUISITION_CHANNEL]: { label: '获客渠道', tone: 'slate' },
[PersonaFeatureKey.FAMILY_STRUCTURE]: { label: '家庭构成', tone: 'slate' },
[PersonaFeatureKey.GENDER]: { label: '性别', tone: 'slate' },
};
export const personaFeatureMeta = (key: string): { label: string; tone: Tone } =>
......
......@@ -12,6 +12,68 @@
export type FeatureTier = 'rule' | 'statistical' | 'model' | 'llm';
export type FeatureTimeSemantics = 'snapshot' | 'window' | 'lifetime' | 'trend' | 'mixed';
/**
* 详情页分组 —— 按「客服打这通电话时的用途」分,不按数据血缘分。
*
* 原来 16 个标签在抽屉里等权重平铺,且顺序取自 `orderBy createdAt`,而同一版的 feature 行是
* 一条 createMany 写进去的、createdAt 完全相同 —— 排序实际未定义,生产上呈现为英文 key 字母序:
* 「急迫等级=紧急」排在最后一个,「性别」「获客渠道」排在最前。
*/
export type PersonaFeatureGroup = 'action' | 'value' | 'basic';
export const PERSONA_FEATURE_GROUP_LABEL: Record<PersonaFeatureGroup, string> = {
action: '跟进要点',
value: '价值与阶段',
basic: '基础属性',
};
/** 分组自身的展示顺序 */
export const PERSONA_FEATURE_GROUP_ORDER: PersonaFeatureGroup[] = ['action', 'value', 'basic'];
/**
* 详情页 `?` 悬浮卡的展示内容 —— **面向客服的人话版**。
*
* 为什么放在标签卡里而不是前端:原先前端 persona-feature-hover.tsx 自己硬编了一份
* `ALGORITHMS` 字典,和后端口径各写各的,已经漂了 ——
* · 权益身份 hover 只列「商保客户 / 医保结算」2 类,extractor 实际产 5 类;
* 还写了「显示保司 + 最近日期」,而 description/data 里压根没有保司名和日期;
* · 转介绍达人 hover 写「社交型 = 推外人 ≥3 人」,真实口径是「推荐 ≥3 人**且**带来成交,
* 直系亲属关系 <3 归社交型」。
* 说明写错比不写更糟(客服照着 hover 跟患者讲)。收到这里 = 跟 algorithm 口径贴脸放,
* 改一处就会看到另一处;CI 侧由 tests/persona-spec-drift.spec.ts 兜底。
*
* 与 `algorithm` 的分工:algorithm 是口径原文(算法/数据同学核对用,含阈值与边界),
* display 是同一件事的客服版(不出现分位、天数阈值以外的技术词)。
*/
export interface PersonaFeatureDisplay {
/** 一句话副标题:这个标签在看什么 */
subtitle: string;
/**
* 取值条目 —— **每条都是「加粗取值 + 一句短说明」**,这个结构本身就是可读性:
* 客服扫的是左边那列取值,看到自己要找的那个再读右边一句。
*
* ⚠️ 不要为了"精简"把 label 去掉或整块留空 —— 试过一版把自明的标签清成空数组,
* 结果卡片只剩三段平铺文字,没有层次也没有对照,反而更难读(取值多的标签尤其明显)。
* 要精简就精简 body 的字数,不是砍掉结构。
*/
rules: Array<{ label: string; body: string }>;
/** 详情页分组 */
group: PersonaFeatureGroup;
/** 组内排序(小的在前;留 10 的间隔便于插新标签) */
order: number;
}
/** 展示排序键:先按组、再按组内序;未登记的标签排最后(仍可见,不吞) */
export function personaFeatureSortKey(key: string): [number, number] {
const d = PERSONA_FEATURE_SPECS[key]?.display;
if (!d) return [PERSONA_FEATURE_GROUP_ORDER.length, 0];
return [PERSONA_FEATURE_GROUP_ORDER.indexOf(d.group), d.order];
}
/** 该标签属于哪个组(未登记 → null,调用方自行归入"其他") */
export const personaFeatureGroup = (key: string): PersonaFeatureGroup | null =>
PERSONA_FEATURE_SPECS[key]?.display?.group ?? null;
export interface PersonaFeatureSpec {
key: string; // PersonaFeatureKey
nameZh: string; // 标签名
......@@ -22,6 +84,7 @@ export interface PersonaFeatureSpec {
dataFields: string[]; // 数据字段
meaning: string; // 标签释义
algorithm: string; // 计算算法(口径,人读;真理实现在 extractor)
display: PersonaFeatureDisplay; // 详情页 ? 悬浮卡内容(客服版)
owner: string;
version: number;
}
......@@ -56,6 +119,19 @@ export const PERSONA_FEATURE_SPECS: Record<string, PersonaFeatureSpec> = {
' R≥4 F≥3 M≥4→重要价值 / R=3 F≥3 M≥4→重要保持 / R≥4 F=2 M≥4→重要发展 / R≤2 F≥3 M≥4→重要挽留',
' R≥4 F≥3 M<4→一般价值 / R=3 F≥3 M<4→一般保持 / R≥4 F=2 M<4→一般发展 / 其余(含 R≤2 任意)→低活跃',
].join('\n'),
display: {
subtitle: '按消费高低 × 来得勤不勤 / 最近来没来 分层',
rules: [
{ label: '重要 / 一般', body: '消费排在同院前 40% / 消费偏低' },
{ label: '价值', body: '最近还在来,而且来得勤' },
{ label: '保持', body: '来得勤,但有段时间没来了' },
{ label: '发展', body: '最近来过,但来的次数少' },
{ label: '挽留', body: '消费高,但很久没来了' },
{ label: '低活跃', body: '很久没来,或三项都偏低' },
],
group: 'value',
order: 10,
},
owner: 'pac-algo',
version: 1,
},
......@@ -66,15 +142,17 @@ export const PERSONA_FEATURE_SPECS: Record<string, PersonaFeatureSpec> = {
nameZh: '年龄段',
tier: 'rule',
timeSemantics: 'snapshot', // 当下年龄(随时间增长,历史读版本流)
// 区间与下方 algorithm / extractor BRACKETS 对齐(下界含、上界含)。
// 原写 婴幼儿(0-3) / 中老年(46-55) 与 algorithm 的 0-2 / 46-54 自相矛盾,已订正。
labelValues: [
'婴幼儿(0-3)',
'婴幼儿(0-2)',
'学龄前(3-6)',
'替牙期(7-11)',
'青少年(12-17)',
'青年(18-25)',
'中青年(26-30)',
'中年(31-45)',
'中老年(46-55)',
'中老年(46-54)',
'老年(55+)',
],
dataSource: '现:PAC patient.birthDate 自算;未来:宿主 CDP「客户综合分析报表」client_age 直接取',
......@@ -85,6 +163,16 @@ export const PERSONA_FEATURE_SPECS: Record<string, PersonaFeatureSpec> = {
'0-2→婴幼儿 / 3-6→学龄前 / 7-11→替牙期 / 12-17→青少年 / 18-25→青年 /',
'26-30→中青年 / 31-45→中年 / 46-54→中老年 / ≥55→老年',
].join('\n'),
display: {
subtitle: '按建档生日算的当下年龄',
rules: [
{ label: '婴幼儿 / 学龄前 / 替牙期', body: '0-2 / 3-6 / 7-11 岁' },
{ label: '青少年 / 青年 / 中青年', body: '12-17 / 18-25 / 26-30 岁' },
{ label: '中年 / 中老年 / 老年', body: '31-45 / 46-54 / 55 岁以上' },
],
group: 'basic',
order: 10,
},
owner: 'pac-algo',
version: 1,
},
......@@ -100,6 +188,15 @@ export const PERSONA_FEATURE_SPECS: Record<string, PersonaFeatureSpec> = {
dataFields: ['client_gender'],
meaning: '客户生理性别,影响沟通话术与项目推荐',
algorithm: 'client_gender 直接映射:"M"/"男"→男性;"F"/"女"→女性;其他→未知',
display: {
subtitle: '影响称呼与项目推荐',
rules: [
{ label: '男性 / 女性', body: '按建档性别' },
{ label: '未知', body: '建档没填,或填的值无法识别' },
],
group: 'basic',
order: 20,
},
owner: 'pac-algo',
version: 1,
},
......@@ -115,6 +212,17 @@ export const PERSONA_FEATURE_SPECS: Record<string, PersonaFeatureSpec> = {
dataFields: ['primary_category', 'sub_category'],
meaning: '客户首次到诊的获客来源,用于渠道价值分析。初诊来源一经判定不做二次改归类',
algorithm: 'host primary_category 经 assembler enum_mapping 归一到 PAC 立柱标准(走入→walk_in 等);二级 sub_category 原值透传。冲突解决(数仓侧):推荐人 > 活动码/渠道码 > 客户类型',
display: {
subtitle: '第一次到诊是怎么来的',
rules: [
{ label: '走入 / 口碑客户', body: '直接上门 / 老客户推荐来的' },
{ label: '集团、地区渠道', body: '销售或营销活动带来' },
{ label: '电商平台 / 自媒体网络', body: '线上平台来的' },
{ label: '内部员工 / 其他', body: '其余来源' },
],
group: 'basic',
order: 40,
},
owner: 'pac-algo',
version: 1,
},
......@@ -134,6 +242,17 @@ export const PERSONA_FEATURE_SPECS: Record<string, PersonaFeatureSpec> = {
'有长辈→多代之家;否则有子女→多口之家;否则有配偶→两口之家;否则有非直系边→单身家庭;无边→不打标签。',
'⚠️ 依赖关系边覆盖,样本稀疏 + 非全量时覆盖偏低。',
].join('\n'),
display: {
subtitle: '按院内已建档的亲属关系推断',
rules: [
{ label: '多代之家', body: '有长辈(父母 / 祖辈)' },
{ label: '多口之家', body: '有子女' },
{ label: '两口之家', body: '有配偶' },
{ label: '单身家庭', body: '只有旁系或朋友' },
],
group: 'basic',
order: 30,
},
owner: 'pac-algo',
version: 1,
},
......@@ -153,6 +272,15 @@ export const PERSONA_FEATURE_SPECS: Record<string, PersonaFeatureSpec> = {
'家庭型:直系家庭关系 ≥3(带家人);否则 社交型(推外人)。不满足→不打标签。',
'⚠️ v1 用 DW 预聚合(逐个被推荐人"均有效转化"需其结算,跨患者+需被推荐人已摄入,留 v2)。',
].join('\n'),
display: {
subtitle: '推荐过 3 人以上,且带来了成交',
rules: [
{ label: '家庭型', body: '带的是家里人' },
{ label: '社交型', body: '带的是家人以外的人' },
],
group: 'value',
order: 50,
},
owner: 'pac-algo',
version: 1,
},
......@@ -172,6 +300,18 @@ export const PERSONA_FEATURE_SPECS: Record<string, PersonaFeatureSpec> = {
'高端保险直付 Bupa/中间带/保险直付/商保;银行私行 招行私行/私人银行/白金卡/贵宾卡;',
'储值会员 储值/预存(或有充值);儿牙会员 儿牙会员/乐牙卡/涂氟年卡;医保 医保。未命中→不打标签。',
].join('\n'),
display: {
subtitle: '历史结算用过的卡券或保险(不代表现在仍有效)',
rules: [
{ label: '高端保险直付', body: 'Bupa、万欣和等商保直付' },
{ label: '银行私行权益', body: '私人银行 / 白金卡' },
{ label: '储值会员', body: '有储值或预存' },
{ label: '儿牙会员', body: '儿牙卡 / 涂氟年卡' },
{ label: '医保客户', body: '用过医保结算' },
],
group: 'value',
order: 40,
},
owner: 'pac-algo',
version: 2,
},
......@@ -192,6 +332,16 @@ export const PERSONA_FEATURE_SPECS: Record<string, PersonaFeatureSpec> = {
'末诊180-540&就诊≥2→待激活;末诊>730→流失客;末诊540-730&就诊≥2→沉睡客;否则→新客兜底。',
'⚠️ 修正 spec 顺序 bug:原"沉睡>540"在"流失>730"前 → 流失永不触发;此处流失提前。',
].join('\n'),
display: {
subtitle: '客户走到了哪个阶段',
rules: [
{ label: '潜客 / 新客', body: '还没来过 / 首诊半年内且不超过 3 次' },
{ label: '成长客 / 成熟客', body: '还在来,消费在涨 / 消费平稳' },
{ label: '待激活 / 沉睡客 / 流失客', body: '半年~1年半 / 1年半~2年 / 2年以上没来' },
],
group: 'value',
order: 20,
},
owner: 'pac-algo',
version: 1,
},
......@@ -210,6 +360,16 @@ export const PERSONA_FEATURE_SPECS: Record<string, PersonaFeatureSpec> = {
'implant→种植史;orthodontic→正畸史;prosthodontic/cosmetic→修复史(冠桥/贴面/嵌体);periodontic→牙周治疗史。',
'不标记 surgical(拔牙)/restorative(充填)/endodontic(根管)等基础治疗。仅 actual;全不命中→不打标签。',
].join('\n'),
display: {
subtitle: '做过哪些主要治疗',
rules: [
{ label: '种植史 / 正畸史', body: '种过牙 / 做过矫正' },
{ label: '修复史', body: '冠桥 / 贴面 / 嵌体' },
{ label: '牙周治疗史', body: '洁牙 / 刮治 / 牙周序列' },
],
group: 'value',
order: 30,
},
owner: 'pac-algo',
version: 1,
},
......@@ -229,6 +389,15 @@ export const PERSONA_FEATURE_SPECS: Record<string, PersonaFeatureSpec> = {
'上午8-12≥50%→上午;下午12-18≥50%→下午;晚间18-21≥50%→晚间。无命中/记录<2→不打标签。',
'⚠️ TZ 硬编北京(+8),多宿主应读 host 时区(follow-up)。',
].join('\n'),
display: {
subtitle: '习惯什么时候来',
rules: [
{ label: '工作日 / 周末', body: '近两年预约多在这几天' },
{ label: '上午 / 下午 / 晚间', body: '8-12 / 12-18 / 18-21 点' },
],
group: 'action',
order: 60,
},
owner: 'pac-algo',
version: 1,
},
......@@ -247,6 +416,15 @@ export const PERSONA_FEATURE_SPECS: Record<string, PersonaFeatureSpec> = {
'遍历有折扣的结算:折扣率 = 1 − discount_cents/amount_cents(DW 无原价字段;discount_*_rate 实为折扣金额)。',
'取最小折扣率(力度最大)+ 保留日期/项目。无折扣→不打标签(业务:无锚点换推增值权益,不直接降价)。',
].join('\n'),
display: {
subtitle: '历史上给过的最深一次折扣',
rules: [
{ label: '最低折扣', body: '原价 500 元以上的治疗里谈成过的最深折扣' },
{ label: '日期 / 项目', body: '那一次是什么时候、做的什么' },
],
group: 'action',
order: 70,
},
owner: 'pac-algo',
version: 1,
},
......@@ -266,6 +444,17 @@ export const PERSONA_FEATURE_SPECS: Record<string, PersonaFeatureSpec> = {
'经常迟到 到店>预约+15min 占比≥50%且≥3次;免打扰 doNotContact;',
'不可等候 notes/tags/病历含 不可等候/时间敏感/赶时间/不能等。全不命中→不打标签。',
].join('\n'),
display: {
subtitle: '排班和联系时要注意的事',
rules: [
{ label: '屡次爽约', body: '近一年履约不到一半' },
{ label: '经常迟到', body: '多次晚到 15 分钟以上' },
{ label: '免打扰', body: '已标记不接受主动联系' },
{ label: '不可等候', body: '提过赶时间 / 不能等' },
],
group: 'action',
order: 30,
},
owner: 'pac-algo',
version: 1,
},
......@@ -285,6 +474,16 @@ export const PERSONA_FEATURE_SPECS: Record<string, PersonaFeatureSpec> = {
'晕针 晕针/害怕打针;晕血 晕血/见血不适;密闭恐惧 幽闭/密闭/长时间张口不适。全不命中→不打标签。',
'⚠️ 关键词按真实数据精炼排假阳:裸"紧张"=颏肌紧张(正畸)、裸"见血"=可见血凝块(临床)、裸"张口受限"=物理开口受限,均剔除。',
].join('\n'),
display: {
subtitle: '沟通时要提前安抚的点',
rules: [
{ label: '看牙恐惧', body: '病历提过害怕看牙' },
{ label: '晕针 / 晕血', body: '提过怕打针 / 见血不适' },
{ label: '密闭恐惧', body: '提过幽闭 / 长时间张口不适' },
],
group: 'action',
order: 50,
},
owner: 'pac-algo',
version: 1,
},
......@@ -304,6 +503,16 @@ export const PERSONA_FEATURE_SPECS: Record<string, PersonaFeatureSpec> = {
'K 码→8 标签:种植←K08(>18)/补牙←K02/根管←K04/牙周←K05,K06/正畸←K07(>12≤40)/早矫←K07(3-12)/',
'修复←K03(默认)/拔牙←K01+K03(残根残冠)。置信度=诊断1.0/建议0.8。⚠️非已丢单(sales_chance)PAC未摄入,省略。',
].join('\n'),
display: {
subtitle: '诊断或建议过、但还没做的项目',
rules: [
{ label: '种植 / 修复 / 拔牙', body: '缺牙待种、需要冠桥、残根残冠需拔' },
{ label: '正畸 / 早矫', body: '成人正畸 13-40 岁 / 儿童早矫 3-12 岁' },
{ label: '根管 / 牙周 / 补牙', body: '牙髓、牙周、龋齿' },
],
group: 'action',
order: 10,
},
owner: 'pac-algo',
version: 1,
},
......@@ -323,6 +532,16 @@ export const PERSONA_FEATURE_SPECS: Record<string, PersonaFeatureSpec> = {
'末诊>90天→紧急 / 30-90→高 / <30 或新发现→中 / 美学预防→低(8 标签不含,暂不触发)。',
'无潜在治疗→不打标签。Step2 取最大(多路径 MAX,现仅 1 路径)。',
].join('\n'),
display: {
subtitle: '有待做的项目,多久没来了',
rules: [
{ label: '紧急', body: '有待治项且 90 天以上没来' },
{ label: '高', body: '30-90 天没来' },
{ label: '中', body: '近期来过或新发现' },
],
group: 'action',
order: 20,
},
owner: 'pac-algo',
version: 1,
},
......@@ -342,7 +561,22 @@ export const PERSONA_FEATURE_SPECS: Record<string, PersonaFeatureSpec> = {
'⚠️ 其余禁忌(糖尿病控制不佳/过敏/抗凝/妊娠/放疗…)规则做不了:① 既往史否定泛滥(含"过敏"97%是"否认过敏",naive 匹配=医疗事故);',
'② 量化条件无数据(HbA1c 全量仅10条/血压自由文本);③ 需控制状态/急性期判断+时效窗口 → 留 Layer C(LLM 抽取)。',
].join('\n'),
display: {
subtitle: '治疗安全预警 · 目前只判年龄',
rules: [
{ label: '种植禁忌', body: '未满 19 岁,颌骨尚未发育完全' },
],
group: 'action',
order: 40,
},
owner: 'pac-algo',
version: 1,
},
};
/**
* 取某标签的客服版说明(详情页 `?` 悬浮卡)。未登记的 key 返回 null —— 前端据此不渲染悬浮卡,
* 而不是编一段说明出来。新特征上线忘了写 display 会在页面上"少个 ?",比显示错的说明安全。
*/
export const personaFeatureDisplay = (key: string): PersonaFeatureDisplay | null =>
PERSONA_FEATURE_SPECS[key]?.display ?? null;
......@@ -14,6 +14,14 @@ export interface PersonaTagOption {
/** data 里的结构化取值(英文 code) */
value: string;
zh: string;
/**
* 一句话说明 —— 筛选面板上 hover 该选项时显示。
*
* 「重要保持」「待激活」「重要发展」这类分群名是算法口径的产物,光看名字猜不出边界
* (重要保持 vs 重要挽留差在哪?待激活 vs 沉睡客几个月分界?),客服只能凭感觉勾。
* 这里用大白话给出**判定条件**,不解释算法。省略 = 名字本身够自明(性别之类)。
*/
hint?: string;
}
export interface PersonaTagFilterDim {
......@@ -33,14 +41,14 @@ export const PERSONA_TAG_FILTER_DIMS: PersonaTagFilterDim[] = [
nameZh: '价值分群',
dataPath: 'segment',
options: [
{ value: 'important_value', zh: '重要价值' },
{ value: 'important_retain', zh: '重要保持' },
{ value: 'important_develop', zh: '重要发展' },
{ value: 'important_winback', zh: '重要挽留' },
{ value: 'general_value', zh: '一般价值' },
{ value: 'general_retain', zh: '一般保持' },
{ value: 'general_develop', zh: '一般发展' },
{ value: 'low_active', zh: '低活跃' },
{ value: 'important_value', zh: '重要价值', hint: '消费高、来得勤,最近还在来' },
{ value: 'important_retain', zh: '重要保持', hint: '消费高、来得勤,但有段时间没来了' },
{ value: 'important_develop', zh: '重要发展', hint: '消费高、最近来过,但来的次数少' },
{ value: 'important_winback', zh: '重要挽留', hint: '消费高,但很久没来了' },
{ value: 'general_value', zh: '一般价值', hint: '来得勤、最近还在来,消费一般' },
{ value: 'general_retain', zh: '一般保持', hint: '来得勤但有段时间没来,消费一般' },
{ value: 'general_develop', zh: '一般发展', hint: '最近来过但次数少,消费一般' },
{ value: 'low_active', zh: '低活跃', hint: '很久没来,或三项都偏低' },
],
},
{
......@@ -48,13 +56,13 @@ export const PERSONA_TAG_FILTER_DIMS: PersonaTagFilterDim[] = [
nameZh: '生命周期',
dataPath: 'stage',
options: [
{ value: 'prospect', zh: '潜客' },
{ value: 'new', zh: '新客' },
{ value: 'growth', zh: '成长客' },
{ value: 'mature', zh: '成熟客' },
{ value: 'reactivate', zh: '待激活' },
{ value: 'dormant', zh: '沉睡客' },
{ value: 'churned', zh: '流失客' },
{ value: 'prospect', zh: '潜客', hint: '还没到过诊,只约过或咨询过' },
{ value: 'new', zh: '新客', hint: '首诊半年内,就诊不超过 3 次' },
{ value: 'growth', zh: '成长客', hint: '还在来,近一年消费明显上涨' },
{ value: 'mature', zh: '成熟客', hint: '还在来,消费平稳' },
{ value: 'reactivate', zh: '待激活', hint: '半年到一年半没来' },
{ value: 'dormant', zh: '沉睡客', hint: '一年半到两年没来' },
{ value: 'churned', zh: '流失客', hint: '两年以上没来' },
],
},
{
......@@ -62,9 +70,9 @@ export const PERSONA_TAG_FILTER_DIMS: PersonaTagFilterDim[] = [
nameZh: '紧迫度',
dataPath: 'level',
options: [
{ value: 'urgent', zh: '紧急' },
{ value: 'high', zh: '高' },
{ value: 'mid', zh: '中' },
{ value: 'urgent', zh: '紧急', hint: '有待做的项目,且 90 天以上没来' },
{ value: 'high', zh: '高', hint: '有待做的项目,30 到 90 天没来' },
{ value: 'mid', zh: '中', hint: '有待做的项目,30 天内来过或刚发现' },
],
},
{
......@@ -72,15 +80,15 @@ export const PERSONA_TAG_FILTER_DIMS: PersonaTagFilterDim[] = [
nameZh: '年龄段',
dataPath: 'bracket',
options: [
{ value: 'infant', zh: '婴幼儿' },
{ value: 'preschool', zh: '学龄前' },
{ value: 'mixed_dentition', zh: '替牙期' },
{ value: 'adolescent', zh: '青少年' },
{ value: 'youth', zh: '青年' },
{ value: 'young_adult', zh: '中青年' },
{ value: 'middle_aged', zh: '中年' },
{ value: 'pre_senior', zh: '中老年' },
{ value: 'senior', zh: '老年' },
{ value: 'infant', zh: '婴幼儿', hint: '0-2 岁' },
{ value: 'preschool', zh: '学龄前', hint: '3-6 岁' },
{ value: 'mixed_dentition', zh: '替牙期', hint: '7-11 岁,换牙阶段' },
{ value: 'adolescent', zh: '青少年', hint: '12-17 岁' },
{ value: 'youth', zh: '青年', hint: '18-25 岁' },
{ value: 'young_adult', zh: '中青年', hint: '26-30 岁' },
{ value: 'middle_aged', zh: '中年', hint: '31-45 岁' },
{ value: 'pre_senior', zh: '中老年', hint: '46-54 岁' },
{ value: 'senior', zh: '老年', hint: '55 岁以上' },
],
},
{
......@@ -97,10 +105,10 @@ export const PERSONA_TAG_FILTER_DIMS: PersonaTagFilterDim[] = [
nameZh: '家庭构成',
dataPath: 'structure',
options: [
{ value: 'multigen', zh: '多代之家' },
{ value: 'nuclear', zh: '多口之家' },
{ value: 'couple', zh: '两口之家' },
{ value: 'single', zh: '单身家庭' },
{ value: 'multigen', zh: '多代之家', hint: '院内登记有长辈(父母 / 祖辈)' },
{ value: 'nuclear', zh: '多口之家', hint: '院内登记有子女' },
{ value: 'couple', zh: '两口之家', hint: '院内登记有配偶' },
{ value: 'single', zh: '单身家庭', hint: '只有旁系或朋友关系' },
],
},
{
......@@ -109,11 +117,11 @@ export const PERSONA_TAG_FILTER_DIMS: PersonaTagFilterDim[] = [
dataPath: 'types',
isArray: true,
options: [
{ value: 'high_insurance', zh: '高端保险直付' },
{ value: 'bank_vip', zh: '银行私行权益' },
{ value: 'stored_value', zh: '储值会员' },
{ value: 'pedo_member', zh: '儿牙会员' },
{ value: 'medical', zh: '医保客户' },
{ value: 'high_insurance', zh: '高端保险直付', hint: '用过 Bupa、万欣和等商业保险直付' },
{ value: 'bank_vip', zh: '银行私行权益', hint: '用过私人银行 / 白金卡等银行权益' },
{ value: 'stored_value', zh: '储值会员', hint: '有储值或预存记录' },
{ value: 'pedo_member', zh: '儿牙会员', hint: '买过儿牙会员卡 / 涂氟年卡' },
{ value: 'medical', zh: '医保客户', hint: '用过医保结算' },
],
},
{
......@@ -122,10 +130,10 @@ export const PERSONA_TAG_FILTER_DIMS: PersonaTagFilterDim[] = [
dataPath: 'types',
isArray: true,
options: [
{ value: 'implant_history', zh: '种植史' },
{ value: 'ortho_history', zh: '正畸史' },
{ value: 'prostho_history', zh: '修复史' },
{ value: 'perio_history', zh: '牙周治疗史' },
{ value: 'implant_history', zh: '种植史', hint: '种过牙' },
{ value: 'ortho_history', zh: '正畸史', hint: '做过矫正' },
{ value: 'prostho_history', zh: '修复史', hint: '做过冠桥 / 贴面 / 嵌体' },
{ value: 'perio_history', zh: '牙周治疗史', hint: '做过洁牙 / 刮治 / 牙周序列治疗' },
],
},
{
......@@ -134,10 +142,10 @@ export const PERSONA_TAG_FILTER_DIMS: PersonaTagFilterDim[] = [
dataPath: 'types',
isArray: true,
options: [
{ value: 'dental_fear', zh: '看牙恐惧' },
{ value: 'needle_faint', zh: '晕针' },
{ value: 'blood_faint', zh: '晕血' },
{ value: 'claustrophobia', zh: '密闭恐惧' },
{ value: 'dental_fear', zh: '看牙恐惧', hint: '病历里提过害怕看牙' },
{ value: 'needle_faint', zh: '晕针', hint: '病历里提过晕针 / 怕打针' },
{ value: 'blood_faint', zh: '晕血', hint: '病历里提过晕血' },
{ value: 'claustrophobia', zh: '密闭恐惧', hint: '病历里提过幽闭 / 长时间张口不适' },
],
},
{
......@@ -146,10 +154,10 @@ export const PERSONA_TAG_FILTER_DIMS: PersonaTagFilterDim[] = [
dataPath: 'types',
isArray: true,
options: [
{ value: 'frequent_no_show', zh: '屡次爽约' },
{ value: 'often_late', zh: '经常迟到' },
{ value: 'do_not_disturb', zh: '免打扰' },
{ value: 'cannot_wait', zh: '不可等候' },
{ value: 'frequent_no_show', zh: '屡次爽约', hint: '近一年至少 3 次预约,履约不到一半' },
{ value: 'often_late', zh: '经常迟到', hint: '近一年多次晚到 15 分钟以上' },
{ value: 'do_not_disturb', zh: '免打扰', hint: '已标记不接受主动联系,不要外呼' },
{ value: 'cannot_wait', zh: '不可等候', hint: '病历或备注提过赶时间 / 不能等' },
],
},
{
......@@ -158,11 +166,11 @@ export const PERSONA_TAG_FILTER_DIMS: PersonaTagFilterDim[] = [
dataPath: 'types',
isArray: true,
options: [
{ value: 'weekday', zh: '工作日' },
{ value: 'weekend', zh: '周末' },
{ value: 'morning', zh: '上午' },
{ value: 'afternoon', zh: '下午' },
{ value: 'evening', zh: '晚间' },
{ value: 'weekday', zh: '工作日', hint: '近两年预约多在工作日' },
{ value: 'weekend', zh: '周末', hint: '近两年预约多在周末' },
{ value: 'morning', zh: '上午', hint: '多约在 8-12 点' },
{ value: 'afternoon', zh: '下午', hint: '多约在 12-18 点' },
{ value: 'evening', zh: '晚间', hint: '多约在 18-21 点' },
],
},
{
......@@ -170,8 +178,8 @@ export const PERSONA_TAG_FILTER_DIMS: PersonaTagFilterDim[] = [
nameZh: '转介绍达人',
dataPath: 'type',
options: [
{ value: 'family', zh: '家庭型' },
{ value: 'social', zh: '社交型' },
{ value: 'family', zh: '家庭型', hint: '推荐 3 人以上并带来成交,带的是家里人' },
{ value: 'social', zh: '社交型', hint: '推荐 3 人以上并带来成交,带的是家人以外的人' },
],
},
{
......@@ -179,7 +187,7 @@ export const PERSONA_TAG_FILTER_DIMS: PersonaTagFilterDim[] = [
nameZh: '禁忌',
dataPath: 'types',
isArray: true,
options: [{ value: 'implant_age', zh: '种植年龄禁忌' }],
options: [{ value: 'implant_age', zh: '种植年龄禁忌', hint: '未满 19 岁,颌骨尚未发育完全' }],
},
{
key: 'potential_treatment',
......@@ -187,14 +195,14 @@ export const PERSONA_TAG_FILTER_DIMS: PersonaTagFilterDim[] = [
dataPath: 'types',
isArray: true,
options: [
{ value: 'implant', zh: '种植' },
{ value: 'ortho', zh: '正畸' },
{ value: 'early_ortho', zh: '早矫' },
{ value: 'endo', zh: '根管' },
{ value: 'perio', zh: '牙周' },
{ value: 'filling', zh: '补牙' },
{ value: 'restoration', zh: '修复' },
{ value: 'extraction', zh: '拔牙' },
{ value: 'implant', zh: '种植', hint: '诊断缺牙,还没种' },
{ value: 'ortho', zh: '正畸', hint: '建议矫正,还没做(13-40 岁)' },
{ value: 'early_ortho', zh: '早矫', hint: '建议儿童早期矫治(3-12 岁)' },
{ value: 'endo', zh: '根管', hint: '诊断牙髓问题,还没做根管' },
{ value: 'perio', zh: '牙周', hint: '诊断牙周问题,还没治' },
{ value: 'filling', zh: '补牙', hint: '诊断龋齿,还没补' },
{ value: 'restoration', zh: '修复', hint: '需要冠桥 / 贴面,还没做' },
{ value: 'extraction', zh: '拔牙', hint: '残根残冠等需拔,还没拔' },
],
},
];
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment