Commit 0eddcffa by luoqi

Revert "优先级 hover 可解释性" 三个 commit

Revert a4b75100 + 22b2c95f + 4477a8c2。

理由:改完比原来更不清晰。截图实例 —— 价值性 9 分、意愿度 8.0 分,右边却写着
「画像未标潜在治疗」「画像未标价值分群」:分数明明算出来了,解释说没数据,自相矛盾。
根因是 breakdown 里那几个展示字段只在 plan 重算后才有,存量 / 其他 host 的 plan 一律落到
"未标"分支 —— 而"未标"这个措辞本身也把「后端没透出」说成了「画像没打标」。

绕了一圈说明这块的设计还没想清楚,不该边改边试。退回原状,择期重做。

保留(与本次 UI 无关,单独重提):.swcrc exclude 正则未锚定的构建坑。

## 重做时手上已有的事实(都已核过,别再查一遍)

· 「看不出高低」是真问题:生产 active+assigned 23.6 万,priority_score
  p50=31 / p80=56 / p90=66 / p95=76 / p99=82。9.78 分是前 0.05%,而客服无从判断。
· 三维输入里 urgencyLevel / rfmSegment / lifecycleStage / treatmentHistory /
  referralChampion / specialAttention 来自画像;consultIntentMatch 来自
  consultation_record;primaryCode / toothCount 来自本条召回的 gap。
· consultIntentMatch 基本不可用:237 万条 consultation_record 只有 9.7% 解析出非空
  intent_categories,其余 214.8 万条源端 intent/content 全为 null(DW 没给,非解析 bug)。
  intentBehavior=2 的 22 万患者里真的没咨询过的只有 230 人(0.1%)。
· 存量对不齐(与本次无关,仍在):按年龄排治疗只落到召回目标,没落到画像 ——
  classifyGapToLabel 里 K08 仍只卡 age>18,生产 2,539 条 plan 目标写「活动义齿为主」
  而画像·潜在治疗仍标「潜在种植」。改它会同时改变「潜在种植」的圈人口径,需业务定。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
