Commit fa96a613 by luoqi

feat: 企微话术(深度档,一次性单块可复制)+ 话术面板改渠道 tab

═ 沿用了什么、没沿用什么 ═
共用(直接 import, 不复制):ScriptContext / buildRichFactBlock 患者事实块 /
  安全护栏 forbiddenWordsBlock / 福利硬约束 / 自报家门占位 / 医生姓脱敏 /
  人群 skills / composeSystem 装配 —— 与"用电话还是企微说"无关。
  复制的代价是护栏两处,改了一处另一处悄悄留旧版,而漏了哪条要等客服已经发给患者才发现。
自己写(draft-wecom-script/):
  · schema:电话 sections[](伴飞逐段高亮要它)→ 企微单块 markdown
  · format.md:口语/分段/口头二选一 → 书面/断行/可复制即发
  · verify 多一条⑤「可直接发送」(无小标题、无占位残留、无给客服看的话、
    无"您现在方便吗"这类需对方当场回话的电话句式)
  · plan 步语义改成"排要点顺序"而非"拆几段"

═ 几个关键取舍 ═
· composeSystem 加 formatPath 覆盖参数, 不往 ScriptTier 里塞 'wecom' ——
  tier 是质量档、渠道是另一维度,混进一个枚举后"深度档企微"就表达不出来了。
  formatPath 一并进 composeHash(否则两个渠道 promptVersion 撞车,eval 数据混在一起)。
· plan_scripts 加 channel + 改 @@unique([planId,channel]), 不另立表:
  状态机/source/agentInvocationId/聚合查询全一样,两张表必然漂。
· 企微**无模板兜底**,失败就是 failed。电话失败可以给模板(客服拿着电话必须有东西念,
  平淡但不出事);企微是原样发给患者的,套话复制发出去比没有更糟,而且发出去收不回。

═ 前端 ═
· 话术面板 3 视图切换(伴飞/卡片/原文)隐藏,那个位置改成渠道 tab 电话/企微;
  电话固定「原文」渲染(视图代码整套保留,开关可恢复)
· 企微视图单块 + 复制按钮;whitespace-pre-wrap 原样呈现, 不过 markdown 渲染器 ——
  客服复制的必须是他看到的那些字
· 执行结果的「触达方式」选择器隐藏(️ 期间 channel 全落 'phone',触达方式分布不可用)

═ 踩的三个坑 ═
1. __dirname 拿不到 format.md(ENOENT):SWC dev 产物在 dist/src/,tsc prod 在 dist/,
   同一个 __dirname 指向不同层级。照抄 resolveScriptRoot 的 cwd 策略。
2. serializeScript 会把正文拆成 sections 并丢原文 → 企微稿 content 长度 0。
   企微单独序列化,不复用那个。
3. ai.module 的 exports 没加(正则打在了 providers 上,那里有同样的三行序列)→
   Nest 启动时 UnknownDependenciesException。

