Commit d7dc50eb by luoqi

feat(persona): 到期改档精确调度 —— 时间跨档也能刷新,但不靠全量扫

## 问题

水位闸只在**事实变了**时放行重算。这是对的 —— 没有它 40 万患者天天全量重算。
但有一类变化不伴随任何事实变动,时间自己走了就会改档:

  急迫等级跨 30/90 天线 · 生命周期跨 180/540/730 · RFM 的 R 跨 540/730/1095/1460
  年龄跨段 · 种植年龄禁忌满 19 岁解除 · 时间偏好/特别关注的滑动窗口把最老记录挤出去

这类患者永远等不到重算。年龄段和年龄禁忌最极端 —— 它们**完全不依赖 fact**,
一个 54 岁患者会一直显示「中老年」,直到他碰巧有新事实进来。而年龄还喂着按龄排治疗。

## 解法:算出确切到期时刻,而不是每天盲扫

FeatureExtractor 新增可选 `nextBoundaryAt(ctx)`,报出"纯时间流逝会让我改档的最近时刻";
PersonaService 取全体最小值存 personas.next_boundary_at;夜扫加一条 UNION 分支
只捞 `next_boundary_at <= now()`,走 (superseded_at, next_boundary_at) 索引。
isFresh 同步加这一维 —— 否则夜扫捞出来入队也会被水位当场 noop 掉,白捞。

八个特征声明了边界(rfm / lifecycle_stage / urgency_level / age_bracket /
contraindication / potential_treatment / time_preference / special_attention),
日期算术统一走 features/time-boundary.ts 的四个原语,不手搓。

顺带把 rfm / lifecycle_stage / urgency_level 各抄一遍的「到诊」口径收进 visit-facts.ts
—— 三个标签的 description 都印着"就诊 N 次 / 末诊 N 天",口径对不上客服当场就能发现。

## 本地实测(friday host,13,268 患者)

  --force 回填   success=0 · refreshed=885 · unchanged=12,383 → 全量跑几乎不产新版本
  边界分布       10,808 有到期时刻 / 2,463 已过全部档界(NULL)
  每日到期量     5~30 条(外推生产 40 万约 150~900/天,而不是 40 万)
  端到端         人为把某条置为已过期 → 夜扫 SQL 捞到 → 不带 --force 重算 noop=0(闸放行)
                 → 内容真没变故判 unchanged 不写 → 边界推进到 2029-06-06,不会重复捞

## 一并清理(逻辑清晰向)

· persona-display.ts 删掉 `[enum] 文本` 前缀解析与 PERSONA_STATUS_ZH —— 服务的四个特征
  (treatment_chain_status / recall_risk / value / do_not_contact_status)W7 已摘除,
  现存 16 个 extractor 没有一个产这种前缀,抽屉里那个 tag 恒为 null。
· PERSONA_FEATURE_META 从 33 个键收到 16 个(= 在跑的全集)。原来混着已摘除的和只存在于
  路线图上的,"看着 30 个标签实际只出 16 个"。枚举保留路线图,展示元数据只登记会上屏的。
· tone 重排:红色只留给警示(急迫/特别关注/禁忌/治疗敏感)。原来「转介绍达人」「折扣锚点」
  这类正面或中性信息也是红的,和「禁忌」同色,客服扫一眼分不出轻重。两条 CI 断言守住。
· mock 画像换成在跑的特征 —— 原来摆着六个系统根本不产出的标签,拿它当参照会看错。
· 折扣锚点超过 3 年在详情页给过时提醒(生产上见过 2018 年的锚点被原样展示)。

453 tests green · pac-web build 通过。

## 部署

