Commit a01716f9 by luoqi

feat(简报): 第②句补「跟我们熟不熟」—— 联系过几次 + 被挂断过几次

产品 2026-08-04:「第二句是他跟我们以前的关系亲密度是什么样的,就是联系他
多不多,然后有没有过不好的体验」。原来第②句只写"最近一次联系是什么事",
"多不多"和"不好的体验"整个缺失。

「不好的体验」只有一种形态(全量查证):
- `type` 只有 常规回访/咨询回访/术后回访 —— 没有投诉类
- `result` 自由文本 3 万行 6765 种取值,长尾里 投诉/不满/纠纷/态度/退费 **一条都没有**
- 真负面只有**「挂断」**:688 次 / 595 位患者(占有回访记录患者的 11.5%)
️ 第一遍扫的时候「无不适」被"不适"接住,假阳性 5816 条 —— 负面词表必须排除否定式

三个刻意的设计:
① 总次数**单独聚合**:回访历史只给最近 3 条,让模型拿那 3 条数总量必然低估
   (实测 1131 位患者被联系过 10 次以上,全会被说成"联系过 3 次");
② 挂断为 0 时**整段不出现**。 不是输出「被挂断 0 次」——一旦出现那个 0,
   模型必然翻译成"沟通一直顺畅"。**没有记录 ≠ 关系好**,那是拿没证据当反面用
   (与 noTag 铁律同一件事);
③ schema 与 prompt **必须同时改** —— 只改一边 = 定义了但没喂,静默失效。
   单独一条测试锁两边。

