Commit 5eb72b84 by luoqi

feat(分配): 画像亮点节点 + 修一处"假绿"(测试用 require 绕过了类型检查)

画像亮点:
- computeHighlights —— 纯函数,比两份分布:**这一格** vs **同治疗项整个池子**。
   判据是「相对基线的偏离」而不是绝对占比:「这批 80% 自费」没用(全池也是),
  「转介绍是平时的 2.6 倍」才是主管会据此改打法的东西。
  ️ 基线取同治疗项, 不取全池 —— 种植的重要价值天然高于补牙,用全池会永远命中。
- 三道闸,缺一道就变噪音:倍数 ≥2 · 本批命中 ≥10 人 · 基线 ≥50 人。
  基线为 0 时**不比** ——  不许当成「这一格独有」凭空下结论。
- CohortAttributesService.dimShares:精简版取数(只出分布),与 describe 共用
  同一份 cohortWhereSql / personaBreakdown, 不另写一套(T6a 同源)。
- 维度只留 rfm / referral_champion / entitlement_status 三个。
  ️ 产品还点了「获客渠道」「折扣锚点」,它们目前只是 PersonaFeatureKey、
  不在 PERSONA_TAG_FILTER_DIMS 里,取不到分布 —— 要加得先补成 hidden 维度。
- 亮点失败不让确认单挂掉(锦上添花),但要 warn 出来。

🔴 修一处假绿:
  给 AssignmentProposalService 加第三个依赖后,测试仍按两参构造却全绿 ——
  因为那两处用的是 `const { X } = require(...)`,返回 any,**arity 查不出来**;
  运行时 this.cohorts 是 undefined,又正好被业务侧 try/catch 吞成"没有亮点"
  ⇒ 测试绿着,而新加的整条路径根本没跑。
  - require → 有类型的 import;补 cohortsStub 显式传入
  - 由此暴露并修掉 5 处此前被掩盖的类型错误(TemperatureValue / find() 可能 undefined)
  - package.json 的 type-check 接上仓库里**已有**的 tsconfig.typecheck.json
    (2026-08-05 因 ReleaseReason.OVER_CAPACITY 同类假绿建的,之前没进脚本)

