Commit 469ef2c9 by luoqi

fix(ai): 历史联系摘要只讲回访事实,不再对到诊/失联下结论

生产实例(陈芃霖 BJ0A094257):
  摘要「上次**有效到诊**为2023年8月,已超一年未复诊」
  实际  接诊记录末次 2025-12-20,画像也写着「末诊207天」—— 差 2 年 4 个月
同句另两处错:「已超一年未复诊」实为 7 个月;「近3个月涂氟邀约」那条回访实为 226 天前。

源头是这条回访记录:
  2023-08-15 | 常规回访 | 已回访 | 已完成 | 内容:复查 | 结果:8月已就诊
诊所客服 2023 年写的一句备注,被模型升格成了患者当前的到诊状态。「有效到诊」这个词
源数据里没有,是模型自己造的。

── 根因 ──
本 AiCall 的输入**只有回访记录,没有任何到诊数据**(见 input.types)。模型只能:
  ① 从回访备注文字里猜到诊 —— 「8月已就诊」被当成事实
  ② 用"没有回访记录"推"没来过" —— 但回访是诊所主动打电话,患者自己来院不产生回访任务
两条路都错。而旧 prompt 不但没拦,关键要求 4 明写「优先突出…长期未到诊」,
示例里还有「此前半年 5 次回访无到诊」在**示范这个错法**。

── 生产规模 ──
795 条 recall_history 摘要,162 条断言"失联/沉睡/未到诊",
其中 88 条(54%)患者实际 180 天内来过,18 条 90 天内来过。
最离谱一条:「自2018年至今无到诊或有效联系,属长期失联状态,建议优先核实联系方式」
—— 该患者 2026-06-24 刚到诊,上个月的事。客服照这个打电话会很尴尬。

── 改法(按业务口径「这里只摘要回访事实」)──
不给它喂到诊数据,而是划清职责:到诊状态有专门的地方出(画像卡「末诊 N 天」按真实接诊算)。
  1) prompt 加【职责边界】硬约束:输入里没有到诊数据,禁止判断是否到诊/失联/沉睡;
     结果字段里的"已就诊"只能当那次回访的记录引述,不得当成到诊状态;
     明确掐断"没有回访 ≠ 没来过"这条推理链
  2) 修掉关键要求 4 和示例里示范错法的句子,新增反例段(把三条真实事故句列为 ✗)
  3) 补程序算好的 daysAgo —— 相对时间不让模型自己减日期
  4) 版本 @2026-07-26-f → @2026-07-27-g

── 这是同一个错误第三次以不同形式出现 ──
07-24 是"未来 vs 过去"(未来排程被说成漏做),今天是"回访 vs 到诊"和"距今多久"。
当初立的纪律是「能程序算的事实全算好、LLM 只润色」,但只补了一个轴,
于是同类错误换个轴又来。本次把职责边界和 daysAgo 都用测试锁住(新增 8 例)。

注:已生成的 795 条摘要是缓存,需另行重刷才会用新 prompt;
    本次先删了陈芃霖那一条供业务观察重新生成效果。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parent 0165bc11
