Commit 4477a8c2 by luoqi

feat(priority): 优先级 hover 讲清「为什么这个分」和「这分算高还是低」

## 问题

hover 把三维拆解列得很细,但两个关键问题都没答:

1. **看不出高低**。只给「9.78 / 10」,客服会以为满分 10、9.78 只是"还行"。
   生产实测分布完全不是这样(active+assigned 23.6 万):p50=31 · p90=66 · p99=82
   —— 97.8 分其实是**前 0.05%**。反过来本地库最高才 31 分,那 31 就是该先打的。
   没有参照系,这个数字读不出任何决策含义。
2. **解释是口径定义,不是这个患者**。右列写的是「病情多急(末诊/超期)」
   「治疗类型 + 牙数预估收入」—— 换谁看都一样,看完仍不知道他为什么是 10 分。

## 改法

**池内位置**:新增 personas 之外的 PriorityDistributionService —— 租户级整数分直方图,
缓存 30 分钟 + single-flight,算分位纯内存(列表一页只查一次分布,不是每行一次)。
池 <50 返回 null 不显示(样本不足给分位会误导)。查询失败降级 null,不拖垮列表。

**逐患者解释**:文案抽到 priority-explain.ts,能从已有数据推的就不另存 ——
急迫档位 ← urgency 分值;治疗类型/牙数 ← reason.signals.triggers/toothPosition;
主诉命中 ← intentBehavior 二值。只有 RFM 分群与生命周期反推不出(8 分既可能是重要保持
也可能是重要发展),由 scorer 在 breakdown 里带上。code→中文复用圈人字典
(新增 personaTagLabel),不另抄映射表。

实际效果(本地真实数据):

    优先级 3.12 / 10   召回池里最该先打的一批(前 1%)
    急迫性 10 × 0.4    紧急 · 90 天以上没来
    价值性 10 × 0.3    种植 · 缺 6 颗以上
    意愿度 2.63 × 0.3  重要挽留 · 没主动问过 · 流失客
    × 新鲜度 0.40      诊断偏老,已按时间降权
    × 置信度 1.00      来自医生诊断

两个 10 分却只有 3.12,原因一眼可见:意愿度低 + 诊断偏老降权 —— 这是旧版讲不出来的。

## 连带修的两个坑

**① breakdown 进不去存量 plan。** reasonNeedsRefresh 只比 signals/evidence,所以
「算法给 breakdown 加字段」和「急迫档位跨了 90 天线」都刷不到存量行(和 2026-07 高龄缺牙
同一形态)。把 breakdown 纳入判据,同样剔掉随时间漂移的 freshness / raw —— 否则每日重算
全量重写。本地 17,298 / 17,304 条就地回填,**0 个新版本**。

**② .swcrc 的 exclude 是正则,不是目录名。** 原写 ["node_modules", "dist"],
而 priority-distribution.service.ts 文件名含 "dist" → 被静默排除,271 个源文件只编译 270 个,
swc 不报错("Successfully compiled: 270 files"),直到启动才 MODULE_NOT_FOUND。
锚定成 ["^node_modules/", "^dist/"],并加断言防回归 —— 这个坑只在 swc 构建路径复现,
nest build(tsc)一切正常,极难排查。

