Commit c29f18c7 by luoqi

feat(script): 标准去模板/深度富输入+质量分/tone 收拢/治疗计划 + 资深审查修复

标准档:4 固定角色字段 → 自由 sections[](固定4段,复用 DeepDraft + 段渲染 +
  段数组安全规则);format.md 去 tier 污染纯指令;流式(段数组 partial 边渲染)。
深度档:富输入 base++ — 历史联系(patient_return_visits 最近5条)+ persona 价值/流失风险,
  经 buildDeepExtensions 进 plan/write/verify;plan 放开结构(去固定段式、允许其他牙开段);
  verify 加 quality 质量评分(1-5,非 gate,回填 judgeScore/judgeRubric 供 eval);format 去污染。
治疗计划:ScriptMedicalRecord.plannedTreatments(treatment_record planned)— 原只读常空的
  emr.treatment_plan 导致话术缺治疗计划;stable/standard 渲染;standard 病历补全到全 SOAP。
tone 收拢:shared/tone.ts 单一源(枚举/describe/label);选择规则只留人群 SKILL(system),
  user prompt 只给信号(熟络度),模型判断。占位:去模板档去 {} 替换占位,只留【时间段】人工填。

资深审查修复:
- P0 SSE 客户端断连无清理 → AbortController 串到底(controller→orchestrator→runner→
  generateObject/streamObject abortSignal):断连即取消在途 LLM(深度3-4步不再白烧),
  abort 不兜底/不写 PlanScript。实测:深度 6s 掐断仅 +1 条 aborted、PlanScript 未脏。
- P1 深度质量分回填到最终 invocation(原挂被丢弃的草稿);深度非流式 cacheHit 真实累计
  (原硬编码 false);extractPrimaryDoctor 注释更正;skill-registry 陈旧 env 名更正。