...@@ -37,6 +37,16 @@ export interface DraftRecallSummaryInput { ...@@ -37,6 +37,16 @@ export interface DraftRecallSummaryInput {
* 摘要必须先回答这一条,再讲整体趋势。 * 摘要必须先回答这一条,再讲整体趋势。
*/ */
isLatestPast: boolean; isLatestPast: boolean;
/**
* ⭐ 距今天数(taskDate 到 today;未来排程为负数,无日期为 null)。
* 同 isFuture / isLatestPast 一样**程序算好**,不让 LLM 自己减日期。
*
* 生产事故背景(2026-07-27):模型自己算相对时间,把 2025-12-10 的回访写成
* 「近3个月」(实为 7.5 个月前)、把末诊 7 个月写成「已超一年未复诊」。
* 「能程序算的事实全算好、LLM 只润色」—— 与 isFuture 同一条纪律,只是那次补的是
* "未来 vs 过去"这个轴,漏了"距今多久"这个轴,于是同类错误换个轴又来一遍。
*/
daysAgo: number | null;
treatmentItems: string | null; // 关联治疗项(如 种植) treatmentItems: string | null; // 关联治疗项(如 种植)
followContent: string | null; // 回访内容(术后复查 / 洁牙提醒 / 活动邀约…) followContent: string | null; // 回访内容(术后复查 / 洁牙提醒 / 活动邀约…)
result: string | null; // 回访结果 result: string | null; // 回访结果
......
...@@ -8,8 +8,11 @@ import type { DraftRecallSummaryInput } from './input.types'; ...@@ -8,8 +8,11 @@ import type { DraftRecallSummaryInput } from './input.types';
* 2026-07-26-f ⭐ 摘要先说「最近一次」(程序标注),再讲整体;并明写"源系统只记事由/ * 2026-07-26-f ⭐ 摘要先说「最近一次」(程序标注),再讲整体;并明写"源系统只记事由/
* 结果标签、没有逐字沟通内容",没记就说没记,禁止补写沟通细节。 * 结果标签、没有逐字沟通内容",没记就说没记,禁止补写沟通细节。
* 来源:业务可用性测试「暂时没有办法详细的看到具体一次沟通什么内容」。 * 来源:业务可用性测试「暂时没有办法详细的看到具体一次沟通什么内容」。
* 2026-07-27-g ⭐ 加【职责边界】:本卡**只讲回访本身**,输入里没有任何到诊数据,
* 禁止对"是否到诊 / 失联 / 沉睡"下结论;并补程序算好的 daysAgo。
* 修生产事故,见下方职责边界段。
*/ */
export const DRAFT_RECALL_SUMMARY_PROMPT_VERSION = 'draft_recall_summary@2026-07-26-f'; export const DRAFT_RECALL_SUMMARY_PROMPT_VERSION = 'draft_recall_summary@2026-07-27-g';
export const DRAFT_RECALL_SUMMARY_SYSTEM = `你是牙科诊所客服主管。下面是一个患者的历史联系/回访记录(结构化、看着累)。请提炼成**重点**,让接手客服**一眼抓住关键**,而不是逐条复述。 export const DRAFT_RECALL_SUMMARY_SYSTEM = `你是牙科诊所客服主管。下面是一个患者的历史联系/回访记录(结构化、看着累)。请提炼成**重点**,让接手客服**一眼抓住关键**,而不是逐条复述。
...@@ -17,7 +20,7 @@ export const DRAFT_RECALL_SUMMARY_SYSTEM = `你是牙科诊所客服主管。下 ...@@ -17,7 +20,7 @@ export const DRAFT_RECALL_SUMMARY_SYSTEM = `你是牙科诊所客服主管。下
1. 输出中文,不要换行/列表/Markdown,总长 ≤70 字。 1. 输出中文,不要换行/列表/Markdown,总长 ≤70 字。
2. **⭐ 先说「最近一次」,再说整体**:开头必须交代标了【最近一次·已发生】的那一条——什么时候、什么事由、什么结果(客服接手第一个想知道的就是"上次聊到哪儿了")。其余记录压成半句趋势即可。没有任何【最近一次·已发生】条目时(全是未来排程/无日期),直接讲整体,不要硬凑。 2. **⭐ 先说「最近一次」,再说整体**:开头必须交代标了【最近一次·已发生】的那一条——什么时候、什么事由、什么结果(客服接手第一个想知道的就是"上次聊到哪儿了")。其余记录压成半句趋势即可。没有任何【最近一次·已发生】条目时(全是未来排程/无日期),直接讲整体,不要硬凑。
3. **是"重点提炼"不是"总结罗列"**:整体那半句抓最值得注意的一点——比如"近期联系密但未到诊""多次未接通""有种植意向待跟进"。**不要**把所有回访类型都列出来。 3. **是"重点提炼"不是"总结罗列"**:整体那半句抓最值得注意的一点——比如"近期联系密但未到诊""多次未接通""有种植意向待跟进"。**不要**把所有回访类型都列出来。
4. 优先突出**对下一步有用的信号**(还没接通 / 已表达意向 / 长期未到诊 / 刚成交别打扰 等)。 4. 优先突出**对下一步有用的信号**(还没接通 / 已表达意向 / 多次未留结果 / 刚成交别打扰 等)。
5. 客观,**禁止**承诺疗效、编造、给医疗建议。 5. 客观,**禁止**承诺疗效、编造、给医疗建议。
6. 严格按 JSON schema 输出,只有一个 key:summary。 6. 严格按 JSON schema 输出,只有一个 key:summary。
...@@ -28,6 +31,22 @@ export const DRAFT_RECALL_SUMMARY_SYSTEM = `你是牙科诊所客服主管。下 ...@@ -28,6 +31,22 @@ export const DRAFT_RECALL_SUMMARY_SYSTEM = `你是牙科诊所客服主管。下
- 某条只有事由没有结果(常见)→ 如实说"未留结果 / 结果未记录",**不要**推断当时聊了什么、患者什么态度。 - 某条只有事由没有结果(常见)→ 如实说"未留结果 / 结果未记录",**不要**推断当时聊了什么、患者什么态度。
- **禁止**输出"患者表示…""沟通中提到…"这类原话式表述,除非结果字段里原文就这么写。 - **禁止**输出"患者表示…""沟通中提到…"这类原话式表述,除非结果字段里原文就这么写。
# ⭐ 职责边界(硬约束,违反即错)
本卡叫「历史联系」,**只讲回访这件事本身**:哪次联系了、事由是什么、结果记了什么。
输入里的全部内容就是回访任务记录 —— **没有任何到诊/就诊/复诊数据**,一条都没有。
- **禁止**判断患者是否到诊、是否失联、是否沉睡、多久没来院。
这些结论有专门的地方出(画像卡的「末诊 N 天」是按真实接诊记录算的),这里说了必错。
- 回访结果字段里出现"已就诊""已到院"这类字,那是**当时那次回访记下的一句话**,
只能当"该次回访的结果"引述(如"3月回访记录已就诊"),**不得**当成患者的到诊状态,
更不得据此推断"上次到诊是X年X月"。
- "没有回访记录" ≠ "没来过" —— 回访是诊所主动打电话,患者自己来院不产生回访任务。
**禁止**用"回访少/没回访"推出"长期未到诊/失联"。
生产事故背景(2026-07-27):795 条摘要里 162 条断言"失联/沉睡/未到诊",
其中 88 条(54%)患者实际 180 天内来过。最离谱一条写"自2018年至今无到诊或有效联系,
属长期失联状态",而该患者上个月刚到诊。另一条把 2023 年回访结果里的"8月已就诊"
写成"上次有效到诊为2023年8月",实际末次接诊是 2025-12-20,差 2 年 4 个月。
# ⭐ 时间纪律(硬约束,违反即错) # ⭐ 时间纪律(硬约束,违反即错)
记录里**包含诊所已排好、但还没到日子的未来任务**,这类条目已用【未来排程·尚未到期】标出。 记录里**包含诊所已排好、但还没到日子的未来任务**,这类条目已用【未来排程·尚未到期】标出。
- 【未来排程·尚未到期】的条目**绝不能**说成"未执行 / 未回访 / 漏做 / 缺失 / 需补做 / 需优先跟进该次"—— - 【未来排程·尚未到期】的条目**绝不能**说成"未执行 / 未回访 / 漏做 / 缺失 / 需补做 / 需优先跟进该次"——
...@@ -35,12 +54,20 @@ export const DRAFT_RECALL_SUMMARY_SYSTEM = `你是牙科诊所客服主管。下 ...@@ -35,12 +54,20 @@ export const DRAFT_RECALL_SUMMARY_SYSTEM = `你是牙科诊所客服主管。下
- 只有标了【已过期·未执行】的条目才算真正漏做。 - 只有标了【已过期·未执行】的条目才算真正漏做。
- "最近一次""最新一次"这类说法**只能指标了【最近一次·已发生】的那一条**,不要用未来排程当"最近一次"。 - "最近一次""最新一次"这类说法**只能指标了【最近一次·已发生】的那一条**,不要用未来排程当"最近一次"。
- 判断哪条是未来、哪条已过期、哪条是最近一次**已经替你算好了**,直接用标记,不要自己比日期。 - 判断哪条是未来、哪条已过期、哪条是最近一次**已经替你算好了**,直接用标记,不要自己比日期。
- **距今多久也算好了**:每条带「N 天前」。要说"近 X 个月""近期""半年多"时**必须**按这个数换算
(30 天≈1 个月),**不要**自己拿日期减。写"近3个月"而记录其实是 226 天前,就是错。
# 示例(风格参考,不要照抄) # 示例(风格参考,不要照抄)
- "最近一次 4 月术后回访已完成、无不适;近 3 次活动邀约均未接通,建议换企微触达。" - "最近一次 4 月术后回访已完成、无不适;近 3 次活动邀约均未接通,建议换企微触达。"
- "最近一次 2 月复查已约到诊;此前半年 5 次回访无到诊,流失风险仍高。" - "最近一次 2 月复查回访已约上;此前半年 5 次回访多未留结果,建议本次确认进度。"
- "最近一次 5 月复查洁牙提醒未执行、未留结果;近一年 3 次回访均未跟进到位。" ← 只有事由没结果的正确写法 - "最近一次 5 月复查洁牙提醒未执行、未留结果;近一年 3 次回访均未跟进到位。" ← 只有事由没结果的正确写法
- "最近一次 4 月术后回访已完成,10 月 9 日已排复查洁牙,暂无需额外动作。" ← 未来排程的正确写法`; - "最近一次 4 月术后回访已完成,10 月 9 日已排复查洁牙,暂无需额外动作。" ← 未来排程的正确写法
# 反例(**不要**这么写)
- ✗ "上次有效到诊为2023年8月,已超一年未复诊" ← 输入里没有到诊数据,凭空断言
- ✗ "自2018年至今无到诊或有效联系,属长期失联状态" ← 用"没有回访"推"没来过"
- ✗ "自2024年4月到诊后已沉睡超18个月" ← 同上,且时长是自己算的
- ✓ "2023年8月回访记录已就诊" ← 同一条源数据,这样引述才对`;
export function buildDraftRecallSummaryPrompt(input: DraftRecallSummaryInput): string { export function buildDraftRecallSummaryPrompt(input: DraftRecallSummaryInput): string {
const lines = const lines =
...@@ -53,8 +80,13 @@ export function buildDraftRecallSummaryPrompt(input: DraftRecallSummaryInput): s ...@@ -53,8 +80,13 @@ export function buildDraftRecallSummaryPrompt(input: DraftRecallSummaryInput): s
// 最近一次要展开讲,给它更长的正文预算;其余条目只是背景趋势,40 字够了。 // 最近一次要展开讲,给它更长的正文预算;其余条目只是背景趋势,40 字够了。
// (源里偶有几百字的方案沟通记录,一刀切 40 字会把唯一一条有信息量的记录砍没) // (源里偶有几百字的方案沟通记录,一刀切 40 字会把唯一一条有信息量的记录砍没)
const budget = it.isLatestPast ? 160 : 40; const budget = it.isLatestPast ? 160 : 40;
// 距今天数也程序算好(见 input.types.daysAgo)—— 模型自己减日期会算错。
// 未来排程已有【未来排程·尚未到期】标记,不再重复报负数天数。
const ago =
it.daysAgo != null && it.daysAgo >= 0 ? `${it.daysAgo}天前` : null;
const parts = [ const parts = [
it.taskDate ?? '—', it.taskDate ?? '—',
ago,
when, when,
it.type ?? '回访', it.type ?? '回访',
it.status, it.status,
......
...@@ -86,6 +86,11 @@ export class RecallSummaryOrchestrator { ...@@ -86,6 +86,11 @@ export class RecallSummaryOrchestrator {
// 第一条**有日期且已到期**的就是它(至多一条;全是未来排程/全无日期 → 一条都不标)。 // 第一条**有日期且已到期**的就是它(至多一条;全是未来排程/全无日期 → 一条都不标)。
// 业务第一诉求是"上次联系聊到哪儿了",摘要必须先答这条 —— 见 input.types.isLatestPast。 // 业务第一诉求是"上次联系聊到哪儿了",摘要必须先答这条 —— 见 input.types.isLatestPast。
const latestPastIndex = dated.findIndex((v) => v.taskDate != null && v.taskDate <= today); const latestPastIndex = dated.findIndex((v) => v.taskDate != null && v.taskDate <= today);
// ⭐「距今多少天」也程序算 —— 模型自己减日期会算错(2026-07-27:把 7.5 个月前的回访
// 说成"近3个月")。口径同 isFuture:按日期(UTC 零点)差,未来排程为负。
const todayMs = Date.parse(`${today}T00:00:00Z`);
const daysAgoOf = (d: string | null): number | null =>
d == null ? null : Math.round((todayMs - Date.parse(`${d}T00:00:00Z`)) / 86400_000);
const input: DraftRecallSummaryInput = { const input: DraftRecallSummaryInput = {
patientNameMasked: maskName(patient?.name ?? null) ?? '该患者', patientNameMasked: maskName(patient?.name ?? null) ?? '该患者',
...@@ -98,6 +103,7 @@ export class RecallSummaryOrchestrator { ...@@ -98,6 +103,7 @@ export class RecallSummaryOrchestrator {
// 无日期 → 不算未来(宁可当普通历史,也不要给出"已排期"的错误暗示) // 无日期 → 不算未来(宁可当普通历史,也不要给出"已排期"的错误暗示)
isFuture: v.taskDate != null && v.taskDate > today, isFuture: v.taskDate != null && v.taskDate > today,
isLatestPast: i === latestPastIndex, isLatestPast: i === latestPastIndex,
daysAgo: daysAgoOf(v.taskDate),
treatmentItems: v.treatmentItems, treatmentItems: v.treatmentItems,
followContent: v.followContent, followContent: v.followContent,
result: v.result, result: v.result,
......
...@@ -33,6 +33,7 @@ function item(over: Partial<DraftRecallSummaryInput['items'][0]> = {}) { ...@@ -33,6 +33,7 @@ function item(over: Partial<DraftRecallSummaryInput['items'][0]> = {}) {
taskStatus: '已完成', taskStatus: '已完成',
isFuture: false, isFuture: false,
isLatestPast: false, isLatestPast: false,
daysAgo: 91,
treatmentItems: null, treatmentItems: null,
followContent: null, followContent: null,
result: '治疗后回访', result: '治疗后回访',
...@@ -246,3 +247,68 @@ describe('DraftRecallSummaryCall.fallback — 兜底也不许把未来排程当 ...@@ -246,3 +247,68 @@ describe('DraftRecallSummaryCall.fallback — 兜底也不许把未来排程当
expect(s).toContain('未留结果'); expect(s).toContain('未留结果');
}); });
}); });
/**
* ⭐ 职责边界 —— 「历史联系」只讲回访,不许对到诊/失联下结论。
*
* 生产事故(2026-07-27 排查):同一类错误换个轴又来一遍。上一次(07-24)是"未来 vs 过去",
* 这次是"回访 vs 到诊"和"距今多久":
* · 陈芃霖:2023-08-15 回访结果原文「8月已就诊」→ 摘要写成"上次**有效到诊**为2023年8月",
* 而 PAC 自己的接诊记录末次是 2025-12-20 —— 差 2 年 4 个月;
* 同句还有"已超一年未复诊"(实为 7 个月)、"近3个月"(该回访实为 226 天前)。
* · 全量:795 条摘要里 162 条断言"失联/沉睡/未到诊",其中 88 条(54%)患者
* 实际 180 天内来过;最离谱一条写"自2018年至今无到诊或有效联系,属长期失联状态",
* 该患者上个月刚到诊。
*
* 根因:本 AiCall 的输入**只有回访记录,没有任何到诊数据**(见 input.types),
* 模型只能从回访备注文字里猜、或用"没有回访"推"没来过"——两条路都错。
* 而旧 prompt 不但没拦,示例里还有"此前半年 5 次回访无到诊"在示范这个错法。
*
* 本组用例锁两件事:① prompt 明确禁止到诊类结论;② 距今天数由程序算好喂进去。
*/
describe('⭐ 职责边界:只摘要回访事实,不判到诊', () => {
test('system prompt 明写「没有到诊数据」并禁止相关结论', () => {
expect(DRAFT_RECALL_SUMMARY_SYSTEM).toContain('没有任何到诊/就诊/复诊数据');
expect(DRAFT_RECALL_SUMMARY_SYSTEM).toContain('禁止');
// "没有回访 ≠ 没来过" 这条推理链必须被显式掐断
expect(DRAFT_RECALL_SUMMARY_SYSTEM).toContain('"没有回访记录" ≠ "没来过"');
});
test('结果字段里的「已就诊」只能当那次回访的记录引述,不得当到诊状态', () => {
expect(DRAFT_RECALL_SUMMARY_SYSTEM).toContain('不得**当成患者的到诊状态');
});
test('⭐ 示例里不能再出现示范错法的句子(旧版"5 次回访无到诊"就是元凶之一)', () => {
const examples = DRAFT_RECALL_SUMMARY_SYSTEM.slice(DRAFT_RECALL_SUMMARY_SYSTEM.indexOf('# 示例'));
expect(examples).not.toContain('回访无到诊');
// 反例段必须把三条真实事故句列为 ✗,让模型见过错法长什么样
expect(examples).toContain('上次有效到诊为2023年8月');
expect(examples).toContain('长期失联状态');
});
test('关键要求里不再把「长期未到诊」列为要突出的信号', () => {
const head = DRAFT_RECALL_SUMMARY_SYSTEM.slice(0, DRAFT_RECALL_SUMMARY_SYSTEM.indexOf('# ⭐ 职责边界'));
expect(head).not.toContain('长期未到诊');
});
test('⭐ 距今天数由程序算好并渲染进 user prompt —— 模型不再自己减日期', () => {
const s = build([item({ taskDate: '2025-12-10', isLatestPast: true, daysAgo: 226, result: '涂氟 邀约' })]);
expect(s).toContain('226天前');
});
test('未来排程不报负数天数(已有【未来排程】标记,重复只会添乱)', () => {
const s = build([item({ taskDate: '2026-10-09', isFuture: true, daysAgo: -77 })]);
expect(s).not.toContain('-77');
expect(s).not.toContain('77天前');
expect(s).toContain('尚未到期');
});
test('无日期的条目不报天数', () => {
const s = build([item({ taskDate: null, daysAgo: null })]);
expect(s).not.toMatch(/\d+天前/);
});
test('时间纪律段要求按 daysAgo 换算相对时间', () => {
expect(DRAFT_RECALL_SUMMARY_SYSTEM).toContain('距今多久也算好了');
});
});
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