两个迁移的回填是同一次运行,且**必须带 --force**:
next_boundary_at 的 NULL 语义是"不再改档",不会被判 stale,不强制跑就永远算不出来。

  pnpm recompute-persona:prod -- --host=jvs-dw --concurrency=8 --force

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
parent e874e74b
...@@ -17,5 +17,8 @@ CREATE INDEX IF NOT EXISTS "patient_facts_patient_id_updated_at_idx" ...@@ -17,5 +17,8 @@ CREATE INDEX IF NOT EXISTS "patient_facts_patient_id_updated_at_idx"
ON "patient_facts"("patient_id", "updated_at"); ON "patient_facts"("patient_id", "updated_at");
-- ⚠️ 部署后需一次性全量重算把存量 fact_watermark 补上(NULL 一律判 stale): -- ⚠️ 部署后需一次性全量重算把存量 fact_watermark 补上(NULL 一律判 stale):
-- pnpm recompute-persona -- --host=jvs-dw --concurrency=8 -- pnpm recompute-persona:prod -- --host=jvs-dw --concurrency=8 --force
-- 不补也能自愈 —— 每晚 stale-scan 会按 LIMIT 速率回填,只是慢。 -- --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)。
...@@ -714,6 +714,14 @@ model Persona { ...@@ -714,6 +714,14 @@ model Persona {
/// 74% 存量画像的累计消费永久停在旧口径(中位高估 91%),还会污染 RFM M 分位打分。 /// 74% 存量画像的累计消费永久停在旧口径(中位高估 91%),还会污染 RFM M 分位打分。
/// null = 存量老版本(没记过 fact 水位) 判定视为 stale,下次必重算一遍补上。 /// null = 存量老版本(没记过 fact 水位) 判定视为 stale,下次必重算一遍补上。
factWatermark DateTime? @map("fact_watermark") @db.Timestamptz(3) 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]) host Host @relation(fields: [hostId], references: [id])
/// Restrict:画像版本流是审计 + 召回合规依据,不随 patient 物删消失 /// Restrict:画像版本流是审计 + 召回合规依据,不随 patient 物删消失
...@@ -724,6 +732,9 @@ model Persona { ...@@ -724,6 +732,9 @@ model Persona {
@@unique([patientId, version]) @@unique([patientId, version])
@@index([hostId, tenantId]) @@index([hostId, tenantId])
@@index([patientId, supersededAt]) @@index([patientId, supersededAt])
/// 夜扫「到期改档」分支:WHERE superseded_at IS NULL AND next_boundary_at <= now()
/// 到期的每天只有几百到几千条,索引扫比全表快几个量级。
@@index([supersededAt, nextBoundaryAt])
@@map("personas") @@map("personas")
} }
......
...@@ -5,6 +5,7 @@ import type { ...@@ -5,6 +5,7 @@ import type {
FeatureExtractorContext, FeatureExtractorContext,
PersonaFeatureDraft, PersonaFeatureDraft,
} from './feature.interface'; } from './feature.interface';
import { nextAgeBoundary } from './time-boundary';
import { ageYearsAt } from './potential-treatment.feature'; import { ageYearsAt } from './potential-treatment.feature';
/** /**
...@@ -65,4 +66,18 @@ export class AgeBracketFeatureExtractor implements FeatureExtractor { ...@@ -65,4 +66,18 @@ export class AgeBracketFeatureExtractor implements FeatureExtractor {
evidence: { factIds: [] }, // 证据=patient.birthDate(主档,非 fact) 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 { ...@@ -6,6 +6,7 @@ import type {
PersonaFeatureDraft, PersonaFeatureDraft,
} from './feature.interface'; } from './feature.interface';
import { ageYearsAt } from './potential-treatment.feature'; import { ageYearsAt } from './potential-treatment.feature';
import { nextAgeBoundary } from './time-boundary';
/** /**
* contraindication 禁忌标签(D.2.4)— 规则层,snapshot · 多标签 * contraindication 禁忌标签(D.2.4)— 规则层,snapshot · 多标签
...@@ -56,4 +57,15 @@ export class ContraindicationFeatureExtractor implements FeatureExtractor { ...@@ -56,4 +57,15 @@ export class ContraindicationFeatureExtractor implements FeatureExtractor {
evidence: { factIds: [] }, // v1 证据=patient.birthDate(年龄禁忌,非 fact) 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 { ...@@ -87,4 +87,15 @@ export interface PersonaFeatureDraft {
export interface FeatureExtractor { export interface FeatureExtractor {
readonly key: PersonaFeatureKey; readonly key: PersonaFeatureKey;
extract(ctx: FeatureExtractorContext): PersonaFeatureDraft | null; extract(ctx: FeatureExtractorContext): PersonaFeatureDraft | null;
/**
* 可选:**纯时间流逝**会让本特征改档的最近时刻(未来时刻;不会再变 → null)。
*
* 水位闸只在事实变了时放行重算,所以"只是时间过去了"导致的改档(急迫跨 90 天线、
* 年龄跨段、滑窗把最老记录挤出去…)本来永远不会被触发。各特征在这里报出自己的到期时刻,
* PersonaService 取全体最小值存 personas.next_boundary_at,每晚只捞到期的那一小撮重算。
*
* 不实现 = 该特征不随时间自行改变(性别 / 获客渠道 / 治疗史 …)。
* 用 time-boundary.ts 里的原语拼,别手搓日期算术。
*/
nextBoundaryAt?(ctx: FeatureExtractorContext): Date | null;
} }
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { PersonaFeatureKey, FactType, FactKind } from '@pac/types'; import { PersonaFeatureKey, FactType } from '@pac/types';
import type { import type {
ActiveFact, ActiveFact,
FeatureExtractor, FeatureExtractor,
FeatureExtractorContext, FeatureExtractorContext,
PersonaFeatureDraft, PersonaFeatureDraft,
} from './feature.interface'; } from './feature.interface';
import { visitFactsOf, visitSpanOf } from './visit-facts';
import { earliestFuture, nextDayBoundary } from './time-boundary';
/** /**
* lifecycle_stage 生命周期(B.1.4)— 规则层,snapshot * lifecycle_stage 生命周期(B.1.4)— 规则层,snapshot
...@@ -56,23 +58,12 @@ export class LifecycleStageFeatureExtractor implements FeatureExtractor { ...@@ -56,23 +58,12 @@ export class LifecycleStageFeatureExtractor implements FeatureExtractor {
const netThis = const netThis =
this.sumCents(pays, 1, yearAgo) + this.sumCents(recharges, 1, yearAgo) - this.sumCents(refunds, 1, yearAgo); this.sumCents(pays, 1, yearAgo) + this.sumCents(recharges, 1, yearAgo) - this.sumCents(refunds, 1, yearAgo);
// 就诊:去重天数 + 首/末 // 就诊:去重天数 + 首/末(口径见 visit-facts.ts,与 rfm / urgency_level 同源)
const visitFacts: ActiveFact[] = [ const visitFacts = visitFactsOf(ctx);
...get(FactType.ENCOUNTER_RECORD), const span = visitSpanOf(visitFacts);
...get(FactType.TREATMENT_RECORD).filter((f) => f.kind === FactKind.ACTUAL), const first = span.first?.getTime() ?? null;
...get(FactType.VISIT_REGISTRATION_RECORD), const last = span.last?.getTime() ?? null;
]; const totalVisits = span.dayCount;
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;
const hasAppt = get(FactType.APPOINTMENT_RECORD).length > 0; const hasAppt = get(FactType.APPOINTMENT_RECORD).length > 0;
// 咨询记录(consultation_record,W7末摄入)— 潜客判定的另一触点(spec:零就诊 + 有预约/咨询) // 咨询记录(consultation_record,W7末摄入)— 潜客判定的另一触点(spec:零就诊 + 有预约/咨询)
const hasConsult = get(FactType.CONSULTATION_RECORD).length > 0; const hasConsult = get(FactType.CONSULTATION_RECORD).length > 0;
...@@ -147,4 +138,19 @@ export class LifecycleStageFeatureExtractor implements FeatureExtractor { ...@@ -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 { ...@@ -6,6 +6,7 @@ import type {
PersonaFeatureDraft, PersonaFeatureDraft,
} from './feature.interface'; } from './feature.interface';
import type { PotentialGap } from '../../clinical-gap/potential-treatment.selector'; import type { PotentialGap } from '../../clinical-gap/potential-treatment.selector';
import { nextAgeBoundary } from './time-boundary';
/** /**
* potential_treatment 潜在治疗(C.1.1 转化机会)— 规则层,snapshot · 多标签 * potential_treatment 潜在治疗(C.1.1 转化机会)— 规则层,snapshot · 多标签
...@@ -29,6 +30,13 @@ import type { PotentialGap } from '../../clinical-gap/potential-treatment.select ...@@ -29,6 +30,13 @@ import type { PotentialGap } from '../../clinical-gap/potential-treatment.select
*/ */
const EXTRACTION_NAME_KW = ['残根', '残冠', '无法保留', '不能保留']; 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 共用) /// 周岁(单一源,potential_treatment / urgency_level 共用)
export function ageYearsAt(birth: Date | null, now: Date): number | null { export function ageYearsAt(birth: Date | null, now: Date): number | null {
if (!birth) return null; if (!birth) return null;
...@@ -127,4 +135,14 @@ export class PotentialTreatmentFeatureExtractor implements FeatureExtractor { ...@@ -127,4 +135,14 @@ export class PotentialTreatmentFeatureExtractor implements FeatureExtractor {
evidence: { factIds: [...factIds] }, 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 { ...@@ -6,11 +6,12 @@ import {
lookupDxTreatment, lookupDxTreatment,
} from '@pac/types'; } from '@pac/types';
import type { import type {
ActiveFact,
FeatureExtractor, FeatureExtractor,
FeatureExtractorContext, FeatureExtractorContext,
PersonaFeatureDraft, PersonaFeatureDraft,
} from './feature.interface'; } from './feature.interface';
import { visitFactsOf, visitSpanOf } from './visit-facts';
import { nextDayBoundary } from './time-boundary';
/** /**
* rfm 价值分群(RFM 八象限 + 生命周期)— 统计层 * rfm 价值分群(RFM 八象限 + 生命周期)— 统计层
...@@ -115,22 +116,9 @@ export class RfmFeatureExtractor implements FeatureExtractor { ...@@ -115,22 +116,9 @@ export class RfmFeatureExtractor implements FeatureExtractor {
const valueTier = RfmFeatureExtractor.VALUE_TIERS.find((t) => monetaryCents >= t.cents)?.tier ?? 0; const valueTier = RfmFeatureExtractor.VALUE_TIERS.find((t) => monetaryCents >= t.cents)?.tier ?? 0;
const mScore = RfmFeatureExtractor.mScore(monetaryCents, ctx.populationStats?.monetaryQuantiles ?? []); const mScore = RfmFeatureExtractor.mScore(monetaryCents, ctx.populationStats?.monetaryQuantiles ?? []);
// ── R / F:就诊(encounter / actual treatment / 挂号)── // ── R / F:就诊(口径见 visit-facts.ts,与 lifecycle_stage / urgency_level 同源)──
const visitFacts: ActiveFact[] = [ const visitFacts = visitFactsOf(ctx);
...get(FactType.ENCOUNTER_RECORD), const { first: firstVisit, last: lastVisit, dayCount: freqCount } = visitSpanOf(visitFacts);
...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
const recencyDays = lastVisit ? Math.floor((now.getTime() - lastVisit.getTime()) / DAY) : 9999; const recencyDays = lastVisit ? Math.floor((now.getTime() - lastVisit.getTime()) / DAY) : 9999;
const firstDays = firstVisit ? Math.floor((now.getTime() - firstVisit.getTime()) / DAY) : 9999; const firstDays = firstVisit ? Math.floor((now.getTime() - firstVisit.getTime()) / DAY) : 9999;
...@@ -198,4 +186,13 @@ export class RfmFeatureExtractor implements FeatureExtractor { ...@@ -198,4 +186,13 @@ export class RfmFeatureExtractor implements FeatureExtractor {
evidence: { factIds: [...new Set([...moneyFactIds, ...visitFacts.map((f) => f.id), ...clinicalFactIds])] }, 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 { ...@@ -6,6 +6,7 @@ import type {
FeatureExtractorContext, FeatureExtractorContext,
PersonaFeatureDraft, PersonaFeatureDraft,
} from './feature.interface'; } from './feature.interface';
import { nextWindowExpiry } from './time-boundary';
/** /**
* special_attention 特别关注(D.2.3)— 规则层,snapshot · 多标签 * special_attention 特别关注(D.2.3)— 规则层,snapshot · 多标签
...@@ -115,4 +116,18 @@ export class SpecialAttentionFeatureExtractor implements FeatureExtractor { ...@@ -115,4 +116,18 @@ export class SpecialAttentionFeatureExtractor implements FeatureExtractor {
evidence: { factIds: [...evidenceIds] }, 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 { ...@@ -6,6 +6,7 @@ import type {
FeatureExtractorContext, FeatureExtractorContext,
PersonaFeatureDraft, PersonaFeatureDraft,
} from './feature.interface'; } from './feature.interface';
import { nextWindowExpiry } from './time-boundary';
/** /**
* time_preference 时间偏好(D.1.2)— 规则层,snapshot · 多标签(允许并列) * time_preference 时间偏好(D.1.2)— 规则层,snapshot · 多标签(允许并列)
...@@ -95,4 +96,17 @@ export class TimePreferenceFeatureExtractor implements FeatureExtractor { ...@@ -95,4 +96,17 @@ export class TimePreferenceFeatureExtractor implements FeatureExtractor {
evidence: { factIds }, 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 { Injectable } from '@nestjs/common';
import { PersonaFeatureKey, FactType, FactKind } from '@pac/types'; import { PersonaFeatureKey } from '@pac/types';
import type { import type {
ActiveFact,
FeatureExtractor, FeatureExtractor,
FeatureExtractorContext, FeatureExtractorContext,
PersonaFeatureDraft, PersonaFeatureDraft,
} from './feature.interface'; } from './feature.interface';
import { ageYearsAt, classifyGapToLabel } from './potential-treatment.feature'; import { ageYearsAt, classifyGapToLabel } from './potential-treatment.feature';
import { visitFactsOf, visitSpanOf } from './visit-facts';
import { nextDayBoundary } from './time-boundary';
/** /**
* urgency_level 急迫等级(C.2.1)— 规则层,snapshot · 单一标签(取最大) * urgency_level 急迫等级(C.2.1)— 规则层,snapshot · 单一标签(取最大)
...@@ -45,23 +46,9 @@ export class UrgencyLevelFeatureExtractor implements FeatureExtractor { ...@@ -45,23 +46,9 @@ export class UrgencyLevelFeatureExtractor implements FeatureExtractor {
if (labelKeys.size === 0) return null; // 无潜在待转(8 标签)→ 此路径无急迫 if (labelKeys.size === 0) return null; // 无潜在待转(8 标签)→ 此路径无急迫
const gaps = ctx.potentialGaps ?? []; const gaps = ctx.potentialGaps ?? [];
// 末诊(末次就诊)— 口径同 lifecycle_stage // 末诊(末次就诊)— 口径见 visit-facts.ts,与 rfm / lifecycle_stage 同源
const get = (t: string) => ctx.factsByType.get(t) ?? []; const { last: lastVisit, lastFactId } = visitSpanOf(visitFactsOf(ctx));
const visitFacts: ActiveFact[] = [ const last = lastVisit?.getTime() ?? null;
...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;
}
}
if (lastFactId) factIds.add(lastFactId); if (lastFactId) factIds.add(lastFactId);
const now = ctx.now.getTime(); const now = ctx.now.getTime();
// 无就诊记录(罕见,有诊断必有诊)→ 回退用 gap 最早诊断天数 // 无就诊记录(罕见,有诊断必有诊)→ 回退用 gap 最早诊断天数
...@@ -92,4 +79,14 @@ export class UrgencyLevelFeatureExtractor implements FeatureExtractor { ...@@ -92,4 +79,14 @@ export class UrgencyLevelFeatureExtractor implements FeatureExtractor {
evidence: { factIds: [...factIds] }, 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 };
}
...@@ -128,8 +128,13 @@ export class PersonaService { ...@@ -128,8 +128,13 @@ export class PersonaService {
* 两个水位都为 null = 该患者既无事务也无事实 → 没有输入可变,判新鲜(baseline 已写过)。 * 两个水位都为 null = 该患者既无事务也无事实 → 没有输入可变,判新鲜(baseline 已写过)。
*/ */
private isFresh( private isFresh(
active: { eventWatermark: bigint | null; factWatermark: Date | null }, active: {
eventWatermark: bigint | null;
factWatermark: Date | null;
nextBoundaryAt: Date | null;
},
wm: Watermarks, wm: Watermarks,
now: Date,
): boolean { ): boolean {
if (wm.factAt !== null) { if (wm.factAt !== null) {
if (active.factWatermark === null) return false; if (active.factWatermark === null) return false;
...@@ -139,6 +144,11 @@ export class PersonaService { ...@@ -139,6 +144,11 @@ export class PersonaService {
if (active.eventWatermark === null) return false; if (active.eventWatermark === null) return false;
if (wm.eventSeq > active.eventWatermark) 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; return true;
} }
...@@ -186,11 +196,12 @@ export class PersonaService { ...@@ -186,11 +196,12 @@ export class PersonaService {
supersededAt: true, supersededAt: true,
eventWatermark: true, eventWatermark: true,
factWatermark: true, factWatermark: true,
nextBoundaryAt: true,
}, },
}); });
const active = latest && !latest.supersededAt ? latest : null; const active = latest && !latest.supersededAt ? latest : null;
if (!input.force && active && this.isFresh(active, wm)) { if (!input.force && active && this.isFresh(active, wm, now)) {
// 落一条 noop 日志便于审计 — 区别于实际算了一遍 // 落一条 noop 日志便于审计 — 区别于实际算了一遍
await this.prisma.personaRecomputeLog.create({ await this.prisma.personaRecomputeLog.create({
data: { data: {
...@@ -342,6 +353,9 @@ export class PersonaService { ...@@ -342,6 +353,9 @@ export class PersonaService {
const drafts: PersonaFeatureDraft[] = []; const drafts: PersonaFeatureDraft[] = [];
let ruleCount = 0; let ruleCount = 0;
let failed = 0; let failed = 0;
// 「到期改档」锚点:各特征报出自己因纯时间流逝而改档的最近时刻,取全体最小值。
// 没有它,不依赖 fact 的特征(年龄段 / 年龄禁忌)永远等不到重算。
let nextBoundaryAt: Date | null = null;
for (const ex of this.registry.extractors) { for (const ex of this.registry.extractors) {
try { try {
const d = ex.extract(ctx); const d = ex.extract(ctx);
...@@ -349,6 +363,8 @@ export class PersonaService { ...@@ -349,6 +363,8 @@ export class PersonaService {
drafts.push(d); drafts.push(d);
ruleCount++; ruleCount++;
} }
const b = ex.nextBoundaryAt?.(ctx) ?? null;
if (b && (!nextBoundaryAt || b < nextBoundaryAt)) nextBoundaryAt = b;
} catch (err) { } catch (err) {
failed++; failed++;
this.logger.error( this.logger.error(
...@@ -367,7 +383,7 @@ export class PersonaService { ...@@ -367,7 +383,7 @@ export class PersonaService {
})) as StoredPersonaFeature[]) })) as StoredPersonaFeature[])
: null; : null;
const verdict = personaWriteVerdict(currentFeatures, drafts); const verdict = personaWriteVerdict(currentFeatures, drafts);
const watermarks = { eventWatermark, factWatermark: wm.factAt }; const watermarks = { eventWatermark, factWatermark: wm.factAt, nextBoundaryAt };
let personaId: string; let personaId: string;
let version: number; let version: number;
......
...@@ -11,15 +11,17 @@ import { QueueProducer } from './queue-producer.service'; ...@@ -11,15 +11,17 @@ import { QueueProducer } from './queue-producer.service';
* - 应用错误期间漏入队的(部署 / panic 错过 enqueue) * - 应用错误期间漏入队的(部署 / panic 错过 enqueue)
* - cold-import 进来但没触发 enqueue 的(脚本路径) * - cold-import 进来但没触发 enqueue 的(脚本路径)
* *
* 判定(两个水位任一落后即算 stale,或 persona 根本不存在): * 判定(任一命中即 stale,或 persona 根本不存在)→ enqueue persona-recompute(jobId 去重):
* - max(patient_transactions.event_seq) > personas.event_watermark * - max(patient_transactions.event_seq) > personas.event_watermark 事务水位(辅)
* - max(patient_facts.updated_at) > personas.fact_watermark ← 主水位 * - max(patient_facts.updated_at) > personas.fact_watermark 事实水位(主)
* → enqueue persona-recompute(BullMQ jobId 自动去重) * - personas.next_boundary_at <= now() 到期改档
* *
* ⚠️ fact 水位是 2026-07 补的:画像读 patient_facts,但原来只判 transaction, * ⚠️ 后两条都是 2026-07 补的,各修一个洞:
* reparse(重写事实、不产事务)后的重算被全部 noop 掉。存量 persona 的 fact_watermark * · fact 水位 —— 画像读 patient_facts,原来只判 transaction,reparse(重写事实、不产事务)
* 为 NULL → 本扫描会持续判它们 stale,以每晚 LIMIT 的速率**自然回填**。 * 后的重算被全部 noop 掉。存量 fact_watermark 为 NULL → 本扫描持续判 stale,自然回填。
* 要一次性补完请跑 `pnpm recompute-persona -- --host=<h> --concurrency=N`。 * · 到期改档 —— 有一类变化不伴随任何事实变动(时间跨过档界)。存量 next_boundary_at 为
* NULL,而 NULL 的语义是"不再改档",**不会自愈**,必须部署后强制回填一次:
* `pnpm recompute-persona -- --host=<h> --concurrency=N --force`。
* *
* 安全:扫描走 GROUP BY + HAVING,不锁表。同一 patient 即便已在主路径队列里, * 安全:扫描走 GROUP BY + HAVING,不锁表。同一 patient 即便已在主路径队列里,
* jobId 去重保证不会重复算。 * jobId 去重保证不会重复算。
...@@ -79,12 +81,16 @@ export class StaleScanService { ...@@ -79,12 +81,16 @@ export class StaleScanService {
} }
/** /**
* 找出"画像 stale"的患者: * 找出"画像 stale"的患者,两类各走一条子查询(UNION):
* - 有 transaction 但没 persona(从来没算过)
* - 有 persona 但 event 水位落后(漏了更新)
* - 有 persona 但 **fact 水位落后**(reparse / 事实层订正 —— 不产新事务的那类变更)
* *
* 用一句 raw SQL 兼顾三种情况;返回 (hostId, tenantId, patientId) 三元组。 * ① 输入变了 —— 有 transaction 但没 persona(从来没算过)/ event 水位落后 /
* **fact 水位落后**(reparse、事实层订正这类不产新事务的变更)。
* ② 到期改档 —— 事实一个字没变,但时间跨过了某条档界(急迫 30/90 天、生命周期
* 180/540/730、RFM 的 R、年龄跨段、滑动窗口过期)。这类患者只能靠 next_boundary_at
* 捞:它是**确切时刻**,所以每晚只命中几百到几千条,走 (superseded_at, next_boundary_at)
* 索引,而不是把 40 万人全重算一遍 —— 那等于把水位闸拆了。
*
* 返回 (hostId, tenantId, patientId) 三元组。
*/ */
private async findStalePatients(): Promise< private async findStalePatients(): Promise<
Array<{ hostId: string; tenantId: string; patientId: string }> Array<{ hostId: string; tenantId: string; patientId: string }>
...@@ -103,17 +109,29 @@ export class StaleScanService { ...@@ -103,17 +109,29 @@ export class StaleScanService {
FROM patient_facts FROM patient_facts
GROUP BY patient_id GROUP BY patient_id
) )
SELECT tx.host_id, tx.tenant_id, tx.patient_id (
FROM tx -- ① 输入变了
LEFT JOIN personas p SELECT tx.host_id, tx.tenant_id, tx.patient_id
ON p.patient_id = tx.patient_id FROM tx
AND p.superseded_at IS NULL LEFT JOIN personas p
LEFT JOIN fx ON fx.patient_id = tx.patient_id ON p.patient_id = tx.patient_id
WHERE p.id IS NULL AND p.superseded_at IS NULL
OR p.event_watermark IS NULL LEFT JOIN fx ON fx.patient_id = tx.patient_id
OR p.event_watermark < tx.max_seq WHERE p.id IS NULL
OR (fx.max_fact_at IS NOT NULL OR p.event_watermark IS NULL
AND (p.fact_watermark IS NULL OR p.fact_watermark < fx.max_fact_at)) 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 LIMIT 5000
`; `;
return rows.map((r) => ({ return rows.map((r) => ({
......
...@@ -53,6 +53,19 @@ describe('spec.display —— 详情页说明', () => { ...@@ -53,6 +53,19 @@ describe('spec.display —— 详情页说明', () => {
test('每个标签都有中文展示名(前端 hover 标题取它)', async () => { test('每个标签都有中文展示名(前端 hover 标题取它)', async () => {
for (const key of await registryKeys()) expect(PERSONA_FEATURE_META[key]).toBeDefined(); 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 —— 圈人字典', () => { describe('PERSONA_TAG_FILTER_DIMS —— 圈人字典', () => {
......
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]);
});
});
...@@ -56,8 +56,12 @@ const RFM = (over: Partial<PersonaFeatureDraft> = {}): PersonaFeatureDraft => ...@@ -56,8 +56,12 @@ const RFM = (over: Partial<PersonaFeatureDraft> = {}): PersonaFeatureDraft =>
}) as PersonaFeatureDraft; }) as PersonaFeatureDraft;
interface Opts { interface Opts {
/** 库里 active persona 的水位;null = 从没算过 */ /** 库里 active persona 的水位 + 到期锚点;null = 从没算过 */
persona: { eventWatermark: bigint | null; factWatermark: Date | null } | null; persona: {
eventWatermark: bigint | null;
factWatermark: Date | null;
nextBoundaryAt?: Date | null;
} | null;
/** 当前实时水位 */ /** 当前实时水位 */
now: { eventSeq: bigint | null; factAt: Date | null }; now: { eventSeq: bigint | null; factAt: Date | null };
/** 当前 active 版本已存的 feature 行 */ /** 当前 active 版本已存的 feature 行 */
...@@ -106,7 +110,7 @@ function makeHarness(o: Opts) { ...@@ -106,7 +110,7 @@ function makeHarness(o: Opts) {
persona: { persona: {
findFirst: jest.fn().mockResolvedValue( findFirst: jest.fn().mockResolvedValue(
o.persona o.persona
? { id: 'persona-old', version: 3, supersededAt: null, ...o.persona } ? { id: 'persona-old', version: 3, supersededAt: null, nextBoundaryAt: null, ...o.persona }
: null, : null,
), ),
}, },
...@@ -198,6 +202,27 @@ describe('水位闸 —— 该不该算', () => { ...@@ -198,6 +202,27 @@ describe('水位闸 —— 该不该算', () => {
expect(r.status).toBe('unchanged'); // 算了,但内容确实没变 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 () => { test('该患者既无事务也无事实、且已有 baseline → noop(没有输入可变)', async () => {
const { svc } = await build({ const { svc } = await build({
persona: { eventWatermark: null, factWatermark: null }, persona: { eventWatermark: null, factWatermark: null },
......
...@@ -210,6 +210,10 @@ export type PersonaFeature = { ...@@ -210,6 +210,10 @@ export type PersonaFeature = {
data?: unknown; 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 = { export const mockPersona = {
id: 'prsn_v7', id: 'prsn_v7',
version: 7, version: 7,
...@@ -217,46 +221,52 @@ export const mockPersona = { ...@@ -217,46 +221,52 @@ export const mockPersona = {
refreshedAt: daysAgo(2), refreshedAt: daysAgo(2),
features: [ features: [
{ {
key: PersonaFeatureKey.VALUE, key: PersonaFeatureKey.URGENCY_LEVEL,
label: '患者价值', label: '急迫等级',
value: 'VIP 金卡 · LTV ¥48,200', value: '紧急 · 末诊140天 · 2 类潜在治疗',
tone: 'indigo', tone: 'rose',
evidence: ['fact_pay_2024_05', 'fact_pay_2024_07', 'fact_pay_2024_10'], evidence: ['fact_dx_2025_12', 'fact_visit_2026_04'],
data: { level: 'urgent', levelZh: '紧急', lastVisitDays: 140, pendingTypes: 2 },
}, },
{ {
key: PersonaFeatureKey.TREATMENT_CHAIN_STATUS, key: PersonaFeatureKey.POTENTIAL_TREATMENT,
label: '治疗链状态', label: '潜在治疗',
value: '2 条闭环/在管 · 1 条断口', value: '潜在种植 / 潜在修复',
tone: 'amber', 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, key: PersonaFeatureKey.RFM,
label: '流失风险', label: '价值分群',
value: '低 — 牙周维护按期', value: '重要价值 · 距上次 140 天 · 就诊20次 · 累计¥48,200',
tone: 'emerald', tone: 'indigo',
evidence: ['fact_visit_2026_04'], 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, key: PersonaFeatureKey.LIFECYCLE_STAGE,
label: '沟通偏好', label: '生命周期',
value: '电话 · 工作日 19:00 后', value: '成熟客 · 末诊140天 · 就诊20次',
tone: 'sky', tone: 'teal',
evidence: ['fact_pref_2024'], evidence: ['fact_visit_2026_04'],
data: { stage: 'mature', label: '成熟客', recencyDays: 140, totalVisits: 20 },
}, },
{ {
key: PersonaFeatureKey.COOPERATION_ATTITUDE, key: PersonaFeatureKey.TREATMENT_HISTORY,
label: '配合态度', label: '治疗史',
value: '高 · 6 次复诊全到', value: '种植史 / 牙周治疗史',
tone: 'emerald', tone: 'amber',
evidence: ['fact_visit_history'], evidence: ['fact_tx_2024_07'],
data: { types: ['implant_history', 'perio_history'], labels: ['种植史', '牙周治疗史'] },
}, },
{ {
key: PersonaFeatureKey.DOCTOR_RELATION, key: PersonaFeatureKey.AGE_BRACKET,
label: '主治医患', label: '年龄段',
value: '李医生(种植)· 王医生(牙周)', value: '中年 · 42岁',
tone: 'slate', tone: 'sky',
evidence: ['fact_emr_2024'], evidence: [],
data: { bracket: 'middle_aged', label: '中年', ageYears: 42 },
}, },
] as PersonaFeature[], ] as PersonaFeature[],
}; };
......
...@@ -10,7 +10,6 @@ import { ...@@ -10,7 +10,6 @@ import {
} from '@pac/types'; } from '@pac/types';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { tone } from './shared'; import { tone } from './shared';
import { cleanPersonaValue } from './persona-display';
import { PersonaFeatureHover } from './persona-feature-hover'; import { PersonaFeatureHover } from './persona-feature-hover';
import { factLabel } from './fact-label'; import { factLabel } from './fact-label';
import type { PersonaFeature } from './mock-data'; import type { PersonaFeature } from './mock-data';
...@@ -31,6 +30,24 @@ import type { AdaptedFact } from './adapt-data'; ...@@ -31,6 +30,24 @@ import type { AdaptedFact } from './adapt-data';
*/ */
const MAX_EVIDENCE_ROWS = 4; 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({ export function PersonaDetailList({
features, features,
facts, facts,
...@@ -90,10 +107,10 @@ function FeatureCard({ ...@@ -90,10 +107,10 @@ function FeatureCard({
}) { }) {
const [showAll, setShowAll] = React.useState(false); const [showAll, setShowAll] = React.useState(false);
const T = tone(f.tone); const T = tone(f.tone);
const { tag, text } = cleanPersonaValue(f.value); const staleHint = staleAnchorHint(f);
// 多值特征(治疗史 / 潜在治疗 / 时间偏好…)后端给结构化 data.labels —— 详情里全部展开, // 多值特征(治疗史 / 潜在治疗 / 时间偏好…)后端给结构化 data.labels —— 详情里全部展开,
// 不像卡片那样截成 "+N"。单值特征回退到 cleanPersonaValue 的主描述文本 // 不像卡片那样截成 "+N"。单值特征直接显示后端那句完整 description
const labels = (f.data as { labels?: unknown } | null | undefined)?.labels; const labels = (f.data as { labels?: unknown } | null | undefined)?.labels;
const valueLabels = const valueLabels =
Array.isArray(labels) && labels.every((x) => typeof x === 'string') ? (labels as string[]) : null; Array.isArray(labels) && labels.every((x) => typeof x === 'string') ? (labels as string[]) : null;
...@@ -125,11 +142,6 @@ function FeatureCard({ ...@@ -125,11 +142,6 @@ function FeatureCard({
<div className={cn('inline-flex items-center gap-1.5 text-[11px] font-semibold', T.text)}> <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)} /> <span className={cn('h-1.5 w-1.5 rounded-full', T.dot)} />
{f.label} {f.label}
{tag && (
<span className="ml-1 rounded bg-slate-100 px-1 py-0 text-[10.5px] font-normal text-slate-600">
{tag}
</span>
)}
</div> </div>
{/* 取值条目 — 多值展开为 chip,单值显示文本 */} {/* 取值条目 — 多值展开为 chip,单值显示文本 */}
...@@ -145,7 +157,15 @@ function FeatureCard({ ...@@ -145,7 +157,15 @@ function FeatureCard({
))} ))}
</div> </div>
) : ( ) : (
text && <div className="mt-1 text-[13px] font-medium text-slate-900">{text}</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>
)} )}
{/* 依据 —— 这个标签是被哪些事实推出来的 */} {/* 依据 —— 这个标签是被哪些事实推出来的 */}
......
/** /**
* persona-display — persona feature.description 展示辅助 * persona feature.description 的展示辅助 —— 把后端那句自包含描述裁成不同密度的展示形态。
* *
* 后端 persona feature description 故意带 `[英文 enum]` 前缀(给 LLM prompt 当 * 后端每个特征产一句 description(如 `重要价值 · 距上次 140 天 · 就诊20次 · 累计¥154,350`),
* machine-readable hint,例 `[gap] 治疗链有 3 处缺口`),前端展示要中文化或分离成 chip * 详情抽屉整句显示,紧凑位(右侧 hint 列 / 标签云 chip)放不下,要短词
* *
* 用法: * 注:这里曾有一套 `[enum] 文本` 前缀的解析(`[gap] 治疗链有 3 处缺口` → chip「缺口」+ 正文),
* const { tag, text } = cleanPersonaValue(feature.value); * 服务的是 treatment_chain_status / recall_risk / value / do_not_contact_status 四个特征 ——
* // tag 渲染成小灰 chip(可空);text 主描述 * 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 列) * 短标签 —— 用于"关键事实"等紧凑位置。
* * `新客/未消费(累计净消费 ¥58.00,含 ...)` → `新客`
* 后端 description 常带详细括号(给客服详情看),紧凑位放不下,要短词: * `重要价值 · 距上次 140 天 · 累计¥154,350` → `重要价值`
* "新客/未消费(累计净消费 ¥58.00,含 ...)" → "新客" * 规则:截到第一个分隔符(`·` / `/` / 半角或全角括号)之前。
* "钻卡(LTV ¥48,200,含 ...)" → "钻卡"
* "[gap] 治疗链有 3 处缺口 — K05..." → "缺口"(取 [enum] 翻译)
*
* 规则:
* ① 有 [enum] 前缀 → 用翻译后的 tag(简洁)
* ② 否则 → 截首段(到第一个 "/" 或 "(" 或 "(" 前)
*/ */
export function shortPersonaValueLabel(raw: string | null | undefined): string { export function shortPersonaValueLabel(raw: string | null | undefined): string {
if (!raw) return '—'; 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(/[·\/(()]/); const stop = raw.search(/[·\/(()]/);
return (stop > 0 ? raw.slice(0, stop) : raw).trim(); return (stop > 0 ? raw.slice(0, stop) : raw).trim();
} }
/** /**
* 多值标签的紧凑显示(方案 A:首值 + "+N" 溢出) * 多值标签的紧凑显示(首值 + `+N` 溢出)。
* *
* 治疗史 / 权益 / 禁忌 / 潜在治疗 / 时间偏好 / 治疗敏感 / 特别关注 这 7 个特征 * 治疗史 / 权益 / 禁忌 / 潜在治疗 / 时间偏好 / 治疗敏感 / 特别关注 这 7 个特征是「`/` 并列多值」,
* 是"`/` 并列多值",description 会被 shortPersonaValueLabel 在 `/` 处误截只剩第一个、 * 直接交给 shortPersonaValueLabel 会在第一个 `/` 处截断、只剩首值且不提示还有更多。
* 且不提示还有更多。这里改读结构化 `data.labels`(后端这 7 个都带),chip 显示 * 这里改读结构化 `data.labels`(这 7 个后端都带),显示首值 + `+N`,完整列表走 hover 卡片。
* 第一个值 + `+N`(N=剩余个数),完整列表走 hover 卡片。单值特征 → { text, more:0 } * 单值特征 → `{ text, more: 0 }`
*/ */
export function compactPersonaValue( export function compactPersonaValue(
raw: string | null | undefined, raw: string | null | undefined,
......
...@@ -37,40 +37,43 @@ export const planScenarioLabel = (key: string): string => ...@@ -37,40 +37,43 @@ export const planScenarioLabel = (key: string): string =>
// PersonaFeatureKey 中文展示 + UI tone // 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 }> = { export const PERSONA_FEATURE_META: Record<string, { label: string; tone: Tone }> = {
[PersonaFeatureKey.RFM]: { label: '价值分群', tone: 'indigo' }, // ⚠️ 警示
[PersonaFeatureKey.AGE_BRACKET]: { label: '年龄段', tone: 'sky' }, [PersonaFeatureKey.URGENCY_LEVEL]: { label: '急迫等级', tone: 'rose' },
[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.SPECIAL_ATTENTION]: { label: '特别关注', tone: 'rose' }, [PersonaFeatureKey.SPECIAL_ATTENTION]: { label: '特别关注', tone: 'rose' },
[PersonaFeatureKey.TREATMENT_SENSITIVITY]: { label: '治疗敏感', tone: 'rose' },
[PersonaFeatureKey.CONTRAINDICATION]: { label: '禁忌标签', tone: 'rose' }, [PersonaFeatureKey.CONTRAINDICATION]: { label: '禁忌标签', tone: 'rose' },
[PersonaFeatureKey.URGENCY_LEVEL]: { label: '急迫等级', tone: 'rose' }, [PersonaFeatureKey.TREATMENT_SENSITIVITY]: { 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.POTENTIAL_TREATMENT]: { label: '潜在治疗', tone: 'amber' }, [PersonaFeatureKey.POTENTIAL_TREATMENT]: { label: '潜在治疗', tone: 'amber' },
[PersonaFeatureKey.COOPERATION_ATTITUDE]: { label: '配合态度', tone: 'emerald' }, [PersonaFeatureKey.TREATMENT_HISTORY]: { label: '治疗史', tone: 'amber' },
[PersonaFeatureKey.PRIMARY_CONTACT_RELATION]: { label: '主要联系人', tone: 'slate' }, // 💰 价值与权益(折扣锚点是价格参考,不是警示 —— 原来标红与"禁忌"同色,轻重不分)
[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 } => export const personaFeatureMeta = (key: string): { label: string; tone: Tone } =>
......
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