1230 passed;type-check 含 tests 干净。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parent 718e1bbf
...@@ -8,7 +8,7 @@ ...@@ -8,7 +8,7 @@
"start": "node dist/main.js", "start": "node dist/main.js",
"start:prod": "node dist/main.js", "start:prod": "node dist/main.js",
"lint": "eslint src", "lint": "eslint src",
"type-check": "tsc --noEmit", "type-check": "tsc --noEmit && tsc --noEmit -p tsconfig.typecheck.json",
"test": "jest --passWithNoTests", "test": "jest --passWithNoTests",
"clean": "rm -rf dist .turbo", "clean": "rm -rf dist .turbo",
"prisma:generate": "prisma generate", "prisma:generate": "prisma generate",
......
...@@ -7,6 +7,7 @@ import { ...@@ -7,6 +7,7 @@ import {
type AgentInfo, type AgentInfo,
type AssignmentProposal, type AssignmentProposal,
type ProposedItem, type ProposedItem,
type Signal,
AnchorMode, AnchorMode,
type AnchorModeValue, type AnchorModeValue,
type TemperatureValue, type TemperatureValue,
...@@ -18,7 +19,8 @@ import { resolveClinicId } from '../../common/decorators/resolve-clinic-id'; ...@@ -18,7 +19,8 @@ import { resolveClinicId } from '../../common/decorators/resolve-clinic-id';
import { AgentRosterService } from './agent-roster.service'; import { AgentRosterService } from './agent-roster.service';
import { assertCohortCriteria, cohortWhereSql, type CohortCriteria } from './cohort-filter'; import { assertCohortCriteria, cohortWhereSql, type CohortCriteria } from './cohort-filter';
// ⭐ 引导节点的确定性判定 —— 纯函数,输入一份提案、输出整套节点(见该文件顶部注释) // ⭐ 引导节点的确定性判定 —— 纯函数,输入一份提案、输出整套节点(见该文件顶部注释)
import { computeSignals } from './assignment-signals'; import { computeHighlights, computeSignals } from './assignment-signals';
import { CohortAttributesService } from './cohort-attributes.service';
/** /**
* AssignmentProposalService —— 「1,189 人的格子 → 一批 100 人 → 落到 N 个客服头上」。 * AssignmentProposalService —— 「1,189 人的格子 → 一批 100 人 → 落到 N 个客服头上」。
...@@ -75,8 +77,49 @@ export class AssignmentProposalService { ...@@ -75,8 +77,49 @@ export class AssignmentProposalService {
constructor( constructor(
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly roster: AgentRosterService, private readonly roster: AgentRosterService,
/// 画像亮点要比两份分布(这一格 vs 同治疗项池子),复用它的取数(⛔ 别另写一套,T6a 同源)
private readonly cohorts: CohortAttributesService,
) {} ) {}
/**
* 画像亮点的维度 —— **⛔ 不要一次全给**:16 个维度全比一遍,总有几个偶然超两倍,
* 亮点就退化成噪音。这里只留主管真会据此改打法的几个。
*
* ⚠️ 产品还点了「获客渠道」「折扣锚点」,它们目前只是 `PersonaFeatureKey`,
* **不在** `PERSONA_TAG_FILTER_DIMS` 里,所以取不到分布。要加得先把它们
* 补成(hidden 的)可筛维度 —— 那是一件独立的小事。
*/
private static readonly HIGHLIGHT_DIMS = ['rfm', 'referral_champion', 'entitlement_status'];
/**
* 算画像亮点 —— 两份分布:**这一格** vs **同治疗项的整个池子**(去掉时间档)。
*
* ⚠️ 失败不许让整张确认单挂掉:亮点是锦上添花,取不到就不出这个节点。
*/
private async highlightSignals(
scope: TenantScopeContext,
criteria: CohortCriteria,
now: Date,
): Promise<Signal[]> {
// 没有治疗项就没有"同类"可比 —— 拿全池当基线会让种植永远命中
if (!criteria.potentialTreatment) return [];
try {
const keys = AssignmentProposalService.HIGHLIGHT_DIMS;
const { temperature: _drop, ...treatmentWide } = criteria;
const [batch, baseline] = await Promise.all([
this.cohorts.dimShares(scope, criteria, keys, now),
this.cohorts.dimShares(scope, treatmentWide, keys, now),
]);
return computeHighlights({
batch: { size: batch.size, dims: batch.dims },
baseline: { size: baseline.size, dims: baseline.dims },
});
} catch (e) {
this.logger.warn(`画像亮点算失败(不影响确认单):${e instanceof Error ? e.message : e}`);
return [];
}
}
async propose( async propose(
scope: TenantScopeContext, scope: TenantScopeContext,
input: { input: {
...@@ -600,7 +643,9 @@ export class AssignmentProposalService { ...@@ -600,7 +643,9 @@ export class AssignmentProposalService {
`${minAfter}~${suggestWaterline} 条),得您定 —— 拖给谁或者移出本批。` `${minAfter}~${suggestWaterline} 条),得您定 —— 拖给谁或者移出本批。`
: `,没有需要您定的了。`), : `,没有需要您定的了。`),
}; };
return { ...proposal, signals: computeSignals(proposal as AssignmentProposal) }; // ⚠️ 亮点单独查库(两份分布),⛔ 不放进 computeSignals —— 那个函数要保持纯。
const highlights = await this.highlightSignals(scope, criteria, now);
return { ...proposal, signals: computeSignals(proposal as AssignmentProposal, highlights) };
} }
/** /**
......
...@@ -45,6 +45,8 @@ export const ASSIGNMENT_INTENTS = { ...@@ -45,6 +45,8 @@ export const ASSIGNMENT_INTENTS = {
BATCH_SIZE_SET: 'batch_size.set', BATCH_SIZE_SET: 'batch_size.set',
/// 换时间档 / 换治疗项(重出一版) /// 换时间档 / 换治疗项(重出一版)
COHORT_WIDEN: 'cohort.widen', COHORT_WIDEN: 'cohort.widen',
/// 加一个画像条件再圈一版(args.personaTags = `维度:取值`)
COHORT_NARROW: 'cohort.narrow',
/// 换档位口径(重出一版) /// 换档位口径(重出一版)
ANCHOR_SWITCH: 'anchor.switch', ANCHOR_SWITCH: 'anchor.switch',
} as const; } as const;
...@@ -61,14 +63,111 @@ const SEV = { ...@@ -61,14 +63,111 @@ const SEV = {
INFO: 4, INFO: 4,
} as const; } as const;
// ═══════════════════════════════════════════════════════════════
// 画像亮点
// ═══════════════════════════════════════════════════════════════
/** 一个维度的分布(结构上兼容 `CohortAttributeDim`,这里只取用得到的字段)。 */
export interface DimShare {
id: string;
nameZh: string;
options: Array<{ value: string; zh: string; count: number }>;
}
/** 比较用的两份分布:这一格 vs 同治疗项整个池子。 */
export interface HighlightSource {
batch: { size: number; dims: DimShare[] };
baseline: { size: number; dims: DimShare[] };
}
/**
* 亮点的三道闸 —— 缺任何一道都会让节点变噪音。
*
* ⚠️ 光看倍数不够:一格 200 人里某项 6 人(3%)对上全池 1%,倍数是 3,
* 但 6 个人不值得主管改话术。⇒ 必须同时卡**绝对人数**。
* ⚠️ 基线太小时比值没有意义(全池只有 20 人,谁占多数都不奇怪)。
*/
const HIGHLIGHT = {
/// 本批占比 ÷ 基线占比 达到几倍才算「亮」
RATIO: 2,
/// 本批至少要有这么多人命中
MIN_COUNT: 10,
/// 基线人群至少这么大才拿来当基线
MIN_BASELINE: 50,
/// 最多亮几个(⚠️ 这是**同一个节点内**取前几项,⛔ 不是截断节点本身)
TOP: 3,
} as const;
/**
* 画像亮点 —— **这一格和整体比,哪里不一样**。
*
* ⭐ 判据用「相对基线的偏离」而不是绝对占比:占比高不等于有信息。
* 「这批 80% 自费」没用(全池也是);「转介绍是平时的 2.6 倍」才是主管会据此改打法的东西。
* ⚠️ 基线取**同治疗项的整个池子**,⛔ 不取全池 ——
* 种植的重要价值天然高于补牙,用全池会让种植永远命中。
* ⚠️ 比的是**这一格的候选集**(圈定的人),不是最终发出去的那 N 个 ——
* 两者在候选不够时相同,候选很多时是子集。措辞里因此说「这一格的人里」而不是「这批」。
*/
export function computeHighlights(src: HighlightSource): Signal[] {
const { batch, baseline } = src;
if (batch.size === 0 || baseline.size < HIGHLIGHT.MIN_BASELINE) return [];
const baseCount = new Map<string, number>();
for (const d of baseline.dims) {
for (const o of d.options) baseCount.set(`${d.id}:${o.value}`, o.count);
}
const hits: Array<{ dim: DimShare; zh: string; value: string; count: number; ratio: number }> = [];
for (const d of batch.dims) {
for (const o of d.options) {
if (o.count < HIGHLIGHT.MIN_COUNT) continue;
const base = baseCount.get(`${d.id}:${o.value}`) ?? 0;
// ⚠️ 基线为 0 时不比 —— 那多半是这个取值刚上线、或者基线口径没覆盖到,
// ⛔ 不能当成「这一格独有」来报(那会是个凭空的结论)。
if (base === 0) continue;
const ratio = o.count / batch.size / (base / baseline.size);
if (ratio >= HIGHLIGHT.RATIO) {
hits.push({ dim: d, zh: o.zh, value: o.value, count: o.count, ratio });
}
}
}
if (hits.length === 0) return [];
hits.sort((a, b) => b.ratio - a.ratio);
return hits.slice(0, HIGHLIGHT.TOP).map((h) => ({
key: `highlight:${h.dim.id}:${h.value}`,
severity: SEV.OPPORTUNITY,
tier: 'action' as const,
title: `这一格里「${h.zh}」特别多:${h.count} 人`,
// ⚠️ 只陈述事实。⛔ 不写「建议用它当话术钩子」—— 怎么用是主管的判断。
why:
`占这一格的 ${pct(h.count, batch.size)},而同类治疗整体是 ${pct(baseCount.get(`${h.dim.id}:${h.value}`) ?? 0, baseline.size)}` +
` ${h.ratio.toFixed(1)} 倍。`,
defaultLabel: '不处理 = 照常整批发,不特殊对待',
options: [
{
label: `只圈这些人`,
intent: ASSIGNMENT_INTENTS.COHORT_NARROW,
args: { personaTags: `${h.dim.id}:${h.value}` },
},
],
}));
}
function pct(n: number, total: number): string {
return total > 0 ? `${Math.round((n / total) * 100)}%` : '0%';
}
/** /**
* 从一份**已经组装好**的提案里算出全套引导节点。 * 从一份**已经组装好**的提案里算出全套引导节点。
* *
* ⚠️ 纯函数:不查库、不看时钟。所有输入都在 proposal 里 —— * ⚠️ 纯函数:不查库、不看时钟。所有输入都在 proposal 里 ——
* 这样它可以被直接测,也不会因为"算节点时又查了一次"和提案本身对不上(T6a 同源)。 * 这样它可以被直接测,也不会因为"算节点时又查了一次"和提案本身对不上(T6a 同源)。
* ⚠️ 画像亮点需要**额外两份分布**(这一格 vs 同治疗项池子),查库的那一步在调用方,
* 算好之后作为 `highlights` 传进来 —— 本函数保持纯。
*/ */
export function computeSignals(p: AssignmentProposal): Signal[] { export function computeSignals(p: AssignmentProposal, highlights: Signal[] = []): Signal[] {
const out: Signal[] = []; const out: Signal[] = [...highlights];
const d = Math.max(1, p.expiresInDays); const d = Math.max(1, p.expiresInDays);
// ── ① 待分配 —— 唯一一个「不处理就真的有人被漏掉」的节点 ────────────── // ── ① 待分配 —— 唯一一个「不处理就真的有人被漏掉」的节点 ──────────────
...@@ -184,7 +283,11 @@ export function computeSignals(p: AssignmentProposal): Signal[] { ...@@ -184,7 +283,11 @@ export function computeSignals(p: AssignmentProposal): Signal[] {
}); });
} }
// ⚠️ 确定性排序:severity 升序,同级按加入顺序(上面的书写顺序即优先级)。 /**
// ⛔ 不截断 —— 分层交给前端(action 展开 / info 折叠)。 * 确定性排序:severity 升序。
* ⚠️ 用**稳定**的比较(同级保持加入顺序),⛔ 别引入任何随机或时间相关的因素 ——
* 同一份提案每次算出来的顺序必须一模一样,否则主管刷新一下节点就换位置。
* ⛔ 不截断 —— 分层交给前端(action 展开 / info 折叠)。
*/
return out.sort((a, b) => a.severity - b.severity); return out.sort((a, b) => a.severity - b.severity);
} }
...@@ -261,6 +261,34 @@ export class CohortAttributesService { ...@@ -261,6 +261,34 @@ export class CohortAttributesService {
} }
/** persona 特征维度:一次查完所有点名的 key,在内存里按 dataPath 取值分桶 */ /** persona 特征维度:一次查完所有点名的 key,在内存里按 dataPath 取值分桶 */
/**
* 精简版分布 —— 只出「各维度各取值命中多少人」+ 人群规模。
*
* ⭐ 给引导节点的**画像亮点**用(assignment-signals.computeHighlights):
* 它要把「这一格的分布」和「同治疗项整个池子的分布」比一比,一次提案要调两回,
* 而 `describe()` 还会顺带算专属覆盖、温度未知数、成品句子 —— 那些这里都用不上。
* ⚠️ 与 `describe()` **共用同一份 `cohortWhereSql` 与 `personaBreakdown`**,
* ⛔ 别在这里另写一套取数:两份必然漂,而漂了不报错(T6a 同源)。
*/
async dimShares(
scope: TenantScopeContext,
criteria: CohortCriteria,
keys: string[],
now: Date = new Date(),
): Promise<{ size: number; dims: CohortAttributeDim[] }> {
const rows = await this.prisma.$queryRaw<Array<{ patientId: string }>>(
Prisma.sql`
SELECT DISTINCT fp.patient_id AS "patientId"
FROM followup_plans fp
JOIN patients p ON p.id = fp.patient_id
WHERE ${cohortWhereSql(scope, criteria, now)}
`,
);
const patientIds = rows.map((r) => r.patientId);
if (patientIds.length === 0) return { size: 0, dims: [] };
return { size: patientIds.length, dims: await this.personaBreakdown(patientIds, resolveDims(keys)) };
}
private async personaBreakdown( private async personaBreakdown(
patientIds: string[], patientIds: string[],
dims: PersonaTagFilterDim[], dims: PersonaTagFilterDim[],
......
import { AssignStrategy, type AgentInfo } from '@pac/types'; import { AssignStrategy, type AgentInfo, type TemperatureValue } from '@pac/types';
import { placeAgents, repickPlaceable } from '../src/modules/plan/assignment-proposal.service'; // ⚠️ **用 import 不用 require**(2026-08-12):原来这两个 describe 里各写了一行
// `const { AssignmentProposalService } = require(...)` —— require 的返回是 any,
// 于是给构造器加第三个依赖时**类型检查查不出来**,运行时那个依赖是 undefined,
// 又正好被业务侧的 try/catch 吞掉 ⇒ 测试全绿而被测路径根本没跑。
import {
AssignmentProposalService,
placeAgents,
repickPlaceable,
} from '../src/modules/plan/assignment-proposal.service';
import type { CohortAttributesService } from '../src/modules/plan/cohort-attributes.service';
/** /**
* 落人算法回归 —— **专属优先 → 无主补空 → 待分配**。 * 落人算法回归 —— **专属优先 → 无主补空 → 待分配**。
...@@ -35,6 +44,15 @@ const pick = (n: number, prefix = 'p') => ...@@ -35,6 +44,15 @@ const pick = (n: number, prefix = 'p') =>
selectionMode: 'rank' as const, selectionMode: 'rank' as const,
})); }));
/**
* 画像亮点的取数桩 —— 默认**什么都不亮**(基线为空 → computeHighlights 直接返回 [])。
* ⚠️ 必须传,⛔ 不能省:省了运行时是 undefined,而业务侧的 try/catch 会把它吞成"没有亮点"。
*/
const cohortsStub = (
over: Partial<CohortAttributesService> = {},
): CohortAttributesService =>
({ dimShares: jest.fn(async () => ({ size: 0, dims: [] })), ...over }) as unknown as CohortAttributesService;
describe('placeAgents —— 专属优先', () => { describe('placeAgents —— 专属优先', () => {
test('⭐ 专属客服在名册内、且没超过本批应得份额 → 命中 dedicated', () => { test('⭐ 专属客服在名册内、且没超过本批应得份额 → 命中 dedicated', () => {
const chosen = pick(3); const chosen = pick(3);
...@@ -392,7 +410,6 @@ describe('placeAgents —— 确定性', () => { ...@@ -392,7 +410,6 @@ describe('placeAgents —— 确定性', () => {
* 他会以为有 56 个人被系统悄悄丢了。 * 他会以为有 56 个人被系统悄悄丢了。
*/ */
describe('selectionNote —— 候选不够时的措辞', () => { describe('selectionNote —— 候选不够时的措辞', () => {
const { AssignmentProposalService } = require('../src/modules/plan/assignment-proposal.service');
/** /**
* ⚠️ 这个假 prisma 必须**照 LIMIT 截断**。 * ⚠️ 这个假 prisma 必须**照 LIMIT 截断**。
...@@ -433,7 +450,11 @@ describe('selectionNote —— 候选不够时的措辞', () => { ...@@ -433,7 +450,11 @@ describe('selectionNote —— 候选不够时的措辞', () => {
rosterNote: '名册说明', rosterNote: '名册说明',
})), })),
}; };
return new AssignmentProposalService(prisma, roster); return new AssignmentProposalService(
prisma as never,
roster as never,
cohortsStub() as never,
);
} }
const SCOPE = { hostId: 'h', tenantId: 't', sourceUnits: [], clinicIds: ['c1'], userId: 'u' }; const SCOPE = { hostId: 'h', tenantId: 't', sourceUnits: [], clinicIds: ['c1'], userId: 'u' };
/// 首次估法:在岗 9 人 × 20 = 180(⚠️ 那个 20 不是容量上限,只是首次没有历史时的估法) /// 首次估法:在岗 9 人 × 20 = 180(⚠️ 那个 20 不是容量上限,只是首次没有历史时的估法)
...@@ -476,7 +497,7 @@ describe('selectionNote —— 候选不够时的措辞', () => { ...@@ -476,7 +497,7 @@ describe('selectionNote —— 候选不够时的措辞', () => {
*/ */
test('🔴 候选 1080 但取数只取回 1.5 倍窗口 → 说的必须是 1080', async () => { test('🔴 候选 1080 但取数只取回 1.5 倍窗口 → 说的必须是 1080', async () => {
const r = await svcWith(1080, 9).propose(SCOPE, { const r = await svcWith(1080, 9).propose(SCOPE, {
clinicId: 'c1', potentialTreatment: 'filling', temperature: 'cold', clinicId: 'c1', potentialTreatment: 'filling', temperature: 'cold' as TemperatureValue,
}); });
expect(r.candidateTotal).toBe(1080); expect(r.candidateTotal).toBe(1080);
expect(r.selectionNote).toContain('从符合条件的 1080 人里挑'); expect(r.selectionNote).toContain('从符合条件的 1080 人里挑');
...@@ -491,7 +512,6 @@ describe('selectionNote —— 候选不够时的措辞', () => { ...@@ -491,7 +512,6 @@ describe('selectionNote —— 候选不够时的措辞', () => {
* 沿用断了不会报错 —— 只是主管每次都要重调一遍,然后觉得"这系统不记事"。 * 沿用断了不会报错 —— 只是主管每次都要重调一遍,然后觉得"这系统不记事"。
*/ */
describe('基数沿用 —— 本批人数与时效', () => { describe('基数沿用 —— 本批人数与时效', () => {
const { AssignmentProposalService } = require('../src/modules/plan/assignment-proposal.service');
const SCOPE = { hostId: 'h', tenantId: 't', sourceUnits: [], clinicIds: ['c1'], userId: 'u' }; const SCOPE = { hostId: 'h', tenantId: 't', sourceUnits: [], clinicIds: ['c1'], userId: 'u' };
const mk = (candidateCount: number, agentCount: number, opts: Record<string, unknown> = {}) => { const mk = (candidateCount: number, agentCount: number, opts: Record<string, unknown> = {}) => {
const prisma = { const prisma = {
...@@ -520,7 +540,11 @@ describe('基数沿用 —— 本批人数与时效', () => { ...@@ -520,7 +540,11 @@ describe('基数沿用 —— 本批人数与时效', () => {
rosterNote: '名册说明', rosterNote: '名册说明',
})), })),
}; };
return new AssignmentProposalService(prisma, roster); return new AssignmentProposalService(
prisma as never,
roster as never,
cohortsStub() as never,
);
}; };
/** /**
...@@ -602,7 +626,7 @@ describe('基数沿用 —— 本批人数与时效', () => { ...@@ -602,7 +626,7 @@ describe('基数沿用 —— 本批人数与时效', () => {
const r = await mk(1000, 9, { const r = await mk(1000, 9, {
lastCriteria: { expiresInDays: 3, agentOverrides: { a3: { maxThisBatch: 5 } } }, lastCriteria: { expiresInDays: 3, agentOverrides: { a3: { maxThisBatch: 5 } } },
}).propose(SCOPE, { clinicId: 'c1', targetCount: 100 }); }).propose(SCOPE, { clinicId: 'c1', targetCount: 100 });
const lily = r.byAgent.find((x: { userId: string }) => x.userId === 'a3'); const lily = r.byAgent.find((x) => x.userId === 'a3')!;
expect(lily.count).toBe(5); expect(lily.count).toBe(5);
expect(lily.overridden).toBe(true); expect(lily.overridden).toBe(true);
// ⚠️ 名额上限**不改批次规模** —— 少的那部分由别人接走 // ⚠️ 名额上限**不改批次规模** —— 少的那部分由别人接走
...@@ -624,8 +648,8 @@ describe('基数沿用 —— 本批人数与时效', () => { ...@@ -624,8 +648,8 @@ describe('基数沿用 —— 本批人数与时效', () => {
lastCriteria: { expiresInDays: 3, agentOverrides: { a3: { expiresInDays: 7 } } }, lastCriteria: { expiresInDays: 3, agentOverrides: { a3: { expiresInDays: 7 } } },
}).propose(SCOPE, { clinicId: 'c1', targetCount: 100 }); }).propose(SCOPE, { clinicId: 'c1', targetCount: 100 });
expect(r.expiresInDays).toBe(3); expect(r.expiresInDays).toBe(3);
expect(r.byAgent.find((x: { userId: string }) => x.userId === 'a3').expiresInDays).toBe(7); expect(r.byAgent.find((x) => x.userId === 'a3')!.expiresInDays).toBe(7);
expect(r.byAgent.find((x: { userId: string }) => x.userId === 'a0').expiresInDays).toBe(3); expect(r.byAgent.find((x) => x.userId === 'a0')!.expiresInDays).toBe(3);
}); });
test('⛔ 精调表里的垃圾值静默丢弃,不让一条坏精调把整张确认单顶掉', async () => { test('⛔ 精调表里的垃圾值静默丢弃,不让一条坏精调把整张确认单顶掉', async () => {
......
import { AnchorMode, type AssignmentProposal } from '@pac/types'; import { AnchorMode, type AssignmentProposal } from '@pac/types';
import { ASSIGNMENT_INTENTS, computeSignals } from '../src/modules/plan/assignment-signals'; import {
ASSIGNMENT_INTENTS,
computeHighlights,
computeSignals,
} from '../src/modules/plan/assignment-signals';
/** /**
* 引导节点的判定 —— 规格见 docs/design/assignment-agent-flow.md §五。 * 引导节点的判定 —— 规格见 docs/design/assignment-agent-flow.md §五。
...@@ -189,3 +193,101 @@ describe('引导节点 · 判定', () => { ...@@ -189,3 +193,101 @@ describe('引导节点 · 判定', () => {
} }
}); });
}); });
/**
* 画像亮点 —— 「这一格和同类治疗整体比,哪里不一样」。
*
* 🔴 判据是**相对基线的偏离**,⛔ 不是绝对占比:占比高不等于有信息
* (「这批 80% 自费」全池也是 80%,说了等于没说)。
*/
describe('引导节点 · 画像亮点', () => {
const dim = (id: string, nameZh: string, opts: Array<[string, string, number]>) => ({
id,
nameZh,
options: opts.map(([value, zh, count]) => ({ value, zh, count })),
});
test('🔴 偏离达到 2 倍且人数够 → 亮;只是占比高但和整体一样 → ⛔ 不亮', () => {
const s = computeHighlights({
// 这一格 200 人:转介绍 62(31%)、自费 160(80%)
batch: {
size: 200,
dims: [
dim('referral_champion', '转介绍达人', [['yes', '转介绍达人', 62]]),
dim('rfm', '价值分群', [['self_pay', '自费', 160]]),
],
},
// 同治疗整体 1000 人:转介绍 120(12%)、自费 800(80%)
baseline: {
size: 1000,
dims: [
dim('referral_champion', '转介绍达人', [['yes', '转介绍达人', 120]]),
dim('rfm', '价值分群', [['self_pay', '自费', 800]]),
],
},
});
expect(s.map((x) => x.key)).toEqual(['highlight:referral_champion:yes']);
expect(s[0]!.title).toContain('62 人');
expect(s[0]!.why).toContain('31%');
expect(s[0]!.why).toContain('12%');
});
test('⛔ 倍数够但人数太少 → 不亮(6 个人不值得主管改打法)', () => {
const s = computeHighlights({
batch: { size: 200, dims: [dim('rfm', '价值分群', [['vip', '重要价值', 6]])] },
baseline: { size: 1000, dims: [dim('rfm', '价值分群', [['vip', '重要价值', 10]])] },
});
expect(s).toEqual([]);
});
test('⛔ 基线人群太小 → 不比(谁占多数都不奇怪)', () => {
const s = computeHighlights({
batch: { size: 30, dims: [dim('rfm', '价值分群', [['vip', '重要价值', 20]])] },
baseline: { size: 40, dims: [dim('rfm', '价值分群', [['vip', '重要价值', 2]])] },
});
expect(s).toEqual([]);
});
test('⛔ 基线里没有这个取值 → 不亮(⛔ 不许当成「这一格独有」凭空下结论)', () => {
const s = computeHighlights({
batch: { size: 200, dims: [dim('rfm', '价值分群', [['vip', '重要价值', 50]])] },
baseline: { size: 1000, dims: [dim('rfm', '价值分群', [['other', '其他', 900]])] },
});
expect(s).toEqual([]);
});
test('⭐ 多个命中时按倍数排序并最多取 3 个', () => {
const many = Array.from({ length: 5 }, (_, i) => [`v${i}`, `取值${i}`, 20 + i * 10] as [string, string, number]);
const s = computeHighlights({
batch: { size: 200, dims: [dim('rfm', '价值分群', many)] },
baseline: { size: 1000, dims: [dim('rfm', '价值分群', many.map(([v, z]) => [v, z, 10] as [string, string, number]))] },
});
expect(s).toHaveLength(3);
// 倍数从大到小 —— 人数越多倍数越大
expect(s.map((x) => x.key)).toEqual([
'highlight:rfm:v4',
'highlight:rfm:v3',
'highlight:rfm:v2',
]);
});
test('🔴 亮点进 computeSignals 后按 severity 排在待分配之后、纯信息之前', () => {
const hl = computeHighlights({
batch: { size: 200, dims: [dim('rfm', '价值分群', [['vip', '重要价值', 60]])] },
baseline: { size: 1000, dims: [dim('rfm', '价值分群', [['vip', '重要价值', 100]])] },
});
const s = computeSignals(
proposal({
pending: [{}] as AssignmentProposal['pending'],
basis: 'default',
byAgent: [agent('张悦', 10)],
}),
hl,
);
expect(s.map((x) => x.key)).toEqual([
'pending',
'highlight:rfm:vip',
'expiry_default',
]);
});
});
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