Commit 0f767c19 by luoqi

fix(简报/话术): 回访摘要说事实,别念状态字段;天数不再乱飘

- 回访摘要改读 result/followContent, 不再念「任务已完成」这类状态字段 ——
  主管要的是事实不是流程
- 「—」这类空占位不再成行:列不出来的就不展示
- daysText:Math.round(74/30)=2 而 Math.round(75/30)=3,相差一天的两次回访
  被渲染成 2 个月 vs 3 个月。改成按天分档 + 30.44,再修 floor(365/30.44)=11
  让整年读成 11 个月
- 🔴 标签泄漏三次同一形状(cold_3y → 本批 → 备注):**输入里出现的任何标签
  都可能被原样抄进输出**。逐一堵掉

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parent 77bf6bd1
...@@ -44,7 +44,7 @@ buildContext(plan_reasons + facts + persona, tier) ...@@ -44,7 +44,7 @@ buildContext(plan_reasons + facts + persona, tier)
→ 落 plan_scripts(status=ready)+ agent_invocations(tier/model/token/cost/source) → 落 plan_scripts(status=ready)+ agent_invocations(tier/model/token/cost/source)
``` ```
**stable**:单次 AiCall 产固定 4 段 —— `opening`(开场)/ `informMissed`(告知应治未治,单项)/ `reviewAdvice`(复查建议,含`【时间段】`占位)/ `closing`(结束语,成功/失败两分支)+ `tone`。失败 → `stableTemplateFallback`(不调 LLM,从 `disease-knowledge` 拼 4 段)。 **stable**:单次 AiCall 产固定 4 段 —— `opening`(开场)/ `informMissed`(告知潜在治疗,单项)/ `reviewAdvice`(复查建议,含`【时间段】`占位)/ `closing`(结束语,成功/失败两分支)+ `tone`。失败 → `stableTemplateFallback`(不调 LLM,从 `disease-knowledge` 拼 4 段)。
**standard**:单次 AiCall,输出 `sections[{title, markdown}]`(固定 4 段但 LLM 自定标题/结构)。失败 → stable 模板转 sections。 **standard**:单次 AiCall,输出 `sections[{title, markdown}]`(固定 4 段但 LLM 自定标题/结构)。失败 → stable 模板转 sections。
......
...@@ -8,7 +8,7 @@ import { DraftPlanScriptCall } from './calls/draft-plan-script/tiers/stable/stab ...@@ -8,7 +8,7 @@ import { DraftPlanScriptCall } from './calls/draft-plan-script/tiers/stable/stab
import { StandardScriptCall } from './calls/draft-plan-script/tiers/standard/standard.call'; import { StandardScriptCall } from './calls/draft-plan-script/tiers/standard/standard.call';
import { DeepPlanCall, DeepWriteCall, DeepVerifyCall } from './calls/draft-plan-script/tiers/deep/calls'; import { DeepPlanCall, DeepWriteCall, DeepVerifyCall } from './calls/draft-plan-script/tiers/deep/calls';
import { DeepScriptStrategy } from './calls/draft-plan-script/tiers/deep/deep.strategy'; import { DeepScriptStrategy } from './calls/draft-plan-script/tiers/deep/deep.strategy';
import { WecomPlanCall, WecomWriteCall, WecomVerifyCall } from './calls/draft-wecom-script/calls'; import { WecomWriteCall } from './calls/draft-wecom-script/calls';
import { WecomScriptStrategy } from './calls/draft-wecom-script/wecom.strategy'; import { WecomScriptStrategy } from './calls/draft-wecom-script/wecom.strategy';
import { WecomScriptOrchestrator } from './orchestrators/wecom-script.orchestrator'; import { WecomScriptOrchestrator } from './orchestrators/wecom-script.orchestrator';
import { DraftPlanScriptSkillRegistry } from './calls/draft-plan-script/shared/skill-registry.service'; import { DraftPlanScriptSkillRegistry } from './calls/draft-plan-script/shared/skill-registry.service';
...@@ -57,9 +57,7 @@ import { PlanModule } from '../plan/plan.module'; ...@@ -57,9 +57,7 @@ import { PlanModule } from '../plan/plan.module';
DeepVerifyCall, // 深度档 步骤3 独立对抗校验 DeepVerifyCall, // 深度档 步骤3 独立对抗校验
DeepScriptStrategy, // 深度档 3 步编排(plan→write→verify→repair→兜底) DeepScriptStrategy, // 深度档 3 步编排(plan→write→verify→repair→兜底)
// AI calls — 企微话术(只有深度档;一次性单块,⛔ 无模板兜底,见 wecom.strategy 文件头) // AI calls — 企微话术(只有深度档;一次性单块,⛔ 无模板兜底,见 wecom.strategy 文件头)
WecomPlanCall,
WecomWriteCall, WecomWriteCall,
WecomVerifyCall,
WecomScriptStrategy, WecomScriptStrategy,
DraftPlanScriptSkillRegistry, // scan & cache draft-plan-script/**​/skills/**​/SKILL.md DraftPlanScriptSkillRegistry, // scan & cache draft-plan-script/**​/skills/**​/SKILL.md
DraftPlanSummaryCall, DraftPlanSummaryCall,
......
...@@ -56,10 +56,21 @@ export function buildRichFactBlock(input: DraftPlanScriptInput): string { ...@@ -56,10 +56,21 @@ export function buildRichFactBlock(input: DraftPlanScriptInput): string {
clinicalContext.daysSinceLastVisit != null clinicalContext.daysSinceLastVisit != null
? new Date(now.getTime() - clinicalContext.daysSinceLastVisit * 86400_000) ? new Date(now.getTime() - clinicalContext.daysSinceLastVisit * 86400_000)
: null; : null;
const lastVisitDisplay = /**
smartDateDisplay(lastVisitDate, now) ?? * ⛔ 拿不到末诊就说「上次」,**不许回落到诊断日**。
(top?.triggerDate ? smartDateDisplay(new Date(top.triggerDate), now) : null) ?? *
'上次'; * 原来这里有一段 `?? top.triggerDate` 的回落 —— 它把**诊断日**贴上「最近一次就诊」的标签,
* LLM 就会说"自从您[诊断日]来过之后",而患者在那之后完全可能又来过。
* 实测(2026-08-05 本地库):2,714 条在跑单里 **788 条(29%)** 患者末诊比诊断晚 30 天以上,
* 这两个日期差得很远,混用就是说错话。
*
* 当前数据每个患者都有就诊记录,所以那段回落一次都没触发过 —— 是颗**哑弹**:
* 遇到摄入不全的宿主(只有诊断没有接诊记录)就会炸,且不报错。
* 口径同「⛔ 不明确的不要输出」:宁可模糊说"上次",不拿另一个语义的日期冒充。
*
* 诊断日**另有出口** —— 病历块的「接诊日期」(见下方 mrBlock),两个时间各司其职。
*/
const lastVisitDisplay = smartDateDisplay(lastVisitDate, now) ?? '上次';
const chiefComplaint = top?.medicalRecord?.chiefComplaint ?? clinicalContext.lastChiefComplaint ?? null; const chiefComplaint = top?.medicalRecord?.chiefComplaint ?? clinicalContext.lastChiefComplaint ?? null;
const diseaseLabel = resolveDiseaseLabel(top ?? null, plan.primaryScenarioLabel); const diseaseLabel = resolveDiseaseLabel(top ?? null, plan.primaryScenarioLabel);
......
...@@ -206,7 +206,7 @@ export interface DraftPlanScriptOutput { ...@@ -206,7 +206,7 @@ export interface DraftPlanScriptOutput {
/** 第一部分·开场白 markdown(以医生名义 + 智能称呼 + 智能日期 + 自报家门) */ /** 第一部分·开场白 markdown(以医生名义 + 智能称呼 + 智能日期 + 自报家门) */
opening: string; opening: string;
/** 第二部分·告知应治未治 markdown(成人 4 句/儿童 5 句:现状/风险/关怀/专业建议,只讲单个应治未治项) */ /** 第二部分·告知潜在治疗 markdown(成人 4 句/儿童 5 句:现状/风险/关怀/专业建议,只讲单个应治未治项) */
informMissed: string; informMissed: string;
/** 第三部分·复查建议 markdown(成人 4 句/儿童 5 句:重要性/维护/复查时长/引导预约【时间段】) */ /** 第三部分·复查建议 markdown(成人 4 句/儿童 5 句:重要性/维护/复查时长/引导预约【时间段】) */
...@@ -216,7 +216,7 @@ export interface DraftPlanScriptOutput { ...@@ -216,7 +216,7 @@ export interface DraftPlanScriptOutput {
closing: string; closing: string;
/** ⭐ 段落标题(标准/深度档:LLM 为 4 段各自起的小标题;稳健档不出 → UI 回退固定标题)。 /** ⭐ 段落标题(标准/深度档:LLM 为 4 段各自起的小标题;稳健档不出 → UI 回退固定标题)。
* 设计:稳健档标题固定(开场白/告知应治未治/复查建议/结束回访语);标准档"标题不定",由 LLM 编排。 */ * 设计:稳健档标题固定(开场白/告知潜在治疗/复查建议/结束回访语);标准档"标题不定",由 LLM 编排。 */
sectionTitles?: { sectionTitles?: {
opening?: string; opening?: string;
informMissed?: string; informMissed?: string;
......
...@@ -14,7 +14,7 @@ import { diseaseLabelForSubKey } from '../../shared/disease-knowledge'; ...@@ -14,7 +14,7 @@ import { diseaseLabelForSubKey } from '../../shared/disease-knowledge';
import { diagnosisCodeNameZh } from '@pac/types'; import { diagnosisCodeNameZh } from '@pac/types';
// ───────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────
// 漏诊项关键要点配置(稳健档"告知应治未治"小节2/3 灵活组合用) // 漏诊项关键要点配置(稳健档"告知潜在治疗"小节2/3 灵活组合用)
// 渐进式披露:user prompt 只塞**命中那一个病种**的要点,不发全表。 // 渐进式披露:user prompt 只塞**命中那一个病种**的要点,不发全表。
// ───────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────
export interface MissedKeyPoints { export interface MissedKeyPoints {
......
...@@ -2,7 +2,7 @@ import { z } from 'zod'; ...@@ -2,7 +2,7 @@ import { z } from 'zod';
import { ToneEnum, TONE_DESCRIBE } from '../../shared/tone'; import { ToneEnum, TONE_DESCRIBE } from '../../shared/tone';
/** /**
* 稳健档 4 段输出 schema(顺序固定:开场白 → 告知应治未治 → 复查建议 → 结束回访语)。 * 稳健档 4 段输出 schema(顺序固定:开场白 → 告知潜在治疗 → 复查建议 → 结束回访语)。
* *
* ⚠️ describe 会被注入 system,所以这里**只描述段用途 + 格式 + 关键硬约束**, * ⚠️ describe 会被注入 system,所以这里**只描述段用途 + 格式 + 关键硬约束**,
* 详细写法(开场顺序 / 句位 / 措辞)以 system 提示词(format.md + 人群句位模板)为单一源, * 详细写法(开场顺序 / 句位 / 措辞)以 system 提示词(format.md + 人群句位模板)为单一源,
...@@ -20,7 +20,7 @@ export const DraftPlanScriptSchema = z.object({ ...@@ -20,7 +20,7 @@ export const DraftPlanScriptSchema = z.object({
informMissed: z informMissed: z
.string() .string()
.describe('第二部分·告知应治未治(约 80-400 字)。**只讲本次一个 {应治未治项}**,温和提醒非推销;markdown `• ` 短句分行。'), .describe('第二部分·告知潜在治疗(约 80-400 字)。**只讲本次一个 {应治未治项}**,温和提醒非推销;markdown `• ` 短句分行。'),
reviewAdvice: z reviewAdvice: z
.string() .string()
......
# 输出结构(稳健档:固定 4 模块) # 输出结构(稳健档:固定 4 模块)
4 段 Markdown 字段,顺序固定、缺一不可、不可乱序: 4 段 Markdown 字段,顺序固定、缺一不可、不可乱序:
1. `opening` 开场白 2. `informMissed` 告知应治未治 3. `reviewAdvice` 复查建议 4. `closing` 结束回访语 1. `opening` 开场白 2. `informMissed` 告知潜在治疗 3. `reviewAdvice` 复查建议 4. `closing` 结束回访语
# 占位符约定(两种,别搞混) # 占位符约定(两种,别搞混)
- `{xxx}` = **替换**:用"本次回访患者信息"里给的同名值填(如 {智能称呼}{应治未治项}{牙位}{诊断医生}{风险要点}{复查时长});输出里不能再出现 `{}` - `{xxx}` = **替换**:用"本次回访患者信息"里给的同名值填(如 {智能称呼}{应治未治项}{牙位}{诊断医生}{风险要点}{复查时长});输出里不能再出现 `{}`
...@@ -11,7 +11,7 @@ ...@@ -11,7 +11,7 @@
- 开场顺序固定:先用 {智能称呼} 称呼并确认对方 → 再 {自报家门}(内含【回访客服】,整串照抄;身份是**医生的助理**,别改写成"客服/顾问") → 以 {诊断医生} 名义体现关怀 → 用 {智能时间显示} 问近况。 - 开场顺序固定:先用 {智能称呼} 称呼并确认对方 → 再 {自报家门}(内含【回访客服】,整串照抄;身份是**医生的助理**,别改写成"客服/顾问") → 以 {诊断医生} 名义体现关怀 → 用 {智能时间显示} 问近况。
- 健康提醒从 {风险要点} 里挑、检查说明用 {复查时长} 原文;给定值直接用,不改写、不重算。 - 健康提醒从 {风险要点} 里挑、检查说明用 {复查时长} 原文;给定值直接用,不改写、不重算。
- 引导预约严格用:「{诊断医生}医生【时间段1】和【时间段2】这两个时间段有空,您看哪个方便?」 - 引导预约严格用:「{诊断医生}医生【时间段1】和【时间段2】这两个时间段有空,您看哪个方便?」
- 告知应治未治、复查建议分成短句。 - 告知潜在治疗、复查建议分成短句。
# 输出前自查(只查高风险) # 输出前自查(只查高风险)
- 没编造?医生 / 诊断 / 牙位 / 时间都来自给定事实。 - 没编造?医生 / 诊断 / 牙位 / 时间都来自给定事实。
......
...@@ -19,7 +19,7 @@ tiers: ['stable'] ...@@ -19,7 +19,7 @@ tiers: ['stable']
- •(熟客可加:{诊断医生}医生上次还和我提起您呢) - •(熟客可加:{诊断医生}医生上次还和我提起您呢)
- • 您自从{智能时间显示}来过之后,口腔情况怎么样? - • 您自从{智能时间显示}来过之后,口腔情况怎么样?
## 第二部分 · 告知应治未治(4 短句,温和提醒、非推销) ## 第二部分 · 告知潜在治疗(4 短句,温和提醒、非推销)
- **小节1 现状描述**:以"之前{诊断医生}医生检查时注意到…"的口吻(指**诊断那次**,可能比最近一次就诊更早;**别说"上次"**以免和开场的最近就诊混);给了 {牙位} 就自然带上(如"您{牙位}有…"),没给则不提牙位。别说"我们发现了…"。 - **小节1 现状描述**:以"之前{诊断医生}医生检查时注意到…"的口吻(指**诊断那次**,可能比最近一次就诊更早;**别说"上次"**以免和开场的最近就诊混);给了 {牙位} 就自然带上(如"您{牙位}有…"),没给则不提牙位。别说"我们发现了…"。
✅ 之前{诊断医生}医生给您检查时,注意到您{牙位}有{应治未治项}的情况 / {诊断医生}医生那次提到您有一点{应治未治项}的问题 ✅ 之前{诊断医生}医生给您检查时,注意到您{牙位}有{应治未治项}的情况 / {诊断医生}医生那次提到您有一点{应治未治项}的问题
- **小节2 健康提醒**:从 {风险要点} 灵活挑 3~4 条,每句一个重点,口语、不堆砌、不吓唬。 - **小节2 健康提醒**:从 {风险要点} 灵活挑 3~4 条,每句一个重点,口语、不堆砌、不吓唬。
......
...@@ -19,7 +19,7 @@ tiers: ['stable'] ...@@ -19,7 +19,7 @@ tiers: ['stable']
- •(熟客可加:{诊断医生}医生上次还和我提起宝宝呢) - •(熟客可加:{诊断医生}医生上次还和我提起宝宝呢)
- • 宝宝自从{智能时间显示}来过之后,牙齿/口腔情况怎么样? - • 宝宝自从{智能时间显示}来过之后,牙齿/口腔情况怎么样?
## 第二部分 · 告知应治未治(分短句,对家长温和提醒、非吓唬) ## 第二部分 · 告知潜在治疗(分短句,对家长温和提醒、非吓唬)
- **小节1 现状描述**:以"之前{诊断医生}医生检查时注意到宝宝…"的口吻带出本次 {应治未治项}(指诊断那次,可能早于最近一次就诊,**别说"上次"**以免和开场混);给了 {牙位} 就自然带上(如"宝宝{牙位}…"),没给则不提牙位。 - **小节1 现状描述**:以"之前{诊断医生}医生检查时注意到宝宝…"的口吻带出本次 {应治未治项}(指诊断那次,可能早于最近一次就诊,**别说"上次"**以免和开场混);给了 {牙位} 就自然带上(如"宝宝{牙位}…"),没给则不提牙位。
- **小节2 健康提醒**:从 {风险要点} 挑 1~2 条,用家长听得懂的话说"不处理可能会怎样"(口语、不堆砌、不吓唬)。 - **小节2 健康提醒**:从 {风险要点} 挑 1~2 条,用家长听得懂的话说"不处理可能会怎样"(口语、不堆砌、不吓唬)。
- **小节3 趁早关怀**:结合 {治疗优势},用"趁现在 / 趁换牙期早干预"说早处理的好处。 - **小节3 趁早关怀**:结合 {治疗优势},用"趁现在 / 趁换牙期早干预"说早处理的好处。
......
...@@ -21,7 +21,7 @@ export const StandardScriptSchema = z.object({ ...@@ -21,7 +21,7 @@ export const StandardScriptSchema = z.object({
// 不加 .min()/.max() 硬约束,长度/段数只作 describe 软引导(对中文偏严 + qwen3.7-max 简洁易误伤) // 不加 .min()/.max() 硬约束,长度/段数只作 describe 软引导(对中文偏严 + qwen3.7-max 简洁易误伤)
title: z title: z
.string() .string()
.describe('该段小标题(约 2-20 字):你自起、自然口语贴这通电话,别用"开场白/告知应治未治/复查建议/结束回访语"这类刻板模板名'), .describe('该段小标题(约 2-20 字):你自起、自然口语贴这通电话,别用"开场白/告知潜在治疗/复查建议/结束回访语"这类刻板模板名'),
markdown: z markdown: z
.string() .string()
.describe('该段正文(约 30-400 字):分短句、行首 `•`;接地病历不编造;具体时间一律用【时间段】占位;无大标题/分隔符/表情'), .describe('该段正文(约 30-400 字):分短句、行首 `•`;接地病历不编造;具体时间一律用【时间段】占位;无大标题/分隔符/表情'),
......
...@@ -16,32 +16,49 @@ const safetyRules: ReadonlyArray<SafetyRule<DraftRecallBriefOutput>> = [ ...@@ -16,32 +16,49 @@ const safetyRules: ReadonlyArray<SafetyRule<DraftRecallBriefOutput>> = [
name: 'no_forbidden_phrases', name: 'no_forbidden_phrases',
severity: 'block', severity: 'block',
check(output) { check(output) {
const hit = FORBIDDEN_PHRASES.filter((p) => output.summary.includes(p)); // 四段拼起来一起扫 —— ⛔ 别只扫 problem:禁词出现在 hook 里同样会被客服念出去
const all = [output.who, output.history, output.problem, output.hook].join(' ');
const hit = FORBIDDEN_PHRASES.filter((p) => all.includes(p));
return { pass: hit.length === 0, message: hit.length > 0 ? `命中禁词: ${hit.join(',')}` : undefined }; return { pass: hit.length === 0, message: hit.length > 0 ? `命中禁词: ${hit.join(',')}` : undefined };
}, },
}, },
]; ];
/** LLM 失败 / safety 拒收时:用召回原因拼一句最朴素的简报。 */ /**
* LLM 失败 / safety 拒收时:用召回原因拼**四段**最朴素的简报。
* ⚠️ 四段都要有内容 —— 空串会让界面出现没头没尾的空行(见 schema 注释)。
*/
function fallback(input: DraftRecallBriefInput): DraftRecallBriefOutput { function fallback(input: DraftRecallBriefInput): DraftRecallBriefOutput {
const r = input.reasons[0]; const r = input.reasons[0];
if (!r) return { summary: '暂无明确召回原因。' }; const who = input.persona.length > 0 ? input.persona.map((p) => p.value).slice(0, 2).join('、') : '—';
const history =
input.returnVisitHistory.length > 0
? `${input.returnVisitHistory[0]!.atText}${input.returnVisitHistory[0]!.type ?? '回访'}`
: '此前无回访记录';
if (!r) return { who, history, problem: '暂无明确召回原因', hook: '可约复查请医生评估' };
const cats = r.expectedCategories.join(' / '); const cats = r.expectedCategories.join(' / ');
const parts = [ const problem = [
r.tooth ? `${r.tooth}` : null, r.subLabel,
r.diagnosis, [r.tooth ? `${r.tooth}` : null, r.diagnosis, `${r.daysSinceText}`, cats ? `未启动 ${cats}` : null]
`${r.daysSinceText}前`, .filter(Boolean)
cats ? `未启动 ${cats}` : null, .join(' · '),
].filter(Boolean); ]
// 切入尾巴(第④问,按优先级):医生计划/建议/医嘱(带原话) > 洁牙锚点(带时间) > 通用复查邀约 .filter(Boolean)
.join(':');
// 切入(第④点,按优先级):本批福利 > 医生计划/建议/医嘱(带原话) > 洁牙锚点(带时间) > 通用复查邀约
const g0 = input.doctorGuidance.find((g) => g.verbatim) ?? input.doctorGuidance[0]; const g0 = input.doctorGuidance.find((g) => g.verbatim) ?? input.doctorGuidance[0];
const KIND_VERB = { plan: '已有计划', recommendation: '建议过', advice: '医嘱交代过' } as const; const KIND_VERB = { plan: '已有计划', recommendation: '建议过', advice: '医嘱交代过' } as const;
const entry = g0 const hook = input.benefitText?.trim()
? `;医生${KIND_VERB[g0.kind]}${g0.verbatim ? `「${g0.verbatim}」` : g0.label},可约复查跟进` ? `本批有「${input.benefitText.trim()},可约复查跟进`
: input.reviewAnchors.lastCleaningText : g0
? `;距上次洁牙/检查 ${input.reviewAnchors.lastCleaningText},可约复查切入` ? `医生${KIND_VERB[g0.kind]}${g0.verbatim ? `「${g0.verbatim}」` : g0.label},可约复查跟进`
: ';可约复查请医生评估'; : input.reviewAnchors.lastCleaningText
return { summary: `${r.subLabel}:${parts.join(' · ')}${entry}。` }; ? `距上次洁牙/检查 ${input.reviewAnchors.lastCleaningText},可约复查切入`
: '可约复查请医生评估';
return { who, history, problem, hook };
} }
@Injectable() @Injectable()
......
...@@ -51,9 +51,64 @@ export interface DraftRecallBriefInput { ...@@ -51,9 +51,64 @@ export interface DraftRecallBriefInput {
tooth: string | null; tooth: string | null;
atText: string | null; atText: string | null;
}>; }>;
/**
* 历史回访联系(第②点的事实依据)—— 最近几条,新到旧。
* ⚠️ 只给**已发生**的联系记录,⛔ 别把未来的预约回访塞进来:
* 第②点要回答"以前怎么联系的、结果如何",把还没发生的说成历史就是编。
* 空数组 = 从没联系过(新客),第②点如实说"此前无回访记录",⛔ 不许留白也不许编。
*/
returnVisitHistory: Array<{
/// 距今(如 2 个月前)。⚠️ 相邻两条可能落到同一个粗粒度描述上,故 outcomeText 必须能区分
atText: string;
type: string | null; // 回访类型(中文)
/** **这次回访是干什么的**(follow_content):半年洁牙提醒 / 种植潜客 / 术后复查… */
topicText: string | null;
/**
* **结果怎么样**(result):未接 / 诊后回访无不适 / 约时间 / 复查…
*
* 🔴 这个字段一开始**漏喂了**(2026-08-06 实测):没有主题的那条只能输出「没留下结果」,
* 而库里写着「诊后回访未接」,详情页「历史联系」卡据此说了「未接通」——
* 两处一对比,简报既含糊又像少了信息。
* ⚠️ 它比 `reachedText` 可信:实测同一条 status='已回访' 而 result='诊后回访未接'。
* 前者只说明任务被标记过,后者才是真的联系结果。⇒ **有它就以它为准**。
*/
resultText: string | null;
/**
* 联系上没有(已回访 / 未回访)—— **只在结果和主题都为空时**当兜底,且它并不可靠(见上)。
*
* ⛔⛔ **刻意不给 `taskStatus`(已完成 / 未完成)**,⛔ 别"补全"回来:
* ① 那是内部流程状态,客服打电话前用不上(产品 2026-08-06:
* 「回访任务已完成就不要说了,总结备注里的事实就行」);
* ② 两个状态一起喂会让摘要说「已联系且任务已完成」,而详情页「历史联系」卡
* 按 taskStatus 说「未完成」—— 两句都对却像自相矛盾(实测 2026-08-05 王利)。
* 不报流程状态,这个矛盾从根上就不存在。
*/
reachedText: string | null;
}>;
/**
* 本批次福利(第④点的最高优先勾子)—— 主管在分配时写的文案原文;没配 = null。
*
* ⚠️ 只在批次仍 `confirmed` 时给(与话术带福利同一个判据,⛔ 别另立标准)。
* ⛔ **只能说这段原文包含的内容**:不得追加条件/期限/名额,不得夸大 —— 与话术同一条硬约束。
*/
benefitText: string | null;
} }
/**
* 四点简报(2026-08-05 从一句话拆开)。
*
* ⚠️ **逻辑没变,只是输出形态变了** —— 四点各自回答的还是原来那四问,
* 输入要素、优先级(医生原话 > 复查锚点)、防编造约束全部照旧。
* ⚠️ 每点都**必须有内容**:没有事实时如实说"暂无",⛔ 不许空串
* (空串会让界面出现一个没头没尾的空行,比一句"暂无"更让人困惑)。
*/
export interface DraftRecallBriefOutput { export interface DraftRecallBriefOutput {
/** 一句话召回简报(中文,≤50 字最佳)*/ /** ① 患者是谁 —— 画像一句话(生命周期/价值/人群) */
summary: string; who: string;
/** ② 以前怎么联系的 —— 回访历史一句话;从没联系过就说"此前无回访记录" */
history: string;
/** ③ 解决什么 + 不处理的后果 —— 一句话 */
problem: string;
/** ④ 怎么开口 —— 切入勾子;优先级:批次福利 > 医生原话 > 复查锚点 */
hook: string;
} }
import type { DraftRecallBriefInput } from './input.types'; import type { DraftRecallBriefInput } from './input.types';
export const DRAFT_RECALL_BRIEF_PROMPT_VERSION = 'draft_recall_brief@2026-07-21-d'; // ⚠️ 换版本号会让缓存整体失效(这是想要的):输出从一句话变四点,旧缓存不该再命中
/// ⚠️ 改了 system prompt / 段名就要升版本,否则旧缓存命中,改动看不见(-hook 这版:把「本批」清出 hook)
export const DRAFT_RECALL_BRIEF_PROMPT_VERSION = 'draft_recall_brief@2026-08-06-result';
export const DRAFT_RECALL_BRIEF_SYSTEM = `你是牙科诊所客服主管,正在给即将打电话的客服做一句话交底。下面给你这个患者的「本次召回原因 + 历史治疗 + 画像」。请提炼成**一句话召回简报** export const DRAFT_RECALL_BRIEF_SYSTEM = `你是牙科诊所客服主管,正在给即将打电话的客服做交底。下面给你这个患者的「本次召回原因 + 历史治疗 + 画像 + 回访历史 + 福利勾子」。请提炼成**四点召回简报**,每点一句话、各管一件事
# 核心:**站在患者立场说"他为什么该来"** # 核心:**站在患者立场说"他为什么该来"**
这句话的重心不是"诊所要邀约你做 X",而是**从患者角度讲清这件事对他意味着什么、为什么值得来处理**——让客服(以及患者)一听就懂"这跟我有关、该来"。 这句话的重心不是"诊所要邀约你做 X",而是**从患者角度讲清这件事对他意味着什么、为什么值得来处理**——让客服(以及患者)一听就懂"这跟我有关、该来"。
...@@ -14,11 +16,33 @@ export const DRAFT_RECALL_BRIEF_SYSTEM = `你是牙科诊所客服主管,正在 ...@@ -14,11 +16,33 @@ export const DRAFT_RECALL_BRIEF_SYSTEM = `你是牙科诊所客服主管,正在
- 根管后未戴冠 → 牙体脆易裂,套上冠才耐用 - 根管后未戴冠 → 牙体脆易裂,套上冠才耐用
(以上是举例口径;按本患者实际诊断/治疗类目对应着说) (以上是举例口径;按本患者实际诊断/治疗类目对应着说)
# 一句话要含四问,但分量不同 # 四点,各管一件事(⛔ 不要把别点的内容塞进来)
1. 患者是谁 —— 一两个词点一下(如 高价值种植老客 / 新客),做前置修饰。 1. **who** 患者是谁 —— 只用画像标签(生命周期 / 价值分群 / 人群细分)。
2. ⭐**他为什么该来(全句重心)** —— 哪颗牙、什么问题、拖了多久没处理 + **对他的影响 / 越拖越…**。这是句子主干。 2. **history** 上次联系是什么事、结果怎么样 —— 只用「回访历史」段,**直接把那件事说出来**。
3. 到诊做什么 —— 目标治疗动作(种植 / 充填…),一笔带过。 ⭐ 一条回访最多给两样东西:**这次是干什么的**(半年洁牙提醒 / 种植潜客…)和
4. **以什么切入(复查建议,收尾)** —— 打电话的开口台阶:邀约的是**低门槛的复查/检查**,顺带请医生评估目标治疗,不是直接卖大项目。切入依据按优先级取**第一个可用的**,只用一档: **结果**(未接 / 无不适 / 约了时间…)。有几样说几样,都有就一起说。
例:"2 个月前常规回访没打通"、"5 天前常规回访提醒他半年洁牙"、
"6 月术后回访说没不适,当时约了拔牙"。
🔴 ⛔ **不许说「没留下结果」「无记录」这种含糊话** —— 结果栏里通常是有东西的
(实测:库里写着"诊后回访未接",却被说成"没留下结果",而详情页那张卡说的是"未接通",
主管一对比就觉得简报少了信息)。**有什么说什么,没有才退回时间+类型**。
🔴 ⛔ **不许出现「备注」「记录里写」「内容为」这类字眼** —— 那是数据存哪儿的说法,
客服要的是**事情本身**。⛔ 也不许加引号照搬原文,用自己的话说清就行。
⛔「5 天前常规回访,备注半年洁牙提醒」 ✅「5 天前常规回访提醒他半年洁牙」
🔴 ⛔ **不许报流程状态**:「任务已完成 / 未完成 / 已归档」这类是**内部流转**,
客服打电话前一点用没有,写进去只是占字数(产品 2026-08-06 走查点名要去掉)。
⚠️ 给了多条时,挑**信息最多的那条**说(有结果/有主题的优先),⛔ 别只看最新那条。
⚠️ 什么都没有时才说时间 + 类型(例:"5 月做过一次常规回访")。
⛔ 没有记录时**如实写"此前无回访记录"**,不许留白、不许编"曾多次联系";
⛔ 不许把还没发生的预约回访说成历史。
3. **problem** ⭐他为什么该来 —— 哪颗牙、什么问题、拖了多久 + **对他的影响 / 越拖越…**;
目标治疗动作(种植 / 充填…)一笔带过。
4. **hook** 以什么切入 —— 打电话的开口台阶:邀约的是**低门槛的复查/检查**,顺带请医生评估目标治疗,不是直接卖大项目。切入依据按优先级取**第一个可用的**,只用一档:
⓪ **福利勾子**(最优先):「福利勾子」段非空 → 围绕它开口。
⛔ **只能说福利原文包含的内容**,不得追加条件 / 期限 / 名额,不得夸大,不得暗示还能再优惠。
🔴 ⛔ **不许出现「本批 / 这批 / 批次 / 名额」这类词** —— 那是主管派单的内部说法,
客服对着患者根本说不出口("本批有 8 折"患者只会觉得莫名其妙)。
福利就**直接说福利本身**(例:"有全场 8 折,可约复查顺带请医生评估拔牙")。
① **医生计划/建议/医嘱**(最优先):「医生计划/建议/医嘱」段有条目 → 围绕它切入,**必须优先引用条目的 原话:「…」**(太长可精简,但保留关键医嘱词,如"待22萌出后早期矫治""定期复诊洁牙"),别只报大类("正畸计划""洗牙计划"太笼统,客服没法转述)。医嘱里含复查/洁牙/复诊类嘱托是最好的切入("医生医嘱里交代过定期复诊洁牙,正好约上")。 ① **医生计划/建议/医嘱**(最优先):「医生计划/建议/医嘱」段有条目 → 围绕它切入,**必须优先引用条目的 原话:「…」**(太长可精简,但保留关键医嘱词,如"待22萌出后早期矫治""定期复诊洁牙"),别只报大类("正畸计划""洗牙计划"太笼统,客服没法转述)。医嘱里含复查/洁牙/复诊类嘱托是最好的切入("医生医嘱里交代过定期复诊洁牙,正好约上")。
- 原话若是**通用套话**(如"定期复查,不适随诊""常嘱"),信息量为零 → 视同无原话,退回大类或下一档; - 原话若是**通用套话**(如"定期复查,不适随诊""常嘱"),信息量为零 → 视同无原话,退回大类或下一档;
- 多条时挑**与本次召回原因最相关**的一条,不要罗列。这是医生真实交代过的,患者最认。 - 多条时挑**与本次召回原因最相关**的一条,不要罗列。这是医生真实交代过的,患者最认。
...@@ -28,23 +52,33 @@ export const DRAFT_RECALL_BRIEF_SYSTEM = `你是牙科诊所客服主管,正在 ...@@ -28,23 +52,33 @@ export const DRAFT_RECALL_BRIEF_SYSTEM = `你是牙科诊所客服主管,正在
- ⭐**防重复**:同一个天数/时长在整句只能出现一次;若切入锚点的时间与召回原因的"拖了 X"相同或同源(同一次就诊),切入处**不再重报时间**,改用不带数字的措辞。 - ⭐**防重复**:同一个天数/时长在整句只能出现一次;若切入锚点的时间与召回原因的"拖了 X"相同或同源(同一次就诊),切入处**不再重报时间**,改用不带数字的措辞。
# 关键要求 # 关键要求
1. 只输出**一句话**(中文,**≤65 字**最佳,最多 90 字),不要换行/列表/Markdown 1. 输出**四段**,每段一句话、不要换行/列表/Markdown。长度:who ≤20 字、history ≤30 字、problem ≤35 字、hook ≤35 字(均为最佳值)
2. **只能用给出的信息,严禁编造患者的意愿 / 情绪 / 决定**: 2. **只能用给出的信息,严禁编造患者的意愿 / 情绪 / 决定**:
- 「未启动 / 应治未治」是临床缺口,**不是患者意愿**——**绝不能**说成"想做 / 有意向 / 考虑中"。 - 「未启动 / 应治未治」是临床缺口,**不是患者意愿**——**绝不能**说成"想做 / 有意向 / 考虑中"。
- 可以讲"缺口对患者的常识性影响"(上面那类),但**不要编个人化情节、不要编具体数值、不要承诺疗效**。 - 可以讲"缺口对患者的常识性影响"(上面那类),但**不要编个人化情节、不要编具体数值、不要承诺疗效**。
- ⭐**来源标注必须严格遵守**:某条若标【医生建议·非确诊缺失】,说明这颗牙**没有"缺失"确诊**,只是医生建议做某治疗(常因牙坏/松动/位置不正,需先拔再种)。**绝不能**说成"缺牙 / 牙空着 / 缺了X个月",要按**医生建议的治疗动作**措辞(如"医生建议种植""医生建议拔除后修复")。标【影像AI所见】的,措辞用"影像提示…"更谨慎,不当医生确诊。 - ⭐**来源标注必须严格遵守**:某条若标【医生建议·非确诊缺失】,说明这颗牙**没有"缺失"确诊**,只是医生建议做某治疗(常因牙坏/松动/位置不正,需先拔再种)。**绝不能**说成"缺牙 / 牙空着 / 缺了X个月",要按**医生建议的治疗动作**措辞(如"医生建议种植""医生建议拔除后修复")。标【影像AI所见】的,措辞用"影像提示…"更谨慎,不当医生确诊。
3. 客观、口吻是"为患者着想"而非"催单"。**禁止**给医疗建议、禁止承诺一定治好。 3. 客观、口吻是"为患者着想"而非"催单"。**禁止**给医疗建议、禁止承诺一定治好。
4. 历史治疗用来体现"熟客 / 信任基础"(如"做过 2 次种植"),不要凭空夸大。 4. 历史治疗用来体现"熟客 / 信任基础"(如"做过 2 次种植"),不要凭空夸大。
5. 严格按 JSON schema 输出,只有一个 key:summary。 5. 严格按 JSON schema 输出,四个 key:who / history / problem / hook。
6. ⭐**每段都必须有内容** —— 没有事实就如实说"暂无 / 此前无回访记录",⛔ 不许给空串。
# 示例(患者立场、重心在"他为什么该来",句尾带切入建议;风格参考,不要照抄)
- (①治疗计划,引用原话)"张小妹 8 岁反颌未矫;医生计划「待22萌出后择期早期矫治」,可约复查看看牙萌出进度、按计划推进。" # 示例(风格参考,不要照抄)
- (①医嘱含复诊嘱托,引用原话要点)"47 龋齿拖 11 个月未补易伤神经;牙周成长客,医生医嘱交代过「定期复诊洁牙」,正好约上顺带评估补牙。" - (⓪有福利)
- (①治疗计划,原话简短直接用)"47 缺了 4 个月的牙一直空着,越拖邻牙越易移位;种植老客,医生已有「种植取模」计划,可约复查按计划推进。" who: "重要发展新客,累计消费 1.3 万" / history: "此前无回访记录"
- (①医生建议过)"36 的龋齿拖了 3 个月还没补,再放任可能伤神经会疼;新客,医生建议过充填,可约复查评估后尽快补上。" problem: "44 牙周炎拖 3 个月未治,再拖易伤牙龈"
- (②锚点"洁牙 8 个月前",无医生计划)"44 牙周炎拖了半年未治;牙周老客,距上次洁牙已 8 个月,可约洁牙检查顺带请医生评估牙周治疗。" hook: "有「老客洁牙 8 折」,可约洁牙检查顺带评估牙周基础治疗"
- (③无任何依据 → 通用切入,不带时间数字)"26 牙髓炎拖半年未根管,再放任易伤神经疼;成熟客,可约复查请医生评估根管方案。" - (①医生计划,引用原话)
- (防重复:召回拖 53 天、锚点也是 53 天 → 切入不再报数)"27 龋齿拖 53 天未补易伤神经;牙周老客,可约复查顺带请医生评估补牙。"`; who: "种植老客,做过 2 次种植" / history: "6 月常规回访,当时约了拔牙"
problem: "47 缺牙 4 个月一直空着,越拖邻牙越易移位"
hook: "医生已有「种植取模」计划,可约复查按计划推进"
- (②洁牙锚点已超 6 个月)
who: "牙周老客" / history: "3 月常规回访没联系上"
problem: "44 牙周炎拖半年未治"
hook: "距上次洁牙已 8 个月,可约洁牙检查顺带请医生评估牙周治疗"
- (③无任何依据 → 通用切入,不带时间数字)
who: "成熟客" / history: "此前无回访记录"
problem: "26 牙髓炎拖半年未根管,再放任易伤神经疼"
hook: "可约复查请医生评估根管方案"`;
export function buildDraftRecallBriefPrompt(input: DraftRecallBriefInput): string { export function buildDraftRecallBriefPrompt(input: DraftRecallBriefInput): string {
const personaLines = const personaLines =
...@@ -98,6 +132,33 @@ export function buildDraftRecallBriefPrompt(input: DraftRecallBriefInput): strin ...@@ -98,6 +132,33 @@ export function buildDraftRecallBriefPrompt(input: DraftRecallBriefInput): strin
` - 最近一次洁牙/检查:${input.reviewAnchors.lastCleaningText ?? '(无记录)'}`, ` - 最近一次洁牙/检查:${input.reviewAnchors.lastCleaningText ?? '(无记录)'}`,
].join('\n'); ].join('\n');
// 回访历史(第②点)—— 空数组必须**显式说"无"**,留白会让模型自己脑补
const visitLines =
input.returnVisitHistory.length > 0
? input.returnVisitHistory
// 🔴 **这一行不许出现字段名**。原来写的是 `,备注:xxx`,模型直接把「备注」
// 两个字抄进了输出(「5 天前常规回访,备注半年洁牙提醒」)—— 与之前「本批」
// 泄漏是同一个病:**段名/标签会被当措辞用**。所以内容只用冒号接上,不加标签。
// ⛔ 别把 taskStatus 拼回这一行(见 input.types 的注释,那是产品明确要去掉的)。
.map((v) => {
// 结果排在主题后面:「这次是干什么的 → 结果怎么样」,与第②点要说的顺序一致。
// ⛔ 两者都空才退到 status(它并不可靠,见 input.types)。
const facts = [v.topicText, v.resultText].filter(Boolean).join(' → ');
const tail = facts || v.reachedText || '';
return ` - ${v.atText}${v.type ? ` ${v.type}` : ''}${tail ? `:${tail}` : ''}`;
})
.join('\n')
: ' (此前无回访记录)';
// 福利勾子(第④点最高优先)—— ⛔ 只能说这段原文包含的内容,见 system prompt ⓪
//
// ⚠️ 段名叫「福利勾子」**不是**「本批福利」:模型会把段名当措辞抄进 hook,
// 实测出过「本批有全场 8 折优惠」——「本批」是主管派单的内部词,对患者说不通。
// ⛔ 别为了"更准确"把批次概念写回段名。
const benefitLine = input.benefitText?.trim()
? `${input.benefitText.trim()}`
: '(没有福利)';
return `患者:${input.patientNameMasked} return `患者:${input.patientNameMasked}
画像: 画像:
...@@ -105,7 +166,12 @@ ${personaLines} ...@@ -105,7 +166,12 @@ ${personaLines}
历史治疗(类目×次数):${historyLine} 历史治疗(类目×次数):${historyLine}
医生计划/建议/医嘱(切入依据①,最优先;优先引用 原话:「…」): 福利勾子(切入依据⓪,最高优先;⛔ 只能说原文包含的内容,⛔ 不许说「本批/这批」):${benefitLine}
回访历史(第②点唯一依据,新到旧;⛔ 输出里不许出现「备注」二字,直接说事):
${visitLines}
医生计划/建议/医嘱(切入依据①;优先引用 原话:「…」):
${guidanceLines} ${guidanceLines}
复查锚点(切入依据②,仅①为空且超6个月才引用时间): 复查锚点(切入依据②,仅①为空且超6个月才引用时间):
...@@ -114,5 +180,5 @@ ${anchorLines} ...@@ -114,5 +180,5 @@ ${anchorLines}
本次召回原因(应治未治): 本次召回原因(应治未治):
${reasonLines} ${reasonLines}
请按四问揉成一句话召回简报。`; 请按上面四点输出召回简报(who / history / problem / hook)。`;
} }
import { z } from 'zod'; import { z } from 'zod';
/** DraftRecallBrief 输出:一句话召回简报。 */ /**
* DraftRecallBrief 输出:**四点**召回简报(2026-08-05 从一句话拆开)。
*
* ⚠️ **判定逻辑一个字没改** —— 输入要素、优先级(批次福利 > 医生原话 > 复查锚点)、
* 防编造的几条硬约束全部照旧,变的只是"一句话"变成"四句各管一件事"。
* 拆开的理由:原来那一句要同时塞进"谁 + 缺口 + 后果 + 切入",长到 65-90 字,
* 客服扫一眼抓不住重点;四点各自成句,想看哪点看哪点。
*
* ⚠️ 四段都 `.min(2)` —— **不许空串**。没有事实时如实写"暂无 / 此前无回访记录",
* 空串会让界面出现一个没头没尾的空行,比一句"暂无"更让人困惑。
*/
export const DraftRecallBriefSchema = z.object({ export const DraftRecallBriefSchema = z.object({
summary: z who: z
.string() .string()
.min(6) .min(2)
.max(120) .max(40)
.describe( .describe(
'一句话中文召回简报(≤65 字最佳,最多 90 字,不带换行/列表/Markdown)。' + '① **患者是谁**(≤20 字最佳)。只用给定画像标签:生命周期(新客/熟客)、价值分群、人群细分。' +
'**站在患者立场讲"他为什么该来"**:把应治未治缺口翻译成患者能感知的影响/价值(如 缺牙久拖邻牙易移位、龋齿不补会伤神经),放句子主干、最突出;' + '⛔ 不编个人情节、不猜职业性格。例:"重要发展新客,累计消费 1.3 万"。',
'"患者是谁"(价值/熟客)一两词前置修饰;句尾给**切入建议**(低门槛复查/洁牙开口台阶,只能引用复查锚点给的事实,无锚点用不带时间数字的通用复查邀约),目标治疗动作随切入一笔带过。口吻为患者着想,不催单。' + ),
'严禁编造患者意愿/情绪:「应治未治」是缺口不是意愿,不能说"想做/有意向";不编个人情节、不编数值、不承诺疗效。' + history: z
'例:"47缺了4个月的牙一直空着,越拖邻牙越易移位;种植老客,距上次洁牙已8个月,可约洁牙检查顺带请医生评估种植。"', .string()
.min(2)
.max(60)
.describe(
'② **上次联系是什么事、结果怎么样**(≤30 字最佳)。只用给定的回访历史,**直接把那件事说出来**:' +
'这次是干什么的(半年洁牙提醒 / 种植潜客…)+ 结果(未接 / 无不适 / 约了时间…),有几样说几样。' +
'🔴 ⛔ **不许说「没留下结果」「无记录」这种含糊话** —— 结果栏通常是有东西的' +
'(实测:库里写着"诊后回访未接"却被说成"没留下结果");有什么说什么。' +
'🔴 ⛔ **不许出现「备注」「记录里写」这类字眼** —— 那是数据存哪儿的说法,客服要的是事情本身;' +
'⛔「常规回访,备注半年洁牙提醒」 ✅「常规回访提醒他半年洁牙」。' +
'🔴 ⛔ **不许报流程状态**(「任务已完成 / 未完成 / 已归档」)—— 那是内部流转,' +
'客服打电话前用不上(产品 2026-08-06 走查点名去掉)。' +
'⚠️ 备注为空才退回「时间 + 类型」,没联系上就说"没联系上"。' +
'⛔ 没有记录时**如实写"此前无回访记录"**,不许留白、不许编"曾多次联系"。' +
'⛔ 不许把未来的预约回访说成历史。例:"6 月常规回访,当时约了拔牙"。',
),
problem: z
.string()
.min(2)
.max(70)
.describe(
'③ **解决什么 + 不处理的后果**(≤35 字最佳)。**站在患者立场讲"他为什么该来"**:' +
'把应治未治缺口翻译成患者能感知的影响(如 缺牙久拖邻牙易移位、龋齿不补会伤神经),' +
'带上牙位和拖了多久。' +
'⛔ 后果要客观,不夸大不吓唬(别下"会掉光""很危险"式结论)。' +
'⛔ **严禁编造患者意愿/情绪**:「应治未治」是缺口不是意愿,不能说"想做/有意向"。' +
'例:"47 缺牙 4 个月未修复,越拖邻牙越易移位"。',
),
hook: z
.string()
.min(2)
.max(70)
.describe(
'④ **怎么开口**(≤35 字最佳)。优先级**从高到低**,取到哪条用哪条,⛔ 不要堆叠:' +
'(a) 有**福利勾子**→ 围绕福利原文开口,**只能说原文包含的内容**,' +
'⛔ 不得追加条件/期限/名额、不得夸大;' +
'⛔ **不许出现「本批/这批/批次」** —— 那是主管派单的内部词,对患者说不通' +
'(实测出过"本批有全场 8 折优惠");福利就直接说福利本身;' +
'(b) 有**医生计划/建议/医嘱**→ 围绕医生真实交代展开,优先引用原话要点(加「」);' +
'(c) 都没有 → 用复查锚点(最近一次治疗 / 洁牙检查距今)给低门槛台阶,' +
'⛔ 只能引用锚点给的事实,没有锚点就用**不带时间数字**的通用复查邀约。' +
'目标治疗动作随切入一笔带过,口吻为患者着想,不催单。' +
'例:"医生计划「定期洁牙」,可约洁牙检查顺带评估牙周基础治疗"。',
), ),
}); });
...@@ -5,18 +5,11 @@ import type { AiCall } from '../../ai-call.interface'; ...@@ -5,18 +5,11 @@ import type { AiCall } from '../../ai-call.interface';
import type { ScriptContext } from '../draft-plan-script/shared/input.types'; import type { ScriptContext } from '../draft-plan-script/shared/input.types';
import { composeSystem } from '../draft-plan-script/shared/skill-composer'; import { composeSystem } from '../draft-plan-script/shared/skill-composer';
import { DraftPlanScriptSkillRegistry } from '../draft-plan-script/shared/skill-registry.service'; import { DraftPlanScriptSkillRegistry } from '../draft-plan-script/shared/skill-registry.service';
import { import { WecomWriteSchema, type WecomWriteZ } from './schema';
WecomPlanSchema, import { buildWecomWritePrompt } from './prompts';
WecomWriteSchema,
WecomVerifySchema,
type WecomPlanZ,
type WecomWriteZ,
type WecomVerifyZ,
} from './schema';
import { buildWecomPlanPrompt, buildWecomWritePrompt, buildWecomVerifyPrompt } from './prompts';
/** /**
* 企微话术的 3 个 AiCall(与电话深度档同构:plan → write → verify,各自落 agent_invocations)。 * 企微话术的**唯一** AiCall(2026-08 从 plan → write → verify 三步砍成一步,见 wecom.strategy 文件头)。
* *
* ═══ 与电话档共用了什么、没共用什么 ═══════════════════════════ * ═══ 与电话档共用了什么、没共用什么 ═══════════════════════════
* 共用(直接 import,⛔ 不复制): * 共用(直接 import,⛔ 不复制):
...@@ -48,71 +41,17 @@ function wecomFormatPath(): string { ...@@ -48,71 +41,17 @@ function wecomFormatPath(): string {
return existsSync(src) ? src : join(process.cwd(), 'dist', rel); return existsSync(src) ? src : join(process.cwd(), 'dist', rel);
} }
const PLAN_SYSTEM = [ /** ⚠️ 只剩 ctx —— 规划(plan)与修订(repairIssues)两条 2026-08 一起砍了,见 wecom.strategy 文件头 */
'你是资深口腔回访话术规划师。基于给定的患者事实,规划一条**发到患者微信里**的医疗关怀消息:要讲哪几点、按什么顺序讲。',
'原则:医疗关怀非销售;以本次聚焦项(应治未治)为主线;每个要点都必须能追到给定事实,不编造。',
'',
'# 这是微信,不是电话',
'患者会**一口气读完**,没有一来一回。所以你排的是「讲的先后」,**不是分段** ——',
'最终产出是一整段连贯文字,不会有小标题。别按"开场/正文/结尾"去想,想的是"先说什么才能让下一句站得住"。',
'',
'# 规划方法:从果(目标)倒推到因',
'先定这条消息的"果" —— 让患者明白"该回来把 X 处理掉、早处理的好处 / 拖着的后果",从而愿意来复查;',
'再倒推"因" —— 为达成它,患者需要先知道什么、被打消什么顾虑。据此把链条拆成层层递进的要点。',
'',
'# 说明风险的分寸:把后果讲清,但不吓唬、不推销',
'"不处理会怎样"要**客观说明**(结合病历 + 牙科常识),让患者理解严重性;',
'但**不夸大、不制造恐慌、不下吓人结论**,也**不报价、不促单、不施压**。',
'',
'不要写正文,只输出要点 JSON。要点 3-6 条。',
].join('\n');
const VERIFY_SYSTEM = [
'你是严格的医疗话术审核员,任务是**对抗式校验**一条准备**发到患者微信**的消息,默认怀疑、宁严勿松。',
'依据给定的"本次回访患者信息"逐项判断:',
'① 接地:每个具体说法(诊断/检查所见/医嘱/时间/牙位/医生)能否在给定事实里找到依据?找不到=编造。',
'② 安全:有无报价/费用、疗效承诺、患者≤18 却提拍片?',
' ⛔ **出现任何时间占位符(【时间段1】【时间段2】【具体预约时间】)= 不合格** ——',
' 企微是整段复制直接发出去的,占位符会原样发给患者;而客服要先手动改一遍的话,',
' "可直接发送"就不成立了。⛔ 自己编具体时间("周三上午")同样不合格。',
' 正确写法是不含具体时间的邀约(「您方便的时候回我一下,我帮您安排」)。',
'③ 逻辑与分寸:围绕"让患者明白该回来处理"展开、层层递进、该说的没缺(点了问题要说后果、说了后果要给出路);',
' 有没有**吓唬/制造恐慌**或**推销/促单/施压**口吻?',
'④ 患者听得懂:有没有诊断代码(如 K08)、英文/内部枚举、生硬术语,或含糊其辞(没说清哪颗牙)?',
'⑤ ⭐**可直接发送**(企微专有):客服会整段复制发出去 ——',
' 有小标题 / `##` / 分段编号 / "第一第二" = 不合格;',
' 混进给客服自己看的话("以下话术供参考""建议这样说") = 不合格;',
' ⛔ 除自报家门里的 【回访客服】 外**残留任何 `【】` 占位或内部标签 = 不合格**;',
' 出现"您现在方便吗""能听清吗"这类**需要对方当场回话**才成立的电话句式 = 不合格。',
'①②③④⑤ 任一不过 → pass=false,并逐条列出 issue(位置、问题、修法);全部通过 → pass=true、issues 空。',
'另外给一组 **quality 质量评分**(1-5)—— **只评"好不好",跟 pass 无关**。',
'只输出 JSON,不改写草稿。',
].join('\n');
@Injectable()
export class WecomPlanCall implements AiCall<ScriptContext, WecomPlanZ> {
readonly kind = 'script' as const;
readonly callKey = 'draft_wecom_script_plan';
readonly promptVersion = 'draft_wecom_script@2026-08-04-plan-v1';
readonly defaultModelId = 'deepseek-v4-flash';
readonly outputSchema = WecomPlanSchema;
buildPrompt(ctx: ScriptContext) {
return { system: PLAN_SYSTEM, prompt: buildWecomPlanPrompt(ctx) };
}
}
export interface WecomWriteInput { export interface WecomWriteInput {
ctx: ScriptContext; ctx: ScriptContext;
plan: WecomPlanZ;
prevDraft?: WecomWriteZ;
repairIssues?: Array<{ section: string; problem: string; fix: string }>;
} }
@Injectable() @Injectable()
export class WecomWriteCall implements AiCall<WecomWriteInput, WecomWriteZ> { export class WecomWriteCall implements AiCall<WecomWriteInput, WecomWriteZ> {
readonly kind = 'script' as const; readonly kind = 'script' as const;
readonly callKey = 'draft_wecom_script_write'; readonly callKey = 'draft_wecom_script_write';
readonly promptVersion = 'draft_wecom_script@2026-08-04-write-v1'; // ⚠️ 换 promptVersion 会让缓存整体失效(这是想要的):流程和形态都变了,旧缓存不该再命中
readonly promptVersion = 'draft_wecom_script@2026-08-05-single-v2';
readonly defaultModelId = 'deepseek-v4-flash'; readonly defaultModelId = 'deepseek-v4-flash';
readonly outputSchema = WecomWriteSchema; readonly outputSchema = WecomWriteSchema;
constructor(private readonly skillRegistry: DraftPlanScriptSkillRegistry) {} constructor(private readonly skillRegistry: DraftPlanScriptSkillRegistry) {}
...@@ -129,20 +68,3 @@ export class WecomWriteCall implements AiCall<WecomWriteInput, WecomWriteZ> { ...@@ -129,20 +68,3 @@ export class WecomWriteCall implements AiCall<WecomWriteInput, WecomWriteZ> {
return { system: composed.systemPrompt, prompt: buildWecomWritePrompt(input) }; return { system: composed.systemPrompt, prompt: buildWecomWritePrompt(input) };
} }
} }
export interface WecomVerifyInput {
ctx: ScriptContext;
draft: WecomWriteZ;
}
@Injectable()
export class WecomVerifyCall implements AiCall<WecomVerifyInput, WecomVerifyZ> {
readonly kind = 'judge' as const;
readonly callKey = 'draft_wecom_script_verify';
readonly promptVersion = 'draft_wecom_script@2026-08-04-verify-v1';
readonly defaultModelId = 'deepseek-v4-flash';
readonly outputSchema = WecomVerifySchema;
buildPrompt(input: WecomVerifyInput) {
return { system: VERIFY_SYSTEM, prompt: buildWecomVerifyPrompt(input) };
}
}
import type { ScriptContext } from '../draft-plan-script/shared/input.types'; import type { ScriptContext } from '../draft-plan-script/shared/input.types';
import { buildRichFactBlock, buildDeepExtensions } from '../draft-plan-script/shared/fact-block'; import { buildRichFactBlock, buildDeepExtensions } from '../draft-plan-script/shared/fact-block';
import type { WecomPlanZ, WecomWriteZ } from './schema';
/** /**
* 企微话术的 user prompt。 * 企微话术的 user prompt。
...@@ -10,96 +9,37 @@ import type { WecomPlanZ, WecomWriteZ } from './schema'; ...@@ -10,96 +9,37 @@ import type { WecomPlanZ, WecomWriteZ } from './schema';
* ⛔ 别复制一份改改:安全护栏(不报价/福利不得加码/高龄不主推种植)全在里面, * ⛔ 别复制一份改改:安全护栏(不报价/福利不得加码/高龄不主推种植)全在里面,
* 复制出去之后改了一处另一处会悄悄留在旧版本,而漏了哪条要等客服发出去才发现。 * 复制出去之后改了一处另一处会悄悄留在旧版本,而漏了哪条要等客服发出去才发现。
* *
* ⚠️ 差异全部集中在**任务段**(--- 之后):电话是"拆几段讲",企微是"排个顺序、写成一条消息"。 * ⚠️ 差异全部集中在**任务段**(--- 之后):电话是"拆几段讲",企微是"写成一条能直接发的消息"。
*/ */
function facts(ctx: ScriptContext): string { function facts(ctx: ScriptContext): string {
const ext = buildDeepExtensions(ctx); const ext = buildDeepExtensions(ctx);
return ext ? `${buildRichFactBlock(ctx)}\n\n${ext}` : buildRichFactBlock(ctx); return ext ? `${buildRichFactBlock(ctx)}\n\n${ext}` : buildRichFactBlock(ctx);
} }
/** 步骤1:要点规划 —— ⚠️ 排的是**顺序**,不是段落 */ /**
export function buildWecomPlanPrompt(ctx: ScriptContext): string { * 唯一一步:一次性写出整块正文。
return `${facts(ctx)} *
* ⚠️ 2026-08 砍掉了 plan(要点顺序)与 repair(按 issue 回喂改写)两条分支 ——
--- * 企微稿是一整块几百字、没有分段结构,"先列大纲再写"收益极小;而修订那条要多花
# 你的任务(本步:排要点顺序,不写正文) * 一次调用和 30 秒,换来的东西客服自己过一遍就能发现(界面一直写着"请核对后使用")。
这条消息是**发到患者微信里**的,他会一口气读完。规划要讲哪几点、按什么顺序讲。 * 安全没有放松:机器硬扫(禁词/承诺/占位残留)仍在 wecom.strategy 里把关。
*/
按"从果(目标)倒推到因"来排:先想清这条消息要达成的果(让患者明白该回来处理本次问题), export function buildWecomWritePrompt(input: { ctx: ScriptContext }): string {
再倒推为达成它患者需要先被讲清什么,把要点排成层层递进的推进线。
后果要客观说清、但不吓唬不推销。要点 3-6 条。
⚠️ 你排的是**讲的先后**,**不是分段** —— 最终产出是一整段连贯文字,不会有小标题。
所以别按"开场/正文/结尾"这种结构去想,想的是"先说什么才能让下一句站得住"。`;
}
/** 步骤2:写 —— 一次性单块(+ repair:上一稿 + 逐条修正约束) */
export function buildWecomWritePrompt(input: {
ctx: ScriptContext;
plan: WecomPlanZ;
prevDraft?: WecomWriteZ;
repairIssues?: Array<{ section: string; problem: string; fix: string }>;
}): string {
if (input.repairIssues?.length) {
const prev = input.prevDraft?.markdown ?? '(上一稿缺失,按要点重写并满足下列修正)';
const issues = input.repairIssues
.map((it, i) => `${i + 1}. 【${it.section}】问题:${it.problem}\n 必须改成:${it.fix}`)
.join('\n');
return `${facts(input.ctx)}
---
# 本步任务:修订(不是重写)
上一稿没通过自检。**在上一稿基础上,严格逐条改掉下面每一处问题**,改完输出完整修订稿。
## 上一稿(待修订)
${prev}
## 必须修正的问题(逐条,缺一不可)
${issues}
## 修订铁律
- 上面每一条都必须改到位,**一条都不能漏**;改法以"必须改成"为准。
- **只动被点名的地方**,其余句子保持原样,不要顺手重写或新增事实。
- 修正不得引入新的违规:不报价/不承诺疗效/≤18 岁不提拍片。
- ⛔ **不许出现任何时间占位符**(【时间段1】等),也不要自己编具体时间 ——
这条消息是整段复制直发的,占位会原样发给患者。约时间用不含具体时间的邀约。
- 仍然是**一整块可直接发送的消息**,不许出现小标题或分段编号。`;
}
const outline = input.plan.points.map((p, i) => `${i + 1}. ${p.point}(${p.why})`).join('\n');
return `${facts(input.ctx)} return `${facts(input.ctx)}
--- ---
# 本步要点顺序(按它写成一条消息,可微调措辞,不要新增事实) # 你的任务
${outline} 写**一条能直接发给患者微信的消息**,复制粘贴即可发送,不用再删改。
# 写的时候保持 # 写的时候保持
- **一条能直接发出去的微信消息**:复制粘贴即可发送,不用再删改。 - **层层递进**:从"想起他"到"点出问题"到"说清后果"到"给出路",句与句承上启下,
- **层层递进**:顺着要点推进,句与句承上启下,别并列罗列或跳跃。 别并列罗列或跳跃。
- **后果说清但有分寸**:不处理的后果客观讲明(结合病历 + 牙科常识),让患者理解严重性; - **后果说清但有分寸**:不处理的后果客观讲明(结合病历 + 牙科常识),让患者理解严重性;
但**不夸大、不吓唬**(别下"会掉光""很危险"式结论)、**不推销促单报价**。`; 但**不夸大、不吓唬**(别下"会掉光""很危险"式结论)、**不推销促单报价**。
} - ⭐ **像微信不像公文**:适度用表情(全文 2-4 个,句末缓和语气),
⛔ 不要每句都带,⛔ 不用夸张促销类表情。
/** 步骤3:独立对抗校验 —— 新开上下文,默认怀疑 */ - ⛔ 除自报家门的 \`【回访客服】\` 外,**不许出现任何其他 \`【】\` 占位**;
export function buildWecomVerifyPrompt(input: { ctx: ScriptContext; draft: WecomWriteZ }): string { 也不要自己编具体时间("周三上午")—— 这条消息整段复制直发,
return `${facts(input.ctx)} 占位会原样进患者微信,编的时间到时对不上。约时间就写
"您方便的时候回我一下,我帮您安排"。`;
---
# 你的任务(本步:对抗校验,不改写)
逐句核对下面这条**准备发给患者微信**的消息:
1. **接地**:每个说法能否追到上面"本次回访患者信息"里的事实?追不到 = 编造 → 记 issue。
2. **安全**:有无报价/费用、疗效承诺、≤18 提拍片?有 = 越界 → 记 issue。
⛔ 出现**任何时间占位符**(【时间段1】等)或自己编的具体时间("周三上午")= 记 issue ——
这条消息整段复制直发,占位会原样发到患者微信里。
3. **逻辑与分寸**:围绕"让患者明白该回来处理"展开、层层递进、该说的没缺(点了问题要说后果、说了后果要给出路);
有没有**吓唬/制造恐慌**或**推销/促单/施压**口吻?任一不到位 → 记 issue。
4. **患者听得懂**:有无诊断代码(如 K08)、英文/内部枚举、生硬术语,或含糊其辞(没说清哪颗牙)?有 = 记 issue。
5. ⭐ **可直接发送**(企微专有,电话档没有这一条):
- 有没有**小标题 / 分段编号 / "第一第二" / \`##\`**?企微是一条消息,出现这些 = 记 issue;
- 有没有**给客服自己看的话**混进正文(如"以下话术供参考""建议这样说")?= 记 issue;
- ⛔ 除自报家门的 \`【回访客服】\` 外,**残留任何 \`【】\` 占位或内部标签** = 记 issue。
①②③④⑤ 全部通过 → pass=true、issues 空;任一不过 → pass=false 并逐条列出(位置、问题、修法)。
## 待校验消息
${input.draft.markdown}`;
} }
...@@ -2,7 +2,13 @@ import { z } from 'zod'; ...@@ -2,7 +2,13 @@ import { z } from 'zod';
import { ToneEnum, TONE_DESCRIBE } from '../draft-plan-script/shared/tone'; import { ToneEnum, TONE_DESCRIBE } from '../draft-plan-script/shared/tone';
/** /**
* 企微话术(深度档)三步的输出 schema。 * 企微话术的输出 schema —— **单次生成,只有一个**。
*
* ⚠️ 2026-08 砍掉了 `WecomPlanSchema`(规划大纲)与 `WecomVerifySchema`(LLM 自检):
* 企微稿是一整块几百字、没有分段结构,"先列大纲"收益极小;而客服拿到后本来就要
* 自己过一遍再发,LLM 自检是在给一个人本来就会做的事再花一次钱和 30 秒。
* 安全**没有放松** —— 机器硬扫(禁词/承诺/占位残留)保留在 wecom.strategy 里,
* 它不花钱、且拦的正是模型自己发现不了的东西。
* *
* ⚠️ 与电话深度档最本质的差别就在这里:**没有 `sections[]`,只有一整块 `markdown`**。 * ⚠️ 与电话深度档最本质的差别就在这里:**没有 `sections[]`,只有一整块 `markdown`**。
* 电话稿分段是给「伴飞」逐段高亮用的;企微是**一条发出去的消息**,分段没有意义, * 电话稿分段是给「伴飞」逐段高亮用的;企微是**一条发出去的消息**,分段没有意义,
...@@ -12,56 +18,21 @@ import { ToneEnum, TONE_DESCRIBE } from '../draft-plan-script/shared/tone'; ...@@ -12,56 +18,21 @@ import { ToneEnum, TONE_DESCRIBE } from '../draft-plan-script/shared/tone';
* 硬长度约束对中文偏严,模型差一点就整体 fail 走兜底;形态靠 system + describe 引导。 * 硬长度约束对中文偏严,模型差一点就整体 fail 走兜底;形态靠 system + describe 引导。
*/ */
// ── 步骤1:要点规划(⚠️ 不是"分段",是**讲的顺序**)── // ── 一次性写出整块正文 ──
export const WecomPlanSchema = z.object({
points: z
.array(
z.object({
point: z.string().describe('这一点要讲什么(一句话,口语化,**必须能追到给定患者信息/病历事实**)'),
why: z.string().describe('为什么排在这个位置——承上启下:接住上一点的什么、为下一点铺什么'),
}),
)
.describe(
'要点**顺序**(3-6 条):从果(让患者明白该回来处理)倒推到因,排成层层递进的推进线。' +
'⚠️ 这是"讲的先后",**不是分段** —— 最终产出是一整段连贯文字,不许出现小标题。',
),
});
export type WecomPlanZ = z.infer<typeof WecomPlanSchema>;
// ── 步骤2:写(一次性单块)──
export const WecomWriteSchema = z.object({ export const WecomWriteSchema = z.object({
tone: ToneEnum.describe(TONE_DESCRIBE), tone: ToneEnum.describe(TONE_DESCRIBE),
markdown: z markdown: z
.string() .string()
.describe( .describe(
'完整企微消息正文(约 120-400 字),**一整块、可直接复制发送**。' + '完整企微消息正文(约 120-400 字),**一整块、可直接复制发送**。' +
'⛔ 不要小标题、不要 `##`、不要分段编号、不要"第一/第二"、不要表情符号。' + '⛔ 不要小标题、不要 `##`、不要分段编号、不要"第一/第二"。' +
'按微信阅读节奏用空行断成几个短自然段;接地病历不编造;时间用【时间段】占位。', '按微信阅读节奏用空行断成几个短自然段;接地病历不编造。' +
'⭐ 适度用**表情**(全文 2-4 个,放在句末缓和语气,如 😊 🦷 ~),' +
'这是微信而不是公文 —— 但⛔ 不要每句都带、不要用夸张促销类表情(🔥💰🉐)。' +
'⛔ 除自报家门的【回访客服】外,**不许出现任何其他【】占位符**(时间尤其不许):' +
'这条消息是整段复制直发的,占位符会原样进患者微信;' +
'要约时间就写"您方便的时候回我一下,我帮您安排"。',
), ),
}); });
export type WecomWriteZ = z.infer<typeof WecomWriteSchema>; export type WecomWriteZ = z.infer<typeof WecomWriteSchema>;
// ── 步骤3:独立对抗校验 ──
export const WecomVerifySchema = z.object({
pass: z.boolean().describe('①接地②安全③逻辑与分寸④可直接发送(无标题/无占位残留/无客服自己看的话)全过 → true'),
issues: z
.array(
z.object({
section: z.string().describe('出问题的位置(可填"开头""结尾""整体")'),
problem: z.string().describe('问题:①接地不实②安全越界(报价/承诺疗效/写死时间/≤18拍片)③逻辑与分寸(跑题/不递进/没说后果/吓唬/推销施压)④不可直接发送(出现小标题、内部标签、给客服看的说明)'),
fix: z.string().describe('修法建议(回喂改写)'),
}),
)
.describe('逐条列出有问题的点;全部 OK 则空数组'),
quality: z
.object({
natural: z.number().describe('像微信里真人发的(1-5):不书面公文、不机器腔'),
warmth: z.number().describe('关怀温度(1-5):医疗关怀感,不冷淡也不推销'),
focus: z.number().describe('聚焦(1-5):紧扣本次问题,不发散'),
nonPushy: z.number().describe('不推销(1-5):邀约自然,不促单 / 不报价 / 不施压'),
sendable: z.number().describe('可直接发送度(1-5):复制粘贴就能发,不用再删改'),
overall: z.number().describe('综合质量分(1-5,可含半分)'),
})
.describe('质量细项打分(1-5);只评质量,不影响 pass/issues'),
});
export type WecomVerifyZ = z.infer<typeof WecomVerifySchema>;
...@@ -3,8 +3,8 @@ import { AiCallRunnerService } from '../../ai-call-runner.service'; ...@@ -3,8 +3,8 @@ import { AiCallRunnerService } from '../../ai-call-runner.service';
import type { AiCallContext } from '../../ai-call.interface'; import type { AiCallContext } from '../../ai-call.interface';
import type { ScriptContext } from '../draft-plan-script/shared/input.types'; import type { ScriptContext } from '../draft-plan-script/shared/input.types';
import { machineSafetyScan } from '../draft-plan-script/shared/safety-rules'; import { machineSafetyScan } from '../draft-plan-script/shared/safety-rules';
import { WecomPlanCall, WecomWriteCall, WecomVerifyCall } from './calls'; import { WecomWriteCall } from './calls';
import type { WecomPlanZ, WecomWriteZ, WecomVerifyZ } from './schema'; import type { WecomWriteZ } from './schema';
export interface WecomScriptResult { export interface WecomScriptResult {
markdown: string; markdown: string;
...@@ -14,208 +14,146 @@ export interface WecomScriptResult { ...@@ -14,208 +14,146 @@ export interface WecomScriptResult {
costYuan: number; costYuan: number;
promptTokens: number; promptTokens: number;
completionTokens: number; completionTokens: number;
stepsRun: string[];
failReason?: string; failReason?: string;
} }
/** /**
* 企微话术编排:规划(plan)→ 写(write,单块)→ 独立对抗校验(verify)→ 不过则 repair(≤1 轮) * 企微话术 —— **单次流式生成**,没有工作流
* *
* ⚠️⚠️ **与电话档最大的行为差别:没有模板兜底,失败就是失败。** * ═══ 2026-08 从三步 pipeline 砍成一步 ═══════════════════════════════
* 电话档失败会回退到 `stableTemplateFallback` —— 因为客服正拿着电话,必须有东西能念, * 原来是 规划大纲 → 撰写 → 安全自检 →(不过)修订,四次 LLM 调用、一轮 60 秒上下,
* 而模板稿念出来只是"平淡",不会出事。 * 前端还得画一套"过程可见"的时间线陪着转。砍掉的理由:
* 企微不一样:产出是**一条要原样发给患者的消息**。给一份模板套话让他复制发出去, * · 企微稿是**一整块几百字**,没有分段结构 —— "先列大纲"对单块文本收益极小
* 患者收到的是一条明显是群发的、可能对不上他情况的消息 —— 那比"没有话术"更糟, * · 客服拿到后**本来就要自己过一遍再发**(界面上一直写着"请核对后使用"),
* 而且发出去收不回。⇒ 生成不出来就如实报失败,让客服自己写。 * LLM 自检那一步是在给一个人本来就会做的事再花一次钱和 30 秒
* · 流式输出让"等"变成"看着它写",体感比一个转圈的白屏好得多
* *
* 其余脊柱与电话深度档一致:每步各过 AiCallRunner、各落 agent_invocations; * ⚠️ **机器安全闸保留**(不是 LLM 那步)。它不花钱、不耗时,而且拦的是
* 机器安全(禁词/承诺/加粗时间)在策略侧硬扫,与 LLM 校验合并决定是否 repair。 * 模型自己发现不了的东西 —— 尤其**残留占位符**:企微是整段复制直发,
* `【时间段1】` 会原样进患者微信。实测模型会照抄电话档的习惯写出占位符,
* ⛔ 只靠 prompt 拦不住。
*
* ⚠️⚠️ **没有模板兜底,失败就是失败**(与电话档最大的行为差别)。
* 电话档失败回退模板 —— 客服正拿着电话,必须有东西能念,模板稿只是平淡不会出事。
* 企微产出是**一条要原样发给患者的消息**:给一份群发感的套话让他复制发出去,
* 患者收到后收不回来,那比"没有话术"更糟。⇒ 生成不出来就如实报失败,让客服自己写。
*/ */
@Injectable() @Injectable()
export class WecomScriptStrategy { export class WecomScriptStrategy {
private readonly logger = new Logger(WecomScriptStrategy.name); private readonly logger = new Logger(WecomScriptStrategy.name);
private readonly MAX_REPAIR = 1;
constructor( constructor(
private readonly runner: AiCallRunnerService, private readonly runner: AiCallRunnerService,
private readonly planCall: WecomPlanCall,
private readonly writeCall: WecomWriteCall, private readonly writeCall: WecomWriteCall,
private readonly verifyCall: WecomVerifyCall,
) {} ) {}
/** /**
* 流式版:边跑边 yield 步骤事件,最后 yield 结果 * 流式:边写边推正文增量,写完过机器闸
* *
* ⭐ 事件形状**刻意与电话深度档对齐**(`{kind:'step', step, status, detail}`)—— * ⭐ 推的是 `markdown` 的**增量**(不是整串重发)—— 前端直接追加,不必每帧重排整段。
* 前端那套「深度过程可见」的时间线组件(ScriptDeepProcess)因此原样复用, * ⚠️ 机器闸在 `done` 之后才判:不过则**丢弃已推的正文**、回一个 failed。
* ⛔ 不必为企微再画一套。企微一轮要跑 60 秒上下,没有过程可见就是一个转圈的白屏。 * 看着"写了又没了"确实不好受,但让带占位符的稿子留在框里、客服一键复制发出去更糟。
* ⚠️ 这里**不推正文增量**:企微稿是一整块,逐字推过去除了闪没有别的意义
* (电话档推的是"段",那是有结构的)。
*/ */
async *runStream( async *runStream(
ctx: ScriptContext, ctx: ScriptContext,
runCtx: AiCallContext, runCtx: AiCallContext,
): AsyncGenerator< ): AsyncGenerator<
| { kind: 'step'; step: 'plan' | 'write' | 'verify' | 'repair'; status: 'running' | 'done'; detail?: { pass?: boolean; issuesCount?: number } } | { kind: 'delta'; text: string }
| { kind: 'result'; result: WecomScriptResult } | { kind: 'result'; result: WecomScriptResult }
> { > {
const emit: Array<{ step: 'plan' | 'write' | 'verify' | 'repair'; status: 'running' | 'done'; detail?: { pass?: boolean; issuesCount?: number } }> = []; let pushed = ''; // 已推出去的正文,用来算增量
// 复用 run() 的全部逻辑,只是把它的步骤回调接出来 —— ⛔ 别把 run() 抄一遍改成生成器, let last: { output: WecomWriteZ; invocationId: string; costYuan: number; promptTokens: number; completionTokens: number } | null = null;
// 那样两条路径的兜底/闸门会各活各的,而它们必须一致。
const collect = (e: (typeof emit)[number]) => emit.push(e);
const runner = this.run(ctx, runCtx, collect);
// 边跑边把已产生的事件吐出去(轮询本地队列;步骤粒度秒级,这个精度足够)
let sent = 0;
let done = false;
const p = runner.finally(() => {
done = true;
});
while (!done) {
while (sent < emit.length) yield { kind: 'step', ...emit[sent++]! };
await new Promise((r) => setTimeout(r, 120));
}
while (sent < emit.length) yield { kind: 'step', ...emit[sent++]! };
yield { kind: 'result', result: await p };
}
async run(
ctx: ScriptContext,
runCtx: AiCallContext,
onStep?: (e: { step: 'plan' | 'write' | 'verify' | 'repair'; status: 'running' | 'done'; detail?: { pass?: boolean; issuesCount?: number } }) => void,
): Promise<WecomScriptResult> {
const step = (
s: 'plan' | 'write' | 'verify' | 'repair',
status: 'running' | 'done',
detail?: { pass?: boolean; issuesCount?: number },
) => onStep?.({ step: s, status, detail });
const steps: string[] = [];
let cost = 0;
let promptTokens = 0;
let completionTokens = 0;
const acc = (r: { costYuan: number; promptTokens: number; completionTokens: number }) => {
cost += r.costYuan;
promptTokens += r.promptTokens;
completionTokens += r.completionTokens;
};
const ensureLive = () => {
if (runCtx.signal?.aborted) throw new Error('生成已取消(客户端断连)');
};
const fail = (reason: string, invocationId = ''): WecomScriptResult => ({
markdown: '',
tone: 'warm',
source: 'failed',
invocationId,
costYuan: cost,
promptTokens,
completionTokens,
stepsRun: steps,
failReason: reason,
});
// ── 步骤1:要点规划(best-effort —— 失败不阻断,让 write 直接按事实自己排)──
ensureLive();
step('plan', 'running');
let plan: WecomPlanZ | undefined;
try { try {
const r = await this.runner.run(this.planCall, ctx, runCtx); for await (const ev of this.runner.stream(this.writeCall, { ctx }, runCtx)) {
acc(r); if (ev.type === 'partial') {
plan = r.output; const md = (ev.partial as Partial<WecomWriteZ>).markdown ?? '';
steps.push('plan'); // ⚠️ 只在**变长**时推增量:structured 流式偶发回吐更短的中间态,
step('plan', 'done'); // 直接 slice 会推出乱码片段。
if (md.length > pushed.length && md.startsWith(pushed)) {
yield { kind: 'delta', text: md.slice(pushed.length) };
pushed = md;
}
} else if (ev.type === 'done') {
last = {
output: ev.output,
invocationId: ev.invocationId,
costYuan: ev.costYuan,
promptTokens: ev.promptTokens,
completionTokens: ev.completionTokens,
};
}
}
} catch (err) { } catch (err) {
if (runCtx.signal?.aborted) throw err; if (runCtx.signal?.aborted) throw err;
this.logger.warn(`wecom plan 失败,跳过规划直接写: ${(err as Error).message}`); yield { kind: 'result', result: this.fail(`生成失败: ${(err as Error).message}`) };
plan = { points: [] }; return;
steps.push('plan:skip');
step('plan', 'done');
} }
// ── 步骤2:写(单块)── if (!last) {
ensureLive(); yield { kind: 'result', result: this.fail('生成失败: 模型没有返回结果') };
step('write', 'running'); return;
let w;
try {
w = await this.runner.run(this.writeCall, { ctx, plan }, runCtx);
} catch (err) {
if (runCtx.signal?.aborted) throw err;
return fail(`写作失败: ${(err as Error).message}`);
} }
acc(w);
let draft = w.output;
let invocationId = w.invocationId;
steps.push('write');
step('write', 'done');
// ── 步骤3:对抗校验 + 机器扫 ── const issues = machineScanIssues(last.output);
const issues: WecomVerifyZ['issues'] = machineScanIssues(draft); if (issues.length > 0) {
ensureLive(); this.logger.warn(`wecom 机器安全闸未过(${issues.length}): ${issues.join('; ')}`);
step('verify', 'running'); yield {
try { kind: 'result',
const v = await this.runner.run(this.verifyCall, { ctx, draft }, runCtx); result: this.fail(`未通过安全检查: ${issues.join(';')}`, last.invocationId, last),
acc(v); };
steps.push('verify'); return;
if (!v.output.pass) issues.push(...v.output.issues);
step('verify', 'done', { pass: v.output.pass, issuesCount: issues.length });
} catch (err) {
if (runCtx.signal?.aborted) throw err;
this.logger.warn(`wecom verify 失败,仅依据机器扫: ${(err as Error).message}`);
steps.push('verify:skip');
step('verify', 'done', { issuesCount: issues.length });
} }
// ── repair(≤1 轮)── yield {
if (issues.length > 0) { kind: 'result',
ensureLive(); result: {
this.logger.debug(`wecom repair: ${issues.length} 个 issue`); markdown: last.output.markdown,
step('repair', 'running', { issuesCount: issues.length }); tone: last.output.tone,
try { source: 'agent',
const w2 = await this.runner.run( invocationId: last.invocationId,
this.writeCall, costYuan: last.costYuan,
{ ctx, plan, repairIssues: issues, prevDraft: draft }, promptTokens: last.promptTokens,
runCtx, completionTokens: last.completionTokens,
); },
acc(w2); };
draft = w2.output; }
invocationId = w2.invocationId;
steps.push('repair'); /** 非流式(重新生成端点 / 测试用)—— 与流式**同一条闸**,⛔ 别让两条路各判各的 */
step('repair', 'done'); async run(ctx: ScriptContext, runCtx: AiCallContext): Promise<WecomScriptResult> {
} catch (err) { let result: WecomScriptResult | null = null;
if (runCtx.signal?.aborted) throw err; for await (const ev of this.runStream(ctx, runCtx)) {
return fail(`修订失败: ${(err as Error).message}`, invocationId); if (ev.kind === 'result') result = ev.result;
}
// 终检:机器闸仍不过 → ⛔ **不放行**(对抗哲学:接地/安全宁可没有也不发错的)。
// ⚠️ 必须复用 machineScanIssues(而不是只调 machineSafetyScan)——
// 占位符残留那条闸只在前者里,漏掉的话修订后仍带【时间段】会被直接放行。
const stillBad = machineScanIssues(draft);
if (stillBad.length > 0) {
return fail(`修订后仍不过机器安全闸: ${stillBad.map((i) => i.problem).join(';')}`, invocationId);
}
} }
return result ?? this.fail('生成失败: 未产出结果');
}
private fail(
reason: string,
invocationId = '',
usage?: { costYuan: number; promptTokens: number; completionTokens: number },
): WecomScriptResult {
return { return {
markdown: draft.markdown, markdown: '',
tone: draft.tone, tone: 'warm',
source: 'agent', source: 'failed',
invocationId, invocationId,
costYuan: cost, costYuan: usage?.costYuan ?? 0,
promptTokens, promptTokens: usage?.promptTokens ?? 0,
completionTokens, completionTokens: usage?.completionTokens ?? 0,
stepsRun: steps, failReason: reason,
}; };
} }
} }
/** /**
* 机器硬扫 → issue(与 LLM 校验合并)。 * 机器硬扫 —— 唯一保留的"检查",不花钱不耗时。
* ⚠️ 复用电话档同一个 `machineSafetyScan`:禁词/疗效承诺/加粗写死时间这些**与渠道无关**, *
* 而且它是"单一源"——两处各写一份的话,改了其中一处另一处就悄悄留在旧规则上。 * ⚠️ 禁词/疗效承诺/加粗写死时间复用电话档同一个 `machineSafetyScan`:这些**与渠道无关**,
* 两处各写一份的话,改了其中一处另一处就悄悄留在旧规则上。
*/ */
function machineScanIssues(draft: WecomWriteZ): WecomVerifyZ['issues'] { function machineScanIssues(draft: WecomWriteZ): string[] {
const issues: WecomVerifyZ['issues'] = machineSafetyScan(draft.markdown).map((problem) => ({ const issues: string[] = machineSafetyScan(draft.markdown);
section: '整体',
problem,
fix: '按机器安全闸要求改掉该处(不报价 / 不承诺疗效 / ≤18 不提拍片)',
}));
/** /**
* ⭐ 企微专有硬闸:**除【回访客服】外不许残留任何 `【】` 占位**。 * ⭐ 企微专有硬闸:**除【回访客服】外不许残留任何 `【】` 占位**。
...@@ -229,11 +167,7 @@ function machineScanIssues(draft: WecomWriteZ): WecomVerifyZ['issues'] { ...@@ -229,11 +167,7 @@ function machineScanIssues(draft: WecomWriteZ): WecomVerifyZ['issues'] {
.map((m) => m[0]) .map((m) => m[0])
.filter((tag) => tag !== '【回访客服】'); .filter((tag) => tag !== '【回访客服】');
if (leftovers.length) { if (leftovers.length) {
issues.push({ issues.push(`残留占位符 ${[...new Set(leftovers)].join('、')} —— 企微是整段复制直发,这会原样发给患者`);
section: '整体',
problem: `残留占位符 ${[...new Set(leftovers)].join('、')} —— 企微是整段复制直发,这会原样发给患者`,
fix: '删掉占位符,改成不含具体时间的邀约,如「您方便的时候回我一下,我帮您安排」',
});
} }
return issues; return issues;
} }
...@@ -2,7 +2,13 @@ import { Injectable, Logger, NotFoundException } from '@nestjs/common'; ...@@ -2,7 +2,13 @@ import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import type { Prisma } from '@prisma/client'; import type { Prisma } from '@prisma/client';
import { calcAge, fmtYearMonth } from '@pac/utils'; import { calcAge, fmtYearMonth } from '@pac/utils';
import { planScenarioLabel, personaFeatureMeta, subLabelZh, treatmentCategoryNameZh } from '@pac/types'; import {
focusOrderReasons,
planScenarioLabel,
personaFeatureMeta,
subLabelZh,
treatmentCategoryNameZh,
} from '@pac/types';
import { PrismaService } from '../../../prisma/prisma.service'; import { PrismaService } from '../../../prisma/prisma.service';
import { AiCallRunnerService } from '../ai-call-runner.service'; import { AiCallRunnerService } from '../ai-call-runner.service';
import type { StreamEvent } from '../ai-call-runner.service'; import type { StreamEvent } from '../ai-call-runner.service';
...@@ -62,7 +68,7 @@ export type PlanScriptStreamEvent = ...@@ -62,7 +68,7 @@ export type PlanScriptStreamEvent =
/** /**
* 渲染后的 section,前端直接消费(2026-06 重构:4 模块对齐业务"参考话术")。 * 渲染后的 section,前端直接消费(2026-06 重构:4 模块对齐业务"参考话术")。
* - opening 开场白 * - opening 开场白
* - informMissed 告知应治未治 * - informMissed 告知潜在治疗
* - reviewAdvice 复查建议 * - reviewAdvice 复查建议
* - closing 结束回访语 * - closing 结束回访语
*/ */
...@@ -449,10 +455,19 @@ export class PlanScriptOrchestrator { ...@@ -449,10 +455,19 @@ export class PlanScriptOrchestrator {
const plan = await this.prisma.followupPlan.findUnique({ const plan = await this.prisma.followupPlan.findUnique({
where: { id: planId }, where: { id: planId },
include: { include: {
reasons: { orderBy: { priorityScore: 'desc' }, take: 3 }, /**
* ⚠️ take 从 3 提到 12:批次意图聚焦要求"主管选的那条必须在候选里"。
* 原来只取分最高的 3 条 —— 若主管选的标签排第 4,它压根没被加载,
* focusOrderReasons 就无从提前,**改了等于没改且不报错**。
* 实测每单最多 8 条 reason,12 是留了余量的安全上界。
* ⚠️ 进 prompt 的仍然只有 3 条 —— 聚焦排序**之后**再 slice(见下方 sortedReasons),
* 所以 prompt 体积不变,变的只是"哪 3 条"。
*/
reasons: { orderBy: { priorityScore: 'desc' }, take: 12 },
// ⭐ 本批次的福利要进话术(T4)。⚠️ 只认 **confirmed** 的批次 —— // ⭐ 本批次的福利要进话术(T4)。⚠️ 只认 **confirmed** 的批次 ——
// 撤销后的批次福利已不适用,带进去就是对患者念一个作废的优惠。 // 撤销后的批次福利已不适用,带进去就是对患者念一个作废的优惠。
assignment: { select: { status: true, attributes: true, expiresAt: true } }, // criteria:批次选的是哪一格(potentialTreatment)→ 话术聚焦哪条 reason,判据同福利。
assignment: { select: { status: true, attributes: true, expiresAt: true, criteria: true } },
}, },
}); });
if (!plan) throw new NotFoundException(`Plan ${planId} not found`); if (!plan) throw new NotFoundException(`Plan ${planId} not found`);
...@@ -582,18 +597,54 @@ export class PlanScriptOrchestrator { ...@@ -582,18 +597,54 @@ export class PlanScriptOrchestrator {
r: PlanWithReasons['reasons'][number], r: PlanWithReasons['reasons'][number],
): { doctor: string | null; date: string | null; medicalRecord: ScriptMedicalRecord | null } => { ): { doctor: string | null; date: string | null; medicalRecord: ScriptMedicalRecord | null } => {
const evidence = (r.evidence ?? {}) as { factIds?: string[] }; const evidence = (r.evidence ?? {}) as { factIds?: string[] };
const leadFactId = evidence.factIds?.[0]; /**
const lead = leadFactId ? factById.get(leadFactId) : undefined; * 🔴 取**最新的未治疗那条**,⛔ 不是 `factIds[0]`。
*
* 两个理由,缺一不可:
* ① **与矩阵同锚**。初选矩阵的温度档按"该标签最新的未治疗诊断"算,话术若拿别的日期,
* 就会出现「矩阵说这人在 1 年内、话术开口说三年前查出的」——
* 主管和客服看到两个时间,而没人知道该信哪个。
* 实测(2026-08-05 本地库):4,573 条 reason 里 269 条有多份证据,其中 **184 条**
* `factIds[0]` 不是最新的,**平均差 952 天(2.6 年)**。
* ② **已治疗的不能当锚**。fact 治完是 `status='fulfilled'` 不是删除(见 patient_facts 口径),
* 拿它当触发日 = 对着一个已经做完的诊断念"您还没做"。
*
* 顺带:医生和病历也跟着落到那次接诊 —— 最近一次确认的医生/主诉比最早那次更贴当下。
*/
const lead = (evidence.factIds ?? [])
.map((id) => factById.get(id))
.filter((f): f is NonNullable<typeof f> => !!f && f.status === 'active')
.sort((a, b) => {
const ta = (a.occurredAt ?? a.plannedFor)?.getTime() ?? 0;
const tb = (b.occurredAt ?? b.plannedFor)?.getTime() ?? 0;
return tb - ta;
})[0];
if (!lead) return { doctor: null, date: null, medicalRecord: null }; if (!lead) return { doctor: null, date: null, medicalRecord: null };
const c = lead.content as Record<string, unknown> | null; const c = lead.content as Record<string, unknown> | null;
const doctor = (c?.doctor_name as string | undefined)?.trim() || null; const doctor = (c?.doctor_name as string | undefined)?.trim() || null;
const date = lead.occurredAt ? lead.occurredAt.toISOString().slice(0, 10) : null; // ⚠️ 与矩阵锚点同口径 `COALESCE(occurred_at, planned_for)` —— 建议类信号(*_RECOMMENDED)
// 常常只有 plannedFor,只读 occurredAt 会得到 null,话术就没了日期而矩阵有,又是一处不一致。
const anchorAt = lead.occurredAt ?? lead.plannedFor;
const date = anchorAt ? anchorAt.toISOString().slice(0, 10) : null;
const medicalRecord = buildMedicalRecord(emrForDiagnosis(lead), facts); const medicalRecord = buildMedicalRecord(emrForDiagnosis(lead), facts);
return { doctor, date, medicalRecord }; return { doctor, date, medicalRecord };
}; };
// ⭐ reasons 单一排序(自包含,不依赖调用方 query 顺序):priorityScore DESC → reasons[0]=聚焦项 /**
const sortedReasons = [...plan.reasons].sort((a, b) => b.priorityScore - a.priorityScore); * ⭐ reasons 单一排序(自包含,不依赖调用方 query 顺序)→ `reasons[0]` = 聚焦项,
* 而聚焦项决定**整篇话术**讲什么(fact-block:主诉/病种/牙位/病历/医生姓/日期锚)。
*
* ⚠️ 批次意图优先:主管点「补牙」那一格分下来的单,话术就该讲补牙,哪怕缺牙分更高。
* 判据 `status === 'confirmed'` 与下方 benefit 完全一致 —— 撤销后批次意图和福利一起作废。
* ⚠️ 详情页(plan-aggregate + plan-detail-app)走同一个 focusOrderReasons,三处必须一致,
* 否则会出现"界面聚焦补牙、话术讲缺牙",比不改更糟。
*/
const scriptFocusLabel =
plan.assignment?.status === 'confirmed'
? ((plan.assignment.criteria as { potentialTreatment?: string } | null)?.potentialTreatment ?? null)
: null;
// slice 在**排序之后** —— 先让批次选的那条冒到最前,再截 3 条进 prompt(体积同改动前)
const sortedReasons = focusOrderReasons(plan.reasons, scriptFocusLabel).slice(0, 3);
return { return {
patient: { patient: {
...@@ -701,7 +752,7 @@ function renderMarkdown( ...@@ -701,7 +752,7 @@ function renderMarkdown(
## ${t?.opening ?? '开场白'} ## ${t?.opening ?? '开场白'}
${out.opening} ${out.opening}
## ${t?.informMissed ?? '告知应治未治'} ## ${t?.informMissed ?? '告知潜在治疗'}
${out.informMissed} ${out.informMissed}
## ${t?.reviewAdvice ?? '复查建议'} ## ${t?.reviewAdvice ?? '复查建议'}
...@@ -740,7 +791,7 @@ function renderSections( ...@@ -740,7 +791,7 @@ function renderSections(
}, },
{ {
id: 'informMissed', id: 'informMissed',
label: t?.informMissed ?? '告知应治未治', label: t?.informMissed ?? '告知潜在治疗',
durationHint: '1–2 分钟', durationHint: '1–2 分钟',
markdown: out.informMissed ?? '', markdown: out.informMissed ?? '',
}, },
...@@ -1103,7 +1154,10 @@ function countCompletedTreatments(facts: FactRow[]): number { ...@@ -1103,7 +1154,10 @@ function countCompletedTreatments(facts: FactRow[]): number {
type PlanWithReasons = Prisma.FollowupPlanGetPayload<{ type PlanWithReasons = Prisma.FollowupPlanGetPayload<{
// assignment:本批次的福利要进话术(T4);select 收窄,别把整个批次拖进来 // assignment:本批次的福利要进话术(T4);select 收窄,别把整个批次拖进来
include: { reasons: true; assignment: { select: { status: true; attributes: true; expiresAt: true } } }; include: {
reasons: true;
assignment: { select: { status: true; attributes: true; expiresAt: true; criteria: true } };
};
}>; }>;
type PatientRow = Prisma.PatientGetPayload<{}>; type PatientRow = Prisma.PatientGetPayload<{}>;
......
import { Injectable, Logger, NotFoundException } from '@nestjs/common'; import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
/**
* `patient_return_visits.result` 里的**零信息**取值 —— 视同没有结果。
* ⛔ 别把它们当结果说出去:「上次回访结果:无」对客服没有任何用,还占字数。
*/
const NOISE_RESULTS = new Set(['无', '无内容', '未填写', '-', '—']);
import { maskName } from '@pac/utils'; import { maskName } from '@pac/utils';
import { import {
PERSONA_FEATURE_SPECS, PERSONA_FEATURE_SPECS,
...@@ -9,17 +15,44 @@ import { ...@@ -9,17 +15,44 @@ import {
} from '@pac/types'; } from '@pac/types';
import { PrismaService } from '../../../prisma/prisma.service'; import { PrismaService } from '../../../prisma/prisma.service';
import { AiCallRunnerService } from '../ai-call-runner.service'; import { AiCallRunnerService } from '../ai-call-runner.service';
import { readBenefitText } from '../../plan/plan-assignment.service';
import { DraftRecallBriefCall } from '../calls/draft-recall-brief/call'; import { DraftRecallBriefCall } from '../calls/draft-recall-brief/call';
import type { DraftRecallBriefInput } from '../calls/draft-recall-brief/input.types'; import type {
DraftRecallBriefInput,
DraftRecallBriefOutput,
} from '../calls/draft-recall-brief/input.types';
import type { TenantScopeContext } from '../../../common/decorators/tenant-scope.decorator'; import type { TenantScopeContext } from '../../../common/decorators/tenant-scope.decorator';
export interface RecallBriefResult { export interface RecallBriefResult {
/** 一句话召回简报;status='empty' 时为 null */ /** 四点召回简报;status='empty' 时为 null */
summary: string | null; summary: DraftRecallBriefOutput | null;
status: 'ready' | 'empty'; status: 'ready' | 'empty';
source?: 'agent' | 'template_fallback'; source?: 'agent' | 'template_fallback';
} }
/**
* 落库形态兼容 —— `plan_summaries.content` 是**单个字符串列**。
* 新版存 JSON(四段);⚠️ 库里还有大量**老行是纯文本一句话**,直接 JSON.parse 会抛。
* 老行整句落到 `problem`(它承载的正是原来那句的主干),其余三段给"—",
* ⛔ 不要丢弃老行:那会让每个存量 plan 打开时白跑一次 LLM。
*/
function parseBriefContent(content: string): DraftRecallBriefOutput {
try {
const o = JSON.parse(content) as Partial<DraftRecallBriefOutput>;
if (o && typeof o.problem === 'string') {
return {
who: o.who ?? '—',
history: o.history ?? '—',
problem: o.problem,
hook: o.hook ?? '—',
};
}
} catch {
// 不是 JSON → 老行,走下面的兜底
}
return { who: '—', history: '—', problem: content, hook: '—' };
}
/** plan_reasons.signals 形态(跟前端 ReasonLine 契约一致)。 */ /** plan_reasons.signals 形态(跟前端 ReasonLine 契约一致)。 */
interface ReasonSignals { interface ReasonSignals {
subKey?: string | null; subKey?: string | null;
...@@ -29,13 +62,27 @@ interface ReasonSignals { ...@@ -29,13 +62,27 @@ interface ReasonSignals {
expectedCategories?: string[]; expectedCategories?: string[];
} }
/** 把天数转人读文本(server 端轻量版,无需精确到日)。 */ /**
function daysText(d: number): string { * 把天数转人读文本(server 端轻量版,无需精确到日)。
*
* 🔴 **向下取整,⛔ 不许用 Math.round**(2026-08-06 实测)。
* 原来是 `Math.round(d / 30)`:74 天 → 「约 2 个月」,75 天 → 「约 3 个月」——
* **相差一天,跨了整整一个月**。同一个患者相邻两天的两条回访因此被写成
* 「2 个月前…、3 个月前…」,读起来像隔了一个月发生的两件事(实测 刘吉琳)。
* 四舍五入还会**高估**拖延时长(75 天说成 3 个月,虚报 20%),与"不夸大"相悖。
* ⚠️ 用 30.44(平均月长)而不是 30:按 30 算 365 天会得出 12 个月,与「1 年」重复。
* ⚠️ 月/年分档按**天数**切(d < 365),⛔ 别按算出来的月数切 —— floor(365/30.44)=11,
* 写成 `months < 12` 会让整整一年被说成「约 11 个月」。
* ⚠️ 任何分档都有边界,但向下取整至少保证**单调**且**不虚报**;
* 精确天数本来就一并给了模型(「74 天(约 2 个月)」),要区分相邻两条时它有得用。
*/
export function daysText(d: number): string {
if (!Number.isFinite(d) || d <= 0) return '近期'; if (!Number.isFinite(d) || d <= 0) return '近期';
if (d < 60) return `${d} 天`; if (d < 60) return `${d} 天`;
const months = Math.round(d / 30); // ⚠️ 分档按**天数**切,⛔ 别按算出来的月数切:floor(365/30.44)=11,
if (months < 12) return `${d} 天(约 ${months} 个月)`; // 写成 `months < 12` 会让整整一年被说成「约 11 个月」,年档永远够不着。
const years = (d / 365).toFixed(1).replace(/\.0$/, ''); if (d < 365) return `${d} 天(约 ${Math.floor(d / 30.44)} 个月)`;
const years = Math.floor((d / 365) * 10) / 10;
return `${d} 天(约 ${years} 年)`; return `${d} 天(约 ${years} 年)`;
} }
...@@ -72,6 +119,8 @@ export class RecallBriefOrchestrator { ...@@ -72,6 +119,8 @@ export class RecallBriefOrchestrator {
orderBy: { priorityScore: 'desc' }, orderBy: { priorityScore: 'desc' },
select: { scenario: true, subKey: true, reason: true, signals: true }, select: { scenario: true, subKey: true, reason: true, signals: true },
}, },
// 本批福利(第④点最高优先);判据同话术:只认 confirmed 的批次
assignment: { select: { status: true, attributes: true } },
}, },
}); });
if (!plan || plan.hostId !== scope.hostId || plan.tenantId !== scope.tenantId) { if (!plan || plan.hostId !== scope.hostId || plan.tenantId !== scope.tenantId) {
...@@ -86,7 +135,7 @@ export class RecallBriefOrchestrator { ...@@ -86,7 +135,7 @@ export class RecallBriefOrchestrator {
}); });
if (existing?.status === 'ready' && existing.content) { if (existing?.status === 'ready' && existing.content) {
return { return {
summary: existing.content, summary: parseBriefContent(existing.content),
status: 'ready', status: 'ready',
source: (existing.source as 'agent' | 'template_fallback' | null) ?? undefined, source: (existing.source as 'agent' | 'template_fallback' | null) ?? undefined,
}; };
...@@ -256,6 +305,60 @@ export class RecallBriefOrchestrator { ...@@ -256,6 +305,60 @@ export class RecallBriefOrchestrator {
select: { name: true }, select: { name: true },
}); });
// 4b) 回访历史(第②点)+ 本批福利(第④点最高优先)
const visits = await this.prisma.patientReturnVisit.findMany({
where: { hostId: plan.hostId, tenantId: plan.tenantId, patientId: plan.patientId },
orderBy: { taskDate: { sort: 'desc', nulls: 'last' } },
take: 3,
// ⛔ 不取 taskStatus:它是内部流程状态,刻意不喂给模型(理由见下面 reachedText 的注释)
select: { taskDate: true, type: true, status: true, result: true, followContent: true },
});
const nowMs = Date.now();
const returnVisitHistory = visits
// ⛔ 只要**已发生**的:未来的预约回访说成历史就是编(见 input.types 的注释)
.filter((v) => v.taskDate != null && v.taskDate.getTime() <= nowMs)
.map((v) => ({
atText: `${daysText(Math.floor((nowMs - v.taskDate!.getTime()) / 86_400_000))}前`,
type: v.type?.trim() || null,
/**
* ⭐ **这次回访是干什么的**(follow_content)—— 主题:半年洁牙提醒 / 种植潜客 / 术后复查…
*/
topicText: v.followContent?.trim().slice(0, 40) || null,
/**
* ⭐⭐ **结果怎么样**(result)—— 未接 / 诊后回访无不适 / 约时间 / 复查…
*
* 🔴 由来(2026-08-06 实测):这个字段**一开始漏喂了**,于是没有内容的那条回访
* 只能输出「没留下结果」——而库里明明写着「诊后回访未接」,详情页「历史联系」卡
* 也据此说了「未接通」。两处一对比,简报显得又含糊又像少了信息。
* ⚠️ `result` 比 `status` **可信**:实测同一条 status='已回访' 而 result='诊后回访未接'
* —— 前者是任务被标记过,后者才是真的联系结果。⇒ 有 result 就以 result 为准。
* ⛔ 「无」「无内容」这类零信息词视同没有,别当成结果说出去。
*/
resultText: NOISE_RESULTS.has(v.result?.trim() ?? '')
? null
: v.result?.trim().slice(0, 40) || null,
/**
* 联系上没有(已回访 / 未回访)—— **只在结果和主题都为空时**当兜底。
* ⚠️ 它并不可靠(见上面 resultText:已回访 + 未接 同时出现过),所以排在最后。
*
* ⛔⛔ **`taskStatus`(已完成 / 未完成 / 已预约)刻意不给**,别"补全"回来:
* ① 那是**内部流程状态**,客服打电话前根本用不上(产品 2026-08-06 走查:
* 「回访任务已完成就不要说了,总结备注里的事实就行」);
* ② 它还会和详情页「历史联系」卡打架 —— 实测(2026-08-05 王利)喂了两个状态后,
* 摘要说「已联系且任务已完成」、那张卡说「未完成」,两句都对却像自相矛盾。
* 不报流程状态,这个矛盾从根上就不存在了。
*/
reachedText: v.status?.trim() || null,
}));
/**
* 本批福利 —— ⚠️ 只在批次仍 `confirmed` 时给。
* 与话术带不带福利**同一个判据**(plan-script.orchestrator 的 benefit),⛔ 别另立标准:
* 撤销后的批次福利已不适用,带进去就是让客服对着患者念一个作废的优惠。
*/
const benefitText =
plan.assignment?.status === 'confirmed' ? readBenefitText(plan.assignment.attributes) : null;
const input: DraftRecallBriefInput = { const input: DraftRecallBriefInput = {
patientNameMasked: maskName(patient?.name ?? null) ?? '该患者', patientNameMasked: maskName(patient?.name ?? null) ?? '该患者',
persona: personaItems, persona: personaItems,
...@@ -263,6 +366,8 @@ export class RecallBriefOrchestrator { ...@@ -263,6 +366,8 @@ export class RecallBriefOrchestrator {
reasons, reasons,
reviewAnchors, reviewAnchors,
doctorGuidance, doctorGuidance,
returnVisitHistory,
benefitText,
}; };
// 5) 跑 AiCall → upsert // 5) 跑 AiCall → upsert
...@@ -283,19 +388,19 @@ export class RecallBriefOrchestrator { ...@@ -283,19 +388,19 @@ export class RecallBriefOrchestrator {
tenantId: plan.tenantId, tenantId: plan.tenantId,
planId: plan.id, planId: plan.id,
type: RecallBriefOrchestrator.TYPE, type: RecallBriefOrchestrator.TYPE,
content: result.output.summary, content: JSON.stringify(result.output),
status: 'ready', status: 'ready',
source: result.source, source: result.source,
agentInvocationId: result.invocationId, agentInvocationId: result.invocationId,
}, },
update: { update: {
content: result.output.summary, content: JSON.stringify(result.output),
status: 'ready', status: 'ready',
source: result.source, source: result.source,
agentInvocationId: result.invocationId, agentInvocationId: result.invocationId,
}, },
}); });
return { summary: result.output.summary, status: 'ready', source: result.source }; return { summary: result.output, status: 'ready', source: result.source };
} }
} }
...@@ -37,16 +37,20 @@ export class WecomScriptOrchestrator { ...@@ -37,16 +37,20 @@ export class WecomScriptOrchestrator {
) {} ) {}
/** /**
* 流式生成 —— yield 步骤事件,最后落库并 yield done。 * 流式生成 —— **推正文增量**,最后落库并 yield done。
* *
* ⭐ 事件形状与电话深度档一致(`{type:'step'|'done'}`),前端整套复用: * ⚠️ 2026-08 改动:原来推的是「步骤」事件(规划/撰写/自检),配一套时间线组件。
* ScriptDeepProcess 时间线、停止按钮、AIStamp。⛔ 不为企微另造一套交互。 * 工作流砍掉之后没有步骤可推了,改推 `delta`(正文增量)——
* 体感从"盯着三个勾等 60 秒"变成"看着它写"。
* ⚠️ 推的是**增量**不是全文:前端直接追加即可,不必每帧重排整段。
* ⚠️ 机器安全闸在 `done` 时判。不过则 `source='failed'` 且 `content` 为空 ——
* 前端必须据此**清掉已经流出来的正文**,⛔ 别把没过闸的稿子留在框里让人复制。
*/ */
async *generateStream( async *generateStream(
planId: string, planId: string,
options: { modelIdOverride?: string; signal?: AbortSignal } = {}, options: { modelIdOverride?: string; signal?: AbortSignal } = {},
): AsyncGenerator< ): AsyncGenerator<
| { type: 'step'; step: string; status: 'running' | 'done'; detail?: unknown } | { type: 'delta'; text: string }
| { type: 'done'; content: string; source: 'agent' | 'failed'; invocationId: string; failReason?: string } | { type: 'done'; content: string; source: 'agent' | 'failed'; invocationId: string; failReason?: string }
> { > {
const { ctx, plan } = await this.load(planId); const { ctx, plan } = await this.load(planId);
...@@ -54,8 +58,8 @@ export class WecomScriptOrchestrator { ...@@ -54,8 +58,8 @@ export class WecomScriptOrchestrator {
let result: WecomScriptGenerateResult | null = null; let result: WecomScriptGenerateResult | null = null;
for await (const ev of this.strategy.runStream(ctx, runCtx)) { for await (const ev of this.strategy.runStream(ctx, runCtx)) {
if (ev.kind === 'step') { if (ev.kind === 'delta') {
yield { type: 'step', step: ev.step, status: ev.status, detail: ev.detail }; yield { type: 'delta', text: ev.text };
} else { } else {
result = await this.persist(plan, ev.result); result = await this.persist(plan, ev.result);
} }
...@@ -116,7 +120,7 @@ export class WecomScriptOrchestrator { ...@@ -116,7 +120,7 @@ export class WecomScriptOrchestrator {
*/ */
private async persist( private async persist(
plan: { id: string; hostId: string; tenantId: string }, plan: { id: string; hostId: string; tenantId: string },
r: { markdown: string; source: 'agent' | 'failed'; invocationId: string; costYuan: number; stepsRun: string[]; failReason?: string }, r: { markdown: string; source: 'agent' | 'failed'; invocationId: string; costYuan: number; failReason?: string },
): Promise<WecomScriptGenerateResult> { ): Promise<WecomScriptGenerateResult> {
const ok = r.source === 'agent' && r.markdown.trim().length > 0; const ok = r.source === 'agent' && r.markdown.trim().length > 0;
const row = await this.prisma.planScript.upsert({ const row = await this.prisma.planScript.upsert({
...@@ -141,7 +145,7 @@ export class WecomScriptOrchestrator { ...@@ -141,7 +145,7 @@ export class WecomScriptOrchestrator {
}); });
if (!ok) { if (!ok) {
this.logger.warn( this.logger.warn(
`企微话术生成失败 plan=${plan.id}: ${r.failReason ?? '未知'};步骤=${r.stepsRun.join('→')}`, `企微话术生成失败 plan=${plan.id}: ${r.failReason ?? '未知'}`,
); );
} }
return { return {
......
import { daysText } from '../src/modules/ai/orchestrators/recall-brief.orchestrator';
/**
* 「相差一天,跨了一个月」的回归(2026-08-06 实测)。
*
* 原实现是 `Math.round(d / 30)`:74 天 → 约 2 个月,75 天 → 约 3 个月。
* 同一个患者 5-23 / 5-24 两条回访(相邻一天)于是被简报写成
* 「2 个月前诊后回访未接,**3 个月前**咨询过种植」—— 读起来像隔了一个月的两件事。
*
* ⚠️ 这不是模型在编:它拿到的输入原文就是「75 天(约 3 个月)」。
* 凡是喂给模型的**换算文本**,算错了它只会照抄,而且看起来完全合理。
*/
describe('daysText —— 向下取整,不虚报', () => {
test('⭐⭐ 相邻两天不许跨月(原 bug 的最小复现)', () => {
expect(daysText(74)).toContain('约 2 个月');
expect(daysText(75)).toContain('约 2 个月');
});
test('⭐ 单调:天数只增不减,月数不许回退', () => {
let prev = 0;
for (let d = 60; d <= 400; d++) {
const m = Number(/约 ([\d.]+) (个月|年)/.exec(daysText(d))?.[1] ?? 0);
const norm = daysText(d).includes('年') ? m * 12 : m;
expect(norm).toBeGreaterThanOrEqual(prev);
prev = norm;
}
});
test('⛔ 不许高估 —— 说出来的月数不能超过真实月数', () => {
for (const d of [61, 75, 89, 91, 100, 200, 364]) {
const m = Number(/约 (\d+) 个月/.exec(daysText(d))?.[1] ?? 0);
expect(m * 30.44).toBeLessThanOrEqual(d);
}
});
test('60 天以内给天数,不换算', () => {
expect(daysText(59)).toBe('59 天');
expect(daysText(1)).toBe('1 天');
expect(daysText(0)).toBe('近期');
});
test('满一年走年档,且同样不高估', () => {
expect(daysText(364)).toContain('个月'); // 差一天还在月档
expect(daysText(365)).toContain('约 1 年');
expect(daysText(400)).toContain('约 1 年'); // 1.09 → 向下到 1(⛔ 不许说成 1.1 年)
expect(daysText(550)).toContain('约 1.5 年');
expect(daysText(740)).toContain('约 2 年');
});
});
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
/**
* 话术的时间锚 —— 必须与初选矩阵**同一个锚点**。
*
* 由来:矩阵的温度档按「该标签**最新的未治疗**诊断」算(锚点 = COALESCE(occurred_at, planned_for))。
* 而话术原来取 `evidence.factIds[0]` —— 数组第一个,由写入顺序决定,既不是最新也不是最早。
*
* 实测(2026-08-05 本地库):4,573 条 reason 中 269 条有多份证据,其中 **184 条** `factIds[0]`
* 不是最新的,**平均差 952 天(2.6 年)**。不改的话会出现:
* 矩阵说这人「1 年内」→ 客服打开,话术开口"您三年前查出的龋齿…"
* 主管和客服看到两个时间,而**没有任何报错**,也没人知道该信哪个。
*
* 本文件是纯文本扫描 —— 这类"两处口径漂移"不会在类型或运行时暴露,只能这么锁。
*/
const SRC = readFileSync(
join(__dirname, '../src/modules/ai/orchestrators/plan-script.orchestrator.ts'),
'utf-8',
);
describe('话术触发锚点与矩阵同源', () => {
test('⭐⭐ 不再直接取 evidence.factIds[0] 当触发 fact', () => {
// 老写法:const leadFactId = evidence.factIds?.[0]
expect(SRC).not.toMatch(/factIds\?\.\[0\]/);
});
test('⭐⭐ 只认未治疗的证据 —— 治完是 status=fulfilled,拿它当锚等于念一个已做完的诊断', () => {
const block = SRC.slice(SRC.indexOf('const resolveReasonTrigger'));
expect(block).toContain("status === 'active'");
});
test('⭐⭐ 按锚点降序取最新(而不是最早/任意)', () => {
const block = SRC.slice(SRC.indexOf('const resolveReasonTrigger'));
// 降序:tb - ta
expect(block).toMatch(/return tb - ta;/);
});
test('⭐ 锚点口径 = COALESCE(occurred_at, planned_for) —— 建议类信号只有 plannedFor', () => {
const block = SRC.slice(SRC.indexOf('const resolveReasonTrigger'));
// 排序键与取值都要带 plannedFor 回落
expect(block).toMatch(/occurredAt \?\? .*plannedFor/);
expect(block).toMatch(/const anchorAt = lead\.occurredAt \?\? lead\.plannedFor;/);
});
});
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