Commit a93a2567 by luoqi

fix(分配): 画像亮点补「百分点」判据 + 获客渠道成维度 + 修圈人提示语泄露内部码

🔴 本地实测发现「倍数 ≥2」**结构上打不着大类**:
朝阳公园的获客渠道是 口碑 33% / 集团营销 23% / 走入 22% ——
口碑要够 2 倍得占到 66%(现实里不可能);而能翻倍的小类(电商 2.3%)
在一格 71 人里连 10 个人都凑不出 ⇒ **整个维度永远不会命中**。
⇒ 命中改成两条取其一:倍数 ≥2(抓小基数聚集) 或 高出 ≥12 个百分点(抓大基数偏移)。
措辞跟着口径走:大基数说"高出 N 个百分点",小基数说"约 N 倍";
排序按可比的偏离强度, 不能只按倍数(那会让百分点命中项永远垫底)。

🔴 修一处内部码泄露:点「只圈这些人」时替主管说的那句话是
「这批只圈符合『referral_champion:yes』的人」—— 内部码原样进对话框,
正是 P0 花一整节要消灭的东西。改成中文名 + 人数(「只圈『口碑客户』那 62 人」),
️ 拿不到中文名就**宁可不发**, 不许退化成把码念出来。
人数带上是因为「圈完还剩多少」正是主管点这一下想知道的事。

获客渠道补成 hidden 可筛维度(闭集 9 项,data.channel);
折扣锚点**不纳入** —— 它是连续量没有闭集,要用得先定分桶(产品判断)。

补一行画像亮点的 trace:没有它,「算了但没命中」和「压根没算」在日志里
长得一模一样(实测就吃了这个亏)。

