Commit b130b9f9 by luoqi

feat(ai): 三处一句话摘要后端 — 历史联系 / 画像标签 / 召回简报

SummaryType 新增 recall_history / persona_tags / recall_brief;各配一个 Qwen AiCall
(draft-recall-summary / draft-persona-summary / draft-recall-brief)+ get-or-generate
orchestrator(缓存 plan_summaries,无数据 status=empty)+ controller GET 端点。
召回简报输入=召回原因+历史治疗(类目×次数)+画像,站患者立场答"谁/解决什么/到诊做什么"。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
parent e8f7d06c
......@@ -10,8 +10,14 @@ import { DeepPlanCall, DeepWriteCall, DeepVerifyCall } from './calls/draft-plan-
import { DeepScriptStrategy } from './calls/draft-plan-script/tiers/deep/deep.strategy';
import { DraftPlanScriptSkillRegistry } from './calls/draft-plan-script/shared/skill-registry.service';
import { DraftPlanSummaryCall } from './calls/draft-plan-summary/call';
import { DraftRecallSummaryCall } from './calls/draft-recall-summary/call';
import { DraftPersonaSummaryCall } from './calls/draft-persona-summary/call';
import { DraftRecallBriefCall } from './calls/draft-recall-brief/call';
import { PlanScriptOrchestrator } from './orchestrators/plan-script.orchestrator';
import { PlanSummaryOrchestrator } from './orchestrators/plan-summary.orchestrator';
import { RecallSummaryOrchestrator } from './orchestrators/recall-summary.orchestrator';
import { PersonaSummaryOrchestrator } from './orchestrators/persona-summary.orchestrator';
import { RecallBriefOrchestrator } from './orchestrators/recall-brief.orchestrator';
import { PlanModule } from '../plan/plan.module';
/**
......@@ -49,14 +55,23 @@ import { PlanModule } from '../plan/plan.module';
DeepScriptStrategy, // 深度档 3 步编排(plan→write→verify→repair→兜底)
DraftPlanScriptSkillRegistry, // scan & cache draft-plan-script/**​/skills/**​/SKILL.md
DraftPlanSummaryCall,
DraftRecallSummaryCall,
DraftPersonaSummaryCall,
DraftRecallBriefCall,
// orchestrators
PlanScriptOrchestrator,
PlanSummaryOrchestrator,
RecallSummaryOrchestrator,
PersonaSummaryOrchestrator,
RecallBriefOrchestrator,
],
exports: [
// 对外暴露 orchestrator(业务方调用入口)+ runner(高级使用 / eval CLI)
PlanScriptOrchestrator,
PlanSummaryOrchestrator,
RecallSummaryOrchestrator,
PersonaSummaryOrchestrator,
RecallBriefOrchestrator,
AiCallRunnerService,
AiProviderService,
// 实时坐席辅助教练复用:skills 注册表(组装 Qwen instructions)
......
import { Injectable } from '@nestjs/common';
import type { AiCall } from '../../ai-call.interface';
import type { SafetyRule } from '../../core/safety-gate.service';
import { DraftPersonaSummarySchema } from './schema';
import type { DraftPersonaSummaryInput, DraftPersonaSummaryOutput } from './input.types';
import {
DRAFT_PERSONA_SUMMARY_PROMPT_VERSION,
DRAFT_PERSONA_SUMMARY_SYSTEM,
buildDraftPersonaSummaryPrompt,
} from './prompt';
const FORBIDDEN_PHRASES = ['一定能', '保证', '绝对', '百分百', '100%', '亲爱的'];
const safetyRules: ReadonlyArray<SafetyRule<DraftPersonaSummaryOutput>> = [
{
name: 'no_forbidden_phrases',
severity: 'block',
check(output) {
const hit = FORBIDDEN_PHRASES.filter((p) => output.summary.includes(p));
return { pass: hit.length === 0, message: hit.length > 0 ? `命中禁词: ${hit.join(',')}` : undefined };
},
},
];
/** LLM 失败 / safety 拒收时:用标签拼一句最朴素的概述。 */
function fallback(input: DraftPersonaSummaryInput): DraftPersonaSummaryOutput {
const n = input.items.length;
if (n === 0) return { summary: '暂无画像标签。' };
const top = input.items.slice(0, 2).map((it) => it.value.split(/[·,,]/)[0]?.trim()).filter(Boolean);
return { summary: `${top.join(' · ') || '画像'}${n} 项画像标签。` };
}
@Injectable()
export class DraftPersonaSummaryCall
implements AiCall<DraftPersonaSummaryInput, DraftPersonaSummaryOutput>
{
readonly kind = 'summary' as const;
readonly callKey = 'draft_persona_summary';
readonly promptVersion = DRAFT_PERSONA_SUMMARY_PROMPT_VERSION;
readonly defaultModelId = 'qwen'; // 这类一句话摘要统一走 Qwen(qwen3.7-max)
readonly outputSchema = DraftPersonaSummarySchema;
readonly safetyRules = safetyRules;
buildPrompt(input: DraftPersonaSummaryInput) {
return {
system: DRAFT_PERSONA_SUMMARY_SYSTEM,
prompt: buildDraftPersonaSummaryPrompt(input),
};
}
fallback(input: DraftPersonaSummaryInput): DraftPersonaSummaryOutput {
return fallback(input);
}
}
/**
* DraftPersonaSummary AiCall — 把"画像标签(persona_features 的标签 + 取值)"提炼成**一句话重点**。
*
* 用途:详情页「画像标签」卡片(PersonaTagsCard),把一堆结构化标签压成一句客服可读的重点,
* 让接手客服一眼抓住"这是个什么样的患者 + 该怎么对待"。
* 跟 DraftRecallSummary(历史联系一句话摘要)完全平行:输入只有标签,输出一句话。
*/
export interface DraftPersonaSummaryInput {
patientNameMasked: string;
/** 画像标签项 */
items: Array<{
label: string; // 标签名(中文,如 价值分群 / 生命周期 / 潜在治疗)
value: string; // 该患者的取值描述(如 重要价值 · 距上次 151 天 · 就诊10次)
}>;
}
export interface DraftPersonaSummaryOutput {
/** 一句话画像重点(中文,≤30 字最佳) */
summary: string;
}
import type { DraftPersonaSummaryInput } from './input.types';
export const DRAFT_PERSONA_SUMMARY_PROMPT_VERSION = 'draft_persona_summary@2026-06-16-b';
export const DRAFT_PERSONA_SUMMARY_SYSTEM = `你是牙科诊所客服主管。下面是一个患者的画像标签(结构化、一堆标签看着累)。请提炼成**一句话重点**,让接手客服**一眼抓住关键**,而不是逐条复述所有标签。
# 关键要求
1. 只输出**一句话**(中文,**≤30 字**最佳,最多 40 字),不要换行/列表/Markdown。
2. **是"重点提炼"不是"标签罗列"**:抓最值得注意的一两点——价值层级、流失风险、应治未治的项目、触达需注意的点(免打扰 / 屡次爽约 / 看牙恐惧)。**不要**把所有标签都念一遍。
3. **只能用给出的标签里有的信息,严禁无中生有 / 拔高**:
- 「潜在治疗」= 医生诊断或建议过、但患者**还没做**的治疗(临床应治未治的缺口),**不是患者的意愿**。只能说成"有种植/修复待跟进""缺牙待修复""有应治未治项",**绝不能**说成"有种植意向""想做""有意愿""考虑中"——那是另一类信息,这里没有。
- 同理,没有的情绪 / 态度 / 决定一律不要编(别凭空说"犹豫""感兴趣""倾向于")。
4. 客观,**禁止**承诺疗效、给医疗建议。
5. 严格按 JSON schema 输出,只有一个 key:summary。
# 示例(风格参考,不要照抄)
- "重要价值客户,有种植、修复待跟进,周末好约。"
- "成长客、消费上升期,有应治未治项可推进。"
- "高价值但久未到诊,流失风险高,先拉近关系。"
- "免打扰标记 + 看牙恐惧,触达需谨慎、放慢节奏。"`;
export function buildDraftPersonaSummaryPrompt(input: DraftPersonaSummaryInput): string {
const lines =
input.items.length > 0
? input.items.map((it, i) => `${i + 1}. ${it.label}:${it.value}`).join('\n')
: '(无画像标签)';
return `患者:${input.patientNameMasked}
画像标签(共 ${input.items.length} 项):
${lines}
请用一句话概括以上画像的重点。`;
}
import { z } from 'zod';
/** DraftPersonaSummary 输出:一句话画像重点。 */
export const DraftPersonaSummarySchema = z.object({
summary: z
.string()
.min(4)
.max(80)
.describe(
'一句话中文画像重点(≤30 字最佳,最多 40 字,不带换行/列表/Markdown)。' +
'提炼最值得注意的一两点(价值层级 / 流失风险 / 应治未治项目 / 触达注意),' +
'让客服一眼抓住"这是谁、该怎么对待"。只用标签里有的信息,严禁无中生有:' +
'「潜在治疗」是应治未治的临床缺口,不是患者意愿,只能说"待跟进",不能说成"有意向/想做"。' +
'客观陈述,不承诺疗效、不臆测。例:"重要价值客户,有种植、修复待跟进,周末好约。"',
),
});
import { Injectable } from '@nestjs/common';
import type { AiCall } from '../../ai-call.interface';
import type { SafetyRule } from '../../core/safety-gate.service';
import { DraftRecallBriefSchema } from './schema';
import type { DraftRecallBriefInput, DraftRecallBriefOutput } from './input.types';
import {
DRAFT_RECALL_BRIEF_PROMPT_VERSION,
DRAFT_RECALL_BRIEF_SYSTEM,
buildDraftRecallBriefPrompt,
} from './prompt';
const FORBIDDEN_PHRASES = ['一定能', '保证', '绝对', '百分百', '100%', '亲爱的'];
const safetyRules: ReadonlyArray<SafetyRule<DraftRecallBriefOutput>> = [
{
name: 'no_forbidden_phrases',
severity: 'block',
check(output) {
const hit = FORBIDDEN_PHRASES.filter((p) => output.summary.includes(p));
return { pass: hit.length === 0, message: hit.length > 0 ? `命中禁词: ${hit.join(',')}` : undefined };
},
},
];
/** LLM 失败 / safety 拒收时:用召回原因拼一句最朴素的简报。 */
function fallback(input: DraftRecallBriefInput): DraftRecallBriefOutput {
const r = input.reasons[0];
if (!r) return { summary: '暂无明确召回原因。' };
const cats = r.expectedCategories.join(' / ');
const parts = [
r.tooth ? `${r.tooth}` : null,
r.diagnosis,
`${r.daysSinceText}前`,
cats ? `未启动 ${cats}` : null,
].filter(Boolean);
return { summary: `${r.subLabel}:${parts.join(' · ')}。` };
}
@Injectable()
export class DraftRecallBriefCall
implements AiCall<DraftRecallBriefInput, DraftRecallBriefOutput>
{
readonly kind = 'summary' as const;
readonly callKey = 'draft_recall_brief';
readonly promptVersion = DRAFT_RECALL_BRIEF_PROMPT_VERSION;
readonly defaultModelId = 'qwen'; // 这类一句话摘要统一走 Qwen(qwen3.7-max)
readonly outputSchema = DraftRecallBriefSchema;
readonly safetyRules = safetyRules;
buildPrompt(input: DraftRecallBriefInput) {
return {
system: DRAFT_RECALL_BRIEF_SYSTEM,
prompt: buildDraftRecallBriefPrompt(input),
};
}
fallback(input: DraftRecallBriefInput): DraftRecallBriefOutput {
return fallback(input);
}
}
/**
* DraftRecallBrief AiCall — 把"本次召回原因 + 历史治疗 + 画像"提炼成**一句话召回简报**。
*
* 用途:详情页「参考话术」标题下方那行(原 RecallReasonLine 结构化富文本),压成一句话,
* 回答客服打这通电话最该心里有数的三问:
* ① 患者是谁(从生命周期 / 价值 / 类型看)
* ② 帮患者解决什么问题(应治未治的缺口)
* ③ 邀约到诊是做什么(具体治疗动作)
*
* 跟 DraftRecallSummary / DraftPersonaSummary 平行,但输入更全(三类信号合一)。
*/
export interface DraftRecallBriefInput {
patientNameMasked: string;
/** 画像标签(含生命周期 / 价值分群 / 人群细分 → 判断"患者是谁")*/
persona: Array<{ label: string; value: string }>;
/** 历史治疗类目 + 次数(已做过什么,判断熟客/新客 + 信任锚)*/
treatmentHistory: Array<{ category: string; count: number }>;
/** 本次召回原因(应治未治项)*/
reasons: Array<{
subLabel: string; // 子场景中文(如 缺失牙未启动修复)
diagnosis: string | null; // 触发诊断中文(如 牙列丢失/缺牙)
tooth: string | null; // 牙位(如 47)
daysSinceText: string; // 距今(如 131 天(4 个月))
expectedCategories: string[]; // 期望/未启动治疗类目中文(如 种植 / 修复 / 冠桥)
}>;
}
export interface DraftRecallBriefOutput {
/** 一句话召回简报(中文,≤50 字最佳)*/
summary: string;
}
import type { DraftRecallBriefInput } from './input.types';
export const DRAFT_RECALL_BRIEF_PROMPT_VERSION = 'draft_recall_brief@2026-06-16-c';
export const DRAFT_RECALL_BRIEF_SYSTEM = `你是牙科诊所客服主管,正在给即将打电话的客服做一句话交底。下面给你这个患者的「本次召回原因 + 历史治疗 + 画像」。请提炼成**一句话召回简报**。
# 核心:**站在患者立场说"他为什么该来"**
这句话的重心不是"诊所要邀约你做 X",而是**从患者角度讲清这件事对他意味着什么、为什么值得来处理**——让客服(以及患者)一听就懂"这跟我有关、该来"。
把"应治未治的缺口"翻译成**患者能感知的影响 / 价值**(用牙科常识、保守表述,不夸大):
- 缺牙久拖 → 影响咀嚼、邻牙易移位 / 对颌牙伸长,越早处理越省事
- 龋齿不补 → 会继续发展、可能伤到牙神经引起疼
- 正畸后不戴 / 不查保持器 → 牙齿可能反弹移位,前期投入白费
- 根管后未戴冠 → 牙体脆易裂,套上冠才耐用
(以上是举例口径;按本患者实际诊断/治疗类目对应着说)
# 一句话要含三问,但分量不同
1. 患者是谁 —— 一两个词点一下(如 高价值种植老客 / 新客),做前置修饰。
2. ⭐**他为什么该来(全句重心)** —— 哪颗牙、什么问题、拖了多久没处理 + **对他的影响 / 越拖越…**。这是句子主干。
3. 到诊做什么 —— 简短收尾,具体治疗动作(种植 / 充填 / 复查…)。
# 关键要求
1. 只输出**一句话**(中文,**≤55 字**最佳,最多 75 字),不要换行/列表/Markdown。
2. **只能用给出的信息,严禁编造患者的意愿 / 情绪 / 决定**:
- 「未启动 / 应治未治」是临床缺口,**不是患者意愿**——**绝不能**说成"想做 / 有意向 / 考虑中"。
- 可以讲"缺口对患者的常识性影响"(上面那类),但**不要编个人化情节、不要编具体数值、不要承诺疗效**。
3. 客观、口吻是"为患者着想"而非"催单"。**禁止**给医疗建议、禁止承诺一定治好。
4. 历史治疗用来体现"熟客 / 信任基础"(如"做过 2 次种植"),不要凭空夸大。
5. 严格按 JSON schema 输出,只有一个 key:summary。
# 示例(患者立场、重心在"他为什么该来",风格参考,不要照抄)
- "47 缺了 4 个月的牙一直空着,越拖邻牙越易移位,早点种上才好咀嚼;患者是做过 2 次种植的老客。"
- "36 的龋齿拖了 3 个月还没补,再放任可能伤到牙神经会疼,趁早补上;新客,目前只洁过牙。"
- "正畸后保持器到期没复查,不及时戴查牙齿易反弹、白做一场,该回来复查;成熟期老客。"`;
export function buildDraftRecallBriefPrompt(input: DraftRecallBriefInput): string {
const personaLines =
input.persona.length > 0
? input.persona.map((p) => ` - ${p.label}:${p.value}`).join('\n')
: ' (无画像标签)';
const historyLine =
input.treatmentHistory.length > 0
? input.treatmentHistory.map((t) => `${t.category}×${t.count}`).join('、')
: '(无历史治疗记录)';
const reasonLines =
input.reasons.length > 0
? input.reasons
.map((r, i) => {
const parts = [
r.subLabel,
r.tooth ? `牙位 ${r.tooth}` : null,
r.diagnosis,
`${r.daysSinceText}前`,
r.expectedCategories.length > 0 ? `未启动 ${r.expectedCategories.join(' / ')}` : null,
].filter(Boolean);
return ` ${i + 1}. ${parts.join(' · ')}`;
})
.join('\n')
: ' (无召回原因)';
return `患者:${input.patientNameMasked}
画像:
${personaLines}
历史治疗(类目×次数):${historyLine}
本次召回原因(应治未治):
${reasonLines}
请按三问揉成一句话召回简报。`;
}
import { z } from 'zod';
/** DraftRecallBrief 输出:一句话召回简报。 */
export const DraftRecallBriefSchema = z.object({
summary: z
.string()
.min(6)
.max(120)
.describe(
'一句话中文召回简报(≤55 字最佳,最多 75 字,不带换行/列表/Markdown)。' +
'**站在患者立场讲"他为什么该来"**:把应治未治缺口翻译成患者能感知的影响/价值(如 缺牙久拖邻牙易移位、龋齿不补会伤神经),放句子主干、最突出;' +
'"患者是谁"(价值/熟客)一两词前置修饰,"到诊做什么"(治疗动作)简短收尾。口吻为患者着想,不催单。' +
'严禁编造患者意愿/情绪:「应治未治」是缺口不是意愿,不能说"想做/有意向";不编个人情节、不编数值、不承诺疗效。' +
'例:"47缺了4个月的牙一直空着,越拖邻牙越易移位,早点种上才好咀嚼;做过2次种植的老客。"',
),
});
import { Injectable } from '@nestjs/common';
import type { AiCall } from '../../ai-call.interface';
import type { SafetyRule } from '../../core/safety-gate.service';
import { DraftRecallSummarySchema } from './schema';
import type { DraftRecallSummaryInput, DraftRecallSummaryOutput } from './input.types';
import {
DRAFT_RECALL_SUMMARY_PROMPT_VERSION,
DRAFT_RECALL_SUMMARY_SYSTEM,
buildDraftRecallSummaryPrompt,
} from './prompt';
const FORBIDDEN_PHRASES = ['一定能', '保证', '绝对', '百分百', '100%', '亲爱的'];
const safetyRules: ReadonlyArray<SafetyRule<DraftRecallSummaryOutput>> = [
{
name: 'no_forbidden_phrases',
severity: 'block',
check(output) {
const hit = FORBIDDEN_PHRASES.filter((p) => output.summary.includes(p));
return { pass: hit.length === 0, message: hit.length > 0 ? `命中禁词: ${hit.join(',')}` : undefined };
},
},
];
/** LLM 失败 / safety 拒收时:用记录拼一句最朴素的概述。 */
function fallback(input: DraftRecallSummaryInput): DraftRecallSummaryOutput {
const n = input.items.length;
if (n === 0) return { summary: '暂无历史联系记录。' };
const top = input.items[0]!;
const top2 = [top.type, top.status].filter(Boolean).join(' · ') || '回访';
return { summary: `共 ${n} 条历史联系,最近一次:${top.taskDate ?? '—'} ${top2}` };
}
@Injectable()
export class DraftRecallSummaryCall
implements AiCall<DraftRecallSummaryInput, DraftRecallSummaryOutput>
{
readonly kind = 'summary' as const;
readonly callKey = 'draft_recall_summary';
readonly promptVersion = DRAFT_RECALL_SUMMARY_PROMPT_VERSION;
readonly defaultModelId = 'qwen'; // 这类一句话摘要统一走 Qwen(qwen3.7-max)
readonly outputSchema = DraftRecallSummarySchema;
readonly safetyRules = safetyRules;
buildPrompt(input: DraftRecallSummaryInput) {
return {
system: DRAFT_RECALL_SUMMARY_SYSTEM,
prompt: buildDraftRecallSummaryPrompt(input),
};
}
fallback(input: DraftRecallSummaryInput): DraftRecallSummaryOutput {
return fallback(input);
}
}
/**
* DraftRecallSummary AiCall — 把"历史联系(诊所回访记录 patient_return_visits)"归纳成**一句话**。
*
* 用途:详情页「历史联系」卡片(ReturnVisitsCard),把多条结构化回访记录压成一句客服可读的概述。
* 跟 DraftPlanSummary(3 段 plan 摘要)平行,但极轻:输入只有回访记录,输出一句话。
*/
export interface DraftRecallSummaryInput {
patientNameMasked: string;
/** 历史联系(诊所回访)记录,时间倒序 */
items: Array<{
taskDate: string | null; // 回访任务日期 YYYY-MM-DD
type: string | null; // 常规回访 / 术后回访 / 咨询回访
status: string | null; // 已回访 / 未回访
treatmentItems: string | null; // 关联治疗项(如 种植)
followContent: string | null; // 回访内容(术后复查 / 洁牙提醒 / 活动邀约…)
result: string | null; // 回访结果
}>;
}
export interface DraftRecallSummaryOutput {
/** 一句话历史联系摘要(中文,≤60 字) */
summary: string;
}
import type { DraftRecallSummaryInput } from './input.types';
export const DRAFT_RECALL_SUMMARY_PROMPT_VERSION = 'draft_recall_summary@2026-06-16-b';
export const DRAFT_RECALL_SUMMARY_SYSTEM = `你是牙科诊所客服主管。下面是一个患者的历史联系/回访记录(结构化、看着累)。请提炼成**一句话重点**,让接手客服**一眼抓住关键**,而不是逐条复述。
# 关键要求
1. 只输出**一句话**(中文,**≤30 字**最佳,最多 40 字),不要换行/列表/Markdown。
2. **是"重点提炼"不是"总结罗列"**:抓最值得注意的那一点——比如"近期联系密但未到诊""多次未接通""有种植意向待跟进""术后回访已完成、可推新项目"。**不要**把所有回访类型都列出来。
3. 优先突出**对下一步有用的信号**(还没接通 / 已表达意向 / 长期未到诊 / 刚成交别打扰 等)。
4. 客观,**禁止**承诺疗效、编造、给医疗建议。
5. 严格按 JSON schema 输出,只有一个 key:summary。
# 示例(风格参考,不要照抄)
- "近 3 次活动邀约均未接通,建议换企微触达。"
- "术后回访已完成,有种植意向待跟进。"
- "半年内 5 次回访无到诊,流失风险高。"`;
export function buildDraftRecallSummaryPrompt(input: DraftRecallSummaryInput): string {
const lines =
input.items.length > 0
? input.items
.map((it, i) => {
const parts = [
it.taskDate ?? '—',
it.type ?? '回访',
it.status,
it.treatmentItems ? `项目:${it.treatmentItems}` : null,
it.followContent ? `内容:${it.followContent.slice(0, 40)}` : null,
it.result ? `结果:${it.result.slice(0, 40)}` : null,
].filter(Boolean);
return `${i + 1}. ${parts.join(' · ')}`;
})
.join('\n')
: '(无历史联系记录)';
return `患者:${input.patientNameMasked}
历史联系 / 回访记录(时间倒序,共 ${input.items.length} 条):
${lines}
请用一句话概括以上历史联系。`;
}
import { z } from 'zod';
/** DraftRecallSummary 输出:一句话回访历史摘要。 */
export const DraftRecallSummarySchema = z.object({
summary: z
.string()
.min(4)
.max(80)
.describe(
'一句话中文回访历史摘要(≤60 字,不带换行/列表/Markdown)。' +
'概括:联系过几次、主要结果(是否约到/拒绝/未接通)、是否有下次回访约定。' +
'客观陈述,不承诺疗效、不臆测。例:"近 3 次电话回访,1 次约到诊、2 次未接通,暂无下次约定。"',
),
});
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import { maskName } from '@pac/utils';
import { PERSONA_FEATURE_SPECS } from '@pac/types';
import { PrismaService } from '../../../prisma/prisma.service';
import { AiCallRunnerService } from '../ai-call-runner.service';
import { DraftPersonaSummaryCall } from '../calls/draft-persona-summary/call';
import type { DraftPersonaSummaryInput } from '../calls/draft-persona-summary/input.types';
import type { TenantScopeContext } from '../../../common/decorators/tenant-scope.decorator';
export interface PersonaSummaryResult {
/** 一句话画像重点;status='empty' 时为 null */
summary: string | null;
status: 'ready' | 'empty';
source?: 'agent' | 'template_fallback';
}
/**
* PersonaSummaryOrchestrator — 画像标签「一句话重点」get-or-generate。
*
* 有 ready 的 plan_summaries[persona_tags] → 直接返回(秒回,不调 LLM)。
* 没有 → 取该 patient 当前 persona 的 features → 跑 AiCall → upsert 存库 → 返回。
* 无 persona / 无 feature → status='empty'(前端继续展示标签云,不显示摘要)。
*
* 与 RecallSummaryOrchestrator(历史联系一句话摘要)完全平行,只换数据源 + AiCall。
*/
@Injectable()
export class PersonaSummaryOrchestrator {
private readonly logger = new Logger(PersonaSummaryOrchestrator.name);
private static readonly TYPE = 'persona_tags' as const;
constructor(
private readonly prisma: PrismaService,
private readonly runner: AiCallRunnerService,
private readonly call: DraftPersonaSummaryCall,
) {}
async getOrGenerate(scope: TenantScopeContext, planId: string): Promise<PersonaSummaryResult> {
const plan = await this.prisma.followupPlan.findUnique({
where: { id: planId },
select: { id: true, hostId: true, tenantId: true, patientId: true },
});
if (!plan || plan.hostId !== scope.hostId || plan.tenantId !== scope.tenantId) {
throw new NotFoundException(`Plan ${planId} not found`);
}
// 1) 已生成 → 直接返回
const existing = await this.prisma.planSummary.findUnique({
where: { planId_type: { planId: plan.id, type: PersonaSummaryOrchestrator.TYPE } },
select: { content: true, status: true, source: true },
});
if (existing?.status === 'ready' && existing.content) {
return {
summary: existing.content,
status: 'ready',
source: (existing.source as 'agent' | 'template_fallback' | null) ?? undefined,
};
}
// 2) 取该患者当前 persona 的画像标签(key + description 取值)
const persona = await this.prisma.persona.findFirst({
where: { patientId: plan.patientId, supersededAt: null },
orderBy: { version: 'desc' },
select: { features: { orderBy: { createdAt: 'asc' }, select: { key: true, description: true } } },
});
const features = persona?.features ?? [];
if (features.length === 0) return { summary: null, status: 'empty' };
const patient = await this.prisma.patient.findUnique({
where: { id: plan.patientId },
select: { name: true },
});
const input: DraftPersonaSummaryInput = {
patientNameMasked: maskName(patient?.name ?? null) ?? '该患者',
items: features.map((f) => ({
label: PERSONA_FEATURE_SPECS[f.key]?.nameZh ?? f.key,
value: f.description,
})),
};
// 3) 跑 AiCall → upsert
const result = await this.runner.run(this.call, input, {
hostId: plan.hostId,
tenantId: plan.tenantId,
workflowRunId: randomUUID(),
linkedPatientId: plan.patientId,
linkedPlanId: plan.id,
bustCache: false,
evalMode: 'production',
});
await this.prisma.planSummary.upsert({
where: { planId_type: { planId: plan.id, type: PersonaSummaryOrchestrator.TYPE } },
create: {
hostId: plan.hostId,
tenantId: plan.tenantId,
planId: plan.id,
type: PersonaSummaryOrchestrator.TYPE,
content: result.output.summary,
status: 'ready',
source: result.source,
agentInvocationId: result.invocationId,
},
update: {
content: result.output.summary,
status: 'ready',
source: result.source,
agentInvocationId: result.invocationId,
},
});
return { summary: result.output.summary, status: 'ready', source: result.source };
}
}
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import { maskName } from '@pac/utils';
import {
PERSONA_FEATURE_SPECS,
subLabelZh,
diagnosisCodeNameZh,
treatmentCategoryNameZh,
} from '@pac/types';
import { PrismaService } from '../../../prisma/prisma.service';
import { AiCallRunnerService } from '../ai-call-runner.service';
import { DraftRecallBriefCall } from '../calls/draft-recall-brief/call';
import type { DraftRecallBriefInput } from '../calls/draft-recall-brief/input.types';
import type { TenantScopeContext } from '../../../common/decorators/tenant-scope.decorator';
export interface RecallBriefResult {
/** 一句话召回简报;status='empty' 时为 null */
summary: string | null;
status: 'ready' | 'empty';
source?: 'agent' | 'template_fallback';
}
/** plan_reasons.signals 形态(跟前端 ReasonLine 契约一致)。 */
interface ReasonSignals {
subKey?: string | null;
triggers?: Array<{ type?: string; code?: string | null }>;
toothPosition?: string | null;
daysSince?: number;
expectedCategories?: string[];
}
/** 把天数转人读文本(server 端轻量版,无需精确到日)。 */
function daysText(d: number): string {
if (!Number.isFinite(d) || d <= 0) return '近期';
if (d < 60) return `${d} 天`;
const months = Math.round(d / 30);
if (months < 12) return `${d} 天(约 ${months} 个月)`;
const years = (d / 365).toFixed(1).replace(/\.0$/, '');
return `${d} 天(约 ${years} 年)`;
}
/**
* RecallBriefOrchestrator — 本次召回「一句话简报」get-or-generate。
*
* 有 ready 的 plan_summaries[recall_brief] → 直接返回(秒回,不调 LLM)。
* 没有 → 取 召回原因(plan_reasons) + 历史治疗(treatment_record actual 计数) + 画像(persona_features)
* → 跑 AiCall(回答 谁/解决什么/到诊做什么 三问)→ upsert 存库 → 返回。
* 无召回原因 → status='empty'。
*
* 与 Recall/PersonaSummaryOrchestrator 平行,只是输入更全(三类信号合一)。
*/
@Injectable()
export class RecallBriefOrchestrator {
private readonly logger = new Logger(RecallBriefOrchestrator.name);
private static readonly TYPE = 'recall_brief' as const;
constructor(
private readonly prisma: PrismaService,
private readonly runner: AiCallRunnerService,
private readonly call: DraftRecallBriefCall,
) {}
async getOrGenerate(scope: TenantScopeContext, planId: string): Promise<RecallBriefResult> {
const plan = await this.prisma.followupPlan.findUnique({
where: { id: planId },
select: {
id: true,
hostId: true,
tenantId: true,
patientId: true,
reasons: {
orderBy: { priorityScore: 'desc' },
select: { scenario: true, subKey: true, reason: true, signals: true },
},
},
});
if (!plan || plan.hostId !== scope.hostId || plan.tenantId !== scope.tenantId) {
throw new NotFoundException(`Plan ${planId} not found`);
}
if (plan.reasons.length === 0) return { summary: null, status: 'empty' };
// 1) 已生成 → 直接返回
const existing = await this.prisma.planSummary.findUnique({
where: { planId_type: { planId: plan.id, type: RecallBriefOrchestrator.TYPE } },
select: { content: true, status: true, source: true },
});
if (existing?.status === 'ready' && existing.content) {
return {
summary: existing.content,
status: 'ready',
source: (existing.source as 'agent' | 'template_fallback' | null) ?? undefined,
};
}
// 2) 召回原因(翻译成中文)
const reasons = plan.reasons.map((r) => {
const s = (r.signals ?? {}) as ReasonSignals;
const code = (s.triggers ?? []).find((t) => /^K\d/i.test(t.code ?? ''))?.code ?? null;
return {
subLabel: subLabelZh(r.scenario, s.subKey ?? '') || r.scenario,
diagnosis: code ? diagnosisCodeNameZh(code) : null,
tooth: (s.toothPosition ?? '').trim() || null,
daysSinceText: daysText(s.daysSince ?? 0),
expectedCategories: (s.expectedCategories ?? []).map((c) => treatmentCategoryNameZh(c)),
};
});
// 3) 历史治疗:treatment_record actual 按 category 计数
const txFacts = await this.prisma.patientFact.findMany({
where: {
hostId: scope.hostId,
tenantId: scope.tenantId,
patientId: plan.patientId,
type: 'treatment_record',
kind: 'actual',
},
select: { content: true },
});
const catCount = new Map<string, number>();
for (const f of txFacts) {
const cat = String((f.content as Record<string, unknown> | null)?.category ?? '').trim();
if (!cat) continue;
catCount.set(cat, (catCount.get(cat) ?? 0) + 1);
}
const treatmentHistory = [...catCount.entries()]
.sort((a, b) => b[1] - a[1])
.map(([cat, count]) => ({ category: treatmentCategoryNameZh(cat), count }));
// 4) 画像标签
const persona = await this.prisma.persona.findFirst({
where: { patientId: plan.patientId, supersededAt: null },
orderBy: { version: 'desc' },
select: { features: { orderBy: { createdAt: 'asc' }, select: { key: true, description: true } } },
});
const personaItems = (persona?.features ?? []).map((f) => ({
label: PERSONA_FEATURE_SPECS[f.key]?.nameZh ?? f.key,
value: f.description,
}));
const patient = await this.prisma.patient.findUnique({
where: { id: plan.patientId },
select: { name: true },
});
const input: DraftRecallBriefInput = {
patientNameMasked: maskName(patient?.name ?? null) ?? '该患者',
persona: personaItems,
treatmentHistory,
reasons,
};
// 5) 跑 AiCall → upsert
const result = await this.runner.run(this.call, input, {
hostId: plan.hostId,
tenantId: plan.tenantId,
workflowRunId: randomUUID(),
linkedPatientId: plan.patientId,
linkedPlanId: plan.id,
bustCache: false,
evalMode: 'production',
});
await this.prisma.planSummary.upsert({
where: { planId_type: { planId: plan.id, type: RecallBriefOrchestrator.TYPE } },
create: {
hostId: plan.hostId,
tenantId: plan.tenantId,
planId: plan.id,
type: RecallBriefOrchestrator.TYPE,
content: result.output.summary,
status: 'ready',
source: result.source,
agentInvocationId: result.invocationId,
},
update: {
content: result.output.summary,
status: 'ready',
source: result.source,
agentInvocationId: result.invocationId,
},
});
return { summary: result.output.summary, status: 'ready', source: result.source };
}
}
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import { maskName } from '@pac/utils';
import { PrismaService } from '../../../prisma/prisma.service';
import { AiCallRunnerService } from '../ai-call-runner.service';
import { DraftRecallSummaryCall } from '../calls/draft-recall-summary/call';
import type { DraftRecallSummaryInput } from '../calls/draft-recall-summary/input.types';
import type { TenantScopeContext } from '../../../common/decorators/tenant-scope.decorator';
export interface RecallSummaryResult {
/** 一句话摘要;status='empty' 时为 null */
summary: string | null;
status: 'ready' | 'empty';
source?: 'agent' | 'template_fallback';
}
/**
* RecallSummaryOrchestrator — 回访历史「一句话摘要」get-or-generate。
*
* 有 ready 的 plan_summaries[recall_history] → 直接返回(秒回,不调 LLM)。
* 没有 → 取该 patient 的回访记录(plan_executions)→ 跑 AiCall → upsert 存库 → 返回。
* 无回访记录 → status='empty'(前端继续展示结构数据,不显示摘要)。
*/
@Injectable()
export class RecallSummaryOrchestrator {
private readonly logger = new Logger(RecallSummaryOrchestrator.name);
private static readonly TYPE = 'recall_history' as const;
constructor(
private readonly prisma: PrismaService,
private readonly runner: AiCallRunnerService,
private readonly call: DraftRecallSummaryCall,
) {}
async getOrGenerate(scope: TenantScopeContext, planId: string): Promise<RecallSummaryResult> {
const plan = await this.prisma.followupPlan.findUnique({
where: { id: planId },
select: { id: true, hostId: true, tenantId: true, patientId: true },
});
if (!plan || plan.hostId !== scope.hostId || plan.tenantId !== scope.tenantId) {
throw new NotFoundException(`Plan ${planId} not found`);
}
// 1) 已生成 → 直接返回
const existing = await this.prisma.planSummary.findUnique({
where: { planId_type: { planId: plan.id, type: RecallSummaryOrchestrator.TYPE } },
select: { content: true, status: true, source: true },
});
if (existing?.status === 'ready' && existing.content) {
return {
summary: existing.content,
status: 'ready',
source: (existing.source as 'agent' | 'template_fallback' | null) ?? undefined,
};
}
// 2) 取历史联系(诊所回访 patient_return_visits,patient 级,最近 12 条)
const visits = await this.prisma.patientReturnVisit.findMany({
where: { hostId: scope.hostId, tenantId: scope.tenantId, patientId: plan.patientId },
orderBy: { taskDate: 'desc' },
take: 12,
select: { taskDate: true, type: true, status: true, treatmentItems: true, followContent: true, result: true },
});
if (visits.length === 0) return { summary: null, status: 'empty' };
const patient = await this.prisma.patient.findUnique({
where: { id: plan.patientId },
select: { name: true },
});
const input: DraftRecallSummaryInput = {
patientNameMasked: maskName(patient?.name ?? null) ?? '该患者',
items: visits.map((v) => ({
taskDate: v.taskDate ? v.taskDate.toISOString().slice(0, 10) : null,
type: v.type,
status: v.status,
treatmentItems: v.treatmentItems,
followContent: v.followContent,
result: v.result,
})),
};
// 3) 跑 AiCall → upsert
const result = await this.runner.run(this.call, input, {
hostId: plan.hostId,
tenantId: plan.tenantId,
workflowRunId: randomUUID(),
linkedPatientId: plan.patientId,
linkedPlanId: plan.id,
bustCache: false,
evalMode: 'production',
});
await this.prisma.planSummary.upsert({
where: { planId_type: { planId: plan.id, type: RecallSummaryOrchestrator.TYPE } },
create: {
hostId: plan.hostId,
tenantId: plan.tenantId,
planId: plan.id,
type: RecallSummaryOrchestrator.TYPE,
content: result.output.summary,
status: 'ready',
source: result.source,
agentInvocationId: result.invocationId,
},
update: {
content: result.output.summary,
status: 'ready',
source: result.source,
agentInvocationId: result.invocationId,
},
});
return { summary: result.output.summary, status: 'ready', source: result.source };
}
}
......@@ -23,6 +23,9 @@ import {
import { PrismaService } from '../../prisma/prisma.service';
import { PlanScriptOrchestrator } from '../ai/orchestrators/plan-script.orchestrator';
import { PlanSummaryOrchestrator } from '../ai/orchestrators/plan-summary.orchestrator';
import { RecallSummaryOrchestrator } from '../ai/orchestrators/recall-summary.orchestrator';
import { PersonaSummaryOrchestrator } from '../ai/orchestrators/persona-summary.orchestrator';
import { RecallBriefOrchestrator } from '../ai/orchestrators/recall-brief.orchestrator';
import { PlanAggregateService } from './plan-aggregate.service';
const ScriptFeedbackSchema = z.object({
......@@ -52,6 +55,9 @@ export class PlansAggregateController {
private readonly demo: PlanAggregateService,
private readonly planScript: PlanScriptOrchestrator,
private readonly planSummary: PlanSummaryOrchestrator,
private readonly recallSummary: RecallSummaryOrchestrator,
private readonly personaSummary: PersonaSummaryOrchestrator,
private readonly recallBrief: RecallBriefOrchestrator,
private readonly prisma: PrismaService,
) {}
......@@ -69,6 +75,33 @@ export class PlansAggregateController {
return this.demo.getPlanDetailByPlanId(scope, planId);
}
// 回访历史「一句话摘要」get-or-generate(详情页回访历史卡进卡片即调:
// 有 ready 摘要秒回;没有则当场跑 AiCall 生成存库再回;无回访记录 → status='empty')。
@Get(':id/recall-summary')
@RequirePermission(Permission.PLAN_VIEW_OWN)
@ApiOperation({ summary: '回访历史一句话摘要(有则取、无则当场生成)' })
getRecallSummary(@TenantScope() scope: TenantScopeContext, @Param('id') planId: string) {
return this.recallSummary.getOrGenerate(scope, planId);
}
// 画像标签「一句话重点」get-or-generate(详情页「画像标签」卡进卡片即调:
// 有 ready 摘要秒回;没有则当场跑 AiCall 生成存库再回;无 persona/feature → status='empty')。
@Get(':id/persona-summary')
@RequirePermission(Permission.PLAN_VIEW_OWN)
@ApiOperation({ summary: '画像标签一句话重点(有则取、无则当场生成)' })
getPersonaSummary(@TenantScope() scope: TenantScopeContext, @Param('id') planId: string) {
return this.personaSummary.getOrGenerate(scope, planId);
}
// 本次召回「一句话简报」get-or-generate(详情页「参考话术」标题下;输入=召回原因+历史治疗+画像,
// 回答 患者是谁 / 帮其解决什么问题 / 邀约到诊做什么 三问;无召回原因 → status='empty')。
@Get(':id/recall-brief')
@RequirePermission(Permission.PLAN_VIEW_OWN)
@ApiOperation({ summary: '本次召回一句话简报(有则取、无则当场生成)' })
getRecallBrief(@TenantScope() scope: TenantScopeContext, @Param('id') planId: string) {
return this.recallBrief.getOrGenerate(scope, planId);
}
// ─────────────────────────────────────────────
// 话术 — 同步重生成 / 流式重生成
// ─────────────────────────────────────────────
......
......@@ -659,9 +659,23 @@ export const SummaryType = {
ONE_PAGE: 'one_page',
MEDICAL_RECORD: 'medical_record',
TREATMENT_CHAIN: 'treatment_chain',
/// 回访历史一句话摘要(详情页「回访历史」卡片;输入=plan_executions 结构化数据)
RECALL_HISTORY: 'recall_history',
/// 画像标签一句话摘要(详情页「画像标签」卡片;输入=persona_features 标签+取值)
PERSONA_TAGS: 'persona_tags',
/// 本次召回一句话简报(详情页「参考话术」标题下;输入=召回原因+历史治疗+画像,
/// 回答 患者是谁 / 帮其解决什么问题 / 邀约到诊做什么 三问)
RECALL_BRIEF: 'recall_brief',
} as const;
export type SummaryType = (typeof SummaryType)[keyof typeof SummaryType];
export const SummaryTypeSchema = z.enum(['one_page', 'medical_record', 'treatment_chain']);
export const SummaryTypeSchema = z.enum([
'one_page',
'medical_record',
'treatment_chain',
'recall_history',
'persona_tags',
'recall_brief',
]);
// =============================================================
// Sync(同步运行账本)
......
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