promptVersion → 2026-08-07-closeness(不升的话老患者读到的还是旧简报,
而本地怎么测都是新的 —— 那种"改完看不到效果"最难查)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parent e9387833
...@@ -86,6 +86,40 @@ export interface DraftRecallBriefInput { ...@@ -86,6 +86,40 @@ export interface DraftRecallBriefInput {
reachedText: string | null; reachedText: string | null;
}>; }>;
/** /**
* **关系亲密度** —— 第②句的另一半(产品 2026-08-04 评审原话:
* 「第二句话是他跟我们以前的关系亲密度是什么样的,**就是联系他多不多**」)。
*
* ⚠️ 上面的 `returnVisitHistory` 只给**最近 3 条**(喂全量会撑爆上下文,也没必要)——
* 所以"多不多"必须单独给个数,⛔ 别让模型去数那 3 条推总量,那必然低估。
* ⚠️ 只统计**已发生**的,与 returnVisitHistory 同一条判据(未来的预约回访不算联系过)。
*/
contactStats: {
/// 已发生的回访总条数;0 = 从没联系过
total: number;
/// 最早一次距今(如 3 年前);total=0 时为 null
sinceText: string | null;
};
/**
* 🔴 **被挂断过几次** —— 回访数据里**唯一有据**的"不好的体验"。0 = 没有记录。
*
* ── 为什么只有这一个(2026-08-07 全量查证)──────────────────────
* 产品要第②句带上「有没有过不好的体验」,而这一句的依据只看回访数据。实测:
* · `type` 只有三种:常规回访 / 咨询回访 / 术后回访 —— **没有投诉类**;
* · `result` 是自由文本(3 万行 6765 种取值),扫遍长尾:
* 投诉 / 不满 / 纠纷 / 态度 / 退费 / 抱怨 —— **一条都没有**;
* 真正的负面只有**「挂断」一种形态**(688 次 / 595 位患者,占有回访记录患者的 11.5%)。
* ⚠️ 第一遍扫的时候「无不适」被"不适"这个词接住了,假阳性 5816 条 ——
* 负面词表必须排除否定式(无不适 / 无异常 / 不疼),⛔ 别再照着直觉拍词表。
*
* ⭐ 「挂断」是**关系信号**不是接通率:患者接了、听出是谁、然后把电话挂了。
* 与 EXECUTION_OUTCOME 里的「秒挂」是同一件事(那边已判为不成功类 + 30 天抑制)。
* ⛔⛔ **没被挂断 ≠ 关系好**,所以 0 的时候第②句**什么都不说**,
* ⛔ 绝不许输出「没有不良记录」「关系良好」—— 那是拿"没证据"当"反面"用,
* 与 noTag 那条铁律是同一件事(违了就是凭空造事实)。
* ⛔ 也不许拿「未接」当不好的体验:没接通的原因太多(换号 / 在忙 / 拦截),那是推测不是事实。
*/
hangUpCount: number;
/**
* 本批次福利(第④点的最高优先勾子)—— 主管在分配时写的文案原文;没配 = null。 * 本批次福利(第④点的最高优先勾子)—— 主管在分配时写的文案原文;没配 = null。
* *
* ⚠️ 只在批次仍 `confirmed` 时给(与话术带福利同一个判据,⛔ 别另立标准)。 * ⚠️ 只在批次仍 `confirmed` 时给(与话术带福利同一个判据,⛔ 别另立标准)。
......
...@@ -2,7 +2,12 @@ import type { DraftRecallBriefInput } from './input.types'; ...@@ -2,7 +2,12 @@ import type { DraftRecallBriefInput } from './input.types';
// ⚠️ 换版本号会让缓存整体失效(这是想要的):输出从一句话变四点,旧缓存不该再命中 // ⚠️ 换版本号会让缓存整体失效(这是想要的):输出从一句话变四点,旧缓存不该再命中
/// ⚠️ 改了 system prompt / 段名就要升版本,否则旧缓存命中,改动看不见(-hook 这版:把「本批」清出 hook) /// ⚠️ 改了 system prompt / 段名就要升版本,否则旧缓存命中,改动看不见(-hook 这版:把「本批」清出 hook)
export const DRAFT_RECALL_BRIEF_PROMPT_VERSION = 'draft_recall_brief@2026-08-06-result'; /**
* ⚠️ **改了 prompt / schema 就必须动这个版本号** —— 它是简报缓存的失效键。
* 不动的话老患者读到的还是旧简报,而你在本地怎么测都是新的(改完看不到效果,最难查)。
* 2026-08-07 `-closeness`:第②句补上"联系过几次 / 被挂断过几次"(产品 08-04 评审)。
*/
export const DRAFT_RECALL_BRIEF_PROMPT_VERSION = 'draft_recall_brief@2026-08-07-closeness';
export const DRAFT_RECALL_BRIEF_SYSTEM = `你是牙科诊所客服主管,正在给即将打电话的客服做交底。下面给你这个患者的「本次召回原因 + 历史治疗 + 画像 + 回访历史 + 福利勾子」。请提炼成**四点召回简报**,每点一句话、各管一件事。 export const DRAFT_RECALL_BRIEF_SYSTEM = `你是牙科诊所客服主管,正在给即将打电话的客服做交底。下面给你这个患者的「本次召回原因 + 历史治疗 + 画像 + 回访历史 + 福利勾子」。请提炼成**四点召回简报**,每点一句话、各管一件事。
...@@ -150,6 +155,24 @@ export function buildDraftRecallBriefPrompt(input: DraftRecallBriefInput): strin ...@@ -150,6 +155,24 @@ export function buildDraftRecallBriefPrompt(input: DraftRecallBriefInput): strin
.join('\n') .join('\n')
: ' (此前无回访记录)'; : ' (此前无回访记录)';
/**
* 🔴 **熟不熟**(第②点的另一半)—— 产品 2026-08-04:「联系他多不多、有没有过不好的体验」。
*
* ⚠️ 必须**单独渲染成一行**:上面 `visitLines` 只有最近 3 条,模型拿它数不出总量
* (实测 1131 位患者被联系过 10 次以上,只看 3 条会一律说成"联系过 3 次")。
* ⛔ **挂断为 0 时整段不出现** —— 出现一句「被挂断 0 次」,模型就会顺手翻译成
* 「沟通一直顺畅」,那是拿"没证据"当"反面"用(与 noTag 铁律同一件事)。
* ⚠️ 行首不带字段名:段名会被当措辞抄进输出(「备注」「本批」都这么泄漏过)。
*/
const closenessLine =
input.contactStats.total === 0
? ' (此前从没联系过)'
: [
` - 一共联系过 ${input.contactStats.total} ` +
(input.contactStats.sinceText ? `,最早是 ${input.contactStats.sinceText}` : ''),
...(input.hangUpCount > 0 ? [` - 其中被挂断 ${input.hangUpCount} `] : []),
].join('\n');
// 福利勾子(第④点最高优先)—— ⛔ 只能说这段原文包含的内容,见 system prompt ⓪ // 福利勾子(第④点最高优先)—— ⛔ 只能说这段原文包含的内容,见 system prompt ⓪
// //
// ⚠️ 段名叫「福利勾子」**不是**「本批福利」:模型会把段名当措辞抄进 hook, // ⚠️ 段名叫「福利勾子」**不是**「本批福利」:模型会把段名当措辞抄进 hook,
...@@ -168,7 +191,11 @@ ${personaLines} ...@@ -168,7 +191,11 @@ ${personaLines}
福利勾子(切入依据⓪,最高优先;⛔ 只能说原文包含的内容,⛔ 不许说「本批/这批」):${benefitLine} 福利勾子(切入依据⓪,最高优先;⛔ 只能说原文包含的内容,⛔ 不许说「本批/这批」):${benefitLine}
回访历史(第②点唯一依据,新到旧;⛔ 输出里不许出现「备注」二字,直接说事): 跟我们熟不熟(第②点前半;⛔ 没写「挂断」就是没有这条记录,不许说成"关系良好"):
${closenessLine}
回访历史(第②点后半,只给最近 3 条,新到旧;⛔ 输出里不许出现「备注」二字,直接说事;
⛔ 别拿这几条数总次数 —— 总数看上面那段):
${visitLines} ${visitLines}
医生计划/建议/医嘱(切入依据①;优先引用 原话:「…」): 医生计划/建议/医嘱(切入依据①;优先引用 原话:「…」):
......
...@@ -23,9 +23,20 @@ export const DraftRecallBriefSchema = z.object({ ...@@ -23,9 +23,20 @@ export const DraftRecallBriefSchema = z.object({
history: z history: z
.string() .string()
.min(2) .min(2)
.max(60) .max(80)
.describe( .describe(
'② **上次联系是什么事、结果怎么样**(≤30 字最佳)。只用给定的回访历史,**直接把那件事说出来**:' + '② **跟我们熟不熟 + 上次联系是什么事、结果怎么样**(≤40 字最佳)。' +
// 🔴 2026-08-04 产品评审:第二句要回答「他跟我们以前的关系亲密度是什么样的,
// 就是联系他多不多,然后有没有过不好的体验」。原来只写了"最近一次",少了"多不多"。
'\n🔴 **先给"熟不熟"再给"上次那件事"**,两件都要:' +
'\n · **熟不熟**看 `contactStats`:联系过几次、从多久前开始。' +
'例:"两年里联系过 7 次";只有 1 次就说"只联系过 1 次";' +
'`total=0` 说"此前无回访记录"。⛔ 别去数下面那几条回访自己算总数 —— 那里只给了最近 3 条。' +
'\n · **不好的体验**看 `hangUpCount`:>0 才说,如实说"被挂断过 N 次";' +
'⛔⛔ **等于 0 时什么都不说** —— 绝不许写"没有不良记录""关系良好""沟通顺畅":' +
'没有记录**不等于**关系好,那是拿"没证据"当"反面"用,是凭空造事实。' +
'⛔ 也不许把"未接"说成不好的体验(没接通的原因太多,那是推测)。' +
'\n · **上次那件事**用下面的回访历史,' +
'这次是干什么的(半年洁牙提醒 / 种植潜客…)+ 结果(未接 / 无不适 / 约了时间…),有几样说几样。' + '这次是干什么的(半年洁牙提醒 / 种植潜客…)+ 结果(未接 / 无不适 / 约了时间…),有几样说几样。' +
'🔴 ⛔ **不许说「没留下结果」「无记录」这种含糊话** —— 结果栏通常是有东西的' + '🔴 ⛔ **不许说「没留下结果」「无记录」这种含糊话** —— 结果栏通常是有东西的' +
'(实测:库里写着"诊后回访未接"却被说成"没留下结果");有什么说什么。' + '(实测:库里写着"诊后回访未接"却被说成"没留下结果");有什么说什么。' +
......
...@@ -359,6 +359,37 @@ export class RecallBriefOrchestrator { ...@@ -359,6 +359,37 @@ export class RecallBriefOrchestrator {
const benefitText = const benefitText =
plan.assignment?.status === 'confirmed' ? readBenefitText(plan.assignment.attributes) : null; plan.assignment?.status === 'confirmed' ? readBenefitText(plan.assignment.attributes) : null;
/**
* 🔴 **关系亲密度**(第②句的另一半)—— 产品 2026-08-04:「联系他多不多、有没有过不好的体验」。
*
* ⚠️ 必须**单独聚合**,⛔ 不能从上面那 3 条 `visits` 里数:
* 那个查询 `take: 3`(喂全量会撑爆上下文),拿它算总量必然低估 ——
* 实测有 1131 位患者被联系过 10 次以上,只看 3 条会一律说成"联系过 3 次"。
* ⚠️ 与 returnVisitHistory **同一条判据**:只算**已发生**的(未来的预约回访不是"联系过")。
* ⭐ 一次 SQL 出两个数,⛔ 别拆成两次查询 —— 拆开必然有一天两个口径漂移。
*/
const [stats] = await this.prisma.$queryRaw<
Array<{ total: bigint; since: Date | null; hangups: bigint }>
>`
SELECT count(*) AS total,
min(task_date) AS since,
count(*) FILTER (WHERE result LIKE '%挂断%') AS hangups
FROM patient_return_visits
WHERE host_id = ${plan.hostId}::uuid
AND tenant_id = ${plan.tenantId}
AND patient_id = ${plan.patientId}::uuid
AND task_date IS NOT NULL
AND task_date <= now()
`;
const totalContacts = Number(stats?.total ?? 0);
const contactStats = {
total: totalContacts,
sinceText:
totalContacts > 0 && stats?.since
? `${daysText(Math.floor((nowMs - stats.since.getTime()) / 86_400_000))}前`
: null,
};
const input: DraftRecallBriefInput = { const input: DraftRecallBriefInput = {
patientNameMasked: maskName(patient?.name ?? null) ?? '该患者', patientNameMasked: maskName(patient?.name ?? null) ?? '该患者',
persona: personaItems, persona: personaItems,
...@@ -367,6 +398,8 @@ export class RecallBriefOrchestrator { ...@@ -367,6 +398,8 @@ export class RecallBriefOrchestrator {
reviewAnchors, reviewAnchors,
doctorGuidance, doctorGuidance,
returnVisitHistory, returnVisitHistory,
contactStats,
hangUpCount: Number(stats?.hangups ?? 0),
benefitText, benefitText,
}; };
......
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { buildDraftRecallBriefPrompt } from '../src/modules/ai/calls/draft-recall-brief/prompt';
import type { DraftRecallBriefInput } from '../src/modules/ai/calls/draft-recall-brief/input.types';
/**
* 🔴 简报第②句 —— 「**跟我们熟不熟** + 上次那件事」。
*
* ── 由来(2026-08-04 产品评审原话)────────────────────────────────
* 「你应该把它严谨,明确写成三句话。第一句这个客户对我们的价值是什么?
* **第二句是他跟我们以前的关系亲密度是什么样的,就是联系他多不多,
* 然后有没有过不好的体验**;第三是这一次要关怀他什么、让他来干什么。」
* 原来第②句只写了"最近一次联系是什么事",**"多不多"和"不好的体验"整个缺失**。
*
* ── 「不好的体验」只有一种形态(2026-08-07 全量查证)───────────────
* 产品定了这一句只看回访数据。实测这份数据里:
* · `type` 只有 常规回访 / 咨询回访 / 术后回访 —— 没有投诉类;
* · `result` 自由文本 3 万行 6765 种取值,长尾里 投诉/不满/纠纷/态度/退费 **一条都没有**;
* · 真正的负面只有**「挂断」**:688 次 / 595 位患者(占有回访记录患者的 11.5%)。
* ⚠️ 第一遍扫的时候「无不适」被"不适"接住,假阳性 5816 条 —— 负面词表必须排除否定式。
*
* ⛔ 这里锁的三件事,做错了都不会报错:
* ① 总次数必须**单独喂**(回访历史只给最近 3 条,拿它数必然低估);
* ② 挂断为 0 时**整段不出现**(出现「被挂断 0 次」模型就会翻译成"沟通顺畅");
* ③ schema 描述与 prompt 渲染**必须同时存在** —— 只改一边 = 定义了但没喂。
*/
const BASE: DraftRecallBriefInput = {
patientNameMasked: '王*',
persona: [{ label: '价值分群', value: '重要发展客户' }],
treatmentHistory: [{ category: '洁牙', count: 2 }],
reasons: [
{
subLabel: '缺失牙未启动修复',
source: 'diagnosis',
diagnosis: '牙列缺损',
tooth: '47',
daysSinceText: '131 天(4 个月)',
expectedCategories: ['种植'],
},
],
reviewAnchors: { lastVisitText: '8 个月前', lastCleaningText: null },
doctorGuidance: [],
returnVisitHistory: [
{
atText: '2 个月前',
type: '常规回访',
topicText: '半年洁牙提醒',
resultText: '未接',
reachedText: null,
},
],
contactStats: { total: 7, sinceText: '2 年' },
hangUpCount: 0,
benefitText: null,
};
describe('简报第②句 —— 跟我们熟不熟', () => {
test('🔴 总联系次数必须单独喂 —— ⛔ 不能让模型去数那最近 3 条', () => {
const p = buildDraftRecallBriefPrompt(BASE);
expect(p).toContain('一共联系过 7 次');
expect(p).toContain('最早是 2 年');
// 明确告诉模型别拿下面那几条数总量(实测 1131 位患者被联系过 10 次以上)
expect(p).toContain('别拿这几条数总次数');
});
test('🔴🔴 被挂断过 → 如实说;⛔ 没挂断过 → 整段不出现', () => {
expect(buildDraftRecallBriefPrompt({ ...BASE, hangUpCount: 3 })).toContain('被挂断 3 次');
// ⛔ 关键:0 的时候连"挂断"两个字都不许出现 ——
// 一旦出现「被挂断 0 次」,模型就会顺手翻译成「沟通一直顺畅」,那是拿没证据当反面用
const zero = buildDraftRecallBriefPrompt(BASE);
expect(zero).not.toContain('被挂断');
expect(zero).not.toContain('0 次');
});
test('⭐ 从没联系过 → 显式说"没联系过",⛔ 不留白(留白模型会脑补)', () => {
const p = buildDraftRecallBriefPrompt({
...BASE,
contactStats: { total: 0, sinceText: null },
returnVisitHistory: [],
});
expect(p).toContain('此前从没联系过');
expect(p).not.toContain('一共联系过');
});
test('⛔ 这一段的行首不许带字段名 —— 段名会被当措辞抄进输出', () => {
// 前科:`,备注:xxx` 让模型输出「常规回访,备注半年洁牙提醒」;
// 段名「本批福利」让它说出「本批有 8 折」。同一个病。
const p = buildDraftRecallBriefPrompt({ ...BASE, hangUpCount: 2 });
expect(p).not.toContain('contactStats');
expect(p).not.toContain('hangUpCount');
});
test('🔴🔴 schema 描述与 prompt 渲染必须同时存在 —— 只改一边 = 定义了但没喂', () => {
const SCHEMA = readFileSync(
join(__dirname, '../src/modules/ai/calls/draft-recall-brief/schema.ts'),
'utf8',
);
// schema 告诉模型"该说什么",prompt 决定"它看不看得到" —— 缺任一边都静默失效
expect(SCHEMA).toMatch(/contactStats/);
expect(SCHEMA).toMatch(/hangUpCount/);
expect(SCHEMA).toMatch(/等于 0 时什么都不说/);
const PROMPT = readFileSync(
join(__dirname, '../src/modules/ai/calls/draft-recall-brief/prompt.ts'),
'utf8',
);
expect(PROMPT).toMatch(/input\.contactStats\.total/);
expect(PROMPT).toMatch(/input\.hangUpCount > 0/);
});
});
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