1247 + 29 passed;web 20 passed。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parent 7ea5a365
......@@ -85,11 +85,16 @@ export class AssignmentProposalService {
* 画像亮点的维度 —— **⛔ 不要一次全给**:16 个维度全比一遍,总有几个偶然超两倍,
* 亮点就退化成噪音。这里只留主管真会据此改打法的几个。
*
* ⚠️ 产品还点了「获客渠道」「折扣锚点」,它们目前只是 `PersonaFeatureKey`
* **不在** `PERSONA_TAG_FILTER_DIMS` 里,所以取不到分布。要加得先把它们
* 补成(hidden 的)可筛维度 —— 那是一件独立的小事
* ⚠️ 「折扣锚点」**暂未纳入**:它的值是「最低折扣率 + 日期/项目」,是**连续量**
* 没有闭集取值 —— 「占比是基线的几倍」无从谈起。要用它得先定分桶
* (如 ≥9折 / 8–9折 / <8折),那是产品判断,⛔ 不在这里替产品拍
*/
private static readonly HIGHLIGHT_DIMS = ['rfm', 'referral_champion', 'entitlement_status'];
private static readonly HIGHLIGHT_DIMS = [
'rfm',
'referral_champion',
'entitlement_status',
'acquisition_channel',
];
/**
* 算画像亮点 —— 两份分布:**这一格** vs **同治疗项的整个池子**(去掉时间档)。
......@@ -110,10 +115,20 @@ export class AssignmentProposalService {
this.cohorts.dimShares(scope, criteria, keys, now),
this.cohorts.dimShares(scope, treatmentWide, keys, now),
]);
return computeHighlights({
const hits = computeHighlights({
batch: { size: batch.size, dims: batch.dims },
baseline: { size: baseline.size, dims: baseline.dims },
});
/**
* ⭐ 这行**必须留**:没有它,「算了但没命中」和「压根没算」在日志里长得一模一样。
* 实测就吃过这个亏 —— 页面上没有亮点,查日志一片安静,分不清是逻辑没跑
* 还是数据本来就没偏离(2026-08-12)。
*/
this.logger.log(
`画像亮点:本格 ${batch.size} 人 / 同治疗基线 ${baseline.size} 人 → 命中 ${hits.length} 条` +
(hits.length ? `(${hits.map((h) => h.title).join(' | ')})` : ''),
);
return hits;
} catch (e) {
this.logger.warn(`画像亮点算失败(不影响确认单):${e instanceof Error ? e.message : e}`);
return [];
......
......@@ -88,8 +88,18 @@ export interface HighlightSource {
* ⚠️ 基线太小时比值没有意义(全池只有 20 人,谁占多数都不奇怪)。
*/
const HIGHLIGHT = {
/// 本批占比 ÷ 基线占比 达到几倍才算「亮」
/// 本批占比 ÷ 基线占比 达到几倍才算「亮」(抓**小基数**维度的异常聚集)
RATIO: 2,
/**
* 或者:占比比基线高出这么多个百分点(抓**大基数**维度的偏移)。
*
* 🔴 2026-08-12 本地实测补的。只用倍数时这条判据**结构上打不着大类**:
* 朝阳公园的获客渠道分布是 口碑 33% / 集团营销 23% / 走入 22% ——
* 口碑要够 2 倍得占到 66%(现实里不可能),而能翻倍的小类(电商 2.3%)
* 在一格 71 人里连 10 个人都凑不出。⇒ 整个维度永远不会命中。
* ⚠️ 两条是**或**的关系:倍数管小基数,百分点管大基数,各抓一种偏离。
*/
POINTS: 12,
/// 本批至少要有这么多人命中
MIN_COUNT: 10,
/// 基线人群至少这么大才拿来当基线
......@@ -117,7 +127,14 @@ export function computeHighlights(src: HighlightSource): Signal[] {
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 }> = [];
const hits: Array<{
dim: DimShare;
zh: string;
value: string;
count: number;
ratio: number;
points: number;
}> = [];
for (const d of batch.dims) {
for (const o of d.options) {
if (o.count < HIGHLIGHT.MIN_COUNT) continue;
......@@ -125,15 +142,25 @@ export function computeHighlights(src: HighlightSource): Signal[] {
// ⚠️ 基线为 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 });
const share = o.count / batch.size;
const baseShare = base / baseline.size;
const ratio = share / baseShare;
const points = (share - baseShare) * 100;
if (ratio >= HIGHLIGHT.RATIO || points >= HIGHLIGHT.POINTS) {
hits.push({ dim: d, zh: o.zh, value: o.value, count: o.count, ratio, points });
}
}
}
if (hits.length === 0) return [];
hits.sort((a, b) => b.ratio - a.ratio);
/**
* ⚠️ 两种命中口径要能**放在一起排序**:把倍数折算成可比的强度
* (2 倍 ≈ 10 个百分点的量级),取两者较大的那个。
* ⛔ 别只按倍数排 —— 那会让百分点命中的项永远垫底。
*/
const strength = (h: { ratio: number; points: number }) =>
Math.max(h.points, (h.ratio - 1) * 10);
hits.sort((a, b) => strength(b) - strength(a));
return hits.slice(0, HIGHLIGHT.TOP).map((h) => ({
key: `highlight:${h.dim.id}:${h.value}`,
severity: SEV.OPPORTUNITY,
......@@ -141,14 +168,25 @@ export function computeHighlights(src: HighlightSource): Signal[] {
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)} 倍。`,
`占这一格的 ${pct(h.count, batch.size)},而同类治疗整体是 ${pct(baseCount.get(`${h.dim.id}:${h.value}`) ?? 0, baseline.size)}` +
// ⚠️ 大基数维度说"高出 N 个百分点"比说"1.4 倍"直观;小基数反过来
(h.ratio >= HIGHLIGHT.RATIO
? `,约 ${h.ratio.toFixed(1)} 倍。`
: `,高出 ${Math.round(h.points)} 个百分点。`),
defaultLabel: '不处理 = 照常整批发,不特殊对待',
options: [
{
label: `只圈这`,
label: `只圈这 ${h.count} `,
intent: ASSIGNMENT_INTENTS.COHORT_NARROW,
args: { personaTags: `${h.dim.id}:${h.value}` },
args: {
// 给工具用的筛选串(内部码)
personaTags: `${h.dim.id}:${h.value}`,
// ⭐ 给**人**看的两样:中文名 + 圈完有多少人。
// 🔴 缺了它们,替主管说的那句话会变成「只圈符合 referral_champion:yes 的人」——
// 内部码原样进对话框,正是 P0 花一整节要消灭的东西。
labelZh: h.zh,
count: h.count,
},
},
],
}));
......
......@@ -361,3 +361,71 @@ describe('给模型的事实', () => {
expect(JSON.stringify(f.要主管定的)).not.toMatch(/pending\.|expiry\.|cohort\./);
});
});
/**
* 🔴 大基数维度的偏离 —— 2026-08-12 本地实测补的判据。
*
* 只用「倍数 ≥2」时,这条判据**结构上打不着大类**:
* 朝阳公园的获客渠道是 口碑 33% / 集团营销 23% / 走入 22%,
* 口碑要够 2 倍得占到 66%(不可能),而能翻倍的小类在一格里凑不满 10 人。
* ⇒ 整个维度永远不会命中。
*/
describe('引导节点 · 画像亮点(大基数)', () => {
const dim = (id: string, nameZh: string, opts: Array<[string, string, number]>) => ({
id,
nameZh,
options: opts.map(([value, zh, count]) => ({ value, zh, count })),
});
test('🔴 33% → 48%(1.45 倍但高出 15 个百分点)→ 必须命中', () => {
const s = computeHighlights({
batch: { size: 200, dims: [dim('acquisition_channel', '获客渠道', [['wom', '口碑客户', 96]])] },
baseline: { size: 1000, dims: [dim('acquisition_channel', '获客渠道', [['wom', '口碑客户', 332]])] },
});
expect(s).toHaveLength(1);
// ⚠️ 大基数说"高出 N 个百分点"比说"1.4 倍"直观
expect(s[0]!.why).toContain('个百分点');
expect(s[0]!.why).not.toContain('倍');
});
test('⭐ 33% → 38%(只高 5 个百分点、1.15 倍)→ ⛔ 不命中', () => {
const s = computeHighlights({
batch: { size: 200, dims: [dim('acquisition_channel', '获客渠道', [['wom', '口碑客户', 76]])] },
baseline: { size: 1000, dims: [dim('acquisition_channel', '获客渠道', [['wom', '口碑客户', 332]])] },
});
expect(s).toEqual([]);
});
test('⭐ 小基数仍走倍数口径,措辞用「倍」', () => {
const s = computeHighlights({
batch: { size: 200, dims: [dim('rfm', '价值分群', [['vip', '重要价值', 30]])] },
baseline: { size: 1000, dims: [dim('rfm', '价值分群', [['vip', '重要价值', 50]])] },
});
expect(s[0]!.why).toContain('倍');
});
test('🔴 两种口径混在一起时,按偏离强度排序(⛔ 不能只按倍数,否则百分点项永远垫底)', () => {
const s = computeHighlights({
batch: {
size: 200,
dims: [
// 高出 25 个百分点(1.75 倍)—— 更值得看
dim('acquisition_channel', '获客渠道', [['wom', '口碑客户', 116]]),
// 2.1 倍但只高出 5.5 个百分点
dim('rfm', '价值分群', [['vip', '重要价值', 21]]),
],
},
baseline: {
size: 1000,
dims: [
dim('acquisition_channel', '获客渠道', [['wom', '口碑客户', 332]]),
dim('rfm', '价值分群', [['vip', '重要价值', 50]]),
],
},
});
expect(s.map((x) => x.key)).toEqual([
'highlight:acquisition_channel:wom',
'highlight:rfm:vip',
]);
});
});
......@@ -169,7 +169,17 @@ describe('引导节点 intent 路由', () => {
}
expect(intentToPrompt('cohort.widen')).toContain('重新排一版');
expect(intentToPrompt('anchor.switch')).toContain('重新排一版');
expect(intentToPrompt('cohort.narrow', { personaTags: 'rfm:vip' })).toContain('rfm:vip');
// 🔴 替主管说的那句话里**不许出现内部码**(这句会原样进对话框)
const narrow = intentToPrompt('cohort.narrow', {
personaTags: 'rfm:vip',
labelZh: '重要价值',
count: 62,
})!;
expect(narrow).toContain('重要价值');
expect(narrow).toContain('62 人');
expect(narrow).not.toContain('rfm:vip');
// ⚠️ 没有中文名就宁可不发 —— ⛔ 不许退化成把码念出来
expect(intentToPrompt('cohort.narrow', { personaTags: 'rfm:vip' })).toBeNull();
});
it('⛔ 两张表不能都认领同一个 intent(否则按钮行为取决于判断顺序)', () => {
......
......@@ -238,10 +238,15 @@ export function intentToPrompt(
args?: Record<string, unknown>,
): string | null {
switch (intent) {
case 'cohort.narrow':
return typeof args?.personaTags === 'string'
? `这批只圈符合「${args.personaTags}」的人,重新排一版`
: null;
case 'cohort.narrow': {
// 🔴 用**中文名 + 人数**,⛔ 不用内部筛选串:这句会原样出现在对话框里,
// 主管看到 `referral_champion:yes` 的第一反应是系统坏了。
// 人数带上,是因为「圈完还剩多少」正是他点这一下想知道的事。
const zh = typeof args?.labelZh === 'string' ? args.labelZh : null;
if (!zh) return null;
const n = typeof args?.count === 'number' ? `那 ${args.count} 人` : '这些人';
return `这批只圈「${zh}${n},重新排一版`;
}
case 'cohort.widen':
return '往后放一档再看看,或者不限时间档,重新排一版';
case 'anchor.switch':
......
......@@ -229,8 +229,21 @@ N 按「在岗 × 每天 15 通 × 时效」算,**只算新增、⛔ 不扣在
#### `highlight` 展开
判定统一为「本批占比 ÷ 基线 ≥ 2」;**基线取同治疗项的池子**,⛔ 不取全池
(种植的重要价值天然高于补牙,用全池会让种植永远命中)。
**基线取同治疗项的池子**,⛔ 不取全池(种植的重要价值天然高于补牙,用全池会让种植永远命中)。
命中条件是**两条取其一**(外加本批命中 ≥10 人、基线 ≥50 人):
| 口径 | 阈值 | 抓什么 |
|---|---|---|
| 倍数 | 本批占比 ÷ 基线 ≥ **2** | **小基数**维度的异常聚集 |
| 百分点 | 本批占比 − 基线 ≥ **12pp** | **大基数**维度的偏移 |
> 🔴 2026-08-12 本地实测补的第二条。只用倍数时,**结构上打不着大类**:
> 朝阳公园的获客渠道是 口碑 33% / 集团营销 23% / 走入 22%,
> 口碑要够 2 倍得占到 66%(现实里不可能);而能翻倍的小类(电商 2.3%)
> 在一格 71 人里连 10 个人都凑不出 ⇒ **整个维度永远不会命中**。
⚠️ 措辞跟着口径走:大基数说「高出 N 个百分点」,小基数说「约 N 倍」。
| 亮点 | 选项 |
|---|---|
......@@ -240,6 +253,10 @@ N 按「在岗 × 每天 15 通 × 时效」算,**只算新增、⛔ 不扣在
| 折扣锚点 | 福利定价参考上次折扣 |
| 获客渠道集中 | 统一话术口径 |
⚠️ **折扣锚点暂未纳入**:它的值是「最低折扣率 + 日期/项目」,是**连续量**,没有闭集取值 ——
「占比是基线的几倍」无从谈起。要用它得先定分桶(如 ≥9折 / 8–9折 / <8折),那是产品判断。
> 💡 它更适合做成**统计事实**(「这批人历史最深折扣中位数 X 折」,供福利定价参考)而不是亮点。
### 交互形态:点击为主,自由输入兜底
**每个选项都是一个可直接点击执行的动作**,⛔ 不是让主管照着打字。
......
......@@ -215,6 +215,31 @@ export const PERSONA_TAG_FILTER_DIMS: PersonaTagFilterDim[] = [
],
},
{
/**
* 获客渠道 —— ⭐ 2026-08-12 补进来,为的是**分配时的画像亮点 + 圈人**:
* 「这一格里口碑客户是平时的 2.6 倍」这类结论要能算,就得先能取到它的分布。
* ⚠️ `hidden`:前端筛选面板暂不展示(面板已经不短了),但工具可以点名用 ——
* hidden 的语义是「面板不展示」,⛔ 不是「不能用」。
* ⚠️ 值域取自 persona-feature-specs 的 `acquisition_channel.labelValues`,
* ⛔ 别在这里另立一套中文:两份必然漂。
*/
key: 'acquisition_channel',
nameZh: '获客渠道',
dataPath: 'channel',
hidden: true,
options: [
{ value: 'walk_in', zh: '走入', hint: '直接上门' },
{ value: 'word_of_mouth', zh: '口碑客户', hint: '老客户推荐来的' },
{ value: 'group_sales', zh: '集团销售渠道' },
{ value: 'group_marketing', zh: '集团营销渠道' },
{ value: 'regional_marketing', zh: '地区营销渠道' },
{ value: 'ecommerce', zh: '电商平台' },
{ value: 'social_media', zh: '自媒体网络' },
{ value: 'internal_staff', zh: '内部员工' },
{ value: 'other', zh: '其他' },
],
},
{
key: 'referral_champion',
nameZh: '转介绍达人',
dataPath: 'type',
......
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