Commit 33dcecb6 by luoqi

fix(recall): 回访摄入去掉 EMR 诊所白名单 + 历史联系摘要不再把未来排程当漏做

生产排查(张学军 BJ0D036664 等)带出的两个独立问题。

## 1. 摄入:删除 fact_returnvisit_out 的 org 白名单

原条件 `organization_id IN (SELECT ... FROM fact_emr_treatment_out)` 本意"与核心
数据同范围",但那张 EMR 表 **瑞尔 63 家 / 瑞泰仅 2 家**,而回访表瑞泰有 98 家:

  瑞尔  保留 3,238,588 / 丢 7,212        → 丢 0.2%
  瑞泰  保留   119,884 / 丢 10,039,288   → 丢 98.8%

后果:PAC 的 6.3 万瑞泰患者只有 5.4% 有回访记录(瑞尔 78.9%),历史联系整体空白。
回访是纯展示数据、不进召回信号,没理由绑定 EMR 诊所集合 → 删掉,范围交给 cohort。

️ 排查中的一个反转,记在 manifest 注释里防后人再踩:DW 的 patient_id **跨品牌复用**
(374 万 id 中 196 万重号,如 708971 在瑞尔=张学军、瑞泰=余盼盼)。所以 cohort 必须用
`(patient_id, brand)` 复合键;摄入侧 sourceUnit 取自本行 brand、按 `brand#patient_id`
索引患者,跨品牌行天然各归各家,不会串号。按品牌正确切分后,瑞尔患者其实一条没漏。

## 2. 摘要:未来排程被写成"未执行,需优先补做"

patient_return_visits.task_date **含未来排程**,而 prompt 既无今日锚点、又按 taskDate
desc 排序 → 未到期的排程恒排第一被当成"最新一次",status=未回访 就被判成漏做。
生产实测:934 条已生成摘要中 196 条最新回访是未来排程,其中 158 条(81%)带
"未执行/补做/漏/尚未"措辞;对照组(最新在过去)仅 38%。

修复按"能程序算的事实全算好、LLM 只润色"这条既有纪律:
  · orchestrator 算 today 与每条的 isFuture(确定性比较,不交给模型)
  · prompt 渲染成【未来排程·尚未到期】显式标记 + 条数警告 + 硬约束文案
  · 补 taskStatus(「已预约」≠「没做」,原先根本没喂给模型)
  · promptVersion → draft_recall_summary@2026-07-24-e

