Commit cebde6b1 by luoqi

feat(ai): S2.2 福利进话术 + 缓存失效规则

🔴 分配功能里**唯一一条「做错了会对患者做出虚假承诺」**的路。

## 那个缓存坑的实际形状(已核实)

`plan_scripts.planId` 是 **@unique**(一 plan 一话术),orchestrator 写的是 upsert;
而生成**只在有人点「生成」时才跑**,之后一路读缓存(loadPlanScript 直接 findUnique)。
召回池又是共享的、未分配的单谁都能打开触发生成。三段后果一段比一段重:

  ① 分配**之前**有人点开过 → 话术已 ready 落库,**福利段永远不会出现**,T4 的归因目的失效
  ② plan 在批次 A(福利甲)→ 退回 → 进批次 B(福利乙)→ 话术缓存**还是甲的文案**,
     客服照着念 = **对患者做了一个不存在的承诺**
  ③ 撤销批次后,话术里的福利仍在

②③ 不是体验问题,是患者会按一个不存在的优惠上门、前台兑现不了。

## 失效规则:**删除**,不是置 pending

置 pending 会把旧 content 留在库里,任何一条**只读 content 不看 status** 的路径
(现在没有,将来难保)都会把过期福利念出去 —— 而这正是要防的那件事。
删掉则物理上不可能读到;审计不丢(agent_invocations 那条记录还在,丢的只是指针)。

落在两处写路径的**事务里**:
· `create()`  配了福利 → 作废这批的话术缓存
· `revoke()`  批次撤销 → **收回的和没收回的都作废**。没收回的那些(客服已打开过)
  手里正拿着一份写着福利的话术,而福利刚被撤销 —— 那才是最危险的一批,他马上要打电话。

️ 只**作废**不急切重生成:作废是一句 deleteMany(很便宜);重生成是**懒的**,
客服打开详情页时走现有的"点生成"流程。一批 100 人若急切重生成 = 100 次 LLM 调用的
钱和延迟,而其中大部分单可能根本没人打开。懒生成让成本随**真实使用**走,不随批次大小走。

## 福利作事实输入进 prompt(T4),带四条禁令

 不走 `AGENT_IDENTITY_PLACEHOLDER` 那套占位符:那是给 **PII 与缓存**用的
(人名不进 LLM、换客服不用重生成);福利是**内容**不是身份 token,硬插一句会打断口语流,
且三档输出形态差异大,占位符要在每档各实现一次。作为事实输入则三档通用。

护栏的四条禁令,每条都对应一种真实会发生的编造:
  不得追加条件/期限/名额/人群  ← "限本月前 20 名""老客户专享"
  不得改写金额/折扣/项目、不得夸大 ← 把"免费"说成"5折"
  原文没写的一律不说            ← 兜底
  追问细节 → "以到院时前台说明为准" ← 不给出口它就会现编一个条款
标成「硬约束」,与"高龄不主推种植""低龄不承诺能不能种"同级,落点也放在一起。

️ 第一版我在 fact-block(标准/深度)和 stable/prompt(稳健)**各写了一份** ——
那必然会漂,而「哪一档漏了哪条禁令」要等客服念出去才发现。已抽成导出的 `benefitBlock`,
两档共用;spec 里加了一条断言直接读 stable/prompt 源码,拦"图省事再抄一份"。

没配福利 → **整段不生成**,不留空钩子(留了模型会自己编一个)。

## 取数只认 confirmed 的批次

orchestrator 装配时 `plan.assignment.status === 'confirmed'` 才带福利 ——
撤销后的批次福利已不适用,带进去就是念一个作废的优惠。
️ 不判 expiresAt:时效是"客服什么时候该打完",不是"福利什么时候失效";
福利本身的有效期写在文案里(如"8 月…"),由主管负责。

## 验证

900 单测(新增 6 条护栏断言)+ 本地端到端:
  造 6 份「旧话术」(模拟分配前被人点开过)→ 带福利分配 → **剩余 0 份** 
  重新生成含新福利的话术 → 撤销批次 → **剩余 0 份** 