471 tests green · pac-web build 通过。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
parent 3a85d4b1
......@@ -21,5 +21,5 @@
"module": {
"type": "commonjs"
},
"exclude": ["node_modules", "dist"]
"exclude": ["^node_modules/", "^dist/"]
}
......@@ -413,8 +413,14 @@ 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 },
{ signals: h.signals ?? null, evidence: { factIds: h.evidence.factIds } },
{ 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,
},
);
});
......
......@@ -95,6 +95,12 @@ 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;
}
/// 置信度因子 = 来源置信度直用(诊断1.0 / 建议0.8 / 影像AI0.5),下限 0.5 防过罚。
......@@ -182,6 +188,8 @@ 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,
},
};
}
......@@ -37,19 +37,45 @@ 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(stableEvidence(oldRow.evidence)) !== canonical(stableEvidence(nextHit.evidence)) ||
canonical(stableBreakdown(oldRow.breakdown)) !== canonical(stableBreakdown(nextHit.breakdown))
);
}
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';
......@@ -18,6 +19,7 @@ import { RecallDebugService } from './recall-debug/recall-debug.service';
controllers: [PlanController, RecallDebugController],
providers: [
PlanService,
PriorityDistributionService,
ExecutionService,
ExecutionCallbackService,
RecycleSchedulerService,
......
......@@ -27,6 +27,7 @@ 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';
......@@ -60,6 +61,7 @@ export class PlanService {
constructor(
private readonly prisma: PrismaService,
private readonly executions: ExecutionService,
private readonly priorityDist: PriorityDistributionService,
) {}
// ─────────────────────────────────────────────
......@@ -126,9 +128,15 @@ 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) => {
const base = serializePlan(p);
items: rows.map((p, i) => {
const base = { ...serializePlan(p), priorityPercentile: pct[i] ?? null };
const patient = patientById.get(p.patientId);
return {
...base,
......@@ -420,7 +428,14 @@ export class PlanService {
if (!patient) throw new NotFoundException(`Patient ${plan.patientId} not found`);
return {
plan: serializePlan(plan),
plan: {
...serializePlan(plan),
priorityPercentile: await this.priorityDist.percentileOf(
scope.hostId,
scope.tenantId,
plan.priorityScore,
),
},
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);
});
});
......@@ -133,3 +133,42 @@ 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,6 +156,8 @@ 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,6 +325,8 @@ export const mockPlan = {
version: 3,
status: 'assigned' as string,
priorityScore: 92,
/// 池内百分位:高于池内多少比例。真实数据池太小 / 后端没给时为 null
priorityPercentile: 97 as number | null,
contactAttempts: 0,
maxContactAttempts: 4,
targetClinic: '望京旗舰店',
......
......@@ -1057,6 +1057,8 @@ function TopBar({
<PriorityHover
score={plan.priorityScore}
breakdown={(plan.reasons[0]?.breakdown as { priority?: PriorityBreakdown } | null | undefined)?.priority}
percentile={plan.priorityPercentile}
signals={plan.reasons[0]?.signals}
>
<span className="hidden md:inline-flex cursor-help flex-none">
<PriorityBar score={plan.priorityScore} label="优先级" />
......
......@@ -59,6 +59,8 @@ export type PlanDetailData = {
version: number;
status: string;
priorityScore: number;
/// 池内百分位(0-100 = 高于池内多少比例);池太小 / 老响应 → 缺省
priorityPercentile?: number | null;
contactAttempts: number;
maxContactAttempts: number;
targetClinicId: string | null;
......
......@@ -593,7 +593,12 @@ function PatientRow({
</span>
)}
<span className="ml-auto flex-none">
<PriorityHover score={p.priorityScore} breakdown={breakdown}>
<PriorityHover
score={p.priorityScore}
breakdown={breakdown}
percentile={p.priorityPercentile}
signals={p.reasons[0]?.signals}
>
<span className="cursor-help">
<PriorityBar score={p.priorityScore} raw={breakdown?.raw} />
</span>
......
......@@ -808,6 +808,8 @@ function PatientPlanCard({
<PriorityHover
score={p.priorityScore}
breakdown={(p.reasons[0]?.breakdown as { priority?: PriorityBreakdown } | null | undefined)?.priority}
percentile={p.priorityPercentile}
signals={p.reasons[0]?.signals}
>
<span className="inline-flex items-center gap-1 cursor-help group/score">
<PriorityBar
......
import { diagnosisCodeNameZh, personaTagLabel } from '@pac/types';
/**
* 把优先级三维的**数字**翻成**这个患者的情况**。
*
* 原先 hover 每行右边写的是口径定义(「病情多急(末诊/超期)」「治疗类型 + 牙数预估收入」),
* 换谁看都一样 —— 看完仍不知道这位患者为什么是 10 分。这里改成讲他自己的事:
* 「紧急 · 90 天以上没来」「种植 · 缺 6 颗以上」「重要价值 · 主动咨询过 · 成熟客」。
*
* 取值来源尽量**从已有数据推**,不额外存:
* 急迫档位 ← urgency 分值(10/7/4/1 一一对应,无歧义)
* 治疗类型 ← reason.signals.triggers[].code;牙数 ← signals.toothPosition
* 主诉命中 ← intentBehavior(8=咨询过 / 2=没有,二值)
* 只有 RFM 分群和生命周期反推不出来(8 分既可能是重要保持也可能是重要发展),
* 由 scorer 在 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; // 综合 ×因子后
/// 讲人话用(不参与计算):数字反推不出来的两项,由 scorer 带出。老 plan 可能没有。
rfmSegment?: string | null;
lifecycleStage?: string | null;
}
/** reason.signals 里本模块用到的部分(其余字段不关心) */
export interface PriorityExplainSignals {
triggers?: Array<{ code?: string | null }> | null;
toothPosition?: string | null;
}
const URGENCY_ZH: Record<number, string> = {
10: '紧急 · 90 天以上没来',
7: '高 · 30-90 天没来',
4: '中 · 近期来过或刚发现',
1: '低',
0: '无待做项目',
};
/** 牙位串 `16;17;27` → 颗数;全口病种为空 → 0 */
function toothCount(toothPosition?: string | null): number {
if (!toothPosition) return 0;
return toothPosition.split(/[;,]+/).filter((s) => s.trim()).length;
}
export function explainUrgency(breakdown: PriorityBreakdown): string {
return URGENCY_ZH[Math.round(breakdown.urgency ?? 0)] ?? '';
}
/** 价值性 = 治疗类型(+ 种植/修复看牙数分档) */
export function explainValue(
breakdown: PriorityBreakdown,
signals?: PriorityExplainSignals | null,
): string {
const code = signals?.triggers?.find((t) => t?.code)?.code ?? '';
const name = code ? diagnosisCodeNameZh(code) : '';
const n = toothCount(signals?.toothPosition);
// 种植/修复的分值就是按牙数抬的,说清楚哪一档比只报名字有用
if (code === 'K08') {
const tier = n >= 6 ? '缺 6 颗以上' : n >= 2 ? `缺 ${n} 颗` : '缺 1 颗';
return `种植 · ${tier}`;
}
if (code === 'K03' && n >= 3) return `${name || '牙体修复'} · ${n} 颗`;
if (name) return n > 0 ? `${name} · ${n} 颗` : name;
return `预估收入档 ${(breakdown.value ?? 0).toFixed(0)}/10`;
}
/** 意愿度 = RFM 分群 · 有没有主动咨询过 · 生命周期 */
export function explainWillingness(breakdown: PriorityBreakdown): string {
const parts: string[] = [];
// 中文名走圈人字典(单一真理源),不在这儿另抄一张表
const rfm = personaTagLabel('rfm', breakdown.rfmSegment);
if (rfm) parts.push(rfm);
// intentBehavior 是二值(8 咨询过 / 2 没有)—— 「主动问过」是最强的意愿信号,一定要说
if (breakdown.intentBehavior !== undefined) {
parts.push(breakdown.intentBehavior >= 8 ? '主动咨询过' : '没主动问过');
}
const stage = personaTagLabel('lifecycle_stage', breakdown.lifecycleStage);
if (stage) parts.push(stage);
return parts.length ? parts.join(' · ') : `RFM ${breakdown.rfmAdherence ?? '-'}`;
}
/** 新鲜度 —— 只在打了折时才值得占一行 */
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,6 +2,19 @@
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,
type PriorityExplainSignals,
} from './priority-explain';
/**
* PriorityHover — 优先级数字旁悬停展示 v3.0 三维拆解。
......@@ -13,26 +26,20 @@ import { HoverCard, HoverCardTrigger, HoverCardContent } from '@/components/ui/h
*
* 列表 + 详情页共用。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,
signals,
children,
}: {
score: number;
breakdown?: PriorityBreakdown | null;
/// 池内百分位(后端 PriorityDistributionService);缺失 → 不渲染位置行
percentile?: number | null;
/// primary reason 的 signals —— 用来把价值性讲成「种植 · 缺 3 颗」而不是「预估收入档 9/10」
signals?: PriorityExplainSignals | null;
children: React.ReactNode;
}) {
return (
......@@ -40,7 +47,12 @@ 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} />
<PriorityBreakdownTable
score={score}
breakdown={breakdown}
percentile={percentile}
signals={signals}
/>
</HoverCardContent>
</HoverCard>
);
......@@ -49,9 +61,13 @@ export function PriorityHover({
function PriorityBreakdownTable({
score,
breakdown,
percentile,
signals,
}: {
score: number;
breakdown?: PriorityBreakdown | null;
percentile?: number | null;
signals?: PriorityExplainSignals | null;
}) {
// 10 分制(保留两位小数):优先用 breakdown.raw(真 2 位精度),无则 score/10
const disp = (breakdown?.raw ?? score / 10).toFixed(2);
......@@ -59,7 +75,7 @@ function PriorityBreakdownTable({
// 老数据 / 异常兜底(旧 6 因子 breakdown 也走这里)
return (
<div className="space-y-1.5">
<Header disp={disp} />
<Header disp={disp} percentile={percentile} />
<p className="text-slate-500 leading-relaxed">
三维加权:急迫性 ×0.4 + 价值性 ×0.3 + 意愿度 ×0.3,再 × 新鲜度 × 置信度。
<br />
......@@ -79,27 +95,29 @@ 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} />
<Header disp={disp} percentile={percentile} />
<table className="w-full tabular-nums">
<tbody>
<Row label="急迫性" value={`${urgency.toFixed(0)} × 0.4`} hint="病情多急(末诊/超期)" />
<Row label="价值性" value={`${value.toFixed(0)} × 0.3`} hint="治疗类型 + 牙数预估收入" />
{/* hint 一律讲**这个患者**的情况,不讲口径定义 —— 定义换谁看都一样,看完还是不知道
他为什么是 10 分。翻译逻辑在 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, signals)} />
<Row
label="意愿度"
value={`${willing.toFixed(1)} × 0.3`}
hint={`RFM ${rfm} / 主诉 ${intent} / 信任 ${trust}`}
hint={explainWillingness(breakdown)}
/>
<Subtotal label="三维小计" value={base.toFixed(1)} />
<Row
label="× 新鲜度"
value={fresh.toFixed(2)}
hint="诊断越老越降权(止损)"
hint={explainFreshness(breakdown)}
tone={fresh < 1 ? 'amber' : undefined}
/>
<Row
label="× 置信度"
value={conf.toFixed(2)}
hint="诊断 1.0 / 建议 0.8 / 影像 0.5"
hint={explainConfidence(breakdown)}
tone={conf < 1 ? 'amber' : undefined}
/>
<Subtotal label="= 总分" value={disp} bold />
......@@ -109,11 +127,26 @@ function PriorityBreakdownTable({
);
}
function Header({ disp }: { disp: string }) {
function Header({ disp, percentile }: { disp: string; percentile?: number | null }) {
const pos = explainPercentile(percentile);
return (
<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 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>
);
}
......
......@@ -220,3 +220,16 @@ 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,6 +38,9 @@ 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