parent a4b75100
......@@ -21,5 +21,5 @@
"module": {
"type": "commonjs"
},
"exclude": ["^node_modules/", "^dist/"]
"exclude": ["node_modules", "dist"]
}
......@@ -413,14 +413,8 @@ export class PlanEngineService {
const h = hitByKey.get(key(r.scenario, r.subKey));
if (h == null) return false;
return reasonNeedsRefresh(
{ signals: r.signals, evidence: r.evidence, breakdown: r.breakdown },
{
signals: h.signals ?? null,
evidence: { factIds: h.evidence.factIds },
breakdown: h.priorityBreakdown
? { priority: { ...h.priorityBreakdown }, subKey: h.subKey ?? null }
: null,
},
{ signals: r.signals, evidence: r.evidence },
{ signals: h.signals ?? null, evidence: { factIds: h.evidence.factIds } },
);
});
......
......@@ -79,10 +79,6 @@ export interface PriorityInput {
isReferralChampion: boolean;
/// 信任 +1:近1年到诊 且 履约良好(非屡次爽约)
recentVisitGood: boolean;
/// 画像·潜在治疗的标签原文 —— **仅透传给展示**,不参与打分。
/// 详情页价值性那一维要陈述画像自己的说法;若前端另从 reason.signals 反推治疗类型,
/// 就会和「画像标签」抽屉两套口径,当场对不齐。
potentialLabels?: string[];
/// 信号置信度(cluster 最优来源):医生诊断 1.0 / 影像AI 0.85 / 建议 0.8 / 未知 0.9
/// v3.0 C.1.1:病历诊断 100% > 影像AI 70-90% > 建议/EMR语义。低置信(影像/建议)降权,防误召冲高分。
confidence: number;
......@@ -99,13 +95,6 @@ export interface PriorityBreakdown {
confidenceFactor: number; // 置信度因子 = 来源置信度(诊断1.0/建议0.8/影像0.5)
base: number; // 三维加权(未乘因子)
raw: number; // 综合 0-10(× 新鲜度 × 置信度后)
/// ── 以下仅供详情页把分数讲成人话,不参与计算 ──
/// 只存**数字反推不出来**的两项:rfmAdherence=8 可能是重要保持也可能是重要发展,
/// trustBase 是 lifecycle 基分加最多 3 项加成、更无从反推。其余(急迫档位、治疗类型、
/// 牙数、主诉命中)都能从已有数字或 reason.signals 推出,不重复存。
rfmSegment: string | null;
lifecycleStage: string | null;
potentialLabels: string[];
}
/// 置信度因子 = 来源置信度直用(诊断1.0 / 建议0.8 / 影像AI0.5),下限 0.5 防过罚。
......@@ -153,11 +142,7 @@ function computeWillingness(input: PriorityInput): {
} {
// RFM 依从
const rfmAdherence = input.rfmSegment ? (RFM_ADHERENCE[input.rfmSegment] ?? 5) : 5;
// 主诉行为:咨询过该类→8;否则→2(召回候选已排除"已预约该项目",故无 10 档)。
// ⚠️ 调权重的人要知道:2 分**不代表患者被动**,而是"查不到意向"。生产实测 237 万条
// consultation_record 里只有 9.7% 解析出非空 intent_categories —— 其余 214.8 万条源端
// intent/content 全为 null(DW 没给,非解析 bug)。所以这一维现状更接近
// "有意向信号则加分",而不是"无意向则扣分";把 2 当成负面证据会系统性压低大批患者。
// 主诉行为:咨询过该类→8;仅诊断被动→2(召回候选已排除"已预约该项目",故无 10 档)
const intentBehavior = input.consultIntentMatch ? 8 : 2;
// 信任基础:lifecycle + 同类治疗史 +1 + 转介 +1 + 近1年到诊履约良好 +1(封顶 10)
let trustBase = input.lifecycleStage ? (TRUST_BASE[input.lifecycleStage] ?? 4) : 4;
......@@ -197,9 +182,6 @@ export function calcPriority(input: PriorityInput): PriorityResult {
confidenceFactor,
base: Math.round(base * 100) / 100,
raw: Math.round(raw * 100) / 100,
rfmSegment: input.rfmSegment,
lifecycleStage: input.lifecycleStage,
potentialLabels: input.potentialLabels ?? [],
},
};
}
......@@ -37,45 +37,19 @@ function stableEvidence(evidence: unknown): unknown {
return Array.isArray(ids) ? [...ids].map(String).sort() : null;
}
/**
* 打分明细的稳定投影 —— 剔掉**随时间自行漂移**的两项。
*
* freshness 诊断过了黄金窗后按天线性衰减,天天变
* raw = base × freshness × 置信度,跟着 freshness 天天变
*
* 其余(urgency / value / willingness / base / rfmSegment / lifecycleStage …)只有患者
* 情况真变了才变 —— 尤其 urgency 跨 30/90 天线是**档位变了**,该刷。
* 不剔这两项 = 每日重算把全部 plan_reasons 重写一遍,正是本模块要避免的事。
*/
function stableBreakdown(breakdown: unknown): unknown {
if (breakdown == null || typeof breakdown !== 'object') return null;
const b = breakdown as Record<string, unknown>;
const priority = b.priority;
if (priority == null || typeof priority !== 'object') return b;
const { freshness: _f, raw: _r, ...restPriority } = priority as Record<string, unknown>;
return { ...b, priority: restPriority };
}
export interface ReasonContent {
signals: unknown;
evidence: unknown;
/// 打分明细(详情页 hover 讲"为什么这个分")。可选:老调用方不传即不参与判定。
breakdown?: unknown;
}
/**
* 该 reason 行是否需要就地刷新。
* true = 语义内容变了(signals / 证据集 / 打分明细)→ 整行重写(含天数等易变字段,反正要写)。
* true = 语义内容变了(signals 或证据集变了)→ 整行重写(含天数等易变字段,反正要写)。
* false = 只是天数漂移 → 不写。
*
* ⚠️ breakdown 也要比:hover 靠它把分数讲成人话,而"算法给 breakdown 加了字段"或
* "急迫档位跨了 90 天线"都不会动 signals —— 只比 signals 的话,存量 plan 永远拿不到
* (同 2026-07 高龄缺牙那次:signals 变了但集合没变,于是 3,170 条永不生效)。
*/
export function reasonNeedsRefresh(oldRow: ReasonContent, nextHit: ReasonContent): boolean {
return (
canonical(stableSignals(oldRow.signals)) !== canonical(stableSignals(nextHit.signals)) ||
canonical(stableEvidence(oldRow.evidence)) !== canonical(stableEvidence(nextHit.evidence)) ||
canonical(stableBreakdown(oldRow.breakdown)) !== canonical(stableBreakdown(nextHit.breakdown))
canonical(stableEvidence(oldRow.evidence)) !== canonical(stableEvidence(nextHit.evidence))
);
}
......@@ -87,8 +87,6 @@ interface PersonaScoreCtx {
treatmentHistory: string[]; // treatment_history.types
isReferralChampion: boolean;
frequentNoShow: boolean; // special_attention 屡次爽约 → 履约差
/// 画像·潜在治疗的标签原文(如 ['潜在种植','潜在修复'])—— 仅供展示,不参与打分
potentialLabels: string[];
}
/// 召回 primaryCode → treatment_history code(信任迁移"同类治疗史 +1"判定)。
......@@ -450,7 +448,6 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
hasTreatmentHistory: hasHist,
isReferralChampion: persona?.isReferralChampion ?? false,
recentVisitGood,
potentialLabels: persona?.potentialLabels ?? [],
confidence: r.cluster_confidence ?? sourceConfidence(r),
});
......@@ -547,12 +544,12 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
JOIN persona_features pf ON pf.persona_id = p.id
WHERE p.patient_id = ANY(${patientIds}::uuid[])
AND p.superseded_at IS NULL
AND pf.key IN ('rfm', 'urgency_level', 'lifecycle_stage', 'treatment_history', 'referral_champion', 'special_attention', 'potential_treatment')
AND pf.key IN ('rfm', 'urgency_level', 'lifecycle_stage', 'treatment_history', 'referral_champion', 'special_attention')
`;
const ctx = new Map<string, PersonaScoreCtx>();
const get = (id: string) =>
ctx.get(id) ??
({ rfmSegment: null, urgencyLevel: null, lifecycleStage: null, recencyDays: null, treatmentHistory: [], isReferralChampion: false, frequentNoShow: false, potentialLabels: [] } as PersonaScoreCtx);
({ rfmSegment: null, urgencyLevel: null, lifecycleStage: null, recencyDays: null, treatmentHistory: [], isReferralChampion: false, frequentNoShow: false } as PersonaScoreCtx);
for (const r of rows) {
const cur = get(r.patient_id);
const d = (r.data ?? {}) as Record<string, unknown>;
......@@ -564,9 +561,6 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
} else if (r.key === 'treatment_history' && Array.isArray(d.types)) cur.treatmentHistory = d.types as string[];
else if (r.key === 'referral_champion') cur.isReferralChampion = true;
else if (r.key === 'special_attention' && Array.isArray(d.types)) cur.frequentNoShow = (d.types as string[]).includes('frequent_no_show');
// 只为「详情页把分数讲成人话」而取:价值性那一维要陈述画像自己的说法,不另外从
// reason.signals 反推治疗类型 —— 两套口径会当场对不齐(见 priority-explain.ts)。
else if (r.key === 'potential_treatment' && Array.isArray(d.labels)) cur.potentialLabels = d.labels as string[];
ctx.set(r.patient_id, cur);
}
return ctx;
......
import { Module } from '@nestjs/common';
import { PlanController } from './plan.controller';
import { PlanService } from './plan.service';
import { PriorityDistributionService } from './priority-distribution.service';
import { ExecutionService } from './execution.service';
import { ExecutionCallbackService } from './execution-callback.service';
import { RecycleSchedulerService } from './recycle-scheduler.service';
......@@ -19,7 +18,6 @@ import { RecallDebugService } from './recall-debug/recall-debug.service';
controllers: [PlanController, RecallDebugController],
providers: [
PlanService,
PriorityDistributionService,
ExecutionService,
ExecutionCallbackService,
RecycleSchedulerService,
......
......@@ -27,7 +27,6 @@ import type {
ListPlansQueryDto,
SubmitExecutionRequestDto,
} from './dto/plan.dto';
import { PriorityDistributionService } from './priority-distribution.service';
import { ExecutionService } from './execution.service';
import { renderAgentIdentity } from '../ai/calls/draft-plan-script/shared/agent-identity';
import type { ScriptAgentIdentity } from '../ai/calls/draft-plan-script/shared/agent-identity';
......@@ -61,7 +60,6 @@ export class PlanService {
constructor(
private readonly prisma: PrismaService,
private readonly executions: ExecutionService,
private readonly priorityDist: PriorityDistributionService,
) {}
// ─────────────────────────────────────────────
......@@ -128,15 +126,9 @@ export class PlanService {
if (!lastOutcomeByPlan.has(e.planId)) lastOutcomeByPlan.set(e.planId, e.outcome);
}
// 池内百分位:租户级直方图缓存,算分位纯内存(见 PriorityDistributionService)。
// 一页只查一次分布,不是每行一次。
const pct = await Promise.all(
rows.map((p) => this.priorityDist.percentileOf(scope.hostId, scope.tenantId, p.priorityScore)),
);
return {
items: rows.map((p, i) => {
const base = { ...serializePlan(p), priorityPercentile: pct[i] ?? null };
items: rows.map((p) => {
const base = serializePlan(p);
const patient = patientById.get(p.patientId);
return {
...base,
......@@ -428,14 +420,7 @@ export class PlanService {
if (!patient) throw new NotFoundException(`Patient ${plan.patientId} not found`);
return {
plan: {
...serializePlan(plan),
priorityPercentile: await this.priorityDist.percentileOf(
scope.hostId,
scope.tenantId,
plan.priorityScore,
),
},
plan: serializePlan(plan),
patient: serializePatient(patient),
persona: persona ? serializePersona(persona) : null,
// 落库正文的自报家门是占位符,读出来按当前登录人回填(见 agent-identity.ts)
......
import { Injectable, Logger } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
/**
* 召回池优先级分布 —— 让「9.78 分」这个数字有参照系。
*
* ── 为什么需要 ──
* 详情页 hover 把三维拆解讲得很细,但客服看完仍然不知道**这分算高还是低** ——
* 光看 9.78/10 会以为满分 10 就是天花板、9.78 只是"还行"。生产实测分布完全不是这样:
* p50=31 · p80=56 · p90=66 · p95=76 · p99=82(active+assigned 共 23.6 万)
* 也就是说 97.8 分是**前 0.05%**,是整池最该先打的那几个。不给参照,这条信息就丢了。
*
* ── 实现 ──
* 整数分 0-100 的直方图(101 个桶),按租户缓存 30 分钟;算某个分数的百分位纯内存,
* 每行零 DB 开销。分布是慢变量(单个患者进出撼动不了 23 万人的分位),30 分钟足够。
*
* ⚠️ 口径是**租户级整池**,不跟随调用者的诊所/品牌可见范围。诊所主管看到的
* 「高于池内 98%」指的是全租户召回池,不是他自己那几家诊所 —— 文案上说「召回池」即此意。
* 要做到按范围分位得给每种 scope 组合各缓存一份,收益不抵复杂度。
*/
@Injectable()
export class PriorityDistributionService {
private readonly logger = new Logger(PriorityDistributionService.name);
/** key=host:tenant → { at, hist:101 桶计数, total } */
private readonly cache = new Map<string, { at: number; hist: Int32Array; total: number }>();
/** single-flight:缓存空/过期时并发调用共享同一个在飞查询(列表每页一次,别打成惊群) */
private readonly inflight = new Map<string, Promise<{ hist: Int32Array; total: number }>>();
private static readonly TTL_MS = 30 * 60 * 1000;
constructor(private readonly prisma: PrismaService) {}
/**
* 该分数在租户召回池里的百分位(0-100)= 严格低于它的占比。
* 池子太小(<50)时返回 null —— 样本不足,给出的百分位会误导。
*/
async percentileOf(hostId: string, tenantId: string, score: number): Promise<number | null> {
const { hist, total } = await this.load(hostId, tenantId);
if (total < 50) return null;
const s = Math.max(0, Math.min(100, Math.round(score)));
let below = 0;
for (let i = 0; i < s; i++) below += hist[i]!;
return Math.round((below / total) * 100);
}
private async load(hostId: string, tenantId: string) {
const key = `${hostId}:${tenantId}`;
const hit = this.cache.get(key);
if (hit && Date.now() - hit.at < PriorityDistributionService.TTL_MS) return hit;
const flying = this.inflight.get(key);
if (flying) return flying;
const promise = (async () => {
const hist = new Int32Array(101);
let total = 0;
try {
const rows = await this.prisma.$queryRaw<Array<{ s: number; n: bigint }>>`
SELECT priority_score AS s, COUNT(*) AS n
FROM followup_plans
WHERE host_id = ${hostId}::uuid AND tenant_id = ${tenantId}
AND status IN ('active', 'assigned')
GROUP BY 1`;
for (const r of rows) {
const i = Math.max(0, Math.min(100, Number(r.s)));
const n = Number(r.n);
hist[i]! += n;
total += n;
}
} catch (err) {
// 分位只是锦上添花,查失败不能拖垮列表 —— 返回空分布,调用方拿到 null 不渲染
this.logger.warn(`优先级分布查询失败 (${key}): ${err instanceof Error ? err.message : err}`);
} finally {
this.inflight.delete(key);
}
this.cache.set(key, { at: Date.now(), hist, total });
return { hist, total };
})();
this.inflight.set(key, promise);
return promise;
}
}
import { Test } from '@nestjs/testing';
import { PriorityDistributionService } from '../src/modules/plan/priority-distribution.service';
import { PrismaService } from '../src/prisma/prisma.service';
/**
* 召回池优先级分位回归。
*
* 由来:hover 把三维拆解讲得很细,客服看完仍不知道**这分算高还是低** —— 看到 9.78/10
* 会以为"还行"。生产实测分布完全不是这样(active+assigned 23.6 万):
* p50=31 · p80=56 · p90=66 · p95=76 · p99=82
* 97.8 分其实是**前 0.05%**,是整池最该先打的。没有参照系这条信息就丢了。
*
* 本文件锁三条:分位算得对、池太小时不给(免误导)、分布查询按租户缓存且不打惊群。
*/
/** 造一个分布:scores 里每个分值一条 plan */
function makePrisma(scores: number[]) {
const calls = { queries: 0 };
const grouped = new Map<number, number>();
for (const s of scores) grouped.set(s, (grouped.get(s) ?? 0) + 1);
const prisma = {
$queryRaw: jest.fn().mockImplementation(async () => {
calls.queries++;
return [...grouped.entries()].map(([s, n]) => ({ s, n: BigInt(n) }));
}),
};
return { prisma: prisma as unknown as PrismaService, calls };
}
async function build(scores: number[]) {
const { prisma, calls } = makePrisma(scores);
const mod = await Test.createTestingModule({
providers: [PriorityDistributionService, { provide: PrismaService, useValue: prisma }],
}).compile();
return { svc: mod.get(PriorityDistributionService), calls, prisma };
}
/** 0..99 各一条 → 分数 n 的分位就是 n */
const UNIFORM = Array.from({ length: 100 }, (_, i) => i);
describe('percentileOf — 分位算得对', () => {
test('⭐ 均匀分布:分位 = 严格低于它的占比', async () => {
const { svc } = await build(UNIFORM);
expect(await svc.percentileOf('h', 't', 0)).toBe(0);
expect(await svc.percentileOf('h', 't', 50)).toBe(50);
expect(await svc.percentileOf('h', 't', 99)).toBe(99);
});
test('⭐ 生产形态(长尾):高分确实落在最顶端', async () => {
// 90% 集中在 20-40,10% 散在 60-100 —— 近似生产
const scores = [
...Array.from({ length: 900 }, (_, i) => 20 + (i % 21)),
...Array.from({ length: 100 }, (_, i) => 60 + (i % 41)),
];
const { svc } = await build(scores);
expect(await svc.percentileOf('h', 't', 31)).toBeLessThan(60); // 中位附近
expect(await svc.percentileOf('h', 't', 98)).toBeGreaterThanOrEqual(99); // 前 1%
});
test('分数越界不炸(<0 / >100 夹到边界)', async () => {
const { svc } = await build(UNIFORM);
expect(await svc.percentileOf('h', 't', -5)).toBe(0);
expect(await svc.percentileOf('h', 't', 999)).toBe(100);
});
test('⭐ 池太小(<50)→ null,不给会误导的分位', async () => {
const { svc } = await build([10, 20, 30]);
expect(await svc.percentileOf('h', 't', 30)).toBeNull();
});
test('空池 → null', async () => {
const { svc } = await build([]);
expect(await svc.percentileOf('h', 't', 50)).toBeNull();
});
test('⭐ 查询失败不拖垮列表 —— 返回 null 而不是抛', async () => {
const prisma = { $queryRaw: jest.fn().mockRejectedValue(new Error('db down')) };
const mod = await Test.createTestingModule({
providers: [PriorityDistributionService, { provide: PrismaService, useValue: prisma as unknown as PrismaService }],
}).compile();
await expect(mod.get(PriorityDistributionService).percentileOf('h', 't', 50)).resolves.toBeNull();
});
});
describe('缓存与防惊群', () => {
test('⭐ 同租户连续取分位只查一次分布(列表每行都要算,不能每行一查)', async () => {
const { svc, calls } = await build(UNIFORM);
for (let i = 0; i < 20; i++) await svc.percentileOf('h', 't', i);
expect(calls.queries).toBe(1);
});
test('⭐ 并发首取共享同一个在飞查询(single-flight)', async () => {
const { svc, calls } = await build(UNIFORM);
await Promise.all(Array.from({ length: 20 }, (_, i) => svc.percentileOf('h', 't', i)));
expect(calls.queries).toBe(1);
});
test('不同租户各自缓存,不串味', async () => {
const { svc, calls } = await build(UNIFORM);
await svc.percentileOf('h', 't1', 50);
await svc.percentileOf('h', 't2', 50);
expect(calls.queries).toBe(2);
});
});
import {
explainConfidence,
explainPercentile,
explainUrgency,
explainValue,
explainWillingness,
type PriorityBreakdown,
} from '../../pac-web/src/components/priority-explain';
/**
* 优先级 hover 的逐患者文案回归。
*
* ⚠️ 测试放在 pac-service 是因为 pac-web 没有测试环境,而这个模块是**纯函数、零 React 依赖**
* (PriorityBreakdown 类型也因此从 priority-hover.tsx 挪了出来)。
*
* ── 为什么值得测 ──
* 这些字是客服照着跟患者开口用的,而且必须和「画像标签」抽屉说同一句话。踩过两次:
*
* ① 从 reason.signals 反推出「种植 · 缺 6 颗」。可同一屏上,79 岁患者的召回目标写的是
* 「活动义齿为主」,画像自己写的是「潜在种植」—— 三处三个说法(生产 2,539 条这种)。
* ② 把 intentBehavior=2 说成「没主动问过」。它来自 consultation_record 不是画像,
* 而且生产 22 万个 2 分患者里真的没咨询过的只有 230 人(0.1%)—— 80% 是源端
* intent/content 全为 null 查不出来,20% 是问了别的项目。这话 99.9% 是错的。
*
* 结论就是本文件第一组断言:**只念画像标签的取值,别处一律不补**。
*/
const bd = (over: Partial<PriorityBreakdown> = {}): PriorityBreakdown => ({
urgency: 10,
value: 10,
willingness: 2.63,
freshness: 1,
confidenceFactor: 1,
rfmSegment: 'important_winback',
lifecycleStage: 'churned',
potentialLabels: ['潜在种植'],
...over,
});
describe('⭐ 红线:只念画像,不从别处补第二套口径', () => {
test('三维文案里不出现非画像来源的说法', () => {
const text = [
explainUrgency(bd()),
explainValue(bd({ potentialLabels: ['潜在种植'] })),
explainWillingness(bd()),
].join(' ');
// 这些都曾被从 reason.signals / consultation_record 推出来过,和画像抽屉对不齐:
// 「缺 N 颗」「种植」来自 signals;「没主动问过」来自 consultation_record
for (const forbidden of ['缺 ', '没主动问过', '没问过', '未咨询', '被动', '咨询']) {
expect(text).not.toContain(forbidden);
}
});
test('价值性念画像·潜在治疗的原文', () => {
expect(explainValue(bd({ potentialLabels: ['潜在种植', '潜在修复'] }))).toBe(
'潜在治疗 潜在种植 / 潜在修复',
);
});
test('画像没标就说没标,不编', () => {
expect(explainValue(bd({ potentialLabels: [] }))).toBe('画像未标潜在治疗');
expect(explainUrgency(bd({ urgency: 0 }))).toBe('画像未标急迫等级');
expect(explainWillingness(bd({ rfmSegment: null, lifecycleStage: null }))).toBe('画像未标价值分群');
});
});
describe('三维念的是画像标签取值', () => {
test('急迫性 = 画像·急迫等级(中文取自圈人字典)', () => {
expect(explainUrgency(bd({ urgency: 10 }))).toBe('急迫等级 紧急');
expect(explainUrgency(bd({ urgency: 7 }))).toBe('急迫等级 高');
expect(explainUrgency(bd({ urgency: 4 }))).toBe('急迫等级 中');
});
test('意愿度 = 画像·价值分群 + 生命周期', () => {
expect(explainWillingness(bd({ rfmSegment: 'important_winback', lifecycleStage: 'churned' }))).toBe(
'价值分群 重要挽留 · 生命周期 流失客',
);
});
test('老 plan 缺字段时不出 undefined', () => {
for (const t of [explainUrgency(bd({ urgency: undefined })), explainValue(bd({ potentialLabels: null })),
explainWillingness(bd({ rfmSegment: null, lifecycleStage: null }))]) {
expect(t).not.toContain('undefined');
expect(t.length).toBeGreaterThan(0);
}
});
test('置信度对应信号来源(这一维本就不是画像,是信号来源)', () => {
expect(explainConfidence(bd({ confidenceFactor: 1 }))).toBe('来自医生诊断');
expect(explainConfidence(bd({ confidenceFactor: 0.8 }))).toBe('来自医生建议');
expect(explainConfidence(bd({ confidenceFactor: 0.5 }))).toContain('影像');
});
});
describe('池内位置 —— 回答"这分算高还是低"', () => {
test('缺失时不渲染(池太小 / 老响应)', () => {
expect(explainPercentile(null)).toBeNull();
expect(explainPercentile(undefined)).toBeNull();
});
test('⭐ 100 分位不能说成「高于池内 100% 的患者」(他自己也在池里)', () => {
expect(explainPercentile(100)).not.toContain('100%');
expect(explainPercentile(100)).toContain('前 1%');
});
test('各档都给得出话', () => {
for (const p of [0, 15, 45, 75, 92, 99]) {
expect(explainPercentile(p)?.length ?? 0).toBeGreaterThan(0);
}
});
});
......@@ -133,42 +133,3 @@ describe('红线:刷新判据不依赖文案格式', () => {
expect(reasonNeedsRefresh(row({ ...BASE }), row({ ...BASE }))).toBe(false);
});
});
describe('breakdown 维度 —— 打分明细也要能就地刷新', () => {
// hover 靠 breakdown 把分数讲成人话。「算法给 breakdown 加了字段」和「急迫档位跨了 90 天线」
// 都不动 signals —— 只比 signals 的话,存量 plan 永远拿不到(同高龄缺牙那次的形态)。
const bd = (over: Record<string, unknown> = {}) => ({
priority: { urgency: 10, value: 8, willingness: 7, base: 8.5, freshness: 1, raw: 8.5, ...over },
subKey: 'missing_tooth',
});
const row = (breakdown: unknown) => ({ signals: { ...BASE }, evidence: EV, breakdown });
test('⭐ breakdown 新增字段(rfmSegment / lifecycleStage)→ 刷新', () => {
const next = bd({ rfmSegment: 'important_value', lifecycleStage: 'mature' });
expect(reasonNeedsRefresh(row(bd()), row(next))).toBe(true);
});
test('⭐ 急迫档位跨线(7→10)→ 刷新;这不动 signals,以前刷不到', () => {
expect(reasonNeedsRefresh(row(bd({ urgency: 7 })), row(bd({ urgency: 10 })))).toBe(true);
});
test('⭐⭐ 红线:只有 freshness / raw 随天数衰减 → **不刷新**(否则每日全量重写)', () => {
const before = bd({ freshness: 1, raw: 8.5 });
const after = bd({ freshness: 0.82, raw: 6.97 });
expect(reasonNeedsRefresh(row(before), row(after))).toBe(false);
});
test('breakdown 完全相同 → 不刷新', () => {
expect(reasonNeedsRefresh(row(bd()), row(bd()))).toBe(false);
});
test('breakdown 缺失 / 形状异常不炸', () => {
expect(reasonNeedsRefresh(row(null), row(null))).toBe(false);
expect(reasonNeedsRefresh(row(undefined), row(null))).toBe(false);
expect(reasonNeedsRefresh(row(bd()), row(null))).toBe(true); // 从有到无算变化
});
test('不传 breakdown 的老调用方行为不变', () => {
expect(reasonNeedsRefresh({ signals: { ...BASE }, evidence: EV }, { signals: { ...BASE }, evidence: EV })).toBe(false);
});
});
import { 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'`。
*
* 同类地雷还有 distinct / distance / district / nodemodules… 任何文件名撞上子串都会中招,
* 而且只在 swc 构建路径(dev / `--builder swc`)复现,`nest build`(tsc)一切正常 —— 极难排查。
*/
describe('.swcrc exclude 必须锚定', () => {
const swcrc = JSON.parse(
readFileSync(join(__dirname, '..', '.swcrc'), 'utf8'),
) as { exclude?: string[] };
test('⭐ 每条 exclude 都以 ^ 开头(否则会误伤文件名含该子串的源文件)', () => {
const unanchored = (swcrc.exclude ?? []).filter((p) => !p.startsWith('^'));
expect(unanchored).toEqual([]);
});
test('⭐ 现有源文件不会被 exclude 误伤', () => {
// 用真实存在过的受害者当样本 —— 加新文件时若又被排除,这条会红
const samples = [
'src/modules/plan/priority-distribution.service.ts',
'src/modules/persona/persona-diff.ts',
'src/modules/sync/cold-import/cold-import.service.ts',
];
const patterns = (swcrc.exclude ?? []).map((p) => new RegExp(p));
const excluded = samples.filter((f) => patterns.some((re) => re.test(f)));
expect(excluded).toEqual([]);
});
});
......@@ -156,8 +156,6 @@ export function adaptData(real: PlanDetailData, dict?: TokenDictionary) {
version: real.plan?.version ?? 0,
status: real.plan?.status ?? 'active',
priorityScore: real.plan?.priorityScore ?? 0,
/// 池内百分位(后端算);老响应没有 → null,PriorityHover 不渲染位置行
priorityPercentile: real.plan?.priorityPercentile ?? null,
contactAttempts: real.plan?.contactAttempts ?? 0,
maxContactAttempts: real.plan?.maxContactAttempts ?? 4,
targetClinic: clinicName(real.plan?.targetClinicId, dict) ?? '(集团统一池)',
......
......@@ -325,8 +325,6 @@ export const mockPlan = {
version: 3,
status: 'assigned' as string,
priorityScore: 92,
/// 池内百分位:高于池内多少比例。真实数据池太小 / 后端没给时为 null
priorityPercentile: 97 as number | null,
contactAttempts: 0,
maxContactAttempts: 4,
targetClinic: '望京旗舰店',
......
......@@ -1057,7 +1057,6 @@ function TopBar({
<PriorityHover
score={plan.priorityScore}
breakdown={(plan.reasons[0]?.breakdown as { priority?: PriorityBreakdown } | null | undefined)?.priority}
percentile={plan.priorityPercentile}
>
<span className="hidden md:inline-flex cursor-help flex-none">
<PriorityBar score={plan.priorityScore} label="优先级" />
......
......@@ -59,8 +59,6 @@ export type PlanDetailData = {
version: number;
status: string;
priorityScore: number;
/// 池内百分位(0-100 = 高于池内多少比例);池太小 / 老响应 → 缺省
priorityPercentile?: number | null;
contactAttempts: number;
maxContactAttempts: number;
targetClinicId: string | null;
......
......@@ -593,11 +593,7 @@ function PatientRow({
</span>
)}
<span className="ml-auto flex-none">
<PriorityHover
score={p.priorityScore}
breakdown={breakdown}
percentile={p.priorityPercentile}
>
<PriorityHover score={p.priorityScore} breakdown={breakdown}>
<span className="cursor-help">
<PriorityBar score={p.priorityScore} raw={breakdown?.raw} />
</span>
......
......@@ -808,7 +808,6 @@ function PatientPlanCard({
<PriorityHover
score={p.priorityScore}
breakdown={(p.reasons[0]?.breakdown as { priority?: PriorityBreakdown } | null | undefined)?.priority}
percentile={p.priorityPercentile}
>
<span className="inline-flex items-center gap-1 cursor-help group/score">
<PriorityBar
......
import { personaTagLabel } from '@pac/types';
/**
* 把优先级三维翻成**这个患者的画像取值**。
*
* ── 一条铁律:只念画像,不从别处补 ──
* 三维的输入本来就取自画像标签(急迫等级 / 价值分群 / 生命周期 / 潜在治疗,见 scorer 入参),
* 所以这里就照抄画像的说法。**不要**为了"讲得更细"去 reason.signals、consultation_record
* 之类的地方另外推一段话 —— 那是第二套口径,和「画像标签」抽屉一定会对不齐。
*
* 已经吃过两次亏:
* · 从 signals.triggers 反推出「种植 · 缺 6 颗」,而同屏的召回目标对 79 岁患者写的是
* 「活动义齿为主」(生产 2,539 条这种);画像自己说的是「潜在种植」——
* 三处三个说法。现在统一念画像那一个。
* · 把 intentBehavior=2 说成「没主动问过」,而它其实来自 consultation_record 且
* 99.9% 是"查不到"。既然它不是画像标签,索性不出现在这里。
*
* 中文名一律走 personaTagLabel(圈人字典),不在本文件另抄映射表。
*/
export interface PriorityBreakdown {
urgency?: number; // 急迫性 0-10
value?: number; // 价值性 0-10
willingness?: number; // 意愿度 0-10
rfmAdherence?: number; // 意愿·RFM依从
intentBehavior?: number; // 意愿·主诉行为
trustBase?: number; // 意愿·信任基础
freshness?: number; // 新鲜度因子 0.4-1
confidenceFactor?: number; // 置信度因子(诊断1.0/建议0.8/影像0.5)
base?: number; // 三维加权(未乘因子)
raw?: number; // 综合 ×因子后
/// 以下三项**仅供展示**,由 scorer 从画像原样带出(不参与计算)。老 plan 可能没有。
rfmSegment?: string | null;
lifecycleStage?: string | null;
/// 画像·潜在治疗的标签原文,如 ['潜在种植','潜在修复']
potentialLabels?: string[] | null;
}
/** reason.signals 里本模块用到的部分(其余字段不关心) */
export interface PriorityExplainSignals {
triggers?: Array<{ code?: string | null }> | null;
toothPosition?: string | null;
}
/// 急迫分值 → 画像·急迫等级的 code(scorer 的 URGENCY_SCORE 反向表,一一对应无歧义)
const URGENCY_CODE: Record<number, string> = { 10: 'urgent', 7: 'high', 4: 'mid', 1: 'low' };
export function explainUrgency(breakdown: PriorityBreakdown): string {
const code = URGENCY_CODE[Math.round(breakdown.urgency ?? 0)];
const zh = code ? personaTagLabel('urgency_level', code) : '';
return zh ? `急迫等级 ${zh}` : '画像未标急迫等级';
}
/** 价值性 —— 念画像·潜在治疗。不从 reason.signals 反推治疗类型(见文件头) */
export function explainValue(breakdown: PriorityBreakdown): string {
const labels = breakdown.potentialLabels ?? [];
return labels.length ? `潜在治疗 ${labels.join(' / ')}` : '画像未标潜在治疗';
}
/** 意愿度 —— 念画像·价值分群 + 生命周期。主诉意向来自 consultation_record,不是画像,不出现在这里 */
export function explainWillingness(breakdown: PriorityBreakdown): string {
const parts: string[] = [];
const rfm = personaTagLabel('rfm', breakdown.rfmSegment);
if (rfm) parts.push(`价值分群 ${rfm}`);
const stage = personaTagLabel('lifecycle_stage', breakdown.lifecycleStage);
if (stage) parts.push(`生命周期 ${stage}`);
return parts.length ? parts.join(' · ') : '画像未标价值分群';
}
/** 新鲜度 —— 只在打了折时才值得占一行 */
export function explainFreshness(breakdown: PriorityBreakdown): string {
const f = breakdown.freshness ?? 1;
return f >= 1 ? '诊断还在黄金窗内' : '诊断偏老,已按时间降权';
}
/** 置信度 —— 因子直接对应信号来源 */
export function explainConfidence(breakdown: PriorityBreakdown): string {
const c = breakdown.confidenceFactor ?? 1;
if (c >= 1) return '来自医生诊断';
if (c >= 0.8) return '来自医生建议';
return '来自影像 AI,已降权';
}
/**
* 池内位置的一句话。
*
* 「9.78 / 10」看着像"还行",实际是整池前 0.05%(生产池 p50 只有 3.1)——
* 不给参照客服判断不了要不要先打他,这正是加这行的原因。
*/
export function explainPercentile(percentile?: number | null): string | null {
if (percentile === null || percentile === undefined) return null;
if (percentile >= 99) return '召回池里最该先打的一批(前 1%)';
if (percentile >= 90) return `高于池内 ${percentile}% 的患者 · 优先打`;
if (percentile >= 60) return `高于池内 ${percentile}% 的患者`;
if (percentile >= 30) return `高于池内 ${percentile}% 的患者 · 中等`;
return `高于池内 ${percentile}% 的患者 · 排在后面`;
}
......@@ -2,18 +2,6 @@
import * as React from 'react';
import { HoverCard, HoverCardTrigger, HoverCardContent } from '@/components/ui/hover-card';
// PriorityBreakdown 定义在 priority-explain.ts(纯模块,不带 JSX)—— 这里再导出,
// 老调用方 `import { PriorityBreakdown } from './priority-hover'` 不受影响。
export type { PriorityBreakdown } from './priority-explain';
import type { PriorityBreakdown } from './priority-explain';
import {
explainConfidence,
explainFreshness,
explainPercentile,
explainUrgency,
explainValue,
explainWillingness,
} from './priority-explain';
/**
* PriorityHover — 优先级数字旁悬停展示 v3.0 三维拆解。
......@@ -25,17 +13,26 @@ import {
*
* 列表 + 详情页共用。breakdown 缺失时回退最简文案。
*/
export interface PriorityBreakdown {
urgency?: number; // 急迫性 0-10
value?: number; // 价值性 0-10
willingness?: number; // 意愿度 0-10
rfmAdherence?: number; // 意愿·RFM依从
intentBehavior?: number; // 意愿·主诉行为
trustBase?: number; // 意愿·信任基础
freshness?: number; // 新鲜度因子 0.4-1
confidenceFactor?: number; // 置信度因子(诊断1.0/建议0.8/影像0.5)
base?: number; // 三维加权(未乘因子)
raw?: number; // 综合 ×因子后
}
export function PriorityHover({
score,
breakdown,
percentile,
children,
}: {
score: number;
breakdown?: PriorityBreakdown | null;
/// 池内百分位(后端 PriorityDistributionService);缺失 → 不渲染位置行
percentile?: number | null;
children: React.ReactNode;
}) {
return (
......@@ -43,11 +40,7 @@ export function PriorityHover({
{/* 直接把 children 作为 trigger,radix 锚定 children 本身(避免多包一层 span 错位)*/}
<HoverCardTrigger asChild>{children}</HoverCardTrigger>
<HoverCardContent align="end" sideOffset={6} className="w-80 p-3 text-[11.5px]">
<PriorityBreakdownTable
score={score}
breakdown={breakdown}
percentile={percentile}
/>
<PriorityBreakdownTable score={score} breakdown={breakdown} />
</HoverCardContent>
</HoverCard>
);
......@@ -56,11 +49,9 @@ export function PriorityHover({
function PriorityBreakdownTable({
score,
breakdown,
percentile,
}: {
score: number;
breakdown?: PriorityBreakdown | null;
percentile?: number | null;
}) {
// 10 分制(保留两位小数):优先用 breakdown.raw(真 2 位精度),无则 score/10
const disp = (breakdown?.raw ?? score / 10).toFixed(2);
......@@ -68,7 +59,7 @@ function PriorityBreakdownTable({
// 老数据 / 异常兜底(旧 6 因子 breakdown 也走这里)
return (
<div className="space-y-1.5">
<Header disp={disp} percentile={percentile} />
<Header disp={disp} />
<p className="text-slate-500 leading-relaxed">
三维加权:急迫性 ×0.4 + 价值性 ×0.3 + 意愿度 ×0.3,再 × 新鲜度 × 置信度。
<br />
......@@ -88,29 +79,27 @@ function PriorityBreakdownTable({
const base = breakdown.base ?? urgency * 0.4 + value * 0.3 + willing * 0.3;
return (
<div className="space-y-1.5">
<Header disp={disp} percentile={percentile} />
<Header disp={disp} />
<table className="w-full tabular-nums">
<tbody>
{/* hint 一律念**画像标签的取值**(而不是口径定义,也不从 signals 另推一套说法),
这样和「画像标签」抽屉里显示的永远是同一句。见 priority-explain.ts 文件头。 */}
<Row label="急迫性" value={`${urgency.toFixed(0)} × 0.4`} hint={explainUrgency(breakdown)} />
<Row label="价值性" value={`${value.toFixed(0)} × 0.3`} hint={explainValue(breakdown)} />
<Row label="急迫性" value={`${urgency.toFixed(0)} × 0.4`} hint="病情多急(末诊/超期)" />
<Row label="价值性" value={`${value.toFixed(0)} × 0.3`} hint="治疗类型 + 牙数预估收入" />
<Row
label="意愿度"
value={`${willing.toFixed(1)} × 0.3`}
hint={explainWillingness(breakdown)}
hint={`RFM ${rfm} / 主诉 ${intent} / 信任 ${trust}`}
/>
<Subtotal label="三维小计" value={base.toFixed(1)} />
<Row
label="× 新鲜度"
value={fresh.toFixed(2)}
hint={explainFreshness(breakdown)}
hint="诊断越老越降权(止损)"
tone={fresh < 1 ? 'amber' : undefined}
/>
<Row
label="× 置信度"
value={conf.toFixed(2)}
hint={explainConfidence(breakdown)}
hint="诊断 1.0 / 建议 0.8 / 影像 0.5"
tone={conf < 1 ? 'amber' : undefined}
/>
<Subtotal label="= 总分" value={disp} bold />
......@@ -120,26 +109,11 @@ function PriorityBreakdownTable({
);
}
function Header({ disp, percentile }: { disp: string; percentile?: number | null }) {
const pos = explainPercentile(percentile);
function Header({ disp }: { disp: string }) {
return (
<div className="border-b border-slate-100 pb-1.5">
<div className="flex items-baseline justify-between">
<span className="text-[13px] font-semibold text-slate-900">优先级 {disp} / 10</span>
<span className="text-[10.5px] text-slate-500">急迫 × 价值 × 意愿</span>
</div>
{/* 池内位置 —— 没有这行,「9.78 / 10」会被当成"还行"(实际生产池中位数只有 3.1) */}
{pos && (
<div className="mt-1 flex items-center gap-1.5">
<span className="h-1 flex-1 overflow-hidden rounded-full bg-slate-100">
<span
className="block h-full rounded-full bg-teal-500"
style={{ width: `${Math.max(2, percentile ?? 0)}%` }}
/>
</span>
<span className="flex-none text-[10.5px] font-medium text-teal-700">{pos}</span>
</div>
)}
<div className="flex items-baseline justify-between border-b border-slate-100 pb-1.5">
<span className="text-[13px] font-semibold text-slate-900">优先级 {disp} / 10</span>
<span className="text-[10.5px] text-slate-500">急迫 × 价值 × 意愿</span>
</div>
);
}
......
......@@ -220,16 +220,3 @@ export function parsePersonaTags(raw: string): Array<{ key: string; value: strin
})
.filter((x): x is { key: string; value: string } => x !== null);
}
/**
* 某维度某取值的中文名(如 `('rfm','important_value') → '重要价值'`)。
*
* 圈人字典本身就是「code ↔ 中文」的单一真理源,凡需要把 rfm.segment / lifecycle.stage
* 这类 code 翻成中文的地方都走这里 —— 别再各自抄一张映射表(优先级 hover 就抄过一版)。
* 未登记 → 返回原 code,不编造。
*/
export function personaTagLabel(dimKey: string, value: string | null | undefined): string {
if (!value) return '';
const dim = PERSONA_TAG_FILTER_DIMS.find((d) => d.key === dimKey);
return dim?.options.find((o) => o.value === value)?.zh ?? value;
}
......@@ -38,9 +38,6 @@ export const FollowupPlanSchema = z.object({
scenario: z.string(),
version: z.number().int(),
priorityScore: z.number(),
/// 该分数在**租户召回池**里的百分位(0-100 = 高于池内多少比例);池子太小或查询失败 → null。
/// 光给 9.78/10 客服判断不了高低(生产池 p50 只有 31),这个数才是"要不要先打他"的依据。
priorityPercentile: z.number().int().nullable().optional(),
inclusionReason: z.string(),
recommendedAt: z.string().nullable(),
recommendedRole: z.string().nullable(),
......
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