测试数据已清理。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parent 64b8d4fd
......@@ -17,6 +17,33 @@ import { deidentifyDoctor } from './pii';
import { AGENT_IDENTITY_PLACEHOLDER } from './agent-identity';
import { DENTURE_FIRST_AGE, IMPLANT_LAST_AGE, EARLY_ORTHO_MAX_AGE } from '@pac/types';
/**
* 本批次福利 —— 作为**事实**给 LLM,让它自然融进话术(T4)。
*
* ⚠️⚠️ 护栏是这段的重点,不是可选的修饰:
* 模型拿到"8月种植体检免费"这一句,极容易顺手补成"限本月前20名""老客户专享"
* "可叠加其他折扣"——**那就是对患者做出了一个不存在的承诺**,患者按它上门,
* 前台兑现不了。这跟"高龄不主推种植""低龄不承诺能不能种"是同一类硬约束,
* 落点也放在一起。
*
* 没配福利 → 返回空串,**整段不生成**,不留空钩子(免得模型自己去编一个)。
*
* ⭐ **导出**:稳健档(tiers/stable/prompt)也用同一份。护栏文案只能有一处 ——
* 两处各写一份,改了其中一处另一处就悄悄留在旧版本上,而"哪一档漏了哪条禁令"
* 要等到客服照着念了才发现。
*/
export function benefitBlock(benefit?: { text: string } | null): string {
const text = benefit?.text?.trim();
if (!text) return '';
return `\n\n## 本次福利(硬约束)
- 本批次配了福利,原文如下(**只有这一句是真的**):
${text}
- 可以自然地融进话术,但**只能陈述这句话本身包含的内容**。
- ⛔ **不得追加任何条件、期限、名额、人群限定**(如"限本月""前20名""老客户专享")。
- ⛔ **不得改写金额/折扣/项目,不得夸大,不得暗示还能再优惠**。
- ⛔ 原文没写的一律不说;患者追问细节 → "具体以到院时前台说明为准"。`;
}
export function buildRichFactBlock(input: DraftPlanScriptInput): string {
const { patient, clinicName, plan, clinicalContext } = input;
const now = new Date();
......@@ -105,7 +132,7 @@ ${others.length ? `\n## 其他可一并关心的问题(以本次聚焦为主,自
## 患者
- ${basics}
- 熟络度:${relationSignal}(语气怎么拿捏见沟通知识,你按这信号判断)${noXray ? '\n\n## 安全(硬约束)\n- 本患者未满 18 岁或年龄未知:**整篇严禁出现"拍片/拍个片/X光/牙片"等任何拍片表述**' : ''}${dentureFirst ? `\n\n## 高龄沟通(约束)\n- 本患者 ${patient.age} 岁:缺牙修复**先讲活动义齿**(创伤小、周期短),种植可作为并行选项一起提,\n 但**不要主推手术、不要承诺能不能种** —— 统一落到"来院让医生按身体条件评估";措辞更耐心,可提示家属陪同。` : ''}${implantLast ? `\n\n## 低龄沟通(硬约束)\n- 本患者 ${patient.age} 岁,颌骨尚未发育完成:**不要主推种植**(种植体不随颌骨生长,未成年是相对禁忌)。\n 涉及缺牙先讲**间隙管理 / 正畸方向**,修复方式统一落到"来院让医生按发育情况评估",不承诺能不能种。` : ''}${earlyOrtho ? `\n\n## 矫治措辞(约束)\n- 本患者 ${patient.age} 岁处替牙期:涉及矫正一律说「**早期矫治**」(干预颌骨发育与间隙管理),\n 不要说成给恒牙列排齐的"正畸/戴牙套";具体做不做、做哪种,落到"来院让医生评估"。` : ''}`;
- 熟络度:${relationSignal}(语气怎么拿捏见沟通知识,你按这信号判断)${noXray ? '\n\n## 安全(硬约束)\n- 本患者未满 18 岁或年龄未知:**整篇严禁出现"拍片/拍个片/X光/牙片"等任何拍片表述**' : ''}${dentureFirst ? `\n\n## 高龄沟通(约束)\n- 本患者 ${patient.age} 岁:缺牙修复**先讲活动义齿**(创伤小、周期短),种植可作为并行选项一起提,\n 但**不要主推手术、不要承诺能不能种** —— 统一落到"来院让医生按身体条件评估";措辞更耐心,可提示家属陪同。` : ''}${implantLast ? `\n\n## 低龄沟通(硬约束)\n- 本患者 ${patient.age} 岁,颌骨尚未发育完成:**不要主推种植**(种植体不随颌骨生长,未成年是相对禁忌)。\n 涉及缺牙先讲**间隙管理 / 正畸方向**,修复方式统一落到"来院让医生按发育情况评估",不承诺能不能种。` : ''}${earlyOrtho ? `\n\n## 矫治措辞(约束)\n- 本患者 ${patient.age} 岁处替牙期:涉及矫正一律说「**早期矫治**」(干预颌骨发育与间隙管理),\n 不要说成给恒牙列排齐的"正畸/戴牙套";具体做不做、做哪种,落到"来院让医生评估"。` : ''}${benefitBlock(input.benefit)}`;
}
/**
......
......@@ -32,6 +32,24 @@ export interface ScriptContext {
/** 诊所名 —— 防 LLM 编造"XX口腔"的锚;⚠️ 自报家门里**不用**它(身份是"{诊断医生}医生的助理X") */
clinicName: string;
/**
* 本批次配的福利(来自 plan_assignments.attributes.benefit)。null = 没配 → **整段不生成**。
*
* ⭐ 走 prompt 输入而不是确定性占位符,是有意的(T4):
* 占位符那套(`AGENT_IDENTITY_PLACEHOLDER`)是给 **PII 与缓存**用的
* —— 人名不进 LLM、换客服不用重生成。福利是**内容**不是身份 token,
* 硬插一句会打断口语流;且三档输出形态差异大(稳健=模板填空 / 标准=自由段落 /
* 深度=多段分析),占位符要在每档各实现一次。作为事实输入则三档通用。
*
* ⚠️⚠️ **必须带护栏**:LLM 拿到一句"8月种植体检免费"极容易顺手补成
* "限本月前 20 名""老客户专享""可叠加折扣" —— 那就是**对患者做出不存在的承诺**。
* 护栏落点与"高龄义齿 / 低龄种植"那两条年龄约束同款,见 fact-block 与 stable/prompt。
*
* ⚠️ 话术缓存是 per-plan(planId @unique),福利一变必须作废缓存,
* 否则客服会照着上一个批次的福利念。见 plan-assignment.service.invalidateScripts。
*/
benefit?: { text: string } | null;
/** ⚠️ 这里**没有** agent(回访客服)字段,是刻意的 —— 姓名不进 LLM 输入。
* 话术缓存是 per-plan(UNIQUE plan_id)、召回池又共享,烤进人名会让后开的客服读到别人的名字。
* 改成「生成期占位 `【回访客服】` → 渲染期按登录人回填」,见 shared/agent-identity.ts。
......
......@@ -2,7 +2,9 @@ import type { DraftPlanScriptInput } from '../../shared/input.types';
import { smartDateDisplay, toothFriendly } from '../../shared/script-facts';
import { resolveDisease } from './phrasing';
import { deidentifyDoctor } from '../../shared/pii';
import { renderTreatmentPlan, buildPersonaGuide } from '../../shared/fact-block';
// ⭐ benefitBlock 与标准/深度档**共用同一份**护栏文案 —— 两处各写一份必然漂,
// 而「哪一档漏了哪条禁令」要等到客服照着念了才会发现。
import { renderTreatmentPlan, buildPersonaGuide, benefitBlock } from '../../shared/fact-block';
import { AGENT_IDENTITY_PLACEHOLDER } from '../../shared/agent-identity';
import { DENTURE_FIRST_AGE, IMPLANT_LAST_AGE, EARLY_ORTHO_MAX_AGE } from '@pac/types';
......@@ -150,5 +152,5 @@ ${advLines}
- ${basics}
## 语气
- ${toneHint}${noXray ? '\n\n## 安全(硬约束)\n- 本患者未满 18 岁或年龄未知:**整篇严禁出现"拍片/拍个片/X光/牙片"等任何拍片表述**(删除模板里的拍片句)' : ''}${dentureFirst ? `\n\n## 高龄沟通(约束)\n- 本患者 ${patient.age} 岁:缺牙修复**先讲活动义齿**,种植可并行提及,\n 但**不要主推手术、不要承诺能不能种** —— 落到"来院让医生按身体条件评估";措辞更耐心,可提示家属陪同。` : ''}${implantLast ? `\n\n## 低龄沟通(硬约束)\n- 本患者 ${patient.age} 岁,颌骨尚未发育完成:**不要主推种植**(未成年相对禁忌)。缺牙先讲间隙管理 / 正畸方向,\n 修复方式落到"来院让医生按发育情况评估",不承诺能不能种。` : ''}${earlyOrtho ? `\n\n## 矫治措辞(约束)\n- 本患者 ${patient.age} 岁处替牙期:涉及矫正一律说「**早期矫治**」,不要说成给恒牙列排齐的"正畸/戴牙套"。` : ''}${persona ? `\n\n${persona}` : ''}`;
- ${toneHint}${noXray ? '\n\n## 安全(硬约束)\n- 本患者未满 18 岁或年龄未知:**整篇严禁出现"拍片/拍个片/X光/牙片"等任何拍片表述**(删除模板里的拍片句)' : ''}${dentureFirst ? `\n\n## 高龄沟通(约束)\n- 本患者 ${patient.age} 岁:缺牙修复**先讲活动义齿**,种植可并行提及,\n 但**不要主推手术、不要承诺能不能种** —— 落到"来院让医生按身体条件评估";措辞更耐心,可提示家属陪同。` : ''}${implantLast ? `\n\n## 低龄沟通(硬约束)\n- 本患者 ${patient.age} 岁,颌骨尚未发育完成:**不要主推种植**(未成年相对禁忌)。缺牙先讲间隙管理 / 正畸方向,\n 修复方式落到"来院让医生按发育情况评估",不承诺能不能种。` : ''}${earlyOrtho ? `\n\n## 矫治措辞(约束)\n- 本患者 ${patient.age} 岁处替牙期:涉及矫正一律说「**早期矫治**」,不要说成给恒牙列排齐的"正畸/戴牙套"。` : ''}${benefitBlock(input.benefit)}${persona ? `\n\n${persona}` : ''}`;
}
......@@ -442,7 +442,12 @@ export class PlanScriptOrchestrator {
private async loadPlanContext(planId: string) {
const plan = await this.prisma.followupPlan.findUnique({
where: { id: planId },
include: { reasons: { orderBy: { priorityScore: 'desc' }, take: 3 } },
include: {
reasons: { orderBy: { priorityScore: 'desc' }, take: 3 },
// ⭐ 本批次的福利要进话术(T4)。⚠️ 只认 **confirmed** 的批次 ——
// 撤销后的批次福利已不适用,带进去就是对患者念一个作废的优惠。
assignment: { select: { status: true, attributes: true, expiresAt: true } },
},
});
if (!plan) throw new NotFoundException(`Plan ${planId} not found`);
......@@ -601,6 +606,12 @@ export class PlanScriptOrchestrator {
// 临时:hardcoded jvs-dw 诊所字典(TODO #56 接 host 字典或新建 clinics 表)
// ⚠️ 直接吐 UUID 进 prompt 会让 LLM 编造"XX 客服中心",必须翻译成中文名
clinicName: resolveClinicName(plan.targetClinicId),
// 福利:只有批次仍 confirmed 时才带。⚠️ 不判 expiresAt —— 时效是"客服什么时候该打完",
// 不是"福利什么时候失效";福利本身的有效期写在文案里(如"8月…"),由主管负责。
benefit:
plan.assignment?.status === 'confirmed'
? readBenefitText(plan.assignment.attributes)
: null,
plan: {
primaryScenarioLabel: plan.reasons[0]
? planScenarioLabel(plan.reasons[0].scenario)
......@@ -1085,7 +1096,8 @@ function countCompletedTreatments(facts: FactRow[]): number {
// ─────────────────────────────────────────────
type PlanWithReasons = Prisma.FollowupPlanGetPayload<{
include: { reasons: true };
// assignment:本批次的福利要进话术(T4);select 收窄,别把整个批次拖进来
include: { reasons: true; assignment: { select: { status: true; attributes: true; expiresAt: true } } };
}>;
type PatientRow = Prisma.PatientGetPayload<{}>;
......@@ -1095,3 +1107,14 @@ type PersonaWithFeatures = Prisma.PersonaGetPayload<{
}>;
type FactRow = Prisma.PatientFactGetPayload<{}>;
/**
* 批次福利文本。⚠️ 别在多处 `as any` 解这个 JSON —— 解错了不报错,只是福利段静默消失。
* (同名函数在 plan-assignment.service 里也有一份:那边是写路径、这边是读路径,
* 刻意各自持有,避免为一个三行函数在两个模块间建依赖。改口径时两处都要改。)
*/
function readBenefitText(attributes: unknown): { text: string } | null {
const a = attributes as { benefit?: { text?: string } } | null | undefined;
const t = a?.benefit?.text;
return typeof t === 'string' && t.trim() ? { text: t.trim() } : null;
}
......@@ -308,6 +308,11 @@ export class PlanAssignmentService {
);
}
// ⭐ 配了福利 → 作废这批的话术缓存,否则福利段永远不会出现(见 invalidateScripts)
if (readBenefitText(dto.attributes) && applied.length > 0) {
await this.invalidateScripts(tx, applied, `批次 ${head.id.slice(0, 8)} 配了福利`);
}
return { head, applied: applied.length, lost };
},
// 500 条 × (updateMany + 事件) 的余量;maxWait 是等连接池的时间,不是执行时间
......@@ -637,6 +642,12 @@ export class PlanAssignmentService {
})),
);
}
// ⭐ 批次撤销 → 福利不再适用。**收回的和没收回的都要作废**:
// 没收回的那些(客服已打开过)手里正拿着一份写着福利的话术,
// 而这个福利刚刚被撤销了 —— 那才是最危险的一批,因为他马上就要打电话。
if (readBenefitText(head.attributes)) {
await this.invalidateScripts(tx, plans.map((p) => p.id), `批次 ${assignmentId.slice(0, 8)} 已撤销`);
}
await tx.planAssignment.update({
where: { id: assignmentId },
data: { status: 'revoked', revokedAt: now, revokedBy: actor.userId },
......@@ -661,6 +672,42 @@ export class PlanAssignmentService {
};
}
/**
* 作废这批 plan 的话术缓存 —— **福利变了就必须作废**。
*
* ── 为什么这是唯一一条「做错了会对患者做出虚假承诺」的路 ──────
* `plan_scripts.planId` 是 **@unique**(一 plan 一话术),而话术只在有人点「生成」时才跑,
* 之后一直读缓存。召回池又是共享的、未分配的单谁都能打开触发生成。三段后果:
* ① 分配**之前**有人点开过 → 话术已 ready 落库,**福利段永远不会出现**,T4 的归因目的失效
* ② plan 在批次 A(福利「8月种植体检免费」)→ 退回 → 进批次 B(福利「正畸首付5折」)
* → 话术缓存**还是 A 的文案**,客服照着念 = **对患者做了一个不存在的承诺**
* ③ 撤销批次后,话术里的福利仍在
* ②③ 不是"体验不好",是真的会让患者按一个不存在的优惠上门。
*
* ── 为什么是**删除**而不是置 status='pending' ────────────────
* 置 pending 会把旧 content 留在库里。任何一条**只读 content 不看 status** 的代码路径
* (现在没有,将来难保)都会把过期福利念出去 —— 而这正是要防的那件事。
* 删掉则物理上不可能读到。审计不丢:agent_invocations 那条记录本身还在,
* 丢的只是一个指针。
*
* ── 成本 ────────────────────────────────────────────────
* ⚠️ 只**作废**不重生成。作废是一句 deleteMany(在事务里,很便宜);
* 重生成是**懒的** —— 客服打开详情页时走现有的"点生成"流程。
* 一批 100 人若急切重生成 = 100 次 LLM 调用的钱和延迟,而其中大部分单可能根本没人打开。
* 懒生成让成本随**真实使用**走,不随批次大小走。
*/
private async invalidateScripts(
tx: Prisma.TransactionClient,
planIds: string[],
why: string,
): Promise<void> {
if (planIds.length === 0) return;
const r = await tx.planScript.deleteMany({ where: { planId: { in: planIds } } });
if (r.count > 0) {
this.logger.log(`话术缓存作废 ${r.count} 条(${why})—— 下次打开详情页时重新生成`);
}
}
/** 按幂等键回查已有批次(命中即视为重放) */
private async findByRequestId(
scope: TenantScopeContext,
......
import { benefitBlock } from '../src/modules/ai/calls/draft-plan-script/shared/fact-block';
/**
* 福利进话术的护栏回归。
*
* 🔴 这是整个分配功能里**唯一一条「做错了会对患者做出虚假承诺」**的路:
* 模型拿到一句「8 月种植体检免费」,极容易顺手补成「限本月前 20 名」「老客户专享」
* 「可叠加其他折扣」—— 患者按它上门,前台兑现不了。
*
* 护栏本身是提示词,单测拦不住 LLM 的自由发挥;这里锁的是**护栏必须在场**、
* 以及**没配福利时整段不出现**(留个空钩子模型就会自己编一个)。
*/
describe('福利进话术 —— 护栏必须在场', () => {
const block = benefitBlock({ text: '8 月种植体检免费' });
test('⭐ 福利原文原样出现,且标明「只有这一句是真的」', () => {
expect(block).toContain('8 月种植体检免费');
expect(block).toContain('只有这一句是真的');
});
test('⭐⭐ 四条禁令一条都不能少 —— 少哪条模型就会在哪条上自由发挥', () => {
// 每一条都对应一种真实会发生的编造:
expect(block).toContain('不得追加任何条件、期限、名额、人群限定'); // "限本月前20名"
expect(block).toContain('不得改写金额/折扣/项目'); // 把"免费"说成"5折"或反之
expect(block).toContain('不得夸大'); // "史上最低"
expect(block).toContain('原文没写的一律不说'); // 兜底
});
test('⭐ 追问细节要有出口,否则模型会现编一个条款', () => {
expect(block).toContain('以到院时前台说明为准');
});
test('标成硬约束,与「高龄不主推种植」同级 —— 不是可选修饰', () => {
expect(block).toContain('本次福利(硬约束)');
});
});
describe('福利进话术 —— 没配就整段不生成', () => {
test('⭐⭐ 不留空钩子:留了模型会自己编一个福利出来', () => {
for (const empty of [undefined, null, { text: '' }, { text: ' ' }]) {
expect(benefitBlock(empty)).toBe('');
}
});
});
describe('两档共用同一份护栏', () => {
test('⭐ 稳健档与标准/深度档用的是**同一个函数** —— 各写一份必然漂', () => {
// 这条断言看着废,实际拦的是"有人为了图省事在 stable/prompt 里再抄一份"。
// 抄了之后改护栏只会改到一处,而"哪一档漏了哪条禁令"要等客服念出去才发现。
const stableSrc = require('node:fs').readFileSync(
require('node:path').join(__dirname, '../src/modules/ai/calls/draft-plan-script/tiers/stable/prompt.ts'),
'utf-8',
) as string;
expect(stableSrc).toContain('benefitBlock');
expect(stableSrc).not.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