两端 tsc 通过;三档 dry-run + 流式 + 中断 均验证。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
parent 0f3665d6
......@@ -110,10 +110,11 @@ export class AiCallRunnerService {
for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
result = await generateObject<any>({ model, schema: call.outputSchema, system, prompt });
result = await generateObject<any>({ model, schema: call.outputSchema, system, prompt, abortSignal: ctx.signal });
break;
} catch (e) {
lastErr = e;
if (ctx.signal?.aborted) break; // 客户端断连 → 别重试,直接抛(下方 abort 分支不兜底)
if (attempt < MAX_ATTEMPTS - 1) {
this.logger.warn(
`call=${call.callKey} generateObject 第 ${attempt + 1} 次失败,重试: ${(e as Error).message}`,
......@@ -177,6 +178,11 @@ export class AiCallRunnerService {
};
} catch (err) {
const errMsg = err instanceof Error ? err.message : String(err);
// 客户端断连/取消 → 不兜底、不落 PlanScript,只记一笔 aborted 后抛出
if (ctx.signal?.aborted) {
await this.recorder.end(invocationId, { status: 'failed', errorMessage: `aborted: ${errMsg}` });
throw err;
}
this.logger.warn(`call=${call.callKey} LLM 失败: ${errMsg}`);
return this.fallback(call, input, ctx, invocationId, `llm: ${errMsg}`);
}
......@@ -210,31 +216,45 @@ export class AiCallRunnerService {
yield { type: 'start', invocationId, modelId, promptVersion: call.promptVersion };
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const stream = streamObject<any>({
model,
schema: call.outputSchema,
system,
prompt,
});
let lastPartial: Partial<TOutput> = {};
for await (const partial of stream.partialObjectStream) {
lastPartial = partial as Partial<TOutput>;
yield { type: 'partial', partial: lastPartial };
// flash 对嵌套 schema 的**最终解析**偶发 "No object generated: could not parse"
// (partial 流没问题、是收尾那次校验碎)→ 跟 run 路径一样重试一次再兜底。
// 重试会重新 stream 一轮 partial;前端按 id 全量替换,重流只是覆盖,无副作用。
const MAX_STREAM_ATTEMPTS = 2;
let finalObject: TOutput | undefined;
let promptTokens = 0;
let completionTokens = 0;
let cachedInputTokens = 0;
let reasoningTokens = 0;
let providerMetadata: unknown = null;
for (let attempt = 1; attempt <= MAX_STREAM_ATTEMPTS; attempt++) {
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const stream = streamObject<any>({ model, schema: call.outputSchema, system, prompt, abortSignal: ctx.signal });
for await (const partial of stream.partialObjectStream) {
yield { type: 'partial', partial: partial as Partial<TOutput> };
}
// 等最终对象(解析失败在此抛)+ 计量(stream.object / usage 都是 Promise)
finalObject = (await stream.object) as TOutput;
const usage = await stream.usage;
promptTokens = usage?.inputTokens ?? 0;
completionTokens = usage?.outputTokens ?? 0;
cachedInputTokens = (usage as { cachedInputTokens?: number } | undefined)?.cachedInputTokens ?? 0;
reasoningTokens = (usage as { reasoningTokens?: number } | undefined)?.reasoningTokens ?? 0;
providerMetadata = await (stream as { providerMetadata?: Promise<unknown> }).providerMetadata;
break;
} catch (streamErr) {
const m = streamErr instanceof Error ? streamErr.message : String(streamErr);
if (ctx.signal?.aborted) throw streamErr; // 客户端断连 → 别重试,抛到外层 abort 分支
if (attempt < MAX_STREAM_ATTEMPTS) {
this.logger.warn(`stream call=${call.callKey}${attempt} 次解析失败,重试: ${m}`);
continue;
}
throw streamErr; // 重试耗尽 → 外层 catch 兜底
}
}
// 等最终对象 + 计量(stream.object / stream.usage 都是 Promise)
const finalObject = (await stream.object) as TOutput;
const usage = await stream.usage;
const promptTokens = usage?.inputTokens ?? 0;
const completionTokens = usage?.outputTokens ?? 0;
const cachedInputTokens = (usage as { cachedInputTokens?: number } | undefined)?.cachedInputTokens ?? 0;
const reasoningTokens = (usage as { reasoningTokens?: number } | undefined)?.reasoningTokens ?? 0;
// 流式时 providerMetadata 在 stream.providerMetadata(Promise)
const providerMetadata = await (stream as { providerMetadata?: Promise<unknown> }).providerMetadata;
const finalObj = finalObject as TOutput; // 循环要么 break(有值)要么 throw
this.logger.log(
`[usage stream] call=${call.callKey} usage=${JSON.stringify(usage)} ` +
`[usage stream] call=${call.callKey} promptTokens=${promptTokens} completionTokens=${completionTokens} ` +
`providerMetadata=${JSON.stringify(providerMetadata ?? null)}`,
);
const costYuan = this.estimateCostYuan(modelId, promptTokens, completionTokens, cachedInputTokens);
......@@ -242,7 +262,7 @@ export class AiCallRunnerService {
// safety gate
if (call.safetyRules && call.safetyRules.length > 0) {
try {
this.safety.check(finalObject, call.safetyRules, {
this.safety.check(finalObj, call.safetyRules, {
callKey: call.callKey,
promptVersion: call.promptVersion,
tenantId: ctx.tenantId,
......@@ -278,7 +298,7 @@ export class AiCallRunnerService {
}
await this.recorder.end(invocationId, {
output: finalObject as Prisma.InputJsonValue,
output: finalObj as Prisma.InputJsonValue,
promptTokens,
completionTokens,
totalTokens: promptTokens + completionTokens,
......@@ -287,11 +307,11 @@ export class AiCallRunnerService {
costYuan,
status: 'succeeded',
});
this.cache.set(inputHash, finalObject);
this.cache.set(inputHash, finalObj);
yield {
type: 'done',
output: finalObject,
output: finalObj,
source: 'agent',
invocationId,
promptTokens,
......@@ -300,6 +320,11 @@ export class AiCallRunnerService {
};
} catch (err) {
const errMsg = err instanceof Error ? err.message : String(err);
// 客户端断连/取消 → 不兜底、不 yield done(消费方已 break),只记 aborted 后收尾
if (ctx.signal?.aborted) {
await this.recorder.end(invocationId, { status: 'failed', errorMessage: `aborted: ${errMsg}` });
return;
}
this.logger.warn(`stream call=${call.callKey} 失败: ${errMsg}`);
if (call.fallback) {
......
......@@ -60,6 +60,9 @@ export interface AiCallContext {
bustCache?: boolean;
/** 覆盖默认模型(eval 切 flash 跑大批量;production 通常不传) */
modelIdOverride?: string;
/** 取消信号(SSE 客户端断连时 abort)→ 透传给 generateObject/streamObject 取消在途 LLM 请求,
* 避免用户关页面/点停止后,深度档仍把 3-4 次调用跑完烧钱。abort 时不走兜底、不落 PlanScript。 */
signal?: AbortSignal;
}
export interface AiCallResult<TOutput> {
......
......@@ -4,6 +4,10 @@
* 把"聚焦病历 + 其他 reason + 近期治疗 + 目标 + 称呼/医生/日期"拼成自然语言事实块。
* 各档的**输出指令**(去模板自由写 / 拆大纲 / 多段)在各自 system(format.md)里,user 只给事实。
* 病种**只给 canonical 名**(去文案模板),风险/好处/检查内容由 LLM 结合病历自供措辞。
*
* ⭐ 占位约定(标准/深度=去模板档):事实用**朴素中文标签**直接给(称呼/本次问题/牙位…),
* LLM 自然取用,**不用 `{}` 替换占位**(那是稳健模板档的填空机制,去模板档用了反而诱导模板化)。
* 仅保留 `【时间段1/2】【具体预约时间】` = 客服手填的**输出占位**(诊所无排班接口,时间不写死)。
*/
import type { DraftPlanScriptInput, ScriptMedicalRecord } from './input.types';
import { smartDateDisplay, toothFriendly } from './script-facts';
......@@ -36,7 +40,7 @@ export function buildRichFactBlock(input: DraftPlanScriptInput): string {
? `我是${clinicName}${input.agent.roleTitle}${input.agent.name}`
: `我是${clinicName}的客服`;
const guardianHint = patient.guardian
? `本次电话打给${patient.guardian.relationshipLabel}(称呼见{智能称呼}),沟通对象是家长,患者是孩子,话术里称孩子为"宝宝"`
? `本次电话打给${patient.guardian.relationshipLabel}(称呼见上方"称呼"),沟通对象是家长,患者是孩子,话术里称孩子为"宝宝"`
: null;
const mrBlock = renderMedicalRecord(top?.medicalRecord ?? null);
......@@ -63,20 +67,20 @@ export function buildRichFactBlock(input: DraftPlanScriptInput): string {
} `;
const noXray = patient.age == null || patient.age <= 18;
return `# 本次回访患者信息(只能用以下事实,不要编造或推断额外信息)
return `# 本次回访患者信息(只能用以下事实,不要编造或推断额外信息;下面是朴素事实,自然取用即可)
## 开场用
- {智能称呼}:${salutation}
- {自报家门}:${selfIntro}
- {智能时间显示}(最近一次就诊,用于开场"自从…来过";诊断日见病历/可能更早):${lastVisitDisplay}
- 称呼:${salutation}
- 自报家门:${selfIntro}
- 最近一次就诊(用于开场"自从…来过";诊断日见病历、可能更早):${lastVisitDisplay}
- 那次主诉:${chiefComplaint ?? '无记录'}
- {诊断医生}:${doctorSurname}医生${guardianHint ? `\n- 触达说明:${guardianHint}` : ''}
- 诊断医生:${doctorSurname}医生${guardianHint ? `\n- 触达说明:${guardianHint}` : ''}
## 本次应治未治(只讲这一个;病种只给名,风险/好处/检查内容你结合下方病历用自己的话说)
- {应治未治项}:${diseaseLabel}${toothText ? `\n- {牙位}:${toothText}(已是患者口语俗称,直接用)` : ''}
- 本次问题:${diseaseLabel}${toothText ? `\n- 牙位:${toothText}(已是患者口语俗称,直接用)` : ''}
## 病历(诊断上下文 — 接地素材,可引用医生原话体现关怀;⚠️ 只引用、不演绎、不报价、不承诺;与本次无关的别提)
${mrBlock || '- (本次诊断无关联病历,按 {应治未治项} + 牙科常识温和提醒)'}
${mrBlock || '- (本次诊断无关联病历,按本次问题 + 牙科常识温和提醒)'}
## 本次目标(内部参考 — 指导复查/邀约方向,不要逐字念给患者)
- ${goal ?? '邀约来院复查,让医生评估本次问题'}
......@@ -86,17 +90,53 @@ ${others.length ? `\n## 其他可一并关心的问题(以本次聚焦为主,自
## 患者
- ${basics}
- 熟络度:${relationSignal}
tone 你判断:最近来过+有历史→可熟络 warm;很久没来(沉睡,如距上次 >1 ) 有历史也别太自来熟、先重新拉近;关系浅→ professional;急性/疼痛→ urgent${noXray ? '\n\n## 安全(硬约束)\n- 本患者未满 18 岁或年龄未知:**整篇严禁出现"拍片/拍个片/X光/牙片"等任何拍片表述**' : ''}`;
- 熟络度:${relationSignal}(语气怎么拿捏见沟通知识,你按这信号判断)${noXray ? '\n\n## 安全(硬约束)\n- 本患者未满 18 岁或年龄未知:**整篇严禁出现"拍片/拍个片/X光/牙片"等任何拍片表述**' : ''}`;
}
/**
* 深度档专属扩展块(base++)—— 叠在 buildRichFactBlock 之后,给"分析(plan)"步更全的信息。
* ① 价值 / 流失风险(persona)→ 定语气;② 历史联系(诊所最近回访,≤5)→ 避免重复、接住上下文。
* 标准档不用(标准 = base+)。返回空串时调用方不追加。
*/
export function buildDeepExtensions(input: DraftPlanScriptInput): string {
const blocks: string[] = [];
const ph = (input.personaHighlights ?? []).filter((p) => p.key === 'value' || p.key === 'recall_risk');
if (ph.length) {
blocks.push(
`## 价值 / 流失风险(内部参考,定语气:高价值→更上心维系;久未到店/高流失→先重新拉近、别急着推)\n${ph
.map((p) => `- ${p.label}:${p.description}`)
.join('\n')}`,
);
}
const ch = input.clinicalContext.contactHistory ?? [];
if (ch.length) {
const lines = ch
.map((c) => {
const head = [c.date, c.type, c.status, c.treatmentItems].filter(Boolean).join(' · ');
const body = [c.followContent, c.result ? `结果:${c.result}` : ''].filter(Boolean).join(';');
return `- ${head || '(无日期)'}${body ? ` — ${body}` : ''}`;
})
.join('\n');
blocks.push(
`## 历史联系(诊所最近的回访/联系记录,最多 5 条 — 别重复人家上次已说过的、可自然接上;⚠️ 只参考不照搬)\n${lines}`,
);
}
return blocks.join('\n\n');
}
/** 聚焦病历 → 接地素材块(SOAP 关键字段;空字段略) */
/** 聚焦病历 → 接地素材块(SOAP 全字段;空字段略),字段集对齐前端"病历快读" */
export function renderMedicalRecord(mr: ScriptMedicalRecord | null): string {
if (!mr) return '';
const lines: string[] = [];
if (mr.date) lines.push(`- 接诊日期:${mr.date}`);
// S 主观
if (mr.presentIllness) lines.push(`- 现病史:${mr.presentIllness}`);
if (mr.pastHistory) lines.push(`- 既往史:${mr.pastHistory}`);
if (mr.generalCondition) lines.push(`- 一般情况:${mr.generalCondition}`);
// O 客观
if (mr.examFindings.length) {
lines.push(
`- 检查所见:${mr.examFindings
......@@ -104,6 +144,7 @@ export function renderMedicalRecord(mr: ScriptMedicalRecord | null): string {
.join(';')}`,
);
}
// A 评估
if (mr.diagnoses.length) {
lines.push(
`- 当次诊断:${mr.diagnoses
......@@ -112,6 +153,15 @@ export function renderMedicalRecord(mr: ScriptMedicalRecord | null): string {
.join('、')}`,
);
}
if (mr.diagnosisText) lines.push(`- 诊断说明:${mr.diagnosisText}`);
// P 计划
if (mr.disposals.length) {
lines.push(
`- 处置:${mr.disposals
.map((d) => (d.toothPosition ? `${toothFriendly(d.toothPosition)} ${d.message}` : d.message))
.join(';')}`,
);
}
if (mr.doctorAdvice) lines.push(`- 医嘱(原话):${mr.doctorAdvice}`);
if (mr.recommendations.length) {
lines.push(
......@@ -120,6 +170,25 @@ export function renderMedicalRecord(mr: ScriptMedicalRecord | null): string {
.join(';')}`,
);
}
if (mr.treatmentPlanText) lines.push(`- 治疗计划:${mr.treatmentPlanText}`);
const treatPlan = renderTreatmentPlan(mr);
if (treatPlan) lines.push(`- 治疗计划:${treatPlan}`);
return lines.join('\n');
}
/** 治疗计划文本 —— 结构化 plannedTreatments(主)+ host 自由文本 treatmentPlanText(常空,补) */
export function renderTreatmentPlan(mr: ScriptMedicalRecord): string | null {
const parts: string[] = [];
if (mr.plannedTreatments.length) {
parts.push(
mr.plannedTreatments
// subtype 更具体(如"充填")优先;无则用 canonical 类目名(如"充填 / 嵌体")
.map(
(t) =>
`${t.subtype || t.categoryLabel}${t.toothPosition ? `(${toothFriendly(t.toothPosition)})` : ''}`,
)
.join('、'),
);
}
if (mr.treatmentPlanText) parts.push(mr.treatmentPlanText);
return parts.length ? parts.join(';') : null;
}
......@@ -6,6 +6,7 @@
*
* 装配责任在 orchestrator,见 plan-script.orchestrator.ts。
*/
import type { Tone } from './tone';
// ⭐ 话术上下文(tier-agnostic):稳健/标准/深度三档共用同一份 ScriptContext;
// 也被实时教练复用(realtime-coach-context)。脊柱 = AiCallRunner,策略 = 各档 AiCall。
......@@ -109,6 +110,16 @@ export interface ScriptContext {
}>;
/** ⭐ 已做治疗总次数(信任锚);LLM 用于:老客可以更家常,新客需自报家门更详细 */
completedTreatmentCount: number;
/** ⭐ 历史联系(诊所最近的回访/联系记录,患者级,**最多最近 5 条**)。页面"历史联系"同源(patient_return_visits)。
* 深度档用:避免重复人家已说过的、自然接上上次的上下文。其余档不渲染。 */
contactHistory: Array<{
date: string | null; // task_date YYYY-MM-DD
type: string | null; // 回访类型(术后/常规/咨询…)
status: string | null; // 已回访 / 未回访
treatmentItems: string | null;
followContent: string | null; // 跟进内容
result: string | null; // 结果
}>;
};
}
......@@ -142,6 +153,12 @@ export interface ScriptMedicalRecord {
diagnosisText: string | null; // diagnosis_text(host 自由文本字段,常空)
/** 医生建议(同次接诊的 recommendation_record,如"建议拔除18/38/48")—— 常是召回理由本身 */
recommendations: Array<{ text: string; toothPosition: string | null }>;
/**
* 计划治疗(同次接诊的 treatment_record kind=planned,结构化)—— **真正的"治疗计划"**。
* host 的 treatment_plan 自由文本字段常空,治疗计划落在独立 treatment_record(planned),
* 故单列一项喂话术(否则话术里"治疗计划"永远缺位,如龋齿已诊断、计划充填未做)。
*/
plannedTreatments: Array<{ categoryLabel: string; subtype: string | null; toothPosition: string | null }>;
}
/**
......@@ -162,8 +179,8 @@ export interface ScriptMedicalRecord {
export type DraftPlanScriptInput = ScriptContext;
export interface DraftPlanScriptOutput {
/** 整体语气标签(给客服参考) */
tone: 'warm' | 'professional' | 'urgent';
/** 整体语气标签(给客服参考)— 单一源见 shared/tone.ts */
tone: Tone;
/** 第一部分·开场白 markdown(以医生名义 + 智能称呼 + 智能日期 + 自报家门) */
opening: string;
......
......@@ -88,3 +88,45 @@ export const SCRIPT_SAFETY_RULES: ReadonlyArray<SafetyRule<DraftPlanScriptOutput
},
// 注:≤18 岁禁拍片 由 prompt/base 约束(SafetyContext 不带 age,无法在此判定)
];
/** 段数组形态(标准/深度自由段)取全文 */
function sectionsText(o: { sections: ReadonlyArray<{ markdown: string }> }): string {
return o.sections.map((s) => s.markdown).join('\n');
}
/**
* 段数组形态(标准档"去模板" = sections[])的机器安全规则 —— 与 4 段版同源
* (FORBIDDEN/COMMIT/BOLD 同一套常量),只是从 sections[].markdown 取全文。
* 自由段无法隔离"复查/结束段",承诺/加粗时间全段扫(更严无妨)。
*/
export const SCRIPT_SAFETY_RULES_SECTIONS: ReadonlyArray<
SafetyRule<{ sections: ReadonlyArray<{ markdown: string }> }>
> = [
{
name: 'no_forbidden_phrases',
severity: 'block',
check(o) {
const hit = FORBIDDEN_PHRASES.filter((p) => sectionsText(o).includes(p));
return { pass: hit.length === 0, message: hit.length ? `命中禁词: ${hit.join(',')}` : undefined };
},
},
{
name: 'no_commit_phrasing',
severity: 'block',
check(o) {
const hit = COMMIT_PHRASES.filter((p) => sectionsText(o).includes(p));
return {
pass: hit.length === 0,
message: hit.length ? `承诺式表述(无排班 API,不能定): ${hit.join(',')}` : undefined,
};
},
},
{
name: 'no_bold_concrete_time',
severity: 'block',
check(o) {
const m = sectionsText(o).match(BOLD_TIME_REGEX);
return { pass: !m, message: m ? `加粗了具体时间"${m[0]}" — 应保留【时间段】占位` : undefined };
},
},
];
......@@ -11,7 +11,7 @@ import {
* 解析 skills/ 根目录(模块级,registry + composer 共用)。
*
* 路径策略(env 优先 → src → dist):
* - env PAC_SCRIPT_SKILLS_DIR:eval / 测试切目录
* - env PAC_SCRIPT_ROOT_DIR:eval / 测试切目录(见下方 resolveScriptRoot)
* - src/modules/.../skills:dev 模式(SWC 编译产物在 dist/src/...,__dirname 不可靠;
* 源文件始终在 src/,直接读最快)
* - dist/modules/.../skills:prod docker(image 只含 dist/,nest-cli.json `assets`
......
......@@ -2,8 +2,8 @@
# 铁律
- **严格尊重事实**:只用"本次回访患者信息"里给的事实;没给或不明确的**一律不编**——医生名 / 诊断 / 牙位 / 时间 / 价格 / 病史都不许杜撰,编错会当场被患者戳穿,宁可泛指或省略。给定值直接用,不改写、不重算。
- **突出本次问题**:以给定的 {应治未治项} 为主线讲透;其他项目不展开、不必逐一交代。
- **医生名义**:以 {诊断医生} 医生的关怀与交代为口吻,有温度。
- **突出本次问题**:以给定的本次应治未治项为主线讲透;其他项目不展开、不必逐一交代。
- **医生名义**:以诊断医生的关怀与交代为口吻,有温度。
- **钱与方案**:不谈费用 / 价格 / 优惠、不报价;可点出本次该处理什么(如"该补上""该做牙周基础治疗"),但不替医生定细化方案,落点是"来院复查、请医生评估"。
- **≤18 岁**:整篇不提拍片。
- **主动约**:引导预约、给时间选择;时间用【时间段】占位,不写死、不承诺。
......
......@@ -4,10 +4,10 @@ description: 成人(患者年龄 ≥13 岁,或年龄未知默认)回访的**共
priority: 90
applies:
ageMin: 13
version: 1.2.0
version: 1.3.0
---
# 成人沟通(≥13 岁 / 年龄未知默认)
- 沟通对象=患者本人;称呼用 {智能称呼}(姓 + 先生/女士)。
- 沟通对象=患者本人;用给定的称呼(姓 + 先生/女士)。
- 语气按"熟络度"判断(看 user 给的 完成治疗次数 + 距上次就诊):最近来过的熟客可 warm 熟络;久未到店(沉睡)即使有历史也先重新拉近、别太自来熟;急性/疼痛可 urgent;其余 professional。
- 突出"{诊断医生}医生发现 / 交代"的口吻,不说"我们发现了…";关怀用"趁现在/早一点",别拿患者年龄、职业说事。
- 突出"诊断医生发现 / 交代"的口吻,不说"我们发现了…";关怀用"趁现在/早一点",别拿患者年龄、职业说事。
......@@ -4,11 +4,11 @@ description: 儿童(年龄 ≤12 岁,对象=家长)回访的**共性沟通知识
priority: 90
applies:
ageMax: 12
version: 1.2.0
version: 1.3.0
---
# 儿童沟通(≤12 岁,对象=家长)
- 沟通对象=家长(不是孩子);话术里称患儿为"宝宝";称呼用 {智能称呼}(如 X 女士 / 宝宝妈妈 / 家长)。
- 沟通对象=家长(不是孩子);话术里称患儿为"宝宝";用给定的称呼(如 X 女士 / 宝宝妈妈 / 家长)。
- 语气默认 warm(温和家常,适合对家长)。熟络度看 user 给的 完成治疗次数 + 距上次就诊:最近常带宝宝来的可更熟络;很久没来的先重新拉近、别太自来熟。
- 复查/邀约对齐本次 {应治未治项},别框成"常规涂氟体检";涂氟、查蛀牙只作顺带关怀。
- 复查/邀约对齐本次应治未治项,别框成"常规涂氟体检";涂氟、查蛀牙只作顺带关怀。
- ⚠️ 儿科红线:整篇不提拍片(拍片/X光/牙片一律不说)。
import { z } from 'zod';
/**
* 话术语气 —— **单一源**(枚举值 + 字段说明 + 中文 label)。
*
* 三档 schema / 类型 / 渲染都引这里,加/改一个 tone 值只动这一处。
* 注:tone **由大模型判断**(感性、不好量化);"怎么选 tone"的软指引在人群共性 SKILL(system),
* user prompt 只给信号(熟络度 / 价值风险 / 病情)。这里只管"有哪几档 + 各档啥意思"。
*/
export const SCRIPT_TONES = ['warm', 'professional', 'urgent'] as const;
export type Tone = (typeof SCRIPT_TONES)[number];
/** zod 枚举(三档 schema 的 tone 字段共用) */
export const ToneEnum = z.enum(SCRIPT_TONES);
/** schema tone 字段说明(只解释"各档啥意思",不含选择规则) */
export const TONE_DESCRIBE = '整体语气:warm=温和家常 / professional=专业稳重 / urgent=有时效紧迫';
/** 渲染用中文 label(话术抬头"语气:…") */
export const TONE_LABEL: Record<Tone, string> = {
warm: '温和家常',
professional: '专业稳重',
urgent: '时效紧迫',
};
......@@ -16,9 +16,9 @@ import { stableTemplateFallback } from '../stable/stable.call';
*/
const PLAN_SYSTEM = [
'你是资深口腔回访话术规划师。基于给定的患者事实,规划一通"医疗关怀回访"电话拆成几段、每段讲什么。',
'原则:医疗关怀非销售;只围绕本次应治未治项为主(可顺带提其他牙问题但不展开);每个要点都必须能追到给定事实,不编造。',
'不要写话术正文,只输出大纲 JSON(段数你定,3-7 段:通常 开场 → 切入本次问题 → (可选)顺带关心 → 复查邀约 → 结束)。',
'你是资深口腔回访话术规划师。基于给定的患者事实,规划一通"医疗关怀回访"电话:拆成几段、每段讲什么。',
'原则:医疗关怀非销售;以本次聚焦项(应治未治)为主线,其他牙问题 / 历史联系 / 价值维系可一并关心(值得就单独开段讲);每个要点都必须能追到给定事实,不编造。',
'不要写话术正文,只输出大纲 JSON。**段数和结构由你按这通电话实际要覆盖的来定**(一般 3-7 段):料多(多颗牙 / 历史联系 / 高价值维系)就多开段讲透,料少就精简——不必套固定段式。',
].join('\n');
const VERIFY_SYSTEM = [
......@@ -26,14 +26,16 @@ const VERIFY_SYSTEM = [
'只依据给定的"本次回访患者信息"判断:',
'① 接地:草稿每个具体说法(诊断/检查所见/医嘱/时间/牙位/医生)能否在给定事实里找到依据?找不到=编造。',
'② 安全:有无报价/费用、疗效承诺、写死具体时间(应保留【时间段】)、患者≤18 却提拍片?',
'全部通过 → pass=true、issues 空;任一不过 → pass=false 并逐条列出 issue(段、问题、修法)。只输出 JSON,不改写草稿。',
'全部通过 → pass=true、issues 空;任一不过 → pass=false 并逐条列出 issue(段、问题、修法)。',
'另外给一组 **quality 质量评分**(1-5:口语自然 / 关怀温度 / 聚焦 / 不推销 + overall 综合)—— **只评"好不好",跟 pass 无关**(接地+安全才决定 pass;写得平庸但合规,pass 仍可 true,quality 给低分即可)。',
'只输出 JSON,不改写草稿。',
].join('\n');
@Injectable()
export class DeepPlanCall implements AiCall<ScriptContext, DeepPlanZ> {
readonly kind = 'script' as const;
readonly callKey = 'draft_plan_script_plan';
readonly promptVersion = 'draft_plan_script@2026-06-06-deep-plan-v1';
readonly promptVersion = 'draft_plan_script@2026-06-06-deep-plan-v6'; // v2: 去 {} 替换占位(朴素 labeled facts)
readonly defaultModelId = 'deepseek-v4-flash';
readonly outputSchema = DeepPlanSchema;
buildPrompt(ctx: ScriptContext) {
......@@ -45,7 +47,7 @@ export class DeepPlanCall implements AiCall<ScriptContext, DeepPlanZ> {
export class DeepWriteCall implements AiCall<DeepWriteInput, DeepWriteZ> {
readonly kind = 'script' as const;
readonly callKey = 'draft_plan_script_write';
readonly promptVersion = 'draft_plan_script@2026-06-06-deep-write-v5';
readonly promptVersion = 'draft_plan_script@2026-06-06-deep-write-v10'; // v6: 去 {} 替换占位 + common.md/deep format 去 brace + 预约句 X医生
readonly defaultModelId = 'deepseek-v4-flash';
readonly outputSchema = DeepWriteSchema;
constructor(private readonly skillRegistry: DraftPlanScriptSkillRegistry) {}
......@@ -63,7 +65,7 @@ export class DeepWriteCall implements AiCall<DeepWriteInput, DeepWriteZ> {
export class DeepVerifyCall implements AiCall<DeepVerifyInput, DeepVerifyZ> {
readonly kind = 'judge' as const;
readonly callKey = 'draft_plan_script_verify';
readonly promptVersion = 'draft_plan_script@2026-06-06-deep-verify-v1';
readonly promptVersion = 'draft_plan_script@2026-06-06-deep-verify-v6'; // v2: 去 {} 替换占位(随 fact-block)
readonly defaultModelId = 'deepseek-v4-flash';
readonly outputSchema = DeepVerifySchema;
buildPrompt(input: DeepVerifyInput) {
......
import { Injectable, Logger } from '@nestjs/common';
import { AiCallRunnerService } from '../../../../ai-call-runner.service';
import { InvocationRecorderService } from '../../../../core/invocation-recorder.service';
import type { AiCallContext } from '../../../../ai-call.interface';
import type { ScriptContext } from '../../shared/input.types';
import { machineSafetyScan } from '../../shared/safety-rules';
import { stableTemplateFallback } from '../stable/stable.call';
import { DeepPlanCall, DeepWriteCall, DeepVerifyCall } from './calls';
import { draftOutputToDeep, type DeepDraft, type DeepPlan, type DeepVerifyIssue } from './types';
import { draftOutputToDeep, type DeepDraft, type DeepPlan, type DeepVerify, type DeepVerifyIssue } from './types';
export interface DeepScriptResult {
draft: DeepDraft;
......@@ -31,6 +32,7 @@ export class DeepScriptStrategy {
constructor(
private readonly runner: AiCallRunnerService,
private readonly recorder: InvocationRecorderService,
private readonly planCall: DeepPlanCall,
private readonly writeCall: DeepWriteCall,
private readonly verifyCall: DeepVerifyCall,
......@@ -41,13 +43,22 @@ export class DeepScriptStrategy {
let cost = 0;
let promptTokens = 0;
let completionTokens = 0;
const acc = (r: { costYuan: number; promptTokens: number; completionTokens: number }) => {
let allCacheHit = true; // P1-2:各步全命中缓存才算 cacheHit(别再硬编码 false)
let ranAny = false;
const acc = (r: { costYuan: number; promptTokens: number; completionTokens: number; cacheHit: boolean }) => {
cost += r.costYuan;
promptTokens += r.promptTokens;
completionTokens += r.completionTokens;
allCacheHit = allCacheHit && r.cacheHit;
ranAny = true;
};
// 客户端断连 → 各步之间快速短路(在途调用由 runner 的 abortSignal 取消)
const ensureLive = () => {
if (runCtx.signal?.aborted) throw new Error('生成已取消(客户端断连)');
};
// ── 步骤1:规划(best-effort;失败用退化大纲,不阻断) ──
ensureLive();
let plan: DeepPlan;
try {
const r = await this.runner.run(this.planCall, ctx, runCtx);
......@@ -55,35 +66,42 @@ export class DeepScriptStrategy {
plan = r.output;
steps.push('plan');
} catch (err) {
if (runCtx.signal?.aborted) throw err; // abort 不退化,直接抛
this.logger.warn(`deep plan 失败,用退化大纲: ${(err as Error).message}`);
plan = degeneratePlan();
steps.push('plan:degenerate');
}
// ── 步骤2:写(有 fallback,必返回) ──
ensureLive();
let w = await this.runner.run(this.writeCall, { ctx, plan }, runCtx);
acc(w);
let draft = w.output;
let invocationId = w.invocationId;
steps.push(w.source === 'template_fallback' ? 'write:fallback' : 'write');
if (w.source === 'template_fallback') {
return this.done(draft, 'template_fallback', invocationId, cost, promptTokens, completionTokens, steps, w.fallbackReason);
return this.done(draft, 'template_fallback', invocationId, cost, promptTokens, completionTokens, steps, false, w.fallbackReason);
}
// ── 步骤3:独立对抗校验 + 机器扫 ──
const issues: DeepVerifyIssue[] = machineScanIssues(draft);
let quality: DeepVerify['quality'] | null = null; // 质量分:回填留到最后,挂到 PlanScript 指向的最终 invocation
ensureLive();
try {
const v = await this.runner.run(this.verifyCall, { ctx, draft }, runCtx);
acc(v);
steps.push('verify');
if (!v.output.pass) issues.push(...v.output.issues);
quality = v.output.quality ?? null;
} catch (err) {
if (runCtx.signal?.aborted) throw err;
this.logger.warn(`deep verify 失败,仅依据机器扫: ${(err as Error).message}`);
steps.push('verify:skip');
}
// ── repair(≤1 轮) ──
if (issues.length > 0) {
ensureLive();
this.logger.debug(`deep repair: ${issues.length} 个 issue`);
const w2 = await this.runner.run(this.writeCall, { ctx, plan, repairIssues: issues }, runCtx);
acc(w2);
......@@ -94,11 +112,24 @@ export class DeepScriptStrategy {
const stillBad = machineSafetyScan(joinDraft(draft));
if (w2.source === 'template_fallback' || stillBad.length > 0) {
const fb = draftOutputToDeep(stableTemplateFallback(ctx));
return this.done(fb, 'template_fallback', invocationId, cost, promptTokens, completionTokens, steps, `repair 后仍不过: ${stillBad.join(';')}`);
return this.done(fb, 'template_fallback', invocationId, cost, promptTokens, completionTokens, steps, false, `repair 后仍不过: ${stillBad.join(';')}`);
}
}
return this.done(draft, 'agent', invocationId, cost, promptTokens, completionTokens, steps);
// P1-1:质量分回填到【最终】invocationId(= PlanScript 指向的那条),非 gate,失败忽略。
// 注:若 repair 过,quality 是 repair 前那稿的评分,挂到 repair 稿上属近似(repair 只修违规、质量相近)。
if (quality) {
await this.recorder
.attachJudge(invocationId, quality.overall, {
natural: quality.natural,
warmth: quality.warmth,
focus: quality.focus,
nonPushy: quality.nonPushy,
})
.catch((e) => this.logger.warn(`attachJudge 失败(忽略): ${(e as Error).message}`));
}
return this.done(draft, 'agent', invocationId, cost, promptTokens, completionTokens, steps, ranAny && allCacheHit);
}
private done(
......@@ -109,10 +140,11 @@ export class DeepScriptStrategy {
promptTokens: number,
completionTokens: number,
stepsRun: string[],
cacheHit: boolean,
fallbackReason?: string,
): DeepScriptResult {
this.logger.log(`deep done source=${source} steps=[${stepsRun.join('→')}] cost=${costYuan.toFixed(4)}`);
return { draft, source, invocationId, cacheHit: false, costYuan, promptTokens, completionTokens, fallbackReason, stepsRun };
return { draft, source, invocationId, cacheHit, costYuan, promptTokens, completionTokens, fallbackReason, stepsRun };
}
}
......@@ -134,10 +166,30 @@ function machineScanIssues(draft: DeepDraft): DeepVerifyIssue[] {
function degeneratePlan(): DeepPlan {
return {
sections: [
{ key: 'opening', title: '开场问候', intent: '称呼+自报家门+医生关怀+问近况', points: ['用给定称呼/自报家门/诊断医生/智能时间'] },
{ key: 'missed', title: '切入本次问题', intent: '温和带出本次应治未治项', points: ['结合病历讲隐患与趁早处理的好处'] },
{ key: 'review', title: '复查邀约', intent: '邀约来院复查本次问题', points: ['用【时间段】引导预约'] },
{ key: 'close', title: '结束', intent: '预约成功/不成功两种收尾', points: ['简短有温度,时间用【具体预约时间】占位'] },
{
key: 'opening',
title: '开场问候',
intent: '打招呼 + 自报家门 + 医生关怀 + 问近况',
points: ['用患者称呼打招呼、确认方便', '自报家门', '以诊断医生名义体现关怀', '用最近一次就诊问近况'],
},
{
key: 'missed',
title: '切入本次问题',
intent: '温和带出本次问题',
points: ['结合病历讲清不处理的隐患与趁早处理的好处'],
},
{
key: 'review',
title: '复查邀约',
intent: '邀约来院复查本次问题',
points: ['用固定预约句式 + 【时间段】占位引导预约'],
},
{
key: 'close',
title: '结束',
intent: '简短收尾',
points: ['约成功确认时间道别;没约成温和留口子,时间用【具体预约时间】占位'],
},
],
};
}
import type { ScriptContext } from '../../shared/input.types';
import { buildRichFactBlock } from '../../shared/fact-block';
import { buildRichFactBlock, buildDeepExtensions } from '../../shared/fact-block';
import type { DeepPlan, DeepWriteInput, DeepVerifyInput } from './types';
/** 步骤1:规划 user prompt —— 厚事实块,产出大纲由 plan 的 system + schema 约束 */
/** 深度档事实块 = 标准厚块 + 深度扩展(价值/风险 + 历史联系);三步共用,给"分析"全信息 */
function deepFacts(ctx: ScriptContext): string {
const ext = buildDeepExtensions(ctx);
return ext ? `${buildRichFactBlock(ctx)}\n\n${ext}` : buildRichFactBlock(ctx);
}
/** 步骤1:规划 user prompt —— 深度厚事实块(全信息),产出大纲由 plan 的 system + schema 约束 */
export function buildPlanPrompt(ctx: ScriptContext): string {
return `${buildRichFactBlock(ctx)}
return `${deepFacts(ctx)}
---
# 你的任务(本步:规划,不写话术)
基于以上事实,规划这通回访电话拆成几段、每段讲什么。要点须来自上面事实,不编造。`;
}
/** 步骤2:写 user prompt —— 厚事实块 + 上一步大纲(+ repair 反馈) */
/** 步骤2:写 user prompt —— 深度厚事实块 + 上一步大纲(+ repair 反馈) */
export function buildWritePrompt(input: DeepWriteInput): string {
const outline = renderPlanOutline(input.plan);
const repair = input.repairIssues?.length
......@@ -19,7 +25,7 @@ export function buildWritePrompt(input: DeepWriteInput): string {
.map((i) => `- [${i.section}] 问题:${i.problem} → 修:${i.fix}`)
.join('\n')}`
: '';
return `${buildRichFactBlock(input.ctx)}
return `${deepFacts(input.ctx)}
---
# 本步大纲(按它逐段写,可微调措辞,不要新增事实)
......@@ -31,7 +37,7 @@ export function buildVerifyPrompt(input: DeepVerifyInput): string {
const draft = input.draft.sections
.map((s, i) => `### 第${i + 1}段:${s.title}\n${s.markdown}`)
.join('\n\n');
return `${buildRichFactBlock(input.ctx)}
return `${deepFacts(input.ctx)}
---
# 你的任务(本步:对抗校验,不改写)
......
import { z } from 'zod';
import { ToneEnum, TONE_DESCRIBE } from '../../shared/tone';
/**
* 深度档 3 步各自的输出 schema(都过 AiCallRunner 的 generateObject 强约束)。
......@@ -27,7 +28,7 @@ export type DeepPlanZ = z.infer<typeof DeepPlanSchema>;
// ── 步骤2:写(多段不定) ──
export const DeepWriteSchema = z.object({
tone: z.enum(['warm', 'professional', 'urgent']).describe('整体语气'),
tone: ToneEnum.describe(TONE_DESCRIBE),
sections: z
.array(
z.object({
......@@ -57,5 +58,15 @@ export const DeepVerifySchema = z.object({
}),
)
.describe('逐条列出有问题的点;全部 OK 则空数组'),
// ⭐ 质量评分(1-5)—— **只评好不好,跟 pass 无关**(pass 只看接地+安全)。仅落账供 eval / 版本对比,不卡生成。
quality: z
.object({
natural: z.number().int().min(1).max(5).describe('口语自然度:像真人一来一回,不书面/机器腔/念稿'),
warmth: z.number().int().min(1).max(5).describe('关怀温度:医疗关怀感,不冷淡也不推销'),
focus: z.number().int().min(1).max(5).describe('聚焦:紧扣本次问题、主线清晰,不发散'),
nonPushy: z.number().int().min(1).max(5).describe('不推销:邀约自然,不促单 / 不报价 / 不施压'),
overall: z.number().min(1).max(5).describe('综合质量分(1-5,可含半分,如 4.5)'),
})
.describe('质量细项打分(1-5);只评质量,不影响 pass/issues'),
});
export type DeepVerifyZ = z.infer<typeof DeepVerifySchema>;
# 输出结构 —— 深度档(多段不定 · 写步)
输出 1 个 JSON:`tone` + `sections`(数组,**段数跟随给定大纲**,不是固定 4 段)。每段 `{title, markdown}`:
- `title`:这段的小标题,自然口语、贴这段内容(别用"开场白/告知应治未治"这种刻板模板名)。
- `markdown`:该段话术正文,短句分行、行首 `•`
# 输出结构(按大纲分段)
输出 `tone` + `sections`(数组,段数跟随给定大纲)。每段 `{title, markdown}``title` 是这段小标题,`markdown` 是该段正文。
# 深度档写法
- **按大纲逐段写**:严格跟随"本步大纲"的段数与每段要点;可润色措辞,**不得新增大纲外的事实**
- **接地病历**:风险/好处/检查内容结合"病历(诊断上下文)"里医生的真实记录,用自己的话讲清;医生没记录的别编。
- **多段的价值**:可把"切入本次问题""可顺带关心的其他牙""复查邀约"等拆成各自独立段,讲深讲透;但仍以本次聚焦项为主
- 仍要:只讲本次 {应治未治项} 为主;以 {诊断医生} 名义体现关怀;短句便于互动
# 怎么写
- **按大纲逐段写**:严格跟随“本步大纲”的段数与每段要点;可润色措辞,**不得新增大纲外的事实**
- **病种措辞自供**:风险与“趁早处理的好处”结合下方病历(检查所见/医嘱/建议)+ 牙科常识,用自己的话讲清;医生没记录的别编。
- **按大纲讲透**:段怎么分由大纲定,你只管把大纲让你单独开段的内容(如某颗牙、复查)展开讲深讲透;以本次聚焦项为主线,顺带项点到为止
- **事实朴素取用**:患者信息以朴素中文标签直接给(称呼/本次问题/牙位/诊断医生/最近一次就诊…),自然用进话里;不写占位符、不留标签字样
# 占位与时间
- `{智能称呼}{自报家门}{诊断医生}{牙位}{智能时间显示}` 用给定值替换,输出里不能再出现 `{}`
- `【时间段1】【时间段2】【具体预约时间】` 原样保留,客服手填;⚠️ 严禁写死"周三上午"等具体时间、严禁加粗时间、严禁"已为您约好"承诺。
- 引导预约用「{诊断医生}医生【时间段1】和【时间段2】这两个时间段有空,您看哪个方便?」
# 这两处按原样写,不要改
1. 引导预约句式(严格用此句式):「X医生【时间段1】和【时间段2】这两个时间段有空,您看哪个方便?」——“X医生”用给定的诊断医生姓替换;两个【时间段】原样保留给客服手填。
2. 时间一律占位:约成功用「我们【具体预约时间】见」;`【时间段1】【时间段2】【具体预约时间】` 原样保留,严禁替换成“周三上午”等具体时间、严禁加粗、严禁“已为您约好”式承诺。
# 开场/结束
- 第一段开场:先用 {智能称呼} 称呼并确认对方方便 → 自报家门 {自报家门} → 以 {诊断医生} 名义体现关怀 → 用 {智能时间显示} 问近况。
- 最后一段结束:预约成功 / 不成功两种,简短有温度;时间用【具体预约时间】占位
# 开场/结束
- 开场段:把这几件事自然说到、顺序你定——用患者称呼称呼并确认对方方便、自报家门、以诊断医生名义体现关怀、用“最近一次就诊”问近况。
- 结束段:简短有温度——约成功就用「我们【具体预约时间】见」确认道别,没约成温和留口子(别写死“下周”)
......@@ -5,6 +5,7 @@
* 差异:多步编排(DeepScriptStrategy)+ 多段不定输出 + 逐句接地校验。
*/
import type { ScriptContext, DraftPlanScriptOutput } from '../../shared/input.types';
import type { Tone } from '../../shared/tone';
// ── 步骤1:规划(planner)产物 ──
export interface DeepPlanSection {
......@@ -27,7 +28,7 @@ export interface DeepDraftSection {
markdown: string;
}
export interface DeepDraft {
tone: 'warm' | 'professional' | 'urgent';
tone: Tone;
sections: DeepDraftSection[];
}
......@@ -44,6 +45,14 @@ export interface DeepVerify {
/** 全部接地 + 安全 → true */
pass: boolean;
issues: DeepVerifyIssue[];
/** 质量细项打分(1-5)—— 只评质量,不影响 pass;回填到 write 调用的 judgeScore/judgeRubric 供 eval */
quality: {
natural: number;
warmth: number;
focus: number;
nonPushy: number;
overall: number;
};
}
// ── 各步 AiCall 输入(复合;runner 落 inputSnapshot 用) ──
......
......@@ -2,6 +2,7 @@ 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 } from '../../shared/fact-block';
/**
* Prompt 版本管理约定:
......@@ -77,7 +78,9 @@ export function buildDraftPlanScriptPrompt(input: DraftPlanScriptInput): string
.join(';')}`,
);
}
if (mr?.treatmentPlanText) doctorNoteLines.push(`- 治疗计划:${mr.treatmentPlanText}`);
// 治疗计划:结构化 plannedTreatments(主)+ host 自由文本(常空,补)—— 不再只靠常空的 treatmentPlanText
const treatPlan = mr ? renderTreatmentPlan(mr) : null;
if (treatPlan) doctorNoteLines.push(`- 治疗计划:${treatPlan}`);
const riskLines = disease.risks.length
? disease.risks.map((r) => ` - ${r}`).join('\n')
......@@ -95,10 +98,10 @@ export function buildDraftPlanScriptPrompt(input: DraftPlanScriptInput): string
const guardianHint = patient.guardian
? `本次电话打给${patient.guardian.relationshipLabel}(称呼见{智能称呼}),沟通对象是家长,患者是孩子,话术里称孩子为"宝宝"`
: null;
// 熟络度信号(给原始事实,不贴"新/老"二分 —— 召回患者基本都有历史)。recency 为主判断 tone(不念出来)
// 熟络度信号(只给原始事实;语气怎么拿捏的规则在人群共性 SKILL,这里不复述 → 单一源)
const toneHint = `已完成 ${clinicalContext.completedTreatmentCount} 次治疗、距上次就诊 ${
clinicalContext.daysSinceLastVisit ?? '未知'
} 最近来过可熟络温和;很久没来(沉睡)有历史也别太自来熟、先重新拉近;关系浅则专业可信为主`;
} (语气怎么拿捏见沟通知识,你按这信号判断)`;
// ≤18 / 年龄未知 → 禁拍片 belt(青少年走成人模板时,成人模板含"拍片"句,这里硬提醒删)
const noXray = patient.age == null || patient.age <= 18;
......
import { z } from 'zod';
import { ToneEnum, TONE_DESCRIBE } from '../../shared/tone';
/**
* 稳健档 4 段输出 schema(顺序固定:开场白 → 告知应治未治 → 复查建议 → 结束回访语)。
......@@ -8,9 +9,7 @@ import { z } from 'zod';
* 不在 describe 里复述,避免与模板冲突(历史教训:describe 写反了开场顺序、写死了"下周")。
*/
export const DraftPlanScriptSchema = z.object({
tone: z
.enum(['warm', 'professional', 'urgent'])
.describe('整体语气:warm=温和家常 / professional=专业稳重 / urgent=有时效紧迫'),
tone: ToneEnum.describe(TONE_DESCRIBE),
opening: z
.string()
......
......@@ -65,7 +65,7 @@ export function stableTemplateFallback(input: DraftPlanScriptInput): DraftPlanSc
* 改 system/prompt 文本 → bump 字母;改 schema → bump 日期。
*/
const DRAFT_PLAN_SCRIPT_PROMPT_VERSION =
'draft_plan_script@2026-06-06-4module-v22'; // v22: 新老客改'熟络度'(recency为主+次数为辅,去二分标签,交LLM);v21: 开场日期改锚【最近一次就诊】(原误用诊断日,患者后来又来过会错位)+'来过之后'/告知'之前那次'区分;v20: schema describe 收口(去与模板矛盾的开场顺序/'下周'/负面例,只留段用途+关键约束)+ prompt/兜底 软化'下周'+ 修陈旧注释;v19: stable format.md 精简(去 common/机器闸重复、自查砍到高风险3条)+ 成人模板优化(去重/换标题/软化结束语);v18: base-common 精简合并去重 + 软化治疗方案口径(可点名/不报价不定细化/落点复查);v17: 目录重组(shared/+tiers/stable/)— base 拆 common+format、人群拆共性知识+稳健句位、病种文案归 stable phrasing、安全单一源 safety-rules、composer tier-aware;修开场顺序冲突。v16: 儿童模板复查段修复(删写死"3个月常规涂氟检查"→对齐本次{应治未治项}+用{复查时长},涂氟降级顺带);child SKILL 1.4.0;v15: user prompt 加"医生那次交代"(医嘱/建议/治疗计划,来自聚焦病历,仅引用不演绎);medicalRecord 补 recommendations;v14: user prompt 加 {牙位}(FDI→俗称)+ 本次目标(plan.goal)+ ≤18 禁拍片 belt;占位收口 {牙位}(删 【缺失牙位】);adult/child 模板带牙位;v13: 撤 token,人名去名留称呼(徐女士/韩医生 直接给,非 token);开场白先称呼确认对方再自报家门;v12: user prompt 人名脱敏(称呼/诊断医生/客服 用 token,生成后回填;监护人全名不进 prompt);v11: 统一通话称呼(年龄+性别+监护人,修"9岁张先生");监护人触达提示;医生标签 最后一次就诊→诊断医生;v10: 病种知识走 disease-knowledge 单一访问源(subKey 优先+文本兜底),修 颌骨囊肿 拿不到风险/优势的 bug;v9: 自报家门用登录客服 岗位+姓名(agent);v8: 占位符统一({}=替换、【】=原样保留);v7: 清除 user prompt 污染;v6: 清 system 污染;v5: 还原原模板
'draft_plan_script@2026-06-06-4module-v25'; // v24: 治疗计划补 plannedTreatments(treatment_record planned 结构化;原只读常空的 emr.treatment_plan → 话术缺治疗计划);v23: common.md + 人群共性 SKILL 去 brace(原 {应治未治项}/{诊断医生}/{智能称呼} 改朴素措辞;稳健自身句位模板的 {} 不变,填空机制照常,行为基本不变);v22: 新老客改'熟络度'(recency为主+次数为辅,去二分标签,交LLM);v21: 开场日期改锚【最近一次就诊】(原误用诊断日,患者后来又来过会错位)+'来过之后'/告知'之前那次'区分;v20: schema describe 收口(去与模板矛盾的开场顺序/'下周'/负面例,只留段用途+关键约束)+ prompt/兜底 软化'下周'+ 修陈旧注释;v19: stable format.md 精简(去 common/机器闸重复、自查砍到高风险3条)+ 成人模板优化(去重/换标题/软化结束语);v18: base-common 精简合并去重 + 软化治疗方案口径(可点名/不报价不定细化/落点复查);v17: 目录重组(shared/+tiers/stable/)— base 拆 common+format、人群拆共性知识+稳健句位、病种文案归 stable phrasing、安全单一源 safety-rules、composer tier-aware;修开场顺序冲突。v16: 儿童模板复查段修复(删写死"3个月常规涂氟检查"→对齐本次{应治未治项}+用{复查时长},涂氟降级顺带);child SKILL 1.4.0;v15: user prompt 加"医生那次交代"(医嘱/建议/治疗计划,来自聚焦病历,仅引用不演绎);medicalRecord 补 recommendations;v14: user prompt 加 {牙位}(FDI→俗称)+ 本次目标(plan.goal)+ ≤18 禁拍片 belt;占位收口 {牙位}(删 【缺失牙位】);adult/child 模板带牙位;v13: 撤 token,人名去名留称呼(徐女士/韩医生 直接给,非 token);开场白先称呼确认对方再自报家门;v12: user prompt 人名脱敏(称呼/诊断医生/客服 用 token,生成后回填;监护人全名不进 prompt);v11: 统一通话称呼(年龄+性别+监护人,修"9岁张先生");监护人触达提示;医生标签 最后一次就诊→诊断医生;v10: 病种知识走 disease-knowledge 单一访问源(subKey 优先+文本兜底),修 颌骨囊肿 拿不到风险/优势的 bug;v9: 自报家门用登录客服 岗位+姓名(agent);v8: 占位符统一({}=替换、【】=原样保留);v7: 清除 user prompt 污染;v6: 清 system 污染;v5: 还原原模板
@Injectable()
export class DraftPlanScriptCall
......
import { z } from 'zod';
import { ToneEnum, TONE_DESCRIBE } from '../../shared/tone';
/**
* 标准档输出 schema —— 与稳健档**同 4 字段形态**(opening/informMissed/reviewAdvice/closing),
* 但 `.describe` **放宽**:只说该段目的与底线,**不给句位/小节脚手架**,段内由 LLM 自由编排。
* 标准档输出 schema —— **去模板 = 自由段数组(固定 4 段)**。
*
* UI/存储与稳健档解耦:orchestrator 的 renderMarkdown/sections 对两档一视同仁(都映射这 4 字段)。
* 跟稳健档的根本区别:稳健是 4 个**写死角色字段**(opening/informMissed/reviewAdvice/closing),
* 标准这里是一个**通用 `sections[]` 数组**(每段 {title, markdown}),**不绑角色**——标题、每段讲什么
* 全由 LLM 定。固定 4 段(`.length(4)`),区别于深度的"段数不限"。
*
* 形态与深度档 DeepDraft 一致({tone, sections[{title,markdown}]})→ orchestrator 复用
* deepDraftToSections / renderMarkdownFromSections 渲染;safety 用 SCRIPT_SAFETY_RULES_SECTIONS。
* 段内"轮廓/写法/固定例外(预约句·时间占位)"在 system 的 format.md,这里只给形态 + 硬底线。
*/
export const StandardScriptSchema = z.object({
tone: z
.enum(['warm', 'professional', 'urgent'])
.describe('整体语气标签:warm=温和家常 / professional=专业稳重 / urgent=有时效紧迫'),
// ⭐ 标准档"标题不定":4 段标题由 LLM 自起,贴这个患者/这通电话,别用刻板模板名
sectionTitles: z
.object({
opening: z.string().min(2).max(20).describe('开场段小标题(你起,自然口语,如"先打个招呼")'),
informMissed: z.string().min(2).max(20).describe('告知段小标题(你起,贴本次问题,如"上次发现的小情况")'),
reviewAdvice: z.string().min(2).max(20).describe('复查建议段小标题(你起)'),
closing: z.string().min(2).max(20).describe('结束段小标题(你起)'),
})
.describe('为下面 4 段各起一个简短自然的小标题;⚠️ 标准档标题不固定,由你定,**别用"开场白/告知应治未治/复查建议/结束回访语"这种刻板模板名**'),
opening: z
.string()
.min(50)
.max(600)
.describe(
[
'【开场白】先用 {智能称呼} 称呼并确认对方方便 → 自报家门 {自报家门} → 以 {诊断医生} 医生名义体现关怀 → 用 {智能时间显示} 问近况。',
'分短句(行首 `•`);自己组织,自然有温度;禁大标题/分隔符/表情。',
].join('\n'),
),
informMissed: z
.string()
.min(80)
.max(900)
.describe(
[
'【告知应治未治】只讲本次这一个 {应治未治项}(给了 {牙位} 就带上)。',
'以"{诊断医生}医生上次检查发现"的口吻,结合"病历(诊断上下文)"里医生的真实记录,用自己的话口语化讲清"不处理的隐患"+"趁早处理的好处";温和提醒、非吓唬、非推销。',
'不要套用现成模板句;医生没记录的别编。分短句(行首 `•`)。',
].join('\n'),
),
reviewAdvice: z
.string()
.min(80)
.max(900)
.describe(
[
'【复查建议】邀约来院复查本次问题(对齐"本次目标"方向,但用关怀口吻、不推销治疗);用自己的话说明这次复查大概看什么(别写死分钟数)。',
'引导预约严格用「{诊断医生}医生【时间段1】和【时间段2】这两个时间段有空,您看哪个方便?」',
'⚠️【时间段】保留占位,禁写死具体时间。分短句(行首 `•`)。',
].join('\n'),
),
tone: ToneEnum.describe(TONE_DESCRIBE),
closing: z
.string()
.min(40)
.max(500)
sections: z
.array(
z.object({
title: z
.string()
.min(2)
.max(20)
.describe('该段小标题:你自起、自然口语贴这通电话,别用"开场白/告知应治未治/复查建议/结束回访语"这类刻板模板名'),
markdown: z
.string()
.min(30)
.max(900)
.describe('该段正文:分短句、行首 `•`;接地病历不编造;具体时间一律用【时间段】占位;无大标题/分隔符/表情'),
}),
)
.length(4)
.describe(
[
'【结束回访语】预约成功 / 不成功两种,简短有温度。',
'【预约成功】「我们【具体预约时间】见」+ 祝福;【预约不成功】「下周再联系您」+ 祝福。',
'禁承诺式"已为您约好";禁加粗具体时间。',
].join('\n'),
'固定 4 段(数组顺序即话术顺序);**角色/标题/每段讲什么都由你定,不要套固定段名**。一通回访通常覆盖:打招呼问近况 / 带出本次问题(隐患+趁早好处)/ 邀约来院复查(段尾含固定预约引导句)/ 简短收尾 —— 但怎么分、起什么标题由你决定。详细写法见 system 提示词。',
),
});
......
# 输出结构(4 模块,顺序固定)—— 标准档(去模板,段内自由编排)
输出 1 个 JSON:`tone` + 4 段 Markdown 字符串,顺序固定:
1. `opening` 开场白
2. `informMissed` 告知应治未治
3. `reviewAdvice` 复查建议
4. `closing` 结束回访语
# 输出结构(固定 4 段)
输出 `tone` + `sections`(数组,正好 4 段)。每段 `{title, markdown}``title` 是你自起的小标题,`markdown` 是该段正文。
# 标准档写法(和稳健档的区别)
- **不给固定句位/小节模板**:每段你自己组织短句、自己决定讲几句、怎么衔接,更贴这个患者。
- **病种措辞自供**:本次只给 {应治未治项} 病种名,**不给现成的风险/优势/复查话术**。风险与"趁早处理的好处"由你结合**下方"病历(诊断上下文)"里医生的真实记录**(检查所见/医嘱/建议)+ 牙科常识,用自己的话口语化讲清楚。
- **接地优先**:能引用医生那次的真实记录就引用(体现"医生一直惦记着你"),**不要脱离病历凭空演绎**;医生没记录的别编。
- 仍要:每段分短句(行首 `•`)、便于互动;只讲本次这一个 {应治未治项};以 {诊断医生} 名义体现关怀。
# 怎么写
- **段不固定**:4 段分别讲什么、起什么标题、怎么衔接,由你定。
- **病种措辞自供**:只给“本次问题”病种名,不给现成话术;风险与“趁早处理的好处”由你结合下方病历(检查所见/医嘱/建议)+ 牙科常识,用自己的话讲清。
- **事实朴素取用**:患者信息以朴素中文标签直接给(称呼/本次问题/牙位/诊断医生/最近一次就诊…),自然用进话里;不写占位符、不留标签字样。
# 各段要点(轮廓,不是句位
- **opening**:先用 {智能称呼} 称呼并确认对方方便 → 自报家门 {自报家门} → 以 {诊断医生} 医生名义体现关怀 → 用 {智能时间显示} 问近况。
- **informMissed**:自然带出本次 {应治未治项}(若给了 {牙位} 就带上),用医生发现的口吻;结合病历讲清"不处理的隐患"和"趁早处理的好处",温和提醒非吓唬、非推销。
- **reviewAdvice**:邀约来院复查本次问题(对齐"本次目标"方向,但用关怀口吻、不推销治疗);用自己的话说明这次复查大概看什么(**别写死分钟数**,可结合病历);引导预约严格用「{诊断医生}医生【时间段1】和【时间段2】这两个时间段有空,您看哪个方便?」
- **closing**:预约成功/不成功两种,简短有温度;时间用【具体预约时间】占位
# 4 段通常覆盖(内容指引,可合并衔接、标题自起
- 打招呼、确认对方方便、自报家门,以诊断医生名义体现关怀,用“最近一次就诊”问近况。
- 带出本次这一个问题(有牙位就带上):以“诊断医生上次检查发现”的口吻,结合病历讲清“隐患”+“趁早处理的好处”,温和提醒、非吓唬非推销。
- 邀约来院复查本次问题(关怀口吻、不推销治疗、别写死分钟数)。
- 简短有温度的收尾:约成功就确认时间道别,没约成温和留口子(别写死“下周”这种绝对时间)
# 时间用占位
⚠️【时间段1】【时间段2】【具体预约时间】原样保留,严禁替换成"周三上午"等具体时间;严禁加粗具体时间、严禁"已为您约好"承诺。
# 这两处按原样写,不要改
1. 引导预约句式(放在“邀约复查”那段段尾,严格用此句式):「X医生【时间段1】和【时间段2】这两个时间段有空,您看哪个方便?」——“X医生”用给定的诊断医生姓替换;两个【时间段】原样保留给客服手填。
2. 时间一律占位:约成功用「我们【具体预约时间】见」;`【时间段1】【时间段2】【具体预约时间】` 原样保留,严禁替换成“周三上午”等具体时间、严禁加粗、严禁“已为您约好”式承诺。
# 占位符约定
- `{xxx}`(如 {智能称呼}{应治未治项}{牙位}{诊断医生}{自报家门}{智能时间显示})= 用"本次回访患者信息"里给的同名值替换,输出里不能再出现 `{}`
- `【时间段1】【时间段2】【具体预约时间】` = 原样保留,客服手填。
- {智能称呼}/{诊断医生} 已是"姓+敬称",直接用。{牙位} 已是俗称;未给则不提牙位。
# 占位
只有 `【时间段1】【时间段2】【具体预约时间】` 需原样保留在话术里(客服手填);其余都是朴素事实,直接自然取用,输出里不要出现别的占位或标签。
import { Injectable, Logger } from '@nestjs/common';
import type { AiCall } from '../../../../ai-call.interface';
import type { SafetyRule } from '../../../../core/safety-gate.service';
import { StandardScriptSchema } from './schema';
import type { DraftPlanScriptInput, DraftPlanScriptOutput } from '../../shared/input.types';
import type { DraftPlanScriptInput } from '../../shared/input.types';
import { buildStandardScriptPrompt } from './prompt';
import { composeSystem } from '../../shared/skill-composer';
import { DraftPlanScriptSkillRegistry } from '../../shared/skill-registry.service';
import { SCRIPT_SAFETY_RULES } from '../../shared/safety-rules';
import { SCRIPT_SAFETY_RULES_SECTIONS } from '../../shared/safety-rules';
import { stableTemplateFallback } from '../stable/stable.call';
import { type DeepDraft, draftOutputToDeep } from '../deep/types';
/**
* StandardScriptCall —— 标准档(投入档之二)。
*
* 与稳健档共用脊柱(AiCallRunner)、输入(ScriptContext)、安全闸(SCRIPT_SAFETY_RULES)、
* 兜底(stableTemplateFallback);**差异**只在策略三件:
* - system:composeSystem(input, skills, 'standard') → base-common + format.standard + 人群共性知识
* (不含稳健句位模板;skill 按 tiers 过滤)
* - user:buildStandardScriptPrompt → 厚输入(病历全 + 其他reason + 近期治疗)、病种只给名(去文案模板)
* - schema:StandardScriptSchema(同 4 字段,describe 放宽,段内 LLM 自由编排)
* 与稳健档共用脊柱(AiCallRunner)、输入(ScriptContext)、兜底(稳健模板);**差异**:
* - 输出形态:**去模板 = 自由 `sections[]`(固定 4 段)**,不绑 opening/informMissed/… 角色
* (与深度档 DeepDraft 同形;区别仅:标准=单步 + 固定 4 段,深度=3 步 pipeline + 段数不限)。
* - system:composeSystem(input, skills, 'standard') → common + format.standard + 人群共性
* - user:buildStandardScriptPrompt(厚病历接地,病种只给名,朴素 labeled facts 无 {} 占位)
* - safety:SCRIPT_SAFETY_RULES_SECTIONS(段数组形态);兜底:稳健模板转 4 段数组(draftOutputToDeep)
*
* callKey 仍用 'draft_plan_script'(同一逻辑调用),档位差异落 promptVersion → eval 可按版本切档对比。
*/
const STANDARD_PROMPT_VERSION = 'draft_plan_script@2026-06-06-standard-v6'; // v3: base-common 精简;v2: 4段标题不定(sectionTitles)
const STANDARD_PROMPT_VERSION = 'draft_plan_script@2026-06-06-standard-v12'; // v11: 病历补全(治疗计划=plannedTreatments 结构化 + 一般情况/处置/诊断说明,对齐页面 emr-soap);v10: format.md 去污染(删 tier 名/去模板对比/见 common.md 等解释性 meta,纯指令);流式输出(段数组 partial 边出边渲染);v9: 真去模板 — 4 固定角色字段 → 自由 sections[];v8: 去 {} 替换占位;v7: format 瘦身 + opening 放开 + closing 软化
@Injectable()
export class StandardScriptCall implements AiCall<DraftPlanScriptInput, DraftPlanScriptOutput> {
export class StandardScriptCall implements AiCall<DraftPlanScriptInput, DeepDraft> {
private readonly logger = new Logger(StandardScriptCall.name);
readonly kind = 'script' as const;
readonly callKey = 'draft_plan_script';
readonly promptVersion = STANDARD_PROMPT_VERSION;
readonly defaultModelId = 'deepseek-v4-flash';
readonly outputSchema = StandardScriptSchema;
readonly safetyRules = SCRIPT_SAFETY_RULES;
// 显式标 SafetyRule<DeepDraft>,让 runner.run 的 TOutput 推成 DeepDraft(否则会被结构型窄化丢 tone)
readonly safetyRules: ReadonlyArray<SafetyRule<DeepDraft>> = SCRIPT_SAFETY_RULES_SECTIONS;
constructor(private readonly skillRegistry: DraftPlanScriptSkillRegistry) {}
......@@ -53,8 +56,8 @@ export class StandardScriptCall implements AiCall<DraftPlanScriptInput, DraftPla
};
}
// 标准档失败 → 回退到稳健模板(安全网,客服永远有东西可用)
fallback(input: DraftPlanScriptInput): DraftPlanScriptOutput {
return stableTemplateFallback(input);
// 标准档失败 → 回退稳健模板(4 段),转成自由段数组形态(安全网,客服永远有东西可用)
fallback(input: DraftPlanScriptInput): DeepDraft {
return draftOutputToDeep(stableTemplateFallback(input));
}
}
......@@ -5,6 +5,7 @@ import { fmtYearMonth } from '@pac/utils';
import { planScenarioLabel, personaFeatureMeta, subLabelZh, treatmentCategoryNameZh } from '@pac/types';
import { PrismaService } from '../../../prisma/prisma.service';
import { AiCallRunnerService } from '../ai-call-runner.service';
import type { StreamEvent } from '../ai-call-runner.service';
import type { AiCall, AiCallContext } from '../ai-call.interface';
import { DraftPlanScriptCall } from '../calls/draft-plan-script/tiers/stable/stable.call';
import { StandardScriptCall } from '../calls/draft-plan-script/tiers/standard/standard.call';
......@@ -12,6 +13,7 @@ import { DeepScriptStrategy } from '../calls/draft-plan-script/tiers/deep/deep.s
import type { DeepDraft } from '../calls/draft-plan-script/tiers/deep/types';
import type { ScriptTier } from '../calls/draft-plan-script/shared/skill.types';
import { callSalutation, pickGuardian } from '../calls/draft-plan-script/shared/pii';
import { TONE_LABEL } from '../calls/draft-plan-script/shared/tone';
import type {
DraftPlanScriptInput,
DraftPlanScriptOutput,
......@@ -80,6 +82,8 @@ export interface PlanScriptGenerateOptions {
agent?: { name: string | null; roleTitle: string };
/** ⭐ 投入档(默认 stable)。stable=4段模板填空 / standard=4段去模板自由编排。深度档后续。 */
tier?: ScriptTier;
/** 取消信号(SSE 客户端断连 → controller abort)→ 透传到 runner 取消在途 LLM(尤其深度 3-4 步) */
signal?: AbortSignal;
}
export interface PlanScriptGenerateResult {
......@@ -105,20 +109,13 @@ export class PlanScriptOrchestrator {
private readonly deepStrategy: DeepScriptStrategy,
) {}
/** 按投入档选单 call 策略(稳健/标准;深度走 deepStrategy 多步,不在此) */
private resolveCall(
tier: ScriptTier | undefined,
): AiCall<DraftPlanScriptInput, DraftPlanScriptOutput> {
return tier === 'standard' ? this.standardCall : this.call;
}
/**
* 复用入口:给实时坐席辅助教练装配同一套 DraftPlanScriptInput(纯 DB 读,无副作用)。
* 实时教练用它的 patient/plan/clinicalContext 拼 Qwen instructions,跟话术生成共享上下文。
*/
async buildScriptInputForPlan(planId: string): Promise<DraftPlanScriptInput> {
const { plan, patient, persona, facts, guardian } = await this.loadPlanContext(planId);
return this.buildCallInput({ plan, patient, persona, facts, guardian });
const ctx = await this.loadPlanContext(planId);
return this.buildCallInput({ ...ctx });
}
/**
......@@ -134,8 +131,8 @@ export class PlanScriptOrchestrator {
options: PlanScriptGenerateOptions = {},
): Promise<PlanScriptGenerateResult> {
// ─── 1. 装配 input(纯 DB 读,不 pull) ───
const { plan, patient, persona, facts, guardian } = await this.loadPlanContext(planId);
const input = this.buildCallInput({ plan, patient, persona, facts, guardian, agent: options.agent });
const { plan, patient, persona, facts, guardian, returnVisits } = await this.loadPlanContext(planId);
const input = this.buildCallInput({ plan, patient, persona, facts, guardian, returnVisits, agent: options.agent });
const patientNameMasked = input.patient.salutation;
const runCtx: AiCallContext = {
hostId: plan.hostId,
......@@ -164,8 +161,17 @@ export class PlanScriptOrchestrator {
cacheHit = r.cacheHit;
costYuan = r.costYuan;
structured = r.draft;
} else if (options.tier === 'standard') {
// 标准档:单步,但输出是自由段数组(DeepDraft 形态)→ 走段渲染(同深度)
const result = await this.runner.run(this.standardCall, input, runCtx);
content = renderMarkdownFromSections(deepDraftToSections(result.output), result.output.tone, { patientNameMasked });
source = result.source;
invocationId = result.invocationId;
cacheHit = result.cacheHit;
costYuan = result.costYuan;
structured = result.output;
} else {
const result = await this.runner.run(this.resolveCall(options.tier), input, runCtx);
const result = await this.runner.run(this.call, input, runCtx);
content = renderMarkdown(result.output, { patientNameMasked });
source = result.source;
invocationId = result.invocationId;
......@@ -236,6 +242,7 @@ export class PlanScriptOrchestrator {
bustCache: true,
modelIdOverride: options.modelIdOverride,
evalMode: 'production',
signal: options.signal,
};
yield {
type: 'start',
......@@ -286,58 +293,93 @@ export class PlanScriptOrchestrator {
return;
}
const workflowRunId = randomUUID();
const events = this.runner.stream(this.resolveCall(options.tier), input, {
const streamCtx: AiCallContext = {
hostId: plan.hostId,
tenantId: plan.tenantId,
workflowRunId,
workflowRunId: randomUUID(),
linkedPatientId: patient.id,
linkedPersonaId: persona?.id,
linkedPlanId: plan.id,
bustCache: true, // 流式强制 bust(命中缓存的话没流可流)
bustCache: true, // 流式强制 bust(命中缓存没流可流)
modelIdOverride: options.modelIdOverride,
evalMode: 'production',
signal: options.signal,
};
// ── 标准档:段数组形态,走 runner.stream 逐字 partial(sections 边出边渲染) ──
if (options.tier === 'standard') {
yield* this.streamAndPersist(this.runner.stream(this.standardCall, input, streamCtx), {
plan,
dryRun: options.dryRun ?? false,
patientNameMasked,
toSections: (o) => deepDraftPartialToSections(o),
toContent: (o) => renderMarkdownFromSections(deepDraftToSections(o), o.tone, { patientNameMasked }),
});
return;
}
// ── 稳健档:4 段 flat schema,走 runner.stream 逐字 partial ──
yield* this.streamAndPersist(this.runner.stream(this.call, input, streamCtx), {
plan,
dryRun: options.dryRun ?? false,
patientNameMasked,
toSections: (o) => renderSections(o, { patientNameMasked }),
toContent: (o) => renderMarkdown(o, { patientNameMasked }),
});
let lastStructured: DraftPlanScriptOutput | null = null;
let lastInvocationId: string | null = null;
let lastSource: 'agent' | 'template_fallback' = 'agent';
let lastCost = 0;
let lastPromptTokens = 0;
let lastCompletionTokens = 0;
let lastFallbackReason: string | undefined;
}
/**
* 流式收尾共用件:消费 runner.stream 事件 → 逐 partial yield 渲染好的 sections,
* done 时写 PlanScript + yield 终事件。稳健(4字段)/标准(段数组)各传自己的渲染器。
*/
private async *streamAndPersist<O extends DraftPlanScriptOutput | DeepDraft>(
events: AsyncGenerator<StreamEvent<O>>,
args: {
plan: PlanWithReasons;
dryRun: boolean;
patientNameMasked: string;
toSections: (o: Partial<O> | O) => ScriptSectionDto[];
toContent: (o: O) => string;
},
): AsyncGenerator<PlanScriptStreamEvent, void, void> {
const { plan, dryRun, patientNameMasked: _pn, toSections, toContent } = args;
let last: O | null = null;
let invId: string | null = null;
let source: 'agent' | 'template_fallback' = 'agent';
let cost = 0;
let promptTokens = 0;
let completionTokens = 0;
let fallbackReason: string | undefined;
for await (const evt of events) {
if (evt.type === 'start') {
lastInvocationId = evt.invocationId;
invId = evt.invocationId;
yield { type: 'start', invocationId: evt.invocationId, modelId: evt.modelId, promptVersion: evt.promptVersion };
} else if (evt.type === 'partial') {
yield {
type: 'partial',
structured: evt.partial,
sections: renderSections(evt.partial, { patientNameMasked }),
};
yield { type: 'partial', structured: evt.partial, sections: toSections(evt.partial) };
} else if (evt.type === 'done') {
lastStructured = evt.output;
lastSource = evt.source;
lastCost = evt.costYuan;
lastPromptTokens = evt.promptTokens;
lastCompletionTokens = evt.completionTokens;
lastFallbackReason = evt.fallbackReason;
last = evt.output;
source = evt.source;
cost = evt.costYuan;
promptTokens = evt.promptTokens;
completionTokens = evt.completionTokens;
fallbackReason = evt.fallbackReason;
invId = evt.invocationId;
} else if (evt.type === 'error') {
yield { type: 'error', message: evt.message };
return;
}
}
if (!lastStructured) {
if (last === null) {
yield { type: 'error', message: '生成结束但未拿到最终对象' };
return;
}
// 写库(dryRun 跳过)。称呼/医生已是去名后的可用形式,无需回填
const content = renderMarkdown(lastStructured, { patientNameMasked });
const content = toContent(last);
const sections = toSections(last);
let planScriptId: string | null = null;
if (!options.dryRun) {
if (!dryRun) {
const row = await this.prisma.planScript.upsert({
where: { planId: plan.id },
create: {
......@@ -346,15 +388,10 @@ export class PlanScriptOrchestrator {
planId: plan.id,
content,
status: 'ready',
source: lastSource,
agentInvocationId: lastInvocationId,
},
update: {
content,
status: 'ready',
source: lastSource,
agentInvocationId: lastInvocationId,
source,
agentInvocationId: invId,
},
update: { content, status: 'ready', source, agentInvocationId: invId },
select: { id: true },
});
planScriptId = row.id;
......@@ -363,15 +400,15 @@ export class PlanScriptOrchestrator {
yield {
type: 'done',
planScriptId,
agentInvocationId: lastInvocationId ?? '',
source: lastSource,
structured: lastStructured,
agentInvocationId: invId ?? '',
source,
structured: last,
content,
sections: renderSections(lastStructured, { patientNameMasked }),
costYuan: lastCost,
promptTokens: lastPromptTokens,
completionTokens: lastCompletionTokens,
fallbackReason: lastFallbackReason,
sections,
costYuan: cost,
promptTokens,
completionTokens,
fallbackReason,
};
}
......@@ -425,7 +462,14 @@ export class PlanScriptOrchestrator {
orderBy: { occurredAt: 'desc' },
});
return { plan, patient, persona, facts, guardian };
// 历史联系(诊所回访/联系记录)— 最近 5 条(深度档用;页面"历史联系"同源)
const returnVisits = await this.prisma.patientReturnVisit.findMany({
where: { patientId: patient.id },
orderBy: { taskDate: 'desc' },
take: 5,
});
return { plan, patient, persona, facts, guardian, returnVisits };
}
private buildCallInput(args: {
......@@ -436,8 +480,17 @@ export class PlanScriptOrchestrator {
agent?: { name: string | null; roleTitle: string };
/** 监护人(未成年触达对象)— loadPlanContext 查好传入;成人/无 → null */
guardian?: { relationship: string; relationshipLabel: string; name: string | null } | null;
/** 历史联系(诊所回访记录,最近 5 条)— loadPlanContext 查好传入 */
returnVisits?: Array<{
taskDate: Date | null;
type: string | null;
status: string | null;
treatmentItems: string | null;
followContent: string | null;
result: string | null;
}>;
}): DraftPlanScriptInput {
const { plan, patient, persona, facts, agent, guardian } = args;
const { plan, patient, persona, facts, agent, guardian, returnVisits } = args;
const patientAge = patient.birthDate ? calcAge(patient.birthDate) : null;
// ⭐ "最近一次就诊"= 患者实际到店的最新一次。口径要全:encounter/emr 之外,
......@@ -574,6 +627,14 @@ export class PlanScriptOrchestrator {
recentTreatments: summarizeRecentTreatments(facts),
completedTreatmentCount: countCompletedTreatments(facts),
primaryDoctorName: extractPrimaryDoctor(facts),
contactHistory: (returnVisits ?? []).map((r) => ({
date: r.taskDate ? r.taskDate.toISOString().slice(0, 10) : null,
type: r.type,
status: r.status,
treatmentItems: r.treatmentItems,
followContent: r.followContent,
result: r.result,
})),
},
};
}
......@@ -609,7 +670,7 @@ ${out.closing}
}
function toneLabel(tone: DraftPlanScriptOutput['tone']): string {
return { warm: '温和家常', professional: '专业稳重', urgent: '时效紧迫' }[tone];
return TONE_LABEL[tone];
}
/**
......@@ -665,6 +726,22 @@ function deepDraftToSections(draft: DeepDraft): ScriptSectionDto[] {
}));
}
/**
* 段数组形态的**流式 partial** → ScriptSectionDto[](标准/深度共用)。
* partial 阶段 sections 可能还没出、或元素只填了一半(title/markdown 缺一)→ 全部容错兜空串。
*/
function deepDraftPartialToSections(
partial: Partial<DeepDraft> | DeepDraft,
): ScriptSectionDto[] {
const secs = partial.sections ?? [];
return secs.map((s, i) => ({
id: `s${i}`,
label: s?.title ?? '',
durationHint: '',
markdown: s?.markdown ?? '',
}));
}
/** 从 ScriptSectionDto[] 拼 PlanScript.content markdown(深度档/统一用) */
function renderMarkdownFromSections(
sections: ScriptSectionDto[],
......@@ -739,6 +816,27 @@ function buildMedicalRecord(
const s = (v as string | undefined)?.trim?.();
return s && s !== 'null' ? s : null;
};
// 计划治疗(同次接诊的 treatment_record kind=planned)—— 结构化"治疗计划"。
// host treatment_plan 自由文本常空,真正的治疗计划落在独立 treatment_record(planned)。
const plannedTreatments = emrExtId
? facts
.filter(
(f) =>
f.type === 'treatment_record' &&
String((f.content as Record<string, unknown> | null)?.status ?? '') === 'planned' &&
String((f.content as Record<string, unknown> | null)?.source_encounter_external_id ?? '') === emrExtId,
)
.map((f) => {
const tc = (f.content as Record<string, unknown> | null) ?? {};
const cat = (tc.category as string | undefined)?.trim() || '';
return {
categoryLabel: (cat && treatmentCategoryNameZh(cat)) || cat,
subtype: str(tc.subtype),
toothPosition: (tc.tooth_position as string | undefined)?.trim() || null,
};
})
.filter((t) => t.categoryLabel)
: [];
return {
date: emr.occurredAt ? emr.occurredAt.toISOString().slice(0, 10) : null,
doctorName: str(c.doctor_name),
......@@ -753,6 +851,7 @@ function buildMedicalRecord(
treatmentPlanText: str(c.treatment_plan),
diagnosisText: str(c.diagnosis_text),
recommendations,
plannedTreatments,
};
}
......@@ -889,14 +988,15 @@ function resolveClinicName(clinicId: string | null): string {
* 没抽到 → null(prompt 里走 fallback,让 LLM 用"您的主治医生"泛指,不编)。
*/
function extractPrimaryDoctor(facts: FactRow[]): string | null {
// 主治医生 = 跨所有 facts 中 doctor_id 出现频次 top 1 的医生
// 主治医生 = 跨所有 facts 中 doctor_id 出现频次 top 1 的医生(长期管该患者的人,非最近一次接诊)。
//
// ⚠️ 必须跟前端 KeyFactsCard 同口径(plan-detail-app.tsx attendingDoctor),
// 否则会出现"UI 显李医生 / AI 话术写王医生"的不一致 → 客服困惑 + AI 编造
// ⚠️ 这是 **fallback** 口径,不是话术里用的主口径。话术医生名优先用聚焦诊断的触发医生:
// prompt/fact-block 都是 `top.triggerDoctor ?? primaryDoctorName`(见 stable/prompt.ts、shared/fact-block.ts),
// 跟前端 KeyFactsCard 的 attendingDoctor(也优先聚焦诊断医生、再退最高频)对齐 —— 对齐发生在那行,不在本函数。
// 本函数只在 reason 无 triggerDoctor 时兜底,故口径是"最高频"而非"聚焦诊断医生"。
//
// 老版本(W4)用"最新一次 treatment/diagnosis 的医生" — 语义偏:
// 患者 5 年都是李医生治,最近一次姜医生临时接诊 → 老版选姜,新版选李
// "主诊"业务语义 = 长期管该患者的人,不是"最近一次接诊的"
// 患者 5 年都是李医生治,最近一次姜医生临时接诊 → 老版选姜,本版选最高频(李)
const idCount = new Map<string, number>();
const idToName = new Map<string, string>();
for (const f of facts) {
......
......@@ -124,12 +124,12 @@ export class PlansAggregateController {
@Query('tier') tier: string | undefined,
@Res() res: Response,
): Promise<void> {
await this.pipeSse(
res,
await this.pipeSse(res, (signal) =>
this.planScript.generateStream(planId, {
modelIdOverride: model,
tier: parseScriptTier(tier),
agent: this.resolveAgent(user),
signal,
}),
);
}
......@@ -215,7 +215,8 @@ export class PlansAggregateController {
@Query('model') model: string | undefined,
@Res() res: Response,
): Promise<void> {
await this.pipeSse(res, this.planSummary.generateStream(planId, { modelIdOverride: model }));
// 摘要档为单次调用,暂不透传 signal(controller 断连仍会停心跳/停写);如需取消在途再透传
await this.pipeSse(res, () => this.planSummary.generateStream(planId, { modelIdOverride: model }));
}
// ─────────────────────────────────────────────
......@@ -224,7 +225,7 @@ export class PlansAggregateController {
private async pipeSse<T extends { type: string }>(
res: Response,
stream: AsyncIterable<T>,
makeStream: (signal: AbortSignal) => AsyncIterable<T>,
): Promise<void> {
res.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
res.setHeader('Cache-Control', 'no-cache, no-transform');
......@@ -232,23 +233,32 @@ export class PlansAggregateController {
res.setHeader('X-Accel-Buffering', 'no'); // 关 nginx buffering
res.flushHeaders?.();
// 心跳防代理断连(每 15s 一个注释行)
const heartbeat = setInterval(() => res.write(': hb\n\n'), 15_000);
// ⭐ 客户端断连(关页面/点停止/网络断)→ abort:取消在途 LLM(尤其深度 3-4 步,别白烧)
// + 停心跳、停写,避免往已关闭 socket 写。
const ac = new AbortController();
res.on('close', () => ac.abort());
// 心跳防代理断连(每 15s 一个注释行);socket 已关则不写
const heartbeat = setInterval(() => {
if (!res.writableEnded) res.write(': hb\n\n');
}, 15_000);
try {
for await (const evt of stream) {
for await (const evt of makeStream(ac.signal)) {
if (ac.signal.aborted) break;
res.write(`event: ${evt.type}\n`);
res.write(`data: ${JSON.stringify(evt)}\n\n`);
const r = res as Response & { flush?: () => void };
r.flush?.();
(res as Response & { flush?: () => void }).flush?.();
if (evt.type === 'done' || evt.type === 'error') break;
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
res.write(`event: error\ndata: ${JSON.stringify({ type: 'error', message })}\n\n`);
if (!ac.signal.aborted && !res.writableEnded) {
const message = err instanceof Error ? err.message : String(err);
res.write(`event: error\ndata: ${JSON.stringify({ type: 'error', message })}\n\n`);
}
} finally {
clearInterval(heartbeat);
res.end();
if (!res.writableEnded) res.end();
}
}
}
......
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