## 本地验证
- 325 单测通过(新增 11 例锁住"未来排程不得说成漏做" + promptVersion 必须 bump)
- 摄入:导入瑞泰患者 殷海霖(2332662,12 条回访旧过滤下一条留不住)→ 12 条全部落库
- 摘要:吕学文(未来排程 2026-10-09 + 真·过期未执行 2026-03-28)连跑 3 次稳定输出
  「3月28日种植咨询回访未执行…需优先跟进;…10月9日已排复查洁牙邀约」
  —— 未来的说"已排"、真漏做的仍报出,两个方向都对

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
parent 25fc944e
......@@ -236,15 +236,23 @@ sql_source:
WHERE tooth != ''
# ── 诊所回访任务(fact_returnvisit_out)→ patient_return_visit upsert(展示用)──
# customer_id AS patient_id(让 cohort 过滤生效);WHERE org ∈ EMR 表出现的所有诊所
# (随 DW 全量,当前 64 家,非 5 家)—— 与核心数据同范围。
# 非召回信号,详情页"回访记录"块展示(常规/术后/咨询回访)。
# customer_id AS patient_id(让 cohort 过滤生效);非召回信号,详情页「历史联系」块展示。
#
# ⭐ 2026-07:**删除原 org 白名单** `WHERE organization_id IN (SELECT ... FROM fact_emr_treatment_out)`。
# 原意是"与核心数据同范围",但 fact_emr_treatment_out 里 **瑞尔 63 家 / 瑞泰只有 2 家**,
# 而回访表瑞泰有 98 家 → 生产实测该条件把 **瑞泰回访丢掉 98.8%**(瑞尔只丢 0.2%):
# PAC 的 6.3 万瑞泰患者里仅 5.4% 有回访记录(瑞尔 78.9%),等于瑞泰患者的历史联系整体空白。
# 回访是纯展示数据、不进召回信号,没有理由绑定 EMR 的诊所集合。
#
# 范围由 cohort `(patient_id, brand)` 复合键兜住(见下方 cohort 段)—— 这也是**必须带 brand** 的原因:
# DW 的 patient_id **跨品牌复用**(374 万 id 中 196 万重号,如 708971 在瑞尔=张学军、瑞泰=余盼盼)。
# 摄入侧 sourceUnit 取自本行 brand、按 `brand#patient_id` 索引患者(cold-import.service),
# 所以跨品牌行天然落到各自品牌的患者名下,不会串号;患者不在 PAC 的行在解析时直接 skip。
fact_returnvisit_out: |
SELECT id, customer_id AS patient_id, brand, organization_id, task_date,
return_visit_type_name, return_visit_status_name, task_status_name,
treatment_items, treatment_items_two, follow_content, return_visit_result
FROM dw_group.fact_returnvisit_out
WHERE organization_id IN (SELECT DISTINCT organization_id FROM dw_group.fact_emr_treatment_out)
# ── 咨询主体(fact_consult_out)→ consultation_record(意向源)──
# patient_register_id = patient_id(试点期实测 94.4% 命中;CH 允许 WHERE 引用别名,同 returnvisit)。
......
......@@ -6,11 +6,27 @@
*/
export interface DraftRecallSummaryInput {
patientNameMasked: string;
/**
* 生成时刻的"今天"(YYYY-MM-DD)。
* ⭐ 必填 —— 回访记录里**含未来排程**(patient_return_visits.task_date 注释写明),
* 不给锚点模型无从判断某条是"还没到期"还是"漏做了"。
*/
today: string;
/** 历史联系(诊所回访)记录,时间倒序 */
items: Array<{
taskDate: string | null; // 回访任务日期 YYYY-MM-DD
type: string | null; // 常规回访 / 术后回访 / 咨询回访
status: string | null; // 已回访 / 未回访
/** 任务状态:已完成 / 未完成 / 已预约 / 创建新回访 —— 「已预约」≠「没做」,不给模型会误判 */
taskStatus: string | null;
/**
* ⭐ 该条是否为**尚未到期的未来排程**(taskDate > today)。
* 由 orchestrator **程序判定**后传入,不让 LLM 自己比日期 —— 这是确定性计算,
* 跟话术侧"能程序算的事实全算好、LLM 只润色"同一条纪律。
* 生产事故背景(2026-07):无此字段时模型把 77 天后的排程写成"最新一次未执行,需优先补做",
* 934 条已生成摘要里 196 条最新回访是未来排程、其中 158 条带"未执行/补做"措辞。
*/
isFuture: boolean;
treatmentItems: string | null; // 关联治疗项(如 种植)
followContent: string | null; // 回访内容(术后复查 / 洁牙提醒 / 活动邀约…)
result: string | null; // 回访结果
......
import type { DraftRecallSummaryInput } from './input.types';
export const DRAFT_RECALL_SUMMARY_PROMPT_VERSION = 'draft_recall_summary@2026-06-16-d';
/**
* 版本历史:
* 2026-06-16-d 初版(仅 taskDate/type/status/内容/结果)
* 2026-07-24-e ⭐ 加今日锚点 + 程序判定的「未来排程」标记 + taskStatus。
* 修生产事故:未来排程被写成"最新一次未执行,需优先补做"。
*/
export const DRAFT_RECALL_SUMMARY_PROMPT_VERSION = 'draft_recall_summary@2026-07-24-e';
export const DRAFT_RECALL_SUMMARY_SYSTEM = `你是牙科诊所客服主管。下面是一个患者的历史联系/回访记录(结构化、看着累)。请提炼成**重点**,让接手客服**一眼抓住关键**,而不是逐条复述。
......@@ -11,20 +17,35 @@ export const DRAFT_RECALL_SUMMARY_SYSTEM = `你是牙科诊所客服主管。下
4. 客观,**禁止**承诺疗效、编造、给医疗建议。
5. 严格按 JSON schema 输出,只有一个 key:summary。
# ⭐ 时间纪律(硬约束,违反即错)
记录里**包含诊所已排好、但还没到日子的未来任务**,这类条目已用【未来排程·尚未到期】标出。
- 【未来排程·尚未到期】的条目**绝不能**说成"未执行 / 未回访 / 漏做 / 缺失 / 需补做 / 需优先跟进该次"——
它只是**还没到时间**,不是任何人的疏漏。需要提及时只能说"已排 X 月 X 日复查洁牙"这类中性表述。
- 只有标了【已过期·未执行】的条目才算真正漏做。
- "最近一次""最新一次"这类说法**只能指已过期的条目**,不要用未来排程当"最近一次"。
- 判断哪条是未来、哪条已过期**已经替你算好了**,直接用标记,不要自己比日期。
# 示例(风格参考,不要照抄)
- "近 3 次活动邀约均未接通,建议换企微触达。"
- "术后回访已完成,有种植意向待跟进。"
- "半年内 5 次回访无到诊,流失风险高。"`;
- "半年内 5 次回访无到诊,流失风险高。"
- "4 月术后回访已完成,10 月 9 日已排复查洁牙,暂无需额外动作。" ← 未来排程的正确写法`;
export function buildDraftRecallSummaryPrompt(input: DraftRecallSummaryInput): string {
const lines =
input.items.length > 0
? input.items
.map((it, i) => {
// 未来 / 过期 由 orchestrator 程序判定后传入(见 input.types.isFuture 注释),
// 这里只负责渲染成模型看得懂的显式标记 —— LLM 不做日期比较。
const when = it.isFuture ? '【未来排程·尚未到期】' : null;
const parts = [
it.taskDate ?? '—',
when,
it.type ?? '回访',
it.status,
// 「已预约」「创建新回访」跟「未完成」语义差很远,漏了会被当成没做
it.taskStatus ? `任务:${it.taskStatus}` : null,
it.treatmentItems ? `项目:${it.treatmentItems}` : null,
it.followContent ? `内容:${it.followContent.slice(0, 40)}` : null,
it.result ? `结果:${it.result.slice(0, 40)}` : null,
......@@ -34,9 +55,15 @@ export function buildDraftRecallSummaryPrompt(input: DraftRecallSummaryInput): s
.join('\n')
: '(无历史联系记录)';
return `患者:${input.patientNameMasked}
const futureCount = input.items.filter((it) => it.isFuture).length;
const futureNote = futureCount
? `\n⚠️ 其中 ${futureCount} 条是**尚未到期的未来排程**(已标注),不得表述为未执行/漏做/需补做。`
: '';
return `今天:${input.today}
患者:${input.patientNameMasked}
历史联系 / 回访记录(时间倒序,共 ${input.items.length} 条):
${lines}
${lines}${futureNote}
请概括以上历史联系的重点。`;
}
......@@ -59,7 +59,10 @@ export class RecallSummaryOrchestrator {
where: { hostId: scope.hostId, tenantId: scope.tenantId, patientId: plan.patientId },
orderBy: { taskDate: 'desc' },
take: 12,
select: { taskDate: true, type: true, status: true, treatmentItems: true, followContent: true, result: true },
select: {
taskDate: true, type: true, status: true, taskStatus: true,
treatmentItems: true, followContent: true, result: true,
},
});
if (visits.length === 0) return { summary: null, status: 'empty' };
......@@ -68,16 +71,28 @@ export class RecallSummaryOrchestrator {
select: { name: true },
});
// ⭐ 未来排程判定放这里(程序算),不交给 LLM —— 回访表含未来排程,
// 模型没有"今天"就分不清"还没到期"和"漏做了"(2026-07 生产事故)。
// 口径:按**日期**比(taskDate 是 @db.Date,无时分秒),taskDate > today 即未到期。
const today = new Date().toISOString().slice(0, 10);
const input: DraftRecallSummaryInput = {
patientNameMasked: maskName(patient?.name ?? null) ?? '该患者',
items: visits.map((v) => ({
taskDate: v.taskDate ? v.taskDate.toISOString().slice(0, 10) : null,
type: v.type,
status: v.status,
treatmentItems: v.treatmentItems,
followContent: v.followContent,
result: v.result,
})),
today,
items: visits.map((v) => {
const taskDate = v.taskDate ? v.taskDate.toISOString().slice(0, 10) : null;
return {
taskDate,
type: v.type,
status: v.status,
taskStatus: v.taskStatus,
// 无日期 → 不算未来(宁可当普通历史,也不要给出"已排期"的错误暗示)
isFuture: taskDate != null && taskDate > today,
treatmentItems: v.treatmentItems,
followContent: v.followContent,
result: v.result,
};
}),
};
// 3) 跑 AiCall → upsert
......
import {
buildDraftRecallSummaryPrompt,
DRAFT_RECALL_SUMMARY_SYSTEM,
DRAFT_RECALL_SUMMARY_PROMPT_VERSION,
} from '../src/modules/ai/calls/draft-recall-summary/prompt';
import type { DraftRecallSummaryInput } from '../src/modules/ai/calls/draft-recall-summary/input.types';
/**
* 历史联系摘要 —— 「未来排程 ≠ 漏做」回归。
*
* 生产事故(2026-07-24 排查):详情页「历史联系」把**诊所已排好、还没到日子**的回访
* 说成"最新一次未执行,需优先补做"。实例:
* · 张学军 task_date=2026-10-09(77 天后)→ "10月9日最新一次洁牙复查回访未执行"
* · 江世琪 2026-09-17(55 天后)、史原 2026-08-25(32 天后)—— 同款措辞
* 当时 934 条已生成摘要里 196 条"最新回访是未来排程",其中 158 条(81%)带
* "未执行/补做/漏/尚未"措辞;对照组(最新在过去)只有 38%。
*
* 根因:prompt **没有今日锚点**,且按 taskDate desc 排序 → 未来排程恒排第一被当成"最新一次";
* taskStatus(已预约/已完成)也没喂给模型。
*
* 修复纪律(本文件锁住):日期比较是**确定性计算**,由 orchestrator 算好 isFuture 传入,
* prompt 只负责把它渲染成显式标记 —— 跟话术侧"能程序算的事实全算好、LLM 只润色"同一条规矩。
*/
const TODAY = '2026-07-24';
function item(over: Partial<DraftRecallSummaryInput['items'][0]> = {}) {
return {
taskDate: '2026-04-24',
type: '常规回访',
status: '已回访',
taskStatus: '已完成',
isFuture: false,
treatmentItems: null,
followContent: null,
result: '治疗后回访',
...over,
};
}
function build(items: DraftRecallSummaryInput['items']) {
return buildDraftRecallSummaryPrompt({ patientNameMasked: '张*', today: TODAY, items });
}
describe('buildDraftRecallSummaryPrompt — 时间锚 + 未来排程标记', () => {
test('⭐ 必须把"今天"写进 prompt(模型据此才能判断远近)', () => {
expect(build([item()])).toContain(`今天:${TODAY}`);
});
test('⭐ 未来排程条目带【未来排程·尚未到期】标记', () => {
const p = build([
item({ taskDate: '2026-10-09', status: '未回访', taskStatus: '未完成', isFuture: true, followContent: '复查洁牙' }),
]);
expect(p).toContain('【未来排程·尚未到期】');
expect(p).toContain('2026-10-09');
});
test('已过期条目**不带**未来标记(否则真漏做会被洗白)', () => {
const p = build([item({ taskDate: '2026-03-02', status: '未回访', taskStatus: '未完成' })]);
expect(p).not.toContain('【未来排程·尚未到期】');
});
test('⭐ 有未来排程时追加显式警告,并报出条数', () => {
const p = build([
item({ taskDate: '2026-10-09', isFuture: true }),
item({ taskDate: '2026-09-17', isFuture: true }),
item(),
]);
expect(p).toContain('其中 2 条');
expect(p).toMatch(/不得表述为未执行/);
});
test('全是历史条目 → 不出现未来排程警告(不给无谓噪声)', () => {
const p = build([item(), item({ taskDate: '2026-03-02' })]);
expect(p).not.toContain('尚未到期的未来排程');
});
test('⭐ taskStatus 必须进 prompt ——「已预约」不是「没做」', () => {
const p = build([item({ taskStatus: '已预约', result: '复诊邀约' })]);
expect(p).toContain('任务:已预约');
});
test('三位真实患者的原始形态:最新一条是未来排程 → 都被正确标注', () => {
// 生产实测数据(2026-07-24 当天)
const real: Array<[string, string]> = [
['2026-10-09', '复查洁牙'], // 张学军
['2026-09-17', '复查洁牙 看其他治疗是否要做,没有保险了'], // 江世琪
['2026-08-25', '46复查拍CT'], // 史原
];
for (const [date, content] of real) {
const p = build([
item({ taskDate: date, status: '未回访', taskStatus: '未完成', isFuture: true, followContent: content }),
]);
expect(p).toContain('【未来排程·尚未到期】');
}
});
});
describe('DRAFT_RECALL_SUMMARY_SYSTEM — 硬约束文本', () => {
test('⭐ system 明令未来排程不得说成未执行 / 漏做 / 需补做', () => {
for (const word of ['未执行', '漏做', '需补做', '未来排程']) {
expect(DRAFT_RECALL_SUMMARY_SYSTEM).toContain(word);
}
});
test('⭐ 明令"最近一次/最新一次"只能指已过期条目', () => {
expect(DRAFT_RECALL_SUMMARY_SYSTEM).toMatch(/最近一次|最新一次/);
expect(DRAFT_RECALL_SUMMARY_SYSTEM).toContain('已过期');
});
test('明令不要自己比日期(确定性计算已由程序完成)', () => {
expect(DRAFT_RECALL_SUMMARY_SYSTEM).toContain('不要自己比日期');
});
test('promptVersion 已 bump(改了 prompt 必须换版本,否则缓存/评估串档)', () => {
expect(DRAFT_RECALL_SUMMARY_PROMPT_VERSION).not.toBe('draft_recall_summary@2026-06-16-d');
expect(DRAFT_RECALL_SUMMARY_PROMPT_VERSION).toMatch(/^draft_recall_summary@\d{4}-\d{2}-\d{2}-[a-z]$/);
});
});
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