实测:真库生成一份企微稿(单块、空行断段、无小标题、收尾是"您回我一下方便的时间就行"
而不是电话的口头二选一),界面 tab 切换 + 复制按钮 + 【回访客服】按登录人回填全部正常。
1020 tests,两个 tsc + next build 干净。
parent 40f578e9
-- plan_scripts 加渠道:phone(电话,分段) | wecom(企微,单块)
-- 存量行默认 phone —— 上线前的话术都是电话稿,不是"未知"。
ALTER TABLE "plan_scripts" ADD COLUMN "channel" TEXT NOT NULL DEFAULT 'phone';
-- 一 plan 一条 → 一 plan 每渠道一条。
-- 原 plan_id 唯一,加上 channel 后不可能产生重复,故直接换。
DROP INDEX IF EXISTS "plan_scripts_plan_id_key";
CREATE UNIQUE INDEX "plan_scripts_plan_id_channel_key" ON "plan_scripts"("plan_id", "channel");
...@@ -1285,7 +1285,19 @@ model PlanScript { ...@@ -1285,7 +1285,19 @@ model PlanScript {
id String @id @default(uuid()) @db.Uuid id String @id @default(uuid()) @db.Uuid
hostId String @map("host_id") @db.Uuid hostId String @map("host_id") @db.Uuid
tenantId String @map("tenant_id") tenantId String @map("tenant_id")
planId String @unique @map("plan_id") @db.Uuid planId String @map("plan_id") @db.Uuid
/**
* 话术**渠道**:`phone`(电话,分段) | `wecom`(企微,一次性单块)
*
* ⚠️ 原来是 `planId @unique`( plan 一条话术)。加渠道后改成 `@@unique([planId, channel])` ——
* 同一个 plan 现在可以同时有电话稿和企微稿,两者**独立生成、独立状态机**
* ⚠️ 存量行默认 `phone`:上线前的话术都是电话稿,这不是"未知"
* 别为企微另立一张表 —— 生成状态机(pending/ready/failed)source
* agentInvocationId、流式接口、聚合查询全都一样,只有正文形态不同。
* 两张表意味着这套东西各维护一份,而它们一定会漂。
*/
channel String @default("phone")
/// 话术正文(Markdown, `## 开场` / `## 跟进` / `## 异议处理` 等子段); /// 话术正文(Markdown, `## 开场` / `## 跟进` / `## 异议处理` 等子段);
/// 应用层 zod 8000 字符兜底(子段合并后空间略大于单段) /// 应用层 zod 8000 字符兜底(子段合并后空间略大于单段)
...@@ -1311,6 +1323,7 @@ model PlanScript { ...@@ -1311,6 +1323,7 @@ model PlanScript {
host Host @relation(fields: [hostId], references: [id]) host Host @relation(fields: [hostId], references: [id])
plan FollowupPlan @relation(fields: [planId], references: [id], onDelete: Cascade) plan FollowupPlan @relation(fields: [planId], references: [id], onDelete: Cascade)
@@unique([planId, channel])
@@index([hostId, tenantId]) @@index([hostId, tenantId])
@@map("plan_scripts") @@map("plan_scripts")
} }
......
...@@ -8,6 +8,9 @@ import { DraftPlanScriptCall } from './calls/draft-plan-script/tiers/stable/stab ...@@ -8,6 +8,9 @@ import { DraftPlanScriptCall } from './calls/draft-plan-script/tiers/stable/stab
import { StandardScriptCall } from './calls/draft-plan-script/tiers/standard/standard.call'; import { StandardScriptCall } from './calls/draft-plan-script/tiers/standard/standard.call';
import { DeepPlanCall, DeepWriteCall, DeepVerifyCall } from './calls/draft-plan-script/tiers/deep/calls'; import { DeepPlanCall, DeepWriteCall, DeepVerifyCall } from './calls/draft-plan-script/tiers/deep/calls';
import { DeepScriptStrategy } from './calls/draft-plan-script/tiers/deep/deep.strategy'; import { DeepScriptStrategy } from './calls/draft-plan-script/tiers/deep/deep.strategy';
import { WecomPlanCall, WecomWriteCall, WecomVerifyCall } from './calls/draft-wecom-script/calls';
import { WecomScriptStrategy } from './calls/draft-wecom-script/wecom.strategy';
import { WecomScriptOrchestrator } from './orchestrators/wecom-script.orchestrator';
import { DraftPlanScriptSkillRegistry } from './calls/draft-plan-script/shared/skill-registry.service'; import { DraftPlanScriptSkillRegistry } from './calls/draft-plan-script/shared/skill-registry.service';
import { DraftPlanSummaryCall } from './calls/draft-plan-summary/call'; import { DraftPlanSummaryCall } from './calls/draft-plan-summary/call';
import { DraftRecallSummaryCall } from './calls/draft-recall-summary/call'; import { DraftRecallSummaryCall } from './calls/draft-recall-summary/call';
...@@ -53,6 +56,11 @@ import { PlanModule } from '../plan/plan.module'; ...@@ -53,6 +56,11 @@ import { PlanModule } from '../plan/plan.module';
DeepWriteCall, // 深度档 步骤2 写(多段) DeepWriteCall, // 深度档 步骤2 写(多段)
DeepVerifyCall, // 深度档 步骤3 独立对抗校验 DeepVerifyCall, // 深度档 步骤3 独立对抗校验
DeepScriptStrategy, // 深度档 3 步编排(plan→write→verify→repair→兜底) DeepScriptStrategy, // 深度档 3 步编排(plan→write→verify→repair→兜底)
// AI calls — 企微话术(只有深度档;一次性单块,⛔ 无模板兜底,见 wecom.strategy 文件头)
WecomPlanCall,
WecomWriteCall,
WecomVerifyCall,
WecomScriptStrategy,
DraftPlanScriptSkillRegistry, // scan & cache draft-plan-script/**​/skills/**​/SKILL.md DraftPlanScriptSkillRegistry, // scan & cache draft-plan-script/**​/skills/**​/SKILL.md
DraftPlanSummaryCall, DraftPlanSummaryCall,
DraftRecallSummaryCall, DraftRecallSummaryCall,
...@@ -60,6 +68,7 @@ import { PlanModule } from '../plan/plan.module'; ...@@ -60,6 +68,7 @@ import { PlanModule } from '../plan/plan.module';
DraftRecallBriefCall, DraftRecallBriefCall,
// orchestrators // orchestrators
PlanScriptOrchestrator, PlanScriptOrchestrator,
WecomScriptOrchestrator,
PlanSummaryOrchestrator, PlanSummaryOrchestrator,
RecallSummaryOrchestrator, RecallSummaryOrchestrator,
PersonaSummaryOrchestrator, PersonaSummaryOrchestrator,
...@@ -68,6 +77,7 @@ import { PlanModule } from '../plan/plan.module'; ...@@ -68,6 +77,7 @@ import { PlanModule } from '../plan/plan.module';
exports: [ exports: [
// 对外暴露 orchestrator(业务方调用入口)+ runner(高级使用 / eval CLI) // 对外暴露 orchestrator(业务方调用入口)+ runner(高级使用 / eval CLI)
PlanScriptOrchestrator, PlanScriptOrchestrator,
WecomScriptOrchestrator, // 企微话术(PlansAggregateController 注入)
PlanSummaryOrchestrator, PlanSummaryOrchestrator,
RecallSummaryOrchestrator, RecallSummaryOrchestrator,
PersonaSummaryOrchestrator, PersonaSummaryOrchestrator,
......
...@@ -35,18 +35,27 @@ export interface ComposedSystem { ...@@ -35,18 +35,27 @@ export interface ComposedSystem {
composeHash: string; composeHash: string;
} }
/** lazy load base —— common(共性,三档一份)+ format(每档一份),按档缓存 */ /**
* lazy load base —— common(共性,各档一份)+ format(每档一份),**按解析后的路径缓存**。
*
* ⚠️ 缓存键从 tier 改成 path,是因为 `formatPath` 覆盖(企微渠道)会让"同一个 tier
* 对应两份 format" —— 还按 tier 缓存的话,先加载的那份会被另一个渠道复用,
* 表现是企微稿写出分段的电话格式(或反过来),而且不报错。
*/
let cachedCommon: string | null = null; let cachedCommon: string | null = null;
const cachedFormat: Partial<Record<ScriptTier, string>> = {}; const cachedFormat = new Map<string, string>();
function loadBase(tier: ScriptTier): string { function loadBase(tier: ScriptTier, formatPath?: string): string {
if (cachedCommon === null) { if (cachedCommon === null) {
cachedCommon = readFileSync(resolveBaseCommonPath(), 'utf-8').trim(); cachedCommon = readFileSync(resolveBaseCommonPath(), 'utf-8').trim();
} }
if (cachedFormat[tier] === undefined) { const path = formatPath ?? resolveBaseFormatPath(tier);
cachedFormat[tier] = readFileSync(resolveBaseFormatPath(tier), 'utf-8').trim(); let format = cachedFormat.get(path);
if (format === undefined) {
format = readFileSync(path, 'utf-8').trim();
cachedFormat.set(path, format);
} }
// 顺序:共性定位/铁律 → 本档输出格式 → 禁词(单一源) // 顺序:共性定位/铁律 → 本档输出格式 → 禁词(单一源)
return `${cachedCommon}\n\n${cachedFormat[tier]}\n\n${forbiddenWordsBlock()}`; return `${cachedCommon}\n\n${format}\n\n${forbiddenWordsBlock()}`;
} }
/** /**
...@@ -106,10 +115,18 @@ export function skillTierOk(skill: Skill, tier: ScriptTier): boolean { ...@@ -106,10 +115,18 @@ export function skillTierOk(skill: Skill, tier: ScriptTier): boolean {
* base = common(共性) + format(该档) + 禁词(单一源); * base = common(共性) + format(该档) + 禁词(单一源);
* skills = applies 命中 且 该档适用(tiers 过滤)。 * skills = applies 命中 且 该档适用(tiers 过滤)。
*/ */
/**
* @param formatPath ⭐ **输出格式覆盖**(可选)—— 企微渠道用。
* `tier` 仍传 `'deep'`:它决定**挑哪些 skill**(人群共性、深度档知识),那部分与渠道无关;
* 但输出形态(电话=分段 sections / 企微=一整块可发送消息)完全不同,只换这一份 format.md。
* ⛔ 别为此往 `ScriptTier` 里加 `'wecom'` —— tier 是**质量档**,渠道是另一个维度,
* 混进同一个枚举之后"深度档企微"这种组合就表达不出来了。
*/
export function composeSystem( export function composeSystem(
input: DraftPlanScriptInput, input: DraftPlanScriptInput,
allSkills: readonly Skill[], allSkills: readonly Skill[],
tier: ScriptTier = 'stable', tier: ScriptTier = 'stable',
formatPath?: string,
): ComposedSystem { ): ComposedSystem {
const context = deriveContext(input); const context = deriveContext(input);
const matched = allSkills const matched = allSkills
...@@ -119,7 +136,7 @@ export function composeSystem( ...@@ -119,7 +136,7 @@ export function composeSystem(
(a.frontmatter.priority ?? 50) - (b.frontmatter.priority ?? 50), (a.frontmatter.priority ?? 50) - (b.frontmatter.priority ?? 50),
); );
const base = loadBase(tier); const base = loadBase(tier, formatPath);
// 只拼 body — 内部 skill name/version 不进提示词(版本归因走 composeHash,见下) // 只拼 body — 内部 skill name/version 不进提示词(版本归因走 composeHash,见下)
const skillsBlock = matched.map((s) => s.body).join('\n\n---\n\n'); const skillsBlock = matched.map((s) => s.body).join('\n\n---\n\n');
...@@ -127,8 +144,14 @@ export function composeSystem( ...@@ -127,8 +144,14 @@ export function composeSystem(
? `${base}\n\n# 本次适用知识 / 模板\n\n${skillsBlock}` ? `${base}\n\n# 本次适用知识 / 模板\n\n${skillsBlock}`
: base; : base;
// composeHash = sha256(tier + matched.name+version join)前 16 hex // composeHash = sha256(tier + format 覆盖 + matched.name@version)前 16 hex
const hashSrc = [tier, ...matched.map((s) => `${s.frontmatter.name}@${s.frontmatter.version}`)].join('|'); // ⚠️ formatPath 必须进哈希:同 tier 同 skills 但换了输出格式(电话/企微)是**两套 system**,
// 不进哈希的话两者算出同一个 composeHash → promptVersion 撞车 → eval 里两个渠道的效果混在一起。
const hashSrc = [
tier,
...(formatPath ? [`fmt:${formatPath.split('/').slice(-4).join('/')}`] : []),
...matched.map((s) => `${s.frontmatter.name}@${s.frontmatter.version}`),
].join('|');
const composeHash = createHash('sha256').update(hashSrc).digest('hex').slice(0, 16); const composeHash = createHash('sha256').update(hashSrc).digest('hex').slice(0, 16);
return { systemPrompt, matchedSkills: matched, context, composeHash }; return { systemPrompt, matchedSkills: matched, context, composeHash };
......
import { Injectable } from '@nestjs/common';
import { existsSync } from 'node:fs';
import { join } from 'node:path';
import type { AiCall } from '../../ai-call.interface';
import type { ScriptContext } from '../draft-plan-script/shared/input.types';
import { composeSystem } from '../draft-plan-script/shared/skill-composer';
import { DraftPlanScriptSkillRegistry } from '../draft-plan-script/shared/skill-registry.service';
import {
WecomPlanSchema,
WecomWriteSchema,
WecomVerifySchema,
type WecomPlanZ,
type WecomWriteZ,
type WecomVerifyZ,
} from './schema';
import { buildWecomPlanPrompt, buildWecomWritePrompt, buildWecomVerifyPrompt } from './prompts';
/**
* 企微话术的 3 个 AiCall(与电话深度档同构:plan → write → verify,各自落 agent_invocations)。
*
* ═══ 与电话档共用了什么、没共用什么 ═══════════════════════════
* 共用(直接 import,⛔ 不复制):
* · ScriptContext 输入契约、`buildRichFactBlock` 患者事实块(谁、哪颗牙、医生说了什么)
* · 安全护栏 `forbiddenWordsBlock` / 福利硬约束 / 自报家门占位 / 医生姓脱敏
* · 人群 skills(成人/儿童共性)与 `composeSystem` 装配逻辑
* —— 这些都跟"用电话还是企微说"无关。复制一份的代价是护栏有两处,
* 改了一处另一处会悄悄留在旧版本,而漏了哪条要等客服**已经发给患者**才发现。
* 没共用(本目录自己写):
* · 输出 schema:电话 `sections[]`(伴飞逐段高亮要它)→ 企微单块 `markdown`
* · format.md:电话是口语/分段/口头二选一 → 企微是书面/断行/可复制即发
* · verify 多一条⑤「可直接发送」(无小标题、无占位残留、无给客服看的话)
*
* ⚠️ 企微**只有深度档**(产品定):它是一条发出去就收不回的消息,
* 没有"边打边看着调整"的机会,所以不给低质量档。
*/
/**
* 本目录自己的输出格式(覆盖 tier 默认的那份)。
*
* ⛔ **不能用 `__dirname`**(踩过:ENOENT)—— SWC dev 的产物在 `dist/src/...`、
* tsc prod 在 `dist/...`,同一个 `__dirname` 在两态下指向不同层级。
* 照抄 `resolveScriptRoot` 那套 env → src → dist 的策略(理由见它的文件头注释):
* `cwd` 在 dev/prod 都是 apps/pac-service 根,才是稳的。
*/
function wecomFormatPath(): string {
const rel = 'modules/ai/calls/draft-wecom-script/skills/_base/format.md';
const src = join(process.cwd(), 'src', rel);
return existsSync(src) ? src : join(process.cwd(), 'dist', rel);
}
const PLAN_SYSTEM = [
'你是资深口腔回访话术规划师。基于给定的患者事实,规划一条**发到患者微信里**的医疗关怀消息:要讲哪几点、按什么顺序讲。',
'原则:医疗关怀非销售;以本次聚焦项(应治未治)为主线;每个要点都必须能追到给定事实,不编造。',
'',
'# 这是微信,不是电话',
'患者会**一口气读完**,没有一来一回。所以你排的是「讲的先后」,**不是分段** ——',
'最终产出是一整段连贯文字,不会有小标题。别按"开场/正文/结尾"去想,想的是"先说什么才能让下一句站得住"。',
'',
'# 规划方法:从果(目标)倒推到因',
'先定这条消息的"果" —— 让患者明白"该回来把 X 处理掉、早处理的好处 / 拖着的后果",从而愿意来复查;',
'再倒推"因" —— 为达成它,患者需要先知道什么、被打消什么顾虑。据此把链条拆成层层递进的要点。',
'',
'# 说明风险的分寸:把后果讲清,但不吓唬、不推销',
'"不处理会怎样"要**客观说明**(结合病历 + 牙科常识),让患者理解严重性;',
'但**不夸大、不制造恐慌、不下吓人结论**,也**不报价、不促单、不施压**。',
'',
'不要写正文,只输出要点 JSON。要点 3-6 条。',
].join('\n');
const VERIFY_SYSTEM = [
'你是严格的医疗话术审核员,任务是**对抗式校验**一条准备**发到患者微信**的消息,默认怀疑、宁严勿松。',
'依据给定的"本次回访患者信息"逐项判断:',
'① 接地:每个具体说法(诊断/检查所见/医嘱/时间/牙位/医生)能否在给定事实里找到依据?找不到=编造。',
'② 安全:有无报价/费用、疗效承诺、写死具体时间(应保留【时间段】)、患者≤18 却提拍片?',
'③ 逻辑与分寸:围绕"让患者明白该回来处理"展开、层层递进、该说的没缺(点了问题要说后果、说了后果要给出路);',
' 有没有**吓唬/制造恐慌**或**推销/促单/施压**口吻?',
'④ 患者听得懂:有没有诊断代码(如 K08)、英文/内部枚举、生硬术语,或含糊其辞(没说清哪颗牙)?',
'⑤ ⭐**可直接发送**(企微专有):客服会整段复制发出去 ——',
' 有小标题 / `##` / 分段编号 / "第一第二" = 不合格;',
' 混进给客服自己看的话("以下话术供参考""建议这样说") = 不合格;',
' 除 【时间段1/2】【具体预约时间】【回访客服】 外还残留别的占位符或内部标签 = 不合格;',
' 出现"您现在方便吗""能听清吗"这类**需要对方当场回话**才成立的电话句式 = 不合格。',
'①②③④⑤ 任一不过 → pass=false,并逐条列出 issue(位置、问题、修法);全部通过 → pass=true、issues 空。',
'另外给一组 **quality 质量评分**(1-5)—— **只评"好不好",跟 pass 无关**。',
'只输出 JSON,不改写草稿。',
].join('\n');
@Injectable()
export class WecomPlanCall implements AiCall<ScriptContext, WecomPlanZ> {
readonly kind = 'script' as const;
readonly callKey = 'draft_wecom_script_plan';
readonly promptVersion = 'draft_wecom_script@2026-08-04-plan-v1';
readonly defaultModelId = 'deepseek-v4-flash';
readonly outputSchema = WecomPlanSchema;
buildPrompt(ctx: ScriptContext) {
return { system: PLAN_SYSTEM, prompt: buildWecomPlanPrompt(ctx) };
}
}
export interface WecomWriteInput {
ctx: ScriptContext;
plan: WecomPlanZ;
prevDraft?: WecomWriteZ;
repairIssues?: Array<{ section: string; problem: string; fix: string }>;
}
@Injectable()
export class WecomWriteCall implements AiCall<WecomWriteInput, WecomWriteZ> {
readonly kind = 'script' as const;
readonly callKey = 'draft_wecom_script_write';
readonly promptVersion = 'draft_wecom_script@2026-08-04-write-v1';
readonly defaultModelId = 'deepseek-v4-flash';
readonly outputSchema = WecomWriteSchema;
constructor(private readonly skillRegistry: DraftPlanScriptSkillRegistry) {}
buildPrompt(input: WecomWriteInput) {
// tier 传 'deep' 只为**挑 skill**(人群共性 / 深度档知识,与渠道无关);
// 输出形态靠第 4 个参数换成企微那份 format.md。见 composeSystem 的注释。
const composed = composeSystem(
input.ctx,
this.skillRegistry.getAllSkills(),
'deep',
wecomFormatPath(),
);
return { system: composed.systemPrompt, prompt: buildWecomWritePrompt(input) };
}
}
export interface WecomVerifyInput {
ctx: ScriptContext;
draft: WecomWriteZ;
}
@Injectable()
export class WecomVerifyCall implements AiCall<WecomVerifyInput, WecomVerifyZ> {
readonly kind = 'judge' as const;
readonly callKey = 'draft_wecom_script_verify';
readonly promptVersion = 'draft_wecom_script@2026-08-04-verify-v1';
readonly defaultModelId = 'deepseek-v4-flash';
readonly outputSchema = WecomVerifySchema;
buildPrompt(input: WecomVerifyInput) {
return { system: VERIFY_SYSTEM, prompt: buildWecomVerifyPrompt(input) };
}
}
import type { ScriptContext } from '../draft-plan-script/shared/input.types';
import { buildRichFactBlock, buildDeepExtensions } from '../draft-plan-script/shared/fact-block';
import type { WecomPlanZ, WecomWriteZ } from './schema';
/**
* 企微话术的 user prompt。
*
* ⭐ **事实块直接复用电话档那一份**(buildRichFactBlock + buildDeepExtensions)——
* 患者是谁、哪颗牙、医生说了什么、上次什么时候来的…… 这些与"用电话还是企微说"完全无关。
* ⛔ 别复制一份改改:安全护栏(不报价/福利不得加码/高龄不主推种植)全在里面,
* 复制出去之后改了一处另一处会悄悄留在旧版本,而漏了哪条要等客服发出去才发现。
*
* ⚠️ 差异全部集中在**任务段**(--- 之后):电话是"拆几段讲",企微是"排个顺序、写成一条消息"。
*/
function facts(ctx: ScriptContext): string {
const ext = buildDeepExtensions(ctx);
return ext ? `${buildRichFactBlock(ctx)}\n\n${ext}` : buildRichFactBlock(ctx);
}
/** 步骤1:要点规划 —— ⚠️ 排的是**顺序**,不是段落 */
export function buildWecomPlanPrompt(ctx: ScriptContext): string {
return `${facts(ctx)}
---
# 你的任务(本步:排要点顺序,不写正文)
这条消息是**发到患者微信里**的,他会一口气读完。规划要讲哪几点、按什么顺序讲。
按"从果(目标)倒推到因"来排:先想清这条消息要达成的果(让患者明白该回来处理本次问题),
再倒推为达成它患者需要先被讲清什么,把要点排成层层递进的推进线。
后果要客观说清、但不吓唬不推销。要点 3-6 条。
⚠️ 你排的是**讲的先后**,**不是分段** —— 最终产出是一整段连贯文字,不会有小标题。
所以别按"开场/正文/结尾"这种结构去想,想的是"先说什么才能让下一句站得住"。`;
}
/** 步骤2:写 —— 一次性单块(+ repair:上一稿 + 逐条修正约束) */
export function buildWecomWritePrompt(input: {
ctx: ScriptContext;
plan: WecomPlanZ;
prevDraft?: WecomWriteZ;
repairIssues?: Array<{ section: string; problem: string; fix: string }>;
}): string {
if (input.repairIssues?.length) {
const prev = input.prevDraft?.markdown ?? '(上一稿缺失,按要点重写并满足下列修正)';
const issues = input.repairIssues
.map((it, i) => `${i + 1}. 【${it.section}】问题:${it.problem}\n 必须改成:${it.fix}`)
.join('\n');
return `${facts(input.ctx)}
---
# 本步任务:修订(不是重写)
上一稿没通过自检。**在上一稿基础上,严格逐条改掉下面每一处问题**,改完输出完整修订稿。
## 上一稿(待修订)
${prev}
## 必须修正的问题(逐条,缺一不可)
${issues}
## 修订铁律
- 上面每一条都必须改到位,**一条都不能漏**;改法以"必须改成"为准。
- **只动被点名的地方**,其余句子保持原样,不要顺手重写或新增事实。
- 修正不得引入新的违规:不报价/不承诺疗效/不写死具体时间(用【时间段】占位)/≤18 岁不提拍片。
- 仍然是**一整块可直接发送的消息**,不许出现小标题或分段编号。`;
}
const outline = input.plan.points.map((p, i) => `${i + 1}. ${p.point}(${p.why})`).join('\n');
return `${facts(input.ctx)}
---
# 本步要点顺序(按它写成一条消息,可微调措辞,不要新增事实)
${outline}
# 写的时候保持
- **一条能直接发出去的微信消息**:复制粘贴即可发送,不用再删改。
- **层层递进**:顺着要点推进,句与句承上启下,别并列罗列或跳跃。
- **后果说清但有分寸**:不处理的后果客观讲明(结合病历 + 牙科常识),让患者理解严重性;
但**不夸大、不吓唬**(别下"会掉光""很危险"式结论)、**不推销促单报价**。`;
}
/** 步骤3:独立对抗校验 —— 新开上下文,默认怀疑 */
export function buildWecomVerifyPrompt(input: { ctx: ScriptContext; draft: WecomWriteZ }): string {
return `${facts(input.ctx)}
---
# 你的任务(本步:对抗校验,不改写)
逐句核对下面这条**准备发给患者微信**的消息:
1. **接地**:每个说法能否追到上面"本次回访患者信息"里的事实?追不到 = 编造 → 记 issue。
2. **安全**:有无报价/费用、疗效承诺、写死具体时间(应保留【时间段】)、≤18 提拍片?有 = 越界 → 记 issue。
3. **逻辑与分寸**:围绕"让患者明白该回来处理"展开、层层递进、该说的没缺(点了问题要说后果、说了后果要给出路);
有没有**吓唬/制造恐慌**或**推销/促单/施压**口吻?任一不到位 → 记 issue。
4. **患者听得懂**:有无诊断代码(如 K08)、英文/内部枚举、生硬术语,或含糊其辞(没说清哪颗牙)?有 = 记 issue。
5. ⭐ **可直接发送**(企微专有,电话档没有这一条):
- 有没有**小标题 / 分段编号 / "第一第二" / \`##\`**?企微是一条消息,出现这些 = 记 issue;
- 有没有**给客服自己看的话**混进正文(如"以下话术供参考""建议这样说")?= 记 issue;
- 除 \`【时间段1/2】【具体预约时间】【回访客服】\` 外,有没有**残留的其它占位符或内部标签**?= 记 issue。
①②③④⑤ 全部通过 → pass=true、issues 空;任一不过 → pass=false 并逐条列出(位置、问题、修法)。
## 待校验消息
${input.draft.markdown}`;
}
import { z } from 'zod';
import { ToneEnum, TONE_DESCRIBE } from '../draft-plan-script/shared/tone';
/**
* 企微话术(深度档)三步的输出 schema。
*
* ⚠️ 与电话深度档最本质的差别就在这里:**没有 `sections[]`,只有一整块 `markdown`**。
* 电话稿分段是给「伴飞」逐段高亮用的;企微是**一条发出去的消息**,分段没有意义,
* 反而会诱导模型写成"第一段…第二段…"的汇报体,复制过去很怪。
*
* ⚠️ 同样不加 .min()/.max() 硬约束 —— 理由与电话档一致(见 tiers/deep/schema.ts):
* 硬长度约束对中文偏严,模型差一点就整体 fail 走兜底;形态靠 system + describe 引导。
*/
// ── 步骤1:要点规划(⚠️ 不是"分段",是**讲的顺序**)──
export const WecomPlanSchema = z.object({
points: z
.array(
z.object({
point: z.string().describe('这一点要讲什么(一句话,口语化,**必须能追到给定患者信息/病历事实**)'),
why: z.string().describe('为什么排在这个位置——承上启下:接住上一点的什么、为下一点铺什么'),
}),
)
.describe(
'要点**顺序**(3-6 条):从果(让患者明白该回来处理)倒推到因,排成层层递进的推进线。' +
'⚠️ 这是"讲的先后",**不是分段** —— 最终产出是一整段连贯文字,不许出现小标题。',
),
});
export type WecomPlanZ = z.infer<typeof WecomPlanSchema>;
// ── 步骤2:写(一次性单块)──
export const WecomWriteSchema = z.object({
tone: ToneEnum.describe(TONE_DESCRIBE),
markdown: z
.string()
.describe(
'完整企微消息正文(约 120-400 字),**一整块、可直接复制发送**。' +
'⛔ 不要小标题、不要 `##`、不要分段编号、不要"第一/第二"、不要表情符号。' +
'按微信阅读节奏用空行断成几个短自然段;接地病历不编造;时间用【时间段】占位。',
),
});
export type WecomWriteZ = z.infer<typeof WecomWriteSchema>;
// ── 步骤3:独立对抗校验 ──
export const WecomVerifySchema = z.object({
pass: z.boolean().describe('①接地②安全③逻辑与分寸④可直接发送(无标题/无占位残留/无客服自己看的话)全过 → true'),
issues: z
.array(
z.object({
section: z.string().describe('出问题的位置(可填"开头""结尾""整体")'),
problem: z.string().describe('问题:①接地不实②安全越界(报价/承诺疗效/写死时间/≤18拍片)③逻辑与分寸(跑题/不递进/没说后果/吓唬/推销施压)④不可直接发送(出现小标题、内部标签、给客服看的说明)'),
fix: z.string().describe('修法建议(回喂改写)'),
}),
)
.describe('逐条列出有问题的点;全部 OK 则空数组'),
quality: z
.object({
natural: z.number().describe('像微信里真人发的(1-5):不书面公文、不机器腔'),
warmth: z.number().describe('关怀温度(1-5):医疗关怀感,不冷淡也不推销'),
focus: z.number().describe('聚焦(1-5):紧扣本次问题,不发散'),
nonPushy: z.number().describe('不推销(1-5):邀约自然,不促单 / 不报价 / 不施压'),
sendable: z.number().describe('可直接发送度(1-5):复制粘贴就能发,不用再删改'),
overall: z.number().describe('综合质量分(1-5,可含半分)'),
})
.describe('质量细项打分(1-5);只评质量,不影响 pass/issues'),
});
export type WecomVerifyZ = z.infer<typeof WecomVerifySchema>;
# 输出结构(一条消息,不分段)
输出 `tone` + `markdown``markdown`**一整条准备发到患者微信里的消息**
⛔ 不要 `sections`、不要小标题、不要 `##`、不要「第一/第二」、不要编号列表、不要表情符号。
⛔ 不要写任何给客服自己看的话(「以下话术供参考」「建议这样说」「话术如下」)——客服会**整段复制发送**,这些字会一起发给患者。
# 这是微信,不是电话
- **他一口气读完**:没有一来一回,你写的就是他看到的全部。所以不能有「您现在方便吗」「能听清吗」这类需要对方回话才成立的句子。
- **按阅读节奏断行**:用空行断成 3-5 个短自然段,每段 1-3 句。⛔ 别写成一大坨,微信里没人读得下去。
- **书面但不端着**:像医生助理认真打的一段字——比电话口语克制,比公文自然。不用「兹」「特此」,也不用「哈喽~」。
- **开头直接称呼 + 自报家门**,不用寒暄铺垫;**结尾留一个明确的下一步**,别用「随时联系我」这种空钩子。
# 这三处按原样写,不要改
1. 自报家门:用给定的「自报家门」整串,其中 `【回访客服】` 原样保留(系统按登录人回填成「助理X」)——别替换成具体姓名,也别自己编一个;身份是**医生的助理**,不要改写成「客服/顾问」。
2. 时间一律占位:`【时间段1】【时间段2】【具体预约时间】` 原样保留,严禁替换成「周三上午」等具体时间、严禁加粗、严禁「已为您约好」式承诺。
3. 引导预约:企微里**不要用电话那种「您看哪个方便?」的口头二选一**——那是要对方当场答的。改成把两个时间段摆出来请他回复,例如「X医生【时间段1】和【时间段2】都有空,您回我一下方便的时间就行」。「X医生」用给定的诊断医生姓替换。
# 写的内容
- **病种措辞自供**:风险与「趁早处理的好处」结合下方病历(检查所见/医嘱/建议)+ 牙科常识,用自己的话讲清;医生没记录的别编。
- **把后果说清、有分寸**:不处理的后果要**客观说明**让患者理解严重性,但**不夸大、不吓唬**(别下「会掉光」「很危险」式结论)、**不推销促单报价**——为患者着想地讲,不是吓他/催他。
- **层层递进**:顺着给定的要点顺序推进,句与句承上启下,别并列罗列或跳跃重复。
- **事实朴素取用**:患者信息以朴素中文标签直接给(称呼/本次问题/牙位/诊断医生/最近一次就诊…),自然用进话里;除上面要求原样保留的 `【】` 外,不写占位符、不留标签字样。
import { Injectable, Logger } from '@nestjs/common';
import { AiCallRunnerService } from '../../ai-call-runner.service';
import type { AiCallContext } from '../../ai-call.interface';
import type { ScriptContext } from '../draft-plan-script/shared/input.types';
import { machineSafetyScan } from '../draft-plan-script/shared/safety-rules';
import { WecomPlanCall, WecomWriteCall, WecomVerifyCall } from './calls';
import type { WecomPlanZ, WecomWriteZ, WecomVerifyZ } from './schema';
export interface WecomScriptResult {
markdown: string;
tone: WecomWriteZ['tone'];
source: 'agent' | 'failed';
invocationId: string;
costYuan: number;
promptTokens: number;
completionTokens: number;
stepsRun: string[];
failReason?: string;
}
/**
* 企微话术编排:规划(plan)→ 写(write,单块)→ 独立对抗校验(verify)→ 不过则 repair(≤1 轮)。
*
* ⚠️⚠️ **与电话档最大的行为差别:没有模板兜底,失败就是失败。**
* 电话档失败会回退到 `stableTemplateFallback` —— 因为客服正拿着电话,必须有东西能念,
* 而模板稿念出来只是"平淡",不会出事。
* 企微不一样:产出是**一条要原样发给患者的消息**。给一份模板套话让他复制发出去,
* 患者收到的是一条明显是群发的、可能对不上他情况的消息 —— 那比"没有话术"更糟,
* 而且发出去收不回。⇒ 生成不出来就如实报失败,让客服自己写。
*
* 其余脊柱与电话深度档一致:每步各过 AiCallRunner、各落 agent_invocations;
* 机器安全(禁词/承诺/加粗时间)在策略侧硬扫,与 LLM 校验合并决定是否 repair。
*/
@Injectable()
export class WecomScriptStrategy {
private readonly logger = new Logger(WecomScriptStrategy.name);
private readonly MAX_REPAIR = 1;
constructor(
private readonly runner: AiCallRunnerService,
private readonly planCall: WecomPlanCall,
private readonly writeCall: WecomWriteCall,
private readonly verifyCall: WecomVerifyCall,
) {}
async run(ctx: ScriptContext, runCtx: AiCallContext): Promise<WecomScriptResult> {
const steps: string[] = [];
let cost = 0;
let promptTokens = 0;
let completionTokens = 0;
const acc = (r: { costYuan: number; promptTokens: number; completionTokens: number }) => {
cost += r.costYuan;
promptTokens += r.promptTokens;
completionTokens += r.completionTokens;
};
const ensureLive = () => {
if (runCtx.signal?.aborted) throw new Error('生成已取消(客户端断连)');
};
const fail = (reason: string, invocationId = ''): WecomScriptResult => ({
markdown: '',
tone: 'warm',
source: 'failed',
invocationId,
costYuan: cost,
promptTokens,
completionTokens,
stepsRun: steps,
failReason: reason,
});
// ── 步骤1:要点规划(best-effort —— 失败不阻断,让 write 直接按事实自己排)──
ensureLive();
let plan: WecomPlanZ | undefined;
try {
const r = await this.runner.run(this.planCall, ctx, runCtx);
acc(r);
plan = r.output;
steps.push('plan');
} catch (err) {
if (runCtx.signal?.aborted) throw err;
this.logger.warn(`wecom plan 失败,跳过规划直接写: ${(err as Error).message}`);
plan = { points: [] };
steps.push('plan:skip');
}
// ── 步骤2:写(单块)──
ensureLive();
let w;
try {
w = await this.runner.run(this.writeCall, { ctx, plan }, runCtx);
} catch (err) {
if (runCtx.signal?.aborted) throw err;
return fail(`写作失败: ${(err as Error).message}`);
}
acc(w);
let draft = w.output;
let invocationId = w.invocationId;
steps.push('write');
// ── 步骤3:对抗校验 + 机器扫 ──
const issues: WecomVerifyZ['issues'] = machineScanIssues(draft);
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);
} catch (err) {
if (runCtx.signal?.aborted) throw err;
this.logger.warn(`wecom verify 失败,仅依据机器扫: ${(err as Error).message}`);
steps.push('verify:skip');
}
// ── repair(≤1 轮)──
if (issues.length > 0) {
ensureLive();
this.logger.debug(`wecom repair: ${issues.length} 个 issue`);
try {
const w2 = await this.runner.run(
this.writeCall,
{ ctx, plan, repairIssues: issues, prevDraft: draft },
runCtx,
);
acc(w2);
draft = w2.output;
invocationId = w2.invocationId;
steps.push('repair');
} catch (err) {
if (runCtx.signal?.aborted) throw err;
return fail(`修订失败: ${(err as Error).message}`, invocationId);
}
// 终检:机器闸仍不过 → ⛔ **不放行**(对抗哲学:接地/安全宁可没有也不发错的)
const stillBad = machineSafetyScan(draft.markdown);
if (stillBad.length > 0) {
return fail(`修订后仍不过机器安全闸: ${stillBad.join(';')}`, invocationId);
}
}
return {
markdown: draft.markdown,
tone: draft.tone,
source: 'agent',
invocationId,
costYuan: cost,
promptTokens,
completionTokens,
stepsRun: steps,
};
}
}
/**
* 机器硬扫 → issue(与 LLM 校验合并)。
* ⚠️ 复用电话档同一个 `machineSafetyScan`:禁词/疗效承诺/加粗写死时间这些**与渠道无关**,
* 而且它是"单一源"——两处各写一份的话,改了其中一处另一处就悄悄留在旧规则上。
*/
function machineScanIssues(draft: WecomWriteZ): WecomVerifyZ['issues'] {
return machineSafetyScan(draft.markdown).map((problem) => ({
section: '整体',
problem,
fix: '按机器安全闸要求改掉该处(不报价 / 不承诺疗效 / 时间用【时间段】占位 / ≤18 不提拍片)',
}));
}
...@@ -195,11 +195,13 @@ export class PlanScriptOrchestrator { ...@@ -195,11 +195,13 @@ export class PlanScriptOrchestrator {
let planScriptId: string | null = null; let planScriptId: string | null = null;
if (!options.dryRun) { if (!options.dryRun) {
const row = await this.prisma.planScript.upsert({ const row = await this.prisma.planScript.upsert({
where: { planId: plan.id }, where: { planId_channel: { planId: plan.id, channel: 'phone' } },
create: { create: {
hostId: plan.hostId, hostId: plan.hostId,
tenantId: plan.tenantId, tenantId: plan.tenantId,
planId: plan.id, planId: plan.id,
channel: 'phone', // 显式写死:同表现在还存企微稿,别依赖列默认值
content, content,
status: 'ready', status: 'ready',
source, source,
...@@ -285,11 +287,13 @@ export class PlanScriptOrchestrator { ...@@ -285,11 +287,13 @@ export class PlanScriptOrchestrator {
let planScriptId: string | null = null; let planScriptId: string | null = null;
if (!options.dryRun) { if (!options.dryRun) {
const row = await this.prisma.planScript.upsert({ const row = await this.prisma.planScript.upsert({
where: { planId: plan.id }, where: { planId_channel: { planId: plan.id, channel: 'phone' } },
create: { create: {
hostId: plan.hostId, hostId: plan.hostId,
tenantId: plan.tenantId, tenantId: plan.tenantId,
planId: plan.id, planId: plan.id,
channel: 'phone', // 显式写死:同表现在还存企微稿,别依赖列默认值
content, content,
status: 'ready', status: 'ready',
source: r.source, source: r.source,
...@@ -404,11 +408,13 @@ export class PlanScriptOrchestrator { ...@@ -404,11 +408,13 @@ export class PlanScriptOrchestrator {
let planScriptId: string | null = null; let planScriptId: string | null = null;
if (!dryRun) { if (!dryRun) {
const row = await this.prisma.planScript.upsert({ const row = await this.prisma.planScript.upsert({
where: { planId: plan.id }, where: { planId_channel: { planId: plan.id, channel: 'phone' } },
create: { create: {
hostId: plan.hostId, hostId: plan.hostId,
tenantId: plan.tenantId, tenantId: plan.tenantId,
planId: plan.id, planId: plan.id,
channel: 'phone', // 显式写死:同表现在还存企微稿,别依赖列默认值
content, content,
status: 'ready', status: 'ready',
source, source,
......
import { Injectable, Logger } from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import { PrismaService } from '../../../prisma/prisma.service';
import { PlanScriptOrchestrator } from './plan-script.orchestrator';
import { WecomScriptStrategy } from '../calls/draft-wecom-script/wecom.strategy';
export interface WecomScriptGenerateResult {
planId: string;
planScriptId: string | null;
agentInvocationId: string;
source: 'agent' | 'failed';
content: string;
costYuan: number;
failReason?: string;
}
/**
* 企微话术编排 —— 读 plan/persona/facts → 跑 3 步 → 写 `plan_scripts(channel='wecom')`。
*
* ⭐ **上下文装配整段复用电话档的 `buildScriptInputForPlan`**:
* 患者是谁、哪颗牙、医生说了什么、上次什么时候来的、福利是什么 —— 与渠道无关。
* ⛔ 别在这里另写一遍取数:那意味着两条链路对"患者事实"各有一套理解,
* 而它们一定会漂(一边加了字段另一边没加,表现是企微稿比电话稿少提一颗牙,还不报错)。
*
* ⚠️ 与电话档另一处不同:**失败不写 `ready`**。
* 电话档失败会落模板兜底稿(客服拿着电话必须有东西念);企微稿是要**原样发给患者**的,
* 落一份套话让他复制发出去比没有更糟 —— 所以失败就写 `failed`,前端显示"生成失败,请手写"。
*/
@Injectable()
export class WecomScriptOrchestrator {
private readonly logger = new Logger(WecomScriptOrchestrator.name);
constructor(
private readonly prisma: PrismaService,
private readonly planScripts: PlanScriptOrchestrator,
private readonly strategy: WecomScriptStrategy,
) {}
async generate(
planId: string,
options: { modelIdOverride?: string; signal?: AbortSignal } = {},
): Promise<WecomScriptGenerateResult> {
// ⭐ 复用电话档的上下文装配(见类注释)
const ctx = await this.planScripts.buildScriptInputForPlan(planId);
const plan = await this.prisma.followupPlan.findUniqueOrThrow({
where: { id: planId },
select: { id: true, hostId: true, tenantId: true, patientId: true },
});
const r = await this.strategy.run(ctx, {
hostId: plan.hostId,
tenantId: plan.tenantId,
linkedPatientId: plan.patientId,
linkedPlanId: plan.id,
// 一次生成的 3 步(plan/write/verify)用同一个 runId 串起来,eval 里能按次回看
workflowRunId: randomUUID(),
bustCache: true,
modelIdOverride: options.modelIdOverride,
evalMode: 'production',
signal: options.signal,
});
const ok = r.source === 'agent' && r.markdown.trim().length > 0;
const row = await this.prisma.planScript.upsert({
where: { planId_channel: { planId: plan.id, channel: 'wecom' } },
create: {
hostId: plan.hostId,
tenantId: plan.tenantId,
planId: plan.id,
channel: 'wecom',
content: ok ? r.markdown : null,
status: ok ? 'ready' : 'failed',
source: ok ? 'agent' : null,
agentInvocationId: r.invocationId || null,
},
update: {
content: ok ? r.markdown : null,
status: ok ? 'ready' : 'failed',
source: ok ? 'agent' : null,
agentInvocationId: r.invocationId || null,
},
select: { id: true },
});
if (!ok) {
this.logger.warn(`企微话术生成失败 plan=${planId}: ${r.failReason ?? '未知'};步骤=${r.stepsRun.join('→')}`);
}
return {
planId: plan.id,
planScriptId: row.id,
agentInvocationId: r.invocationId,
source: r.source,
content: ok ? r.markdown : '',
costYuan: r.costYuan,
...(r.failReason ? { failReason: r.failReason } : {}),
};
}
}
...@@ -147,6 +147,10 @@ export class PlanAggregateService { ...@@ -147,6 +147,10 @@ export class PlanAggregateService {
// W4:话术从 DB 加载(LLM 流式生成完会 upsert 到 plan_scripts) // W4:话术从 DB 加载(LLM 流式生成完会 upsert 到 plan_scripts)
// 没生成过 → script=null,前端走 mock 兜底 // 没生成过 → script=null,前端走 mock 兜底
const scriptRow = plan ? await this.loadPlanScript(plan.id) : null; const scriptRow = plan ? await this.loadPlanScript(plan.id) : null;
// ⚠️ 企微稿**独立一行**(channel='wecom'),与电话稿各有各的 status ——
// ⛔ 别在这里做"企微没有就回落电话稿":那份是口语分段的,复制发给患者很怪,
// 而客服不会注意到自己发错了东西。没有就是 null,前端如实显示。
const wecomRow = plan ? await this.loadPlanScript(plan.id, 'wecom') : null;
// ⭐ 落库正文里的自报家门是占位符【回访客服】,读出来按**当前登录人**回填 —— 缓存 per-plan、 // ⭐ 落库正文里的自报家门是占位符【回访客服】,读出来按**当前登录人**回填 —— 缓存 per-plan、
// 召回池共享,烤进人名会让后开的客服看到别人的名字(见 agent-identity.ts) // 召回池共享,烤进人名会让后开的客服看到别人的名字(见 agent-identity.ts)
const script = scriptRow const script = scriptRow
...@@ -239,6 +243,20 @@ export class PlanAggregateService { ...@@ -239,6 +243,20 @@ export class PlanAggregateService {
chains, chains,
facts: facts.map(serializeFact), facts: facts.map(serializeFact),
script: script ? serializeScript(script) : null, script: script ? serializeScript(script) : null,
/**
* 企微稿 —— ⛔ **不能走 `serializeScript`**(踩过:前端拿到 content 长度为 0)。
* 那个序列化器会把正文 `parseScriptMarkdownToSections` 拆成段、**并丢掉原文** ——
* 电话稿要的就是段,而企微稿**只有原文**,拆完就什么都不剩了。
* 这里原样透出 content(仍要回填【回访客服】占位:落库存的是占位,读时按登录人渲染)。
*/
wecomScript: wecomRow
? {
id: wecomRow.id,
status: wecomRow.status,
content: renderAgentIdentity(wecomRow.content, agent),
updatedAt: wecomRow.updatedAt.toISOString(),
}
: null,
recallHistory, recallHistory,
returnVisits: (patient.returnVisits ?? []).map((r) => ({ returnVisits: (patient.returnVisits ?? []).map((r) => ({
taskDate: r.taskDate ? r.taskDate.toISOString().slice(0, 10) : null, taskDate: r.taskDate ? r.taskDate.toISOString().slice(0, 10) : null,
...@@ -286,9 +304,9 @@ export class PlanAggregateService { ...@@ -286,9 +304,9 @@ export class PlanAggregateService {
* W4:加载该 plan 的最新 ready 话术(LLM 生成完会 upsert 进 plan_scripts)。 * W4:加载该 plan 的最新 ready 话术(LLM 生成完会 upsert 进 plan_scripts)。
* pending/failed 的不返回 — 前端走 mock 兜底,客服点"重新生成"再触发 LLM。 * pending/failed 的不返回 — 前端走 mock 兜底,客服点"重新生成"再触发 LLM。
*/ */
private loadPlanScript(planId: string) { private loadPlanScript(planId: string, channel: 'phone' | 'wecom' = 'phone') {
return this.prisma.planScript.findUnique({ return this.prisma.planScript.findUnique({
where: { planId }, where: { planId_channel: { planId, channel } },
}); });
} }
......
...@@ -25,6 +25,7 @@ import { ...@@ -25,6 +25,7 @@ import {
} from '../../common/decorators/current-user.decorator'; } from '../../common/decorators/current-user.decorator';
import { PrismaService } from '../../prisma/prisma.service'; import { PrismaService } from '../../prisma/prisma.service';
import { PlanScriptOrchestrator } from '../ai/orchestrators/plan-script.orchestrator'; import { PlanScriptOrchestrator } from '../ai/orchestrators/plan-script.orchestrator';
import { WecomScriptOrchestrator } from '../ai/orchestrators/wecom-script.orchestrator';
import type { PlanScriptStreamEvent } from '../ai/orchestrators/plan-script.orchestrator'; import type { PlanScriptStreamEvent } from '../ai/orchestrators/plan-script.orchestrator';
import { PlanSummaryOrchestrator } from '../ai/orchestrators/plan-summary.orchestrator'; import { PlanSummaryOrchestrator } from '../ai/orchestrators/plan-summary.orchestrator';
import { RecallSummaryOrchestrator } from '../ai/orchestrators/recall-summary.orchestrator'; import { RecallSummaryOrchestrator } from '../ai/orchestrators/recall-summary.orchestrator';
...@@ -63,6 +64,7 @@ export class PlansAggregateController { ...@@ -63,6 +64,7 @@ export class PlansAggregateController {
constructor( constructor(
private readonly demo: PlanAggregateService, private readonly demo: PlanAggregateService,
private readonly planScript: PlanScriptOrchestrator, private readonly planScript: PlanScriptOrchestrator,
private readonly wecomScript: WecomScriptOrchestrator,
private readonly planSummary: PlanSummaryOrchestrator, private readonly planSummary: PlanSummaryOrchestrator,
private readonly recallSummary: RecallSummaryOrchestrator, private readonly recallSummary: RecallSummaryOrchestrator,
private readonly personaSummary: PersonaSummaryOrchestrator, private readonly personaSummary: PersonaSummaryOrchestrator,
...@@ -138,6 +140,39 @@ export class PlansAggregateController { ...@@ -138,6 +140,39 @@ export class PlansAggregateController {
// 话术 — 同步重生成 / 流式重生成 // 话术 — 同步重生成 / 流式重生成
// ───────────────────────────────────────────── // ─────────────────────────────────────────────
/**
* 生成 / 重新生成**企微**话术。
*
* ⚠️ 同步返回,**不做流式** —— 企微稿是一整块、几百字,几秒就出;
* 为它再铺一条 SSE 通道只是把电话档那套逐段推送复制一遍,而这里没有"段"可推。
* ⚠️ 失败**不兜底**:返回 source='failed' + failReason,前端如实显示。
* ⛔ 别回落成电话稿(口语分段的东西复制发给患者很怪,而客服不会注意到)。
*/
@Post(':id/wecom-script:regenerate')
@RequirePermission(Permission.PLAN_VIEW_OWN)
@ApiOperation({ summary: '生成 / 重新生成企微话术(一次性单块)' })
async regenerateWecomScript(
@TenantScope() scope: TenantScopeContext,
@CurrentUser() user: AuthenticatedUser,
@Param('id') planId: string,
@Query('model') model?: string,
) {
// 认领闸:同电话档 —— 生成要真花 AI 钱
await this.assertClaimed(scope, planId, user.sub);
const result = await this.wecomScript.generate(planId, { modelIdOverride: model });
const agent = resolveScriptAgent(user);
return {
planId: result.planId,
planScriptId: result.planScriptId,
agentInvocationId: result.agentInvocationId,
source: result.source,
costYuan: result.costYuan,
// 落库存的是【回访客服】占位,返回时按当前登录人回填
content: renderAgentIdentity(result.content, agent),
...(result.failReason ? { failReason: result.failReason } : {}),
};
}
@Post(':id/script:regenerate') @Post(':id/script:regenerate')
@RequirePermission(Permission.PLAN_VIEW_OWN) @RequirePermission(Permission.PLAN_VIEW_OWN)
@ApiOperation({ summary: '重新生成 plan 话术(测试 / 调试用,输入不变)' }) @ApiOperation({ summary: '重新生成 plan 话术(测试 / 调试用,输入不变)' })
......
...@@ -605,7 +605,7 @@ export class PlanService { ...@@ -605,7 +605,7 @@ export class PlanService {
? await this.resolveAgentName(scope, plan.assigneeUserId) ? await this.resolveAgentName(scope, plan.assigneeUserId)
: null; : null;
const [patient, persona, scriptRow, summariesRows, executions] = await Promise.all([ const [patient, persona, scriptRow, wecomRow, summariesRows, executions] = await Promise.all([
this.prisma.patient.findUnique({ this.prisma.patient.findUnique({
where: { id: plan.patientId }, where: { id: plan.patientId },
include: { profile: true }, include: { profile: true },
...@@ -615,7 +615,9 @@ export class PlanService { ...@@ -615,7 +615,9 @@ export class PlanService {
include: { features: true }, include: { features: true },
orderBy: { version: 'desc' }, orderBy: { version: 'desc' },
}), }),
this.prisma.planScript.findUnique({ where: { planId: plan.id } }), this.prisma.planScript.findUnique({ where: { planId_channel: { planId: plan.id, channel: 'phone' } } }),
// 企微稿独立一行,与电话稿各有各的 status —— ⛔ 别做企微没有就回落电话稿
this.prisma.planScript.findUnique({ where: { planId_channel: { planId: plan.id, channel: 'wecom' } } }),
this.prisma.planSummary.findMany({ where: { planId: plan.id } }), this.prisma.planSummary.findMany({ where: { planId: plan.id } }),
this.prisma.planExecution.findMany({ this.prisma.planExecution.findMany({
where: { planId: plan.id }, where: { planId: plan.id },
...@@ -635,6 +637,15 @@ export class PlanService { ...@@ -635,6 +637,15 @@ export class PlanService {
script: scriptRow script: scriptRow
? serializeScript({ ...scriptRow, content: renderAgentIdentity(scriptRow.content, agent) }) ? serializeScript({ ...scriptRow, content: renderAgentIdentity(scriptRow.content, agent) })
: null, : null,
// ⛔ 不走 serializeScript:它会把正文拆成 sections 并丢掉原文,而企微稿只有原文(见 plan-aggregate 同处注释)
wecomScript: wecomRow
? {
id: wecomRow.id,
status: wecomRow.status as 'ready' | 'pending' | 'failed',
content: renderAgentIdentity(wecomRow.content, agent),
updatedAt: wecomRow.updatedAt.toISOString(),
}
: null,
summaries: summariesRows.map(serializeSummary), summaries: summariesRows.map(serializeSummary),
executions: executions.map(serializeExecution), executions: executions.map(serializeExecution),
evidence: computePlanEvidence(plan, persona), evidence: computePlanEvidence(plan, persona),
......
...@@ -222,6 +222,12 @@ export function adaptData(real: PlanDetailData, dict?: TokenDictionary) { ...@@ -222,6 +222,12 @@ export function adaptData(real: PlanDetailData, dict?: TokenDictionary) {
sections: real.script.sections, sections: real.script.sections,
} as typeof mockScript) } as typeof mockScript)
: EMPTY_SCRIPT, : EMPTY_SCRIPT,
/**
* 企微话术 —— **原样透传,不做 mock 兜底**(与电话档的关键差别)。
* 电话稿没生成时给一份 mock 让页面不空;企微稿是要**原样发给患者**的,
* 给假数据的风险是客服直接复制发出去 —— 所以没有就是 null,视图显示"尚未生成"。
*/
wecomScript: real.wecomScript ?? null,
// 召回历史(患者级)— 后端 plan-aggregate 透出;无则空数组 // 召回历史(患者级)— 后端 plan-aggregate 透出;无则空数组
recallHistory: real.recallHistory ?? [], recallHistory: real.recallHistory ?? [],
// 诊所回访记录(5 试点,展示用)— 后端透出;无则空数组 // 诊所回访记录(5 试点,展示用)— 后端透出;无则空数组
......
...@@ -52,6 +52,16 @@ const OUTCOME_GROUPS = ( ...@@ -52,6 +52,16 @@ const OUTCOME_GROUPS = (
})) }))
.filter((grp) => grp.options.length > 0); .filter((grp) => grp.options.length > 0);
/**
* 「触达方式」选择器 —— 暂时隐藏(2026-08-04 产品走查)。置 `true` 即恢复。
*
* ⚠️ 隐藏的只是**选择器**:`channel` 仍然照常提交(取 `defaultChannel`,即 'phone'),
* 服务端那一列 NOT NULL,不传会 400。
* ⚠️ 于是这段时间落库的 channel **全是 'phone'** —— 触达方式的分布在此期间**不可用**,
* ⛔ 别拿它出报表(会得出"全都是打电话"的假结论)。要恢复分析就得先把这个选择器放回来。
*/
const CHANNEL_PICKER_VISIBLE: boolean = false;
const CHANNELS = [ const CHANNELS = [
{ {
value: 'phone', value: 'phone',
...@@ -150,6 +160,7 @@ export function OutcomeForm({ ...@@ -150,6 +160,7 @@ export function OutcomeForm({
本任务已{plan.status === 'abandoned' ? '放弃' : plan.status === 'superseded' ? '被新版本替代' : '结案'},不可再提交执行;如需重新跟进,等召回重新生成。 本任务已{plan.status === 'abandoned' ? '放弃' : plan.status === 'superseded' ? '被新版本替代' : '结案'},不可再提交执行;如需重新跟进,等召回重新生成。
</div> </div>
)} )}
{CHANNEL_PICKER_VISIBLE && (
<div className="flex-none"> <div className="flex-none">
<div className="text-[10.5px] font-semibold text-slate-500 uppercase tracking-wider mb-1.5"> <div className="text-[10.5px] font-semibold text-slate-500 uppercase tracking-wider mb-1.5">
触达方式 <span className="text-rose-500">*</span> 触达方式 <span className="text-rose-500">*</span>
...@@ -186,6 +197,7 @@ export function OutcomeForm({ ...@@ -186,6 +197,7 @@ export function OutcomeForm({
})} })}
</div> </div>
</div> </div>
)}
<div className="flex-none"> <div className="flex-none">
<div className="text-[10.5px] font-semibold text-slate-500 uppercase tracking-wider mb-1.5"> <div className="text-[10.5px] font-semibold text-slate-500 uppercase tracking-wider mb-1.5">
......
...@@ -62,6 +62,7 @@ import { shortPersonaValueLabel, compactPersonaValue } from './persona-display'; ...@@ -62,6 +62,7 @@ import { shortPersonaValueLabel, compactPersonaValue } from './persona-display';
import { PersonaFeatureHover } from './persona-feature-hover'; import { PersonaFeatureHover } from './persona-feature-hover';
import { ReasonLine } from './reason-line'; import { ReasonLine } from './reason-line';
import { ScriptView, type ScriptViewMode } from './script-viewer'; import { ScriptView, type ScriptViewMode } from './script-viewer';
import { WecomScriptView } from './wecom-script-view';
import { ScriptDeepProcess } from './script-deep-process'; import { ScriptDeepProcess } from './script-deep-process';
import { OutcomeForm } from './outcome-form'; import { OutcomeForm } from './outcome-form';
import { Drawer, type DrawerKind } from './drawer'; import { Drawer, type DrawerKind } from './drawer';
...@@ -109,6 +110,8 @@ export type PlanDetailAppData = { ...@@ -109,6 +110,8 @@ export type PlanDetailAppData = {
facts?: AdaptedFact[]; facts?: AdaptedFact[];
summaries: typeof mockSummaries; summaries: typeof mockSummaries;
script: typeof mockScript; script: typeof mockScript;
/// 企微话术(独立生成链;null/缺省 = 还没生成过)。⚠️ 与 script 各有各的 status,⛔ 别互相回落
wecomScript?: { id: string; content: string | null; status: string; updatedAt: string } | null;
/// 召回历史(患者级)— 可选,缺省空数组 /// 召回历史(患者级)— 可选,缺省空数组
recallHistory?: RecallHistoryItem[]; recallHistory?: RecallHistoryItem[];
/// 诊所回访记录(5 试点,展示用)— 可选,缺省空数组 /// 诊所回访记录(5 试点,展示用)— 可选,缺省空数组
...@@ -136,6 +139,17 @@ export type ReturnVisitItem = { ...@@ -136,6 +139,17 @@ export type ReturnVisitItem = {
*/ */
const HEADER_PRIORITY_VISIBLE: boolean = false; const HEADER_PRIORITY_VISIBLE: boolean = false;
/**
* 话术 3 视图切换(伴飞 / 卡片 / 原文)—— 暂时隐藏(2026-08-04),那个位置让给**渠道** tab。
* 置 `true` 即恢复;`ScriptView` 的三个分支代码整套保留,⛔ 不是删除。
* ⚠️ 隐藏期间电话稿固定用 `markdown`(原文)渲染 —— 与 `scriptMode` 的初值一致,
* ⛔ 别改那个初值,否则藏起来之后会渲染成谁也切不回来的另一种视图。
*/
const SCRIPT_VIEW_SWITCH_VISIBLE: boolean = false;
/** 话术渠道 */
type ScriptChannel = 'phone' | 'wecom';
const FALLBACK_DATA: PlanDetailAppData = { const FALLBACK_DATA: PlanDetailAppData = {
patient: mockPatient, patient: mockPatient,
chains: mockChains, chains: mockChains,
...@@ -181,6 +195,8 @@ export function PlanDetailApp({ ...@@ -181,6 +195,8 @@ export function PlanDetailApp({
// 画像抽屉打开时要定位到哪个标签(点身份卡首屏 chip 进来时带上);从「详情 →」进则为 null // 画像抽屉打开时要定位到哪个标签(点身份卡首屏 chip 进来时带上);从「详情 →」进则为 null
const [personaFocusKey, setPersonaFocusKey] = useState<string | null>(null); const [personaFocusKey, setPersonaFocusKey] = useState<string | null>(null);
const [scriptMode, setScriptMode] = useState<ScriptViewMode>('markdown'); const [scriptMode, setScriptMode] = useState<ScriptViewMode>('markdown');
/** 话术渠道 tab:电话(分段稿,固定「原文」渲染)/ 企微(单块,可复制) */
const [scriptChannel, setScriptChannel] = useState<ScriptChannel>('phone');
// 话术生成模型(具体型号);默认 qwen3.7-max(极快 · 简洁) // 话术生成模型(具体型号);默认 qwen3.7-max(极快 · 简洁)
const [scriptModel, setScriptModel] = useState<ScriptModel>('qwen3.7-max'); const [scriptModel, setScriptModel] = useState<ScriptModel>('qwen3.7-max');
// 投入档(默认稳健);跟模型并列,客服在重新生成处选 // 投入档(默认稳健);跟模型并列,客服在重新生成处选
...@@ -704,7 +720,13 @@ export function PlanDetailApp({ ...@@ -704,7 +720,13 @@ export function PlanDetailApp({
</div> </div>
</div> </div>
<div className="flex flex-wrap items-center gap-1.5 sm:gap-2"> <div className="flex flex-wrap items-center gap-1.5 sm:gap-2">
{/* 话术 3 模式切换:伴飞 / 卡片 / 原文 */} {/*
⭐ 这个位置原来是话术 3 视图切换(伴飞 / 卡片 / 原文),
2026-08-04 改成**渠道**切换:电话 / 企微。
三个视图先隐藏(见 SCRIPT_VIEW_SWITCH_VISIBLE),电话固定用「原文」渲染。
⚠️ 视图代码整套保留(ScriptView 的 copilot/cards 分支还在),不是删除。
*/}
{SCRIPT_VIEW_SWITCH_VISIBLE && (
<div className="inline-flex flex-none items-center rounded-md bg-slate-100 p-0.5 text-[11.5px]"> <div className="inline-flex flex-none items-center rounded-md bg-slate-100 p-0.5 text-[11.5px]">
{([ {([
['copilot', '伴飞'], ['copilot', '伴飞'],
...@@ -725,6 +747,27 @@ export function PlanDetailApp({ ...@@ -725,6 +747,27 @@ export function PlanDetailApp({
</button> </button>
))} ))}
</div> </div>
)}
{/* 渠道切换(沿用上面那个分段控件的样式与位置) */}
<div className="inline-flex flex-none items-center rounded-md bg-slate-100 p-0.5 text-[11.5px]">
{([
['phone', '电话'],
['wecom', '企微'],
] as [ScriptChannel, string][]).map(([c, label]) => (
<button
key={c}
onClick={() => setScriptChannel(c)}
className={cn(
'px-2 sm:px-2.5 py-1 rounded transition-colors',
scriptChannel === c
? 'bg-white font-semibold text-slate-900 shadow-sm'
: 'text-slate-500 hover:text-slate-800',
)}
>
{label}
</button>
))}
</div>
<RegenBtn <RegenBtn
streaming={isStreaming} streaming={isStreaming}
model={scriptModel} model={scriptModel}
...@@ -773,7 +816,15 @@ export function PlanDetailApp({ ...@@ -773,7 +816,15 @@ export function PlanDetailApp({
{deepSteps && deepSteps.length > 0 && ( {deepSteps && deepSteps.length > 0 && (
<ScriptDeepProcess steps={deepSteps} /> <ScriptDeepProcess steps={deepSteps} />
)} )}
{!isStreaming && !hasScriptContent ? ( {scriptChannel === 'wecom' ? (
<WecomScriptView
planId={plan.id}
script={data.wecomScript ?? null}
onGate={gateCheck}
onToast={showToast}
onRefresh={() => onRefreshAggregate?.()}
/>
) : !isStreaming && !hasScriptContent ? (
<div className="h-full min-h-[160px] flex flex-col items-center justify-center text-center gap-2 text-slate-400"> <div className="h-full min-h-[160px] flex flex-col items-center justify-center text-center gap-2 text-slate-400">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="w-8 h-8"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="w-8 h-8">
<path d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01" /> <path d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01" />
......
...@@ -229,6 +229,17 @@ export type PlanDetailData = { ...@@ -229,6 +229,17 @@ export type PlanDetailData = {
markdown: string; markdown: string;
}>; }>;
} | null; } | null;
/**
* 企微话术 —— **一整块**,不分段(所以没有 `sections`)。
* ⚠️ 与 `script` 是两条独立生成链、各有各的 status;⛔ 别互相回落
* (电话稿是口语分段的,复制发给患者很怪,而客服不会注意到自己发错了)。
*/
wecomScript?: {
id: string;
status: string; // ready / pending / failed
content: string | null;
updatedAt: string;
} | null;
/// 召回历史(患者级,跨所有 plan 版本,最近 8 条)。 /// 召回历史(患者级,跨所有 plan 版本,最近 8 条)。
/// 用途:plan 再次被召回时,客服看到"上次召回结果 / 为什么被暂缓"(终态 outcome 即原因)。 /// 用途:plan 再次被召回时,客服看到"上次召回结果 / 为什么被暂缓"(终态 outcome 即原因)。
recallHistory?: Array<{ recallHistory?: Array<{
......
'use client';
import { useState } from 'react';
import { Check, Copy, Loader2, RefreshCw } from 'lucide-react';
import { plansApi } from '@/components/plans/plans-api';
import { cn } from '@/lib/utils';
/**
* 企微话术视图 —— **一整块可直接复制发送的消息**。
*
* ⚠️ 与电话稿的三个视图(伴飞/卡片/原文)不是同一套东西:那三个是"同一份分段稿的不同看法";
* 这里根本没有段,它就是一条消息。所以⛔ 不复用 `ScriptView`。
*
* ⚠️ **失败态如实显示**,⛔ 不回落去显示电话稿 —— 电话稿是口语分段的,
* 复制发给患者会很怪,而客服不会注意到自己发错了东西(见服务端 wecom.strategy 的说明)。
*/
export function WecomScriptView({
planId,
script,
onGate,
onToast,
onRefresh,
}: {
planId: string;
/** 只要 content+status 两个字段;⛔ 别收紧成 PlanScript —— 上游是聚合响应的宽松形状 */
script: { content: string | null; status: string } | null;
/** 认领闸:生成要真花 AI 钱 */
onGate: () => boolean;
onToast: (kind: string, title: string, msg: string) => void;
onRefresh: () => void | Promise<void>;
}) {
const [busy, setBusy] = useState(false);
const [copied, setCopied] = useState(false);
const content = script?.status === 'ready' ? (script.content ?? '') : '';
const generate = async () => {
if (busy) return;
if (!onGate()) return;
setBusy(true);
try {
const r = await plansApi.regenerateWecomScript(planId);
if (r.source !== 'agent' || !r.content) {
onToast('rose', '企微话术生成失败', r.failReason?.slice(0, 80) ?? '请重试或手写');
}
await onRefresh();
} catch (err) {
onToast('rose', '生成失败', err instanceof Error ? err.message.slice(0, 80) : String(err));
} finally {
setBusy(false);
}
};
const copy = async () => {
if (!content) return;
try {
await navigator.clipboard.writeText(content);
setCopied(true);
// 2 秒后复原 —— ⛔ 不弹 toast:复制是高频动作,每次弹一条是噪音,
// 按钮本身变成「已复制」已经是足够的反馈。
setTimeout(() => setCopied(false), 2000);
} catch {
onToast('amber', '复制失败', '请手动选中文本复制');
}
};
// ── 尚未生成 / 生成失败 ────────────────────────────────
if (!content) {
const failed = script?.status === 'failed';
return (
<div className="h-full min-h-[160px] flex flex-col items-center justify-center text-center gap-2 text-slate-400">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="w-8 h-8">
<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z" />
</svg>
<p className="text-[13px] text-slate-500">{failed ? '企微话术生成失败' : '尚未生成企微话术'}</p>
<p className="text-[12px] text-slate-400">
{failed ? '可以重试,或这次自己写一条' : '企微稿是一整段、可直接复制发送'}
</p>
<button
type="button"
onClick={generate}
disabled={busy}
className="mt-1 inline-flex items-center gap-1.5 rounded-md bg-brand-600 px-2.5 py-1 text-[11.5px] font-medium text-white transition-colors hover:bg-brand-700 disabled:opacity-60"
>
{busy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <RefreshCw className="h-3.5 w-3.5" />}
{busy ? '生成中…' : failed ? '重试' : '生成'}
</button>
</div>
);
}
// ── 已就绪 ────────────────────────────────────────────
return (
<div className="flex h-full flex-col gap-2">
<div className="flex flex-none items-center justify-end gap-1.5">
<button
type="button"
onClick={generate}
disabled={busy}
title="重新生成"
className="inline-flex items-center gap-1 rounded-md border border-slate-100 bg-white px-2 py-1 text-[11px] text-slate-500 transition-colors hover:border-brand-300 hover:bg-brand-50 hover:text-brand-700 disabled:opacity-60"
>
{busy ? <Loader2 className="h-3 w-3 animate-spin" /> : <RefreshCw className="h-3 w-3" />}
重新生成
</button>
<button
type="button"
onClick={copy}
className={cn(
'inline-flex items-center gap-1 rounded-md px-2.5 py-1 text-[11px] font-medium transition-colors',
copied ? 'bg-emerald-50 text-emerald-700' : 'bg-brand-600 text-white hover:bg-brand-700',
)}
>
{copied ? <Check className="h-3 w-3" /> : <Copy className="h-3 w-3" />}
{copied ? '已复制' : '复制'}
</button>
</div>
{/*
⚠️ 用 `whitespace-pre-wrap` 原样呈现,⛔ 不过 markdown 渲染器:
客服复制的必须是**他看到的那些字**。经 markdown 渲染后,`**加粗**` 之类会变成样式,
复制出来却带着星号发给患者 —— 所见与所复制不一致,而他不会逐字校对。
*/}
<div className="min-h-0 flex-1 overflow-y-auto rounded-md bg-slate-50/70 p-3">
<p className="whitespace-pre-wrap text-[13px] leading-relaxed text-slate-800">{content}</p>
</div>
</div>
);
}
...@@ -121,6 +121,28 @@ export const plansApi = { ...@@ -121,6 +121,28 @@ export const plansApi = {
`/pac/v1/patients/${encodeURIComponent(patientId)}/phone-reveal`, `/pac/v1/patients/${encodeURIComponent(patientId)}/phone-reveal`,
), ),
/**
* 生成 / 重新生成**企微**话术(一整块,可直接复制发送)。
*
* ⚠️ 同步返回,**不走 SSE** —— 企微稿是一整块几百字,几秒出;没有"段"可以逐段推。
* ⚠️ `source==='failed'` 是**正常返回**不是异常:企微不做模板兜底(套话复制发给患者
* 比没有更糟),调用方要据此显示失败态并让客服自己写。
*/
regenerateWecomScript: (planId: string, model?: string) =>
api.post<{
planId: string;
planScriptId: string | null;
agentInvocationId: string;
source: 'agent' | 'failed';
costYuan: number;
content: string;
failReason?: string;
}>(
`/pac/v1/plans/${encodeURIComponent(planId)}/wecom-script:regenerate` +
(model ? `?model=${encodeURIComponent(model)}` : ''),
{},
),
/** W4 末:话术 thumbs up/down 反馈("本段是否好用") /** W4 末:话术 thumbs up/down 反馈("本段是否好用")
* - POST /pac/v1/plans/:id/script-feedback { feedback: 'up'|'down' } * - POST /pac/v1/plans/:id/script-feedback { feedback: 'up'|'down' }
* - 写入 agent_invocations.user_feedback;同 invocation 反复点会覆盖以最新为准 */ * - 写入 agent_invocations.user_feedback;同 invocation 反复点会覆盖以最新为准 */
......
...@@ -40,7 +40,7 @@ ...@@ -40,7 +40,7 @@
### T3 · 不使用无数据支撑的因素 ### T3 · 不使用无数据支撑的因素
客服的**态度、能力**没有数据(全生产仅 7 条执行记录),不进决策。 客服的**态度、能力**没有数据,不进决策。
分配依据只用**客观事实**(专属客服、在岗、负载)与**主管的显式指定** 分配依据只用**客观事实**(专属客服、在岗、负载)与**主管的显式指定**
> 推论:不为客服建立系统性的能力/态度评分 —— 一旦分数可见即成绩效工具,会诱导行为扭曲。 > 推论:不为客服建立系统性的能力/态度评分 —— 一旦分数可见即成绩效工具,会诱导行为扭曲。
......
...@@ -270,7 +270,30 @@ export const PlanDetailResponseSchema = z.object({ ...@@ -270,7 +270,30 @@ export const PlanDetailResponseSchema = z.object({
plan: FollowupPlanSchema, plan: FollowupPlanSchema,
patient: PatientSchema, patient: PatientSchema,
persona: PersonaSchema.nullable(), persona: PersonaSchema.nullable(),
script: PlanScriptSchema.nullable().describe('plan-level 单条话术(可能在生成中)'), script: PlanScriptSchema.nullable().describe('电话话术(分段;可能在生成中)'),
/**
* ⭐ 企微话术(一整块可直接发送的消息)。null = 还没生成过。
*
* ⚠️ 与 `script` 是**两条独立的生成链**,各自有自己的 status ——
* ⛔ 别把它当成 `script` 的另一种渲染:内容、prompt、校验规则都不一样
* (企微多一条"可直接发送"的校验:无小标题、无占位残留、无给客服看的话)。
* ⚠️ 企微**没有模板兜底**:生成失败就是 `status='failed'` + content 为空,
* 前端要如实显示"生成失败",⛔ 不许回落去显示电话稿 —— 那份是口语分段的,
* 复制发给患者会很怪,而且客服不会注意到自己发错了东西。
*/
wecomScript: z
.object({
id: z.string().uuid(),
status: AssetStatusSchema,
/**
* ⭐ **原文**,不拆段。⛔ 别复用 `PlanScriptSchema` —— 那个形状是给电话稿的
* (它带 `sections`,序列化时把正文拆掉),企微稿拆完就什么都不剩了(踩过)。
*/
content: z.string().nullable(),
updatedAt: z.string(),
})
.nullable()
.describe('企微话术(单块原文;独立生成链,无模板兜底)'),
summaries: z.array(PlanSummarySchema).describe('3 种 type 各一条(部分可能在生成中)'), summaries: z.array(PlanSummarySchema).describe('3 种 type 各一条(部分可能在生成中)'),
executions: z.array(PlanExecutionSchema), executions: z.array(PlanExecutionSchema),
evidence: PlanEvidenceSchema, evidence: PlanEvidenceSchema,
......
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