Commit ae0b29c0 by luoqi

feat(ai/brief): 召回简报三问→四问 — 复查切入建议(事实锚定,防编造)

第④问「以什么切入」:打电话的低门槛开口台阶,依据按优先级取第一个可用档:
① 医生计划/建议/医嘱(最优先,统一 verbatim 原话属性):
   - 治疗计划 subtype 原话(如「待22萌出后择期早期矫治」)
   - 医生建议 recommendation_record.name 原文
   - 医嘱 emr_record.doctor_advice(2.2万条真实医嘱;≥6字过滤"常嘱"水词,取最近2条)
   规则:必须优先引用「」原话要点(过长精简保留医嘱词),别只报大类;
   通用套话(定期复查不适随诊)视同无原话降档;多条挑与召回原因最相关的一条
② 洁牙/检查锚点(①为空才用):最近一次 preventive/periodontic 距今,
   超6个月才可引用时间,不足视同没有
③ 通用复查邀约(不带时间数字);严禁编"该洁牙了/上次洁牙已X个月"

防重复:同一天数整句只出现一次;锚点与召回原因"拖了X"同源时切入不再报数。
字数放宽 ≤65 最佳/90 上限;兜底模板同优先级;prompt 版本 2026-07-21-d。

注:已缓存旧简报不自动重生成,上线时按 type=recall_brief 清一遍缓存即可。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
parent a9d230cb
Pipeline #3409 failed in 0 seconds
...@@ -33,7 +33,15 @@ function fallback(input: DraftRecallBriefInput): DraftRecallBriefOutput { ...@@ -33,7 +33,15 @@ function fallback(input: DraftRecallBriefInput): DraftRecallBriefOutput {
`${r.daysSinceText}前`, `${r.daysSinceText}前`,
cats ? `未启动 ${cats}` : null, cats ? `未启动 ${cats}` : null,
].filter(Boolean); ].filter(Boolean);
return { summary: `${r.subLabel}:${parts.join(' · ')}。` }; // 切入尾巴(第④问,按优先级):医生计划/建议/医嘱(带原话) > 洁牙锚点(带时间) > 通用复查邀约
const g0 = input.doctorGuidance.find((g) => g.verbatim) ?? input.doctorGuidance[0];
const KIND_VERB = { plan: '已有计划', recommendation: '建议过', advice: '医嘱交代过' } as const;
const entry = g0
? `;医生${KIND_VERB[g0.kind]}${g0.verbatim ? `「${g0.verbatim}」` : g0.label},可约复查跟进`
: input.reviewAnchors.lastCleaningText
? `;距上次洁牙/检查 ${input.reviewAnchors.lastCleaningText},可约复查切入`
: ';可约复查请医生评估';
return { summary: `${r.subLabel}:${parts.join(' · ')}${entry}。` };
} }
@Injectable() @Injectable()
......
...@@ -2,10 +2,11 @@ ...@@ -2,10 +2,11 @@
* DraftRecallBrief AiCall — 把"本次召回原因 + 历史治疗 + 画像"提炼成**一句话召回简报**。 * DraftRecallBrief AiCall — 把"本次召回原因 + 历史治疗 + 画像"提炼成**一句话召回简报**。
* *
* 用途:详情页「参考话术」标题下方那行(原 RecallReasonLine 结构化富文本),压成一句话, * 用途:详情页「参考话术」标题下方那行(原 RecallReasonLine 结构化富文本),压成一句话,
* 回答客服打这通电话最该心里有数的问: * 回答客服打这通电话最该心里有数的问:
* ① 患者是谁(从生命周期 / 价值 / 类型看) * ① 患者是谁(从生命周期 / 价值 / 类型看)
* ② 帮患者解决什么问题(应治未治的缺口) * ② 帮患者解决什么问题(应治未治的缺口)
* ③ 邀约到诊是做什么(具体治疗动作) * ③ 邀约到诊是做什么(具体治疗动作)
* ④ 以什么切入(复查建议)—— 低门槛开口台阶,必须锚在 reviewAnchors 事实上,无事实不得编
* *
* 跟 DraftRecallSummary / DraftPersonaSummary 平行,但输入更全(三类信号合一)。 * 跟 DraftRecallSummary / DraftPersonaSummary 平行,但输入更全(三类信号合一)。
*/ */
...@@ -26,6 +27,30 @@ export interface DraftRecallBriefInput { ...@@ -26,6 +27,30 @@ export interface DraftRecallBriefInput {
daysSinceText: string; // 距今(如 131 天(4 个月)) daysSinceText: string; // 距今(如 131 天(4 个月))
expectedCategories: string[]; // 期望/未启动治疗类目中文(如 种植 / 修复 / 冠桥) expectedCategories: string[]; // 期望/未启动治疗类目中文(如 种植 / 修复 / 冠桥)
}>; }>;
/**
* 复查切入锚点(第④问的兜底事实依据,防编造):
* lastVisitText — 最近一次实际治疗距今(如 8 个月前);null=无记录
* lastCleaningText — 最近一次洁牙/检查/牙周洁治距今;null=无记录
* 仅当 doctorGuidance 为空时使用;LLM 只能引用这里的值,没给就用不带时间的通用复查邀约。
*/
reviewAnchors: {
lastVisitText: string | null;
lastCleaningText: string | null;
};
/**
* 医生计划/建议/医嘱(第④问的**最优先**事实依据)—— 统一条目形态,核心是 verbatim 原话属性:
* kind — plan=治疗计划(treatment_record kind=planned) / recommendation=医生建议 / advice=医嘱(emr.doctor_advice)
* label — 归一名(治疗类目 / 建议名;医嘱固定 '医嘱')
* verbatim — **原话**(计划=subtype 原文 / 建议=name 原文 / 医嘱=doctor_advice 原文;截断 ≤40 字);null=只有大类
* 有任一条 → 切入围绕医生真实交代展开(优先引用原话要点),不再引用洁牙锚点时间。
*/
doctorGuidance: Array<{
kind: 'plan' | 'recommendation' | 'advice';
label: string;
verbatim: string | null;
tooth: string | null;
atText: string | null;
}>;
} }
export interface DraftRecallBriefOutput { export interface DraftRecallBriefOutput {
......
import type { DraftRecallBriefInput } from './input.types'; import type { DraftRecallBriefInput } from './input.types';
export const DRAFT_RECALL_BRIEF_PROMPT_VERSION = 'draft_recall_brief@2026-06-26-a'; export const DRAFT_RECALL_BRIEF_PROMPT_VERSION = 'draft_recall_brief@2026-07-21-d';
export const DRAFT_RECALL_BRIEF_SYSTEM = `你是牙科诊所客服主管,正在给即将打电话的客服做一句话交底。下面给你这个患者的「本次召回原因 + 历史治疗 + 画像」。请提炼成**一句话召回简报**。 export const DRAFT_RECALL_BRIEF_SYSTEM = `你是牙科诊所客服主管,正在给即将打电话的客服做一句话交底。下面给你这个患者的「本次召回原因 + 历史治疗 + 画像」。请提炼成**一句话召回简报**。
...@@ -14,13 +14,21 @@ export const DRAFT_RECALL_BRIEF_SYSTEM = `你是牙科诊所客服主管,正在 ...@@ -14,13 +14,21 @@ export const DRAFT_RECALL_BRIEF_SYSTEM = `你是牙科诊所客服主管,正在
- 根管后未戴冠 → 牙体脆易裂,套上冠才耐用 - 根管后未戴冠 → 牙体脆易裂,套上冠才耐用
(以上是举例口径;按本患者实际诊断/治疗类目对应着说) (以上是举例口径;按本患者实际诊断/治疗类目对应着说)
# 一句话要含问,但分量不同 # 一句话要含问,但分量不同
1. 患者是谁 —— 一两个词点一下(如 高价值种植老客 / 新客),做前置修饰。 1. 患者是谁 —— 一两个词点一下(如 高价值种植老客 / 新客),做前置修饰。
2. ⭐**他为什么该来(全句重心)** —— 哪颗牙、什么问题、拖了多久没处理 + **对他的影响 / 越拖越…**。这是句子主干。 2. ⭐**他为什么该来(全句重心)** —— 哪颗牙、什么问题、拖了多久没处理 + **对他的影响 / 越拖越…**。这是句子主干。
3. 到诊做什么 —— 简短收尾,具体治疗动作(种植 / 充填 / 复查…)。 3. 到诊做什么 —— 目标治疗动作(种植 / 充填…),一笔带过。
4. **以什么切入(复查建议,收尾)** —— 打电话的开口台阶:邀约的是**低门槛的复查/检查**,顺带请医生评估目标治疗,不是直接卖大项目。切入依据按优先级取**第一个可用的**,只用一档:
① **医生计划/建议/医嘱**(最优先):「医生计划/建议/医嘱」段有条目 → 围绕它切入,**必须优先引用条目的 原话:「…」**(太长可精简,但保留关键医嘱词,如"待22萌出后早期矫治""定期复诊洁牙"),别只报大类("正畸计划""洗牙计划"太笼统,客服没法转述)。医嘱里含复查/洁牙/复诊类嘱托是最好的切入("医生医嘱里交代过定期复诊洁牙,正好约上")。
- 原话若是**通用套话**(如"定期复查,不适随诊""常嘱"),信息量为零 → 视同无原话,退回大类或下一档;
- 多条时挑**与本次召回原因最相关**的一条,不要罗列。这是医生真实交代过的,患者最认。
② **洁牙/检查锚点**(①为空才用):"最近一次洁牙/检查 X 前"**已超 6 个月**才可引用时间("距上次洁牙已 X,约个洁牙检查顺便让医生看看…");**不足 6 个月的锚点视同没有,禁止引用其时间**。
③ **通用复查邀约**(①②都不可用):不带时间数字("约个复查请医生评估")。**绝不能编"该洁牙了/上次洁牙已 X 个月"**。
- 召回原因本身含复查语义(保持器复查 / 根管后复查等)→ 可直接用该复查切入(视同①级)。
- ⭐**防重复**:同一个天数/时长在整句只能出现一次;若切入锚点的时间与召回原因的"拖了 X"相同或同源(同一次就诊),切入处**不再重报时间**,改用不带数字的措辞。
# 关键要求 # 关键要求
1. 只输出**一句话**(中文,**≤55 字**最佳,最多 75 字),不要换行/列表/Markdown。 1. 只输出**一句话**(中文,**≤65 字**最佳,最多 90 字),不要换行/列表/Markdown。
2. **只能用给出的信息,严禁编造患者的意愿 / 情绪 / 决定**: 2. **只能用给出的信息,严禁编造患者的意愿 / 情绪 / 决定**:
- 「未启动 / 应治未治」是临床缺口,**不是患者意愿**——**绝不能**说成"想做 / 有意向 / 考虑中"。 - 「未启动 / 应治未治」是临床缺口,**不是患者意愿**——**绝不能**说成"想做 / 有意向 / 考虑中"。
- 可以讲"缺口对患者的常识性影响"(上面那类),但**不要编个人化情节、不要编具体数值、不要承诺疗效**。 - 可以讲"缺口对患者的常识性影响"(上面那类),但**不要编个人化情节、不要编具体数值、不要承诺疗效**。
...@@ -29,11 +37,14 @@ export const DRAFT_RECALL_BRIEF_SYSTEM = `你是牙科诊所客服主管,正在 ...@@ -29,11 +37,14 @@ export const DRAFT_RECALL_BRIEF_SYSTEM = `你是牙科诊所客服主管,正在
4. 历史治疗用来体现"熟客 / 信任基础"(如"做过 2 次种植"),不要凭空夸大。 4. 历史治疗用来体现"熟客 / 信任基础"(如"做过 2 次种植"),不要凭空夸大。
5. 严格按 JSON schema 输出,只有一个 key:summary。 5. 严格按 JSON schema 输出,只有一个 key:summary。
# 示例(患者立场、重心在"他为什么该来",风格参考,不要照抄) # 示例(患者立场、重心在"他为什么该来",句尾带切入建议;风格参考,不要照抄)
- "47 缺了 4 个月的牙一直空着,越拖邻牙越易移位,早点种上才好咀嚼;患者是做过 2 次种植的老客。" - (①治疗计划,引用原话)"张小妹 8 岁反颌未矫;医生计划「待22萌出后择期早期矫治」,可约复查看看牙萌出进度、按计划推进。"
- "36 的龋齿拖了 3 个月还没补,再放任可能伤到牙神经会疼,趁早补上;新客,目前只洁过牙。" - (①医嘱含复诊嘱托,引用原话要点)"47 龋齿拖 11 个月未补易伤神经;牙周成长客,医生医嘱交代过「定期复诊洁牙」,正好约上顺带评估补牙。"
- "正畸后保持器到期没复查,不及时戴查牙齿易反弹、白做一场,该回来复查;成熟期老客。" - (①治疗计划,原话简短直接用)"47 缺了 4 个月的牙一直空着,越拖邻牙越易移位;种植老客,医生已有「种植取模」计划,可约复查按计划推进。"
- (【医生建议·非确诊缺失】示例,注意不说"缺牙/空着")"35 牙医生建议种植修复,拖了 3 个多月没启动,早处理咀嚼更稳;做过 7 次种植的高价值老客。"`; - (①医生建议过)"36 的龋齿拖了 3 个月还没补,再放任可能伤神经会疼;新客,医生建议过充填,可约复查评估后尽快补上。"
- (②锚点"洁牙 8 个月前",无医生计划)"44 牙周炎拖了半年未治;牙周老客,距上次洁牙已 8 个月,可约洁牙检查顺带请医生评估牙周治疗。"
- (③无任何依据 → 通用切入,不带时间数字)"26 牙髓炎拖半年未根管,再放任易伤神经疼;成熟客,可约复查请医生评估根管方案。"
- (防重复:召回拖 53 天、锚点也是 53 天 → 切入不再报数)"27 龋齿拖 53 天未补易伤神经;牙周老客,可约复查顺带请医生评估补牙。"`;
export function buildDraftRecallBriefPrompt(input: DraftRecallBriefInput): string { export function buildDraftRecallBriefPrompt(input: DraftRecallBriefInput): string {
const personaLines = const personaLines =
...@@ -69,6 +80,24 @@ export function buildDraftRecallBriefPrompt(input: DraftRecallBriefInput): strin ...@@ -69,6 +80,24 @@ export function buildDraftRecallBriefPrompt(input: DraftRecallBriefInput): strin
.join('\n') .join('\n')
: ' (无召回原因)'; : ' (无召回原因)';
// 第④问事实依据 —— 每条带显式「原话」属性;无记录明说,LLM 不得自行推断时间
const KIND_LABEL = { plan: '治疗计划', recommendation: '医生建议', advice: '医嘱' } as const;
const guidanceItems = input.doctorGuidance.map((g) => {
const parts = [
`[${KIND_LABEL[g.kind]}] ${g.label}`,
g.verbatim ? `原话:「${g.verbatim}」` : '(无原话,仅大类)',
g.tooth ? `牙位 ${g.tooth}` : null,
g.atText ? `(${g.atText})` : null,
].filter(Boolean);
return ` - ${parts.join(' · ')}`;
});
const guidanceLines = guidanceItems.length > 0 ? guidanceItems.join('\n') : ' (无)';
const anchorLines = [
` - 最近一次实际治疗:${input.reviewAnchors.lastVisitText ?? '(无记录)'}`,
` - 最近一次洁牙/检查:${input.reviewAnchors.lastCleaningText ?? '(无记录)'}`,
].join('\n');
return `患者:${input.patientNameMasked} return `患者:${input.patientNameMasked}
画像: 画像:
...@@ -76,8 +105,14 @@ ${personaLines} ...@@ -76,8 +105,14 @@ ${personaLines}
历史治疗(类目×次数):${historyLine} 历史治疗(类目×次数):${historyLine}
医生计划/建议/医嘱(切入依据①,最优先;优先引用 原话:「…」):
${guidanceLines}
复查锚点(切入依据②,仅①为空且超6个月才引用时间):
${anchorLines}
本次召回原因(应治未治): 本次召回原因(应治未治):
${reasonLines} ${reasonLines}
请按问揉成一句话召回简报。`; 请按问揉成一句话召回简报。`;
} }
...@@ -7,10 +7,10 @@ export const DraftRecallBriefSchema = z.object({ ...@@ -7,10 +7,10 @@ export const DraftRecallBriefSchema = z.object({
.min(6) .min(6)
.max(120) .max(120)
.describe( .describe(
'一句话中文召回简报(≤55 字最佳,最多 75 字,不带换行/列表/Markdown)。' + '一句话中文召回简报(≤65 字最佳,最多 90 字,不带换行/列表/Markdown)。' +
'**站在患者立场讲"他为什么该来"**:把应治未治缺口翻译成患者能感知的影响/价值(如 缺牙久拖邻牙易移位、龋齿不补会伤神经),放句子主干、最突出;' + '**站在患者立场讲"他为什么该来"**:把应治未治缺口翻译成患者能感知的影响/价值(如 缺牙久拖邻牙易移位、龋齿不补会伤神经),放句子主干、最突出;' +
'"患者是谁"(价值/熟客)一两词前置修饰,"到诊做什么"(治疗动作)简短收尾。口吻为患者着想,不催单。' + '"患者是谁"(价值/熟客)一两词前置修饰;句尾给**切入建议**(低门槛复查/洁牙开口台阶,只能引用复查锚点给的事实,无锚点用不带时间数字的通用复查邀约),目标治疗动作随切入一笔带过。口吻为患者着想,不催单。' +
'严禁编造患者意愿/情绪:「应治未治」是缺口不是意愿,不能说"想做/有意向";不编个人情节、不编数值、不承诺疗效。' + '严禁编造患者意愿/情绪:「应治未治」是缺口不是意愿,不能说"想做/有意向";不编个人情节、不编数值、不承诺疗效。' +
'例:"47缺了4个月的牙一直空着,越拖邻牙越易移位,早点种上才好咀嚼;做过2次种植的老客。"', '例:"47缺了4个月的牙一直空着,越拖邻牙越易移位;种植老客,距上次洁牙已8个月,可约洁牙检查顺带请医生评估种植。"',
), ),
}); });
...@@ -127,14 +127,115 @@ export class RecallBriefOrchestrator { ...@@ -127,14 +127,115 @@ export class RecallBriefOrchestrator {
type: 'treatment_record', type: 'treatment_record',
kind: 'actual', kind: 'actual',
}, },
select: { content: true }, select: { content: true, occurredAt: true },
}); });
const catCount = new Map<string, number>(); const catCount = new Map<string, number>();
// 复查切入锚点(第④问事实依据):最近一次实际治疗 + 最近一次洁牙/检查(preventive/periodontic)
let lastVisitAt: Date | null = null;
let lastCleaningAt: Date | null = null;
const CLEANING_CATEGORIES = new Set(['preventive', 'periodontic']);
for (const f of txFacts) { for (const f of txFacts) {
const cat = String((f.content as Record<string, unknown> | null)?.category ?? '').trim(); const cat = String((f.content as Record<string, unknown> | null)?.category ?? '').trim();
if (!cat) continue; if (!cat) continue;
catCount.set(cat, (catCount.get(cat) ?? 0) + 1); catCount.set(cat, (catCount.get(cat) ?? 0) + 1);
if (f.occurredAt) {
if (!lastVisitAt || f.occurredAt > lastVisitAt) lastVisitAt = f.occurredAt;
if (CLEANING_CATEGORIES.has(cat) && (!lastCleaningAt || f.occurredAt > lastCleaningAt)) {
lastCleaningAt = f.occurredAt;
}
}
} }
const now = Date.now();
const anchorText = (d: Date | null): string | null => {
if (!d) return null;
const days = Math.floor((now - d.getTime()) / 86_400_000);
return days <= 0 ? '近期' : `${daysText(days)}前`;
};
const reviewAnchors = {
lastVisitText: anchorText(lastVisitAt),
lastCleaningText: anchorText(lastCleaningAt),
};
// 3.5) 医生计划/建议/医嘱(第④问切入的最优先事实依据,统一带 verbatim 原话):
// 治疗计划 = treatment_record kind='planned' / 医生建议 = recommendation_record /
// 医嘱 = emr_record.content.doctor_advice(真实医嘱文本;"常嘱"级短水词丢弃)
const clip = (s: string, max: number): string => (s.length > max ? `${s.slice(0, max)}…` : s);
const [plannedFacts, recFacts, emrFacts] = await Promise.all([
this.prisma.patientFact.findMany({
where: {
hostId: scope.hostId,
tenantId: scope.tenantId,
patientId: plan.patientId,
type: 'treatment_record',
kind: 'planned',
},
orderBy: { occurredAt: 'desc' },
take: 3,
select: { content: true, occurredAt: true },
}),
this.prisma.patientFact.findMany({
where: {
hostId: scope.hostId,
tenantId: scope.tenantId,
patientId: plan.patientId,
type: 'recommendation_record',
},
orderBy: { occurredAt: 'desc' },
take: 3,
select: { content: true, occurredAt: true },
}),
this.prisma.patientFact.findMany({
where: {
hostId: scope.hostId,
tenantId: scope.tenantId,
patientId: plan.patientId,
type: 'emr_record',
},
orderBy: { occurredAt: 'desc' },
take: 5,
select: { content: true, occurredAt: true },
}),
]);
const doctorGuidance: DraftRecallBriefInput['doctorGuidance'] = [
...plannedFacts.map((f) => {
const c = (f.content ?? {}) as Record<string, unknown>;
const raw = String(c.subtype ?? '').trim();
return {
kind: 'plan' as const,
label: treatmentCategoryNameZh(String(c.category ?? '')),
verbatim: raw ? clip(raw, 40) : null,
tooth: String(c.tooth_position ?? '').trim() || null,
atText: anchorText(f.occurredAt),
};
}),
...recFacts.map((f) => {
const c = (f.content ?? {}) as Record<string, unknown>;
const code = String(c.code ?? '').trim();
const name = String(c.name ?? '').trim();
return {
kind: 'recommendation' as const,
label: code ? diagnosisCodeNameZh(code) : '医生建议',
verbatim: name ? clip(name, 40) : null,
tooth: String(c.tooth_position ?? '').trim() || null,
atText: anchorText(f.occurredAt),
};
}),
// 医嘱:只收有信息量的(≥6 字过滤"常嘱"级水词),最近 2 条
...emrFacts
.map((f) => ({
advice: String(((f.content ?? {}) as Record<string, unknown>).doctor_advice ?? '').trim(),
occurredAt: f.occurredAt,
}))
.filter((x) => x.advice.length >= 6)
.slice(0, 2)
.map((x) => ({
kind: 'advice' as const,
label: '医嘱',
verbatim: clip(x.advice, 40),
tooth: null,
atText: anchorText(x.occurredAt),
})),
];
const treatmentHistory = [...catCount.entries()] const treatmentHistory = [...catCount.entries()]
.sort((a, b) => b[1] - a[1]) .sort((a, b) => b[1] - a[1])
.map(([cat, count]) => ({ category: treatmentCategoryNameZh(cat), count })); .map(([cat, count]) => ({ category: treatmentCategoryNameZh(cat), count }));
...@@ -160,6 +261,8 @@ export class RecallBriefOrchestrator { ...@@ -160,6 +261,8 @@ export class RecallBriefOrchestrator {
persona: personaItems, persona: personaItems,
treatmentHistory, treatmentHistory,
reasons, reasons,
reviewAnchors,
doctorGuidance,
}; };
// 5) 跑 AiCall → upsert // 5) 跑 AiCall → upsert
......
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