Commit e5a1b5c2 by luoqi

fix: 企微话术沿用顶栏生成入口 + 走深度交互;禁止输出时间占位

1) 不再另造生成按钮
   企微视图里的「生成/重新生成」删掉,统一走顶栏那一个入口(与电话稿同一个)。
   两个"生成"按钮=两条路径两套状态,必然不一致。
   切到企微时隐藏档位下拉(只有深度档,摆一个单选下拉是误导);模型下拉保留。

2) 交互与电话深度档一致(过程可见)
   加 GET :id/wecom-script:stream(SSE),事件形状与 script:stream 对齐 →
   前端 ScriptDeepProcess 时间线 / 停止 / AIStamp 整套复用,不另画一套。
   企微一轮跑 60s 上下,没有过程可见就是一个转圈白屏。
   ️ 只推步骤不推正文增量:企微是一整块,逐字推只是闪。

3)  禁止输出任何时间占位符
   电话稿留【时间段1】是对的(客服边打边填);企微这条消息是整段复制直发的 ——
   占位会原样发到患者微信里,或者客服得先手动编辑一遍,"可直接发送"当场不成立。
   改成不含具体时间的邀约(「您方便的时候回我一下,我帮您安排」)。
   四处一起改才拦得住:format.md / verify system / verify user prompt / repair 铁律,
   外加策略侧硬扫兜底(只靠 prompt 拦不住,实测模型照着电话档习惯写出来了)。
   forbiddenWordsBlock 加 timePlaceholders 参数 —— 不然它那句「占位照旧保留」
   会和企微 format.md 的「一个都不许出现」拼进同一份 system 打架。

踩的坑:
· 企微 done 事件不带 costYuan → 外层 toast 直接 .toFixed() 整页崩(实测)。
  两条流的 done 形状不一样,别假设一致。
· 步骤时间线渲染了两遍(外层已有一份,我在视图里又画了一份)。

实测:切企微 → 顶栏「重新生成」→ 步骤逐个亮 → 正文出,收尾是
「您看最近工作日哪天方便,回我一下帮您安排复查时间」,无任何【时间段】占位。
1020 tests(1 条并发计时测试在全量负载下抖动,单独跑通过,与本次改动无关),
两个 tsc + next build 干净。
parent fa96a613
...@@ -53,8 +53,18 @@ export function machineSafetyScan(text: string): string[] { ...@@ -53,8 +53,18 @@ export function machineSafetyScan(text: string): string[] {
return problems; return problems;
} }
/** prompt 用的禁词块(system 注入;与机器闸同源,避免漂移) */ /**
export function forbiddenWordsBlock(): string { * prompt 用的禁词块(system 注入;与机器闸同源,避免漂移)。
*
* @param opts.timePlaceholders 是否保留【时间段】占位。
* ⭐ 电话档 `true`(客服边打边填);⛔ **企微档必须 `false`** ——
* 企微那条消息是整段复制直接发出去的,占位符会原样发给患者。
* ⚠️ 这个参数存在的唯一原因是:本块与企微的 format.md 会拼进**同一份 system**,
* 不参数化就等于给模型两条互相矛盾的指令(一边说"照旧保留"、一边说"一个都不许出现"),
* 而它照哪条做全看运气。
*/
export function forbiddenWordsBlock(opts: { timePlaceholders?: boolean } = {}): string {
const keepPlaceholders = opts.timePlaceholders ?? true;
return [ return [
'# 禁词(整篇严禁出现)', '# 禁词(整篇严禁出现)',
FORBIDDEN_PHRASES.join(' / '), FORBIDDEN_PHRASES.join(' / '),
...@@ -62,7 +72,10 @@ export function forbiddenWordsBlock(): string { ...@@ -62,7 +72,10 @@ export function forbiddenWordsBlock(): string {
'', '',
'# 说人话(患者听得懂)', '# 说人话(患者听得懂)',
'严禁把内部代码 / 专业术语原样念给患者:**不出现诊断代码(如 K08、K05.1)、英文或全大写下划线枚举(如 IMPLANT_RECOMMENDED)**。', '严禁把内部代码 / 专业术语原样念给患者:**不出现诊断代码(如 K08、K05.1)、英文或全大写下划线枚举(如 IMPLANT_RECOMMENDED)**。',
'一律翻成大白话:"K08" → "缺了一颗小磨牙";牙位/诊断说成患者能懂的位置和说法。占位标签【时间段】等照旧保留(那是给客服填的)。', '一律翻成大白话:"K08" → "缺了一颗小磨牙";牙位/诊断说成患者能懂的位置和说法。' +
(keepPlaceholders
? '占位标签【时间段】等照旧保留(那是给客服填的)。'
: '⛔ 本篇**不许出现任何 `【】` 占位标签**(除自报家门里的【回访客服】),尤其是时间占位。'),
].join('\n'); ].join('\n');
} }
......
...@@ -44,7 +44,7 @@ export interface ComposedSystem { ...@@ -44,7 +44,7 @@ export interface ComposedSystem {
*/ */
let cachedCommon: string | null = null; let cachedCommon: string | null = null;
const cachedFormat = new Map<string, string>(); const cachedFormat = new Map<string, string>();
function loadBase(tier: ScriptTier, formatPath?: string): string { function loadBase(tier: ScriptTier, formatPath?: string, timePlaceholders = true): string {
if (cachedCommon === null) { if (cachedCommon === null) {
cachedCommon = readFileSync(resolveBaseCommonPath(), 'utf-8').trim(); cachedCommon = readFileSync(resolveBaseCommonPath(), 'utf-8').trim();
} }
...@@ -55,7 +55,7 @@ function loadBase(tier: ScriptTier, formatPath?: string): string { ...@@ -55,7 +55,7 @@ function loadBase(tier: ScriptTier, formatPath?: string): string {
cachedFormat.set(path, format); cachedFormat.set(path, format);
} }
// 顺序:共性定位/铁律 → 本档输出格式 → 禁词(单一源) // 顺序:共性定位/铁律 → 本档输出格式 → 禁词(单一源)
return `${cachedCommon}\n\n${format}\n\n${forbiddenWordsBlock()}`; return `${cachedCommon}\n\n${format}\n\n${forbiddenWordsBlock({ timePlaceholders })}`;
} }
/** /**
...@@ -127,6 +127,12 @@ export function composeSystem( ...@@ -127,6 +127,12 @@ export function composeSystem(
allSkills: readonly Skill[], allSkills: readonly Skill[],
tier: ScriptTier = 'stable', tier: ScriptTier = 'stable',
formatPath?: string, formatPath?: string,
/**
* 是否保留【时间段】占位。电话档 true;⛔ **企微必须 false** ——
* 不然禁词块里那句「占位标签照旧保留」会跟企微 format.md 的「一个都不许出现」
* 拼进同一份 system 打架,模型照哪条做全看运气。
*/
timePlaceholders = true,
): ComposedSystem { ): ComposedSystem {
const context = deriveContext(input); const context = deriveContext(input);
const matched = allSkills const matched = allSkills
...@@ -136,7 +142,7 @@ export function composeSystem( ...@@ -136,7 +142,7 @@ export function composeSystem(
(a.frontmatter.priority ?? 50) - (b.frontmatter.priority ?? 50), (a.frontmatter.priority ?? 50) - (b.frontmatter.priority ?? 50),
); );
const base = loadBase(tier, formatPath); const base = loadBase(tier, formatPath, timePlaceholders);
// 只拼 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');
......
...@@ -71,14 +71,18 @@ const VERIFY_SYSTEM = [ ...@@ -71,14 +71,18 @@ const VERIFY_SYSTEM = [
'你是严格的医疗话术审核员,任务是**对抗式校验**一条准备**发到患者微信**的消息,默认怀疑、宁严勿松。', '你是严格的医疗话术审核员,任务是**对抗式校验**一条准备**发到患者微信**的消息,默认怀疑、宁严勿松。',
'依据给定的"本次回访患者信息"逐项判断:', '依据给定的"本次回访患者信息"逐项判断:',
'① 接地:每个具体说法(诊断/检查所见/医嘱/时间/牙位/医生)能否在给定事实里找到依据?找不到=编造。', '① 接地:每个具体说法(诊断/检查所见/医嘱/时间/牙位/医生)能否在给定事实里找到依据?找不到=编造。',
'② 安全:有无报价/费用、疗效承诺、写死具体时间(应保留【时间段】)、患者≤18 却提拍片?', '② 安全:有无报价/费用、疗效承诺、患者≤18 却提拍片?',
' ⛔ **出现任何时间占位符(【时间段1】【时间段2】【具体预约时间】)= 不合格** ——',
' 企微是整段复制直接发出去的,占位符会原样发给患者;而客服要先手动改一遍的话,',
' "可直接发送"就不成立了。⛔ 自己编具体时间("周三上午")同样不合格。',
' 正确写法是不含具体时间的邀约(「您方便的时候回我一下,我帮您安排」)。',
'③ 逻辑与分寸:围绕"让患者明白该回来处理"展开、层层递进、该说的没缺(点了问题要说后果、说了后果要给出路);', '③ 逻辑与分寸:围绕"让患者明白该回来处理"展开、层层递进、该说的没缺(点了问题要说后果、说了后果要给出路);',
' 有没有**吓唬/制造恐慌**或**推销/促单/施压**口吻?', ' 有没有**吓唬/制造恐慌**或**推销/促单/施压**口吻?',
'④ 患者听得懂:有没有诊断代码(如 K08)、英文/内部枚举、生硬术语,或含糊其辞(没说清哪颗牙)?', '④ 患者听得懂:有没有诊断代码(如 K08)、英文/内部枚举、生硬术语,或含糊其辞(没说清哪颗牙)?',
'⑤ ⭐**可直接发送**(企微专有):客服会整段复制发出去 ——', '⑤ ⭐**可直接发送**(企微专有):客服会整段复制发出去 ——',
' 有小标题 / `##` / 分段编号 / "第一第二" = 不合格;', ' 有小标题 / `##` / 分段编号 / "第一第二" = 不合格;',
' 混进给客服自己看的话("以下话术供参考""建议这样说") = 不合格;', ' 混进给客服自己看的话("以下话术供参考""建议这样说") = 不合格;',
' 除 【时间段1/2】【具体预约时间】【回访客服】 外还残留别的占位符或内部标签 = 不合格;', ' ⛔ 除自报家门里的 【回访客服】 外**残留任何 `【】` 占位或内部标签 = 不合格**;',
' 出现"您现在方便吗""能听清吗"这类**需要对方当场回话**才成立的电话句式 = 不合格。', ' 出现"您现在方便吗""能听清吗"这类**需要对方当场回话**才成立的电话句式 = 不合格。',
'①②③④⑤ 任一不过 → pass=false,并逐条列出 issue(位置、问题、修法);全部通过 → pass=true、issues 空。', '①②③④⑤ 任一不过 → pass=false,并逐条列出 issue(位置、问题、修法);全部通过 → pass=true、issues 空。',
'另外给一组 **quality 质量评分**(1-5)—— **只评"好不好",跟 pass 无关**。', '另外给一组 **quality 质量评分**(1-5)—— **只评"好不好",跟 pass 无关**。',
...@@ -120,6 +124,7 @@ export class WecomWriteCall implements AiCall<WecomWriteInput, WecomWriteZ> { ...@@ -120,6 +124,7 @@ export class WecomWriteCall implements AiCall<WecomWriteInput, WecomWriteZ> {
this.skillRegistry.getAllSkills(), this.skillRegistry.getAllSkills(),
'deep', 'deep',
wecomFormatPath(), wecomFormatPath(),
false, // ⛔ 企微不留【时间段】占位(整段复制直发,占位会原样发给患者)
); );
return { system: composed.systemPrompt, prompt: buildWecomWritePrompt(input) }; return { system: composed.systemPrompt, prompt: buildWecomWritePrompt(input) };
} }
......
...@@ -60,7 +60,9 @@ ${issues} ...@@ -60,7 +60,9 @@ ${issues}
## 修订铁律 ## 修订铁律
- 上面每一条都必须改到位,**一条都不能漏**;改法以"必须改成"为准。 - 上面每一条都必须改到位,**一条都不能漏**;改法以"必须改成"为准。
- **只动被点名的地方**,其余句子保持原样,不要顺手重写或新增事实。 - **只动被点名的地方**,其余句子保持原样,不要顺手重写或新增事实。
- 修正不得引入新的违规:不报价/不承诺疗效/不写死具体时间(用【时间段】占位)/≤18 岁不提拍片。 - 修正不得引入新的违规:不报价/不承诺疗效/≤18 岁不提拍片。
- ⛔ **不许出现任何时间占位符**(【时间段1】等),也不要自己编具体时间 ——
这条消息是整段复制直发的,占位会原样发给患者。约时间用不含具体时间的邀约。
- 仍然是**一整块可直接发送的消息**,不许出现小标题或分段编号。`; - 仍然是**一整块可直接发送的消息**,不许出现小标题或分段编号。`;
} }
...@@ -86,14 +88,16 @@ export function buildWecomVerifyPrompt(input: { ctx: ScriptContext; draft: Wecom ...@@ -86,14 +88,16 @@ export function buildWecomVerifyPrompt(input: { ctx: ScriptContext; draft: Wecom
# 你的任务(本步:对抗校验,不改写) # 你的任务(本步:对抗校验,不改写)
逐句核对下面这条**准备发给患者微信**的消息: 逐句核对下面这条**准备发给患者微信**的消息:
1. **接地**:每个说法能否追到上面"本次回访患者信息"里的事实?追不到 = 编造 → 记 issue。 1. **接地**:每个说法能否追到上面"本次回访患者信息"里的事实?追不到 = 编造 → 记 issue。
2. **安全**:有无报价/费用、疗效承诺、写死具体时间(应保留【时间段】)、≤18 提拍片?有 = 越界 → 记 issue。 2. **安全**:有无报价/费用、疗效承诺、≤18 提拍片?有 = 越界 → 记 issue。
⛔ 出现**任何时间占位符**(【时间段1】等)或自己编的具体时间("周三上午")= 记 issue ——
这条消息整段复制直发,占位会原样发到患者微信里。
3. **逻辑与分寸**:围绕"让患者明白该回来处理"展开、层层递进、该说的没缺(点了问题要说后果、说了后果要给出路); 3. **逻辑与分寸**:围绕"让患者明白该回来处理"展开、层层递进、该说的没缺(点了问题要说后果、说了后果要给出路);
有没有**吓唬/制造恐慌**或**推销/促单/施压**口吻?任一不到位 → 记 issue。 有没有**吓唬/制造恐慌**或**推销/促单/施压**口吻?任一不到位 → 记 issue。
4. **患者听得懂**:有无诊断代码(如 K08)、英文/内部枚举、生硬术语,或含糊其辞(没说清哪颗牙)?有 = 记 issue。 4. **患者听得懂**:有无诊断代码(如 K08)、英文/内部枚举、生硬术语,或含糊其辞(没说清哪颗牙)?有 = 记 issue。
5. ⭐ **可直接发送**(企微专有,电话档没有这一条): 5. ⭐ **可直接发送**(企微专有,电话档没有这一条):
- 有没有**小标题 / 分段编号 / "第一第二" / \`##\`**?企微是一条消息,出现这些 = 记 issue; - 有没有**小标题 / 分段编号 / "第一第二" / \`##\`**?企微是一条消息,出现这些 = 记 issue;
- 有没有**给客服自己看的话**混进正文(如"以下话术供参考""建议这样说")?= 记 issue; - 有没有**给客服自己看的话**混进正文(如"以下话术供参考""建议这样说")?= 记 issue;
- 除 \`【时间段1/2】【具体预约时间】【回访客服】\` 外,有没有**残留的其它占位符或内部标签**?= 记 issue。 - ⛔ 除自报家门的 \`【回访客服】\` 外,**残留任何 \`【】\` 占位或内部标签** = 记 issue。
①②③④⑤ 全部通过 → pass=true、issues 空;任一不过 → pass=false 并逐条列出(位置、问题、修法)。 ①②③④⑤ 全部通过 → pass=true、issues 空;任一不过 → pass=false 并逐条列出(位置、问题、修法)。
## 待校验消息 ## 待校验消息
......
...@@ -12,11 +12,22 @@ ...@@ -12,11 +12,22 @@
- **书面但不端着**:像医生助理认真打的一段字——比电话口语克制,比公文自然。不用「兹」「特此」,也不用「哈喽~」。 - **书面但不端着**:像医生助理认真打的一段字——比电话口语克制,比公文自然。不用「兹」「特此」,也不用「哈喽~」。
- **开头直接称呼 + 自报家门**,不用寒暄铺垫;**结尾留一个明确的下一步**,别用「随时联系我」这种空钩子。 - **开头直接称呼 + 自报家门**,不用寒暄铺垫;**结尾留一个明确的下一步**,别用「随时联系我」这种空钩子。
# 这三处按原样写,不要改 # 自报家门按原样写,不要改
1. 自报家门:用给定的「自报家门」整串,其中 `【回访客服】` 原样保留(系统按登录人回填成「助理X」)——别替换成具体姓名,也别自己编一个;身份是**医生的助理**,不要改写成「客服/顾问」。 用给定的「自报家门」整串,其中 `【回访客服】` 原样保留(系统按登录人回填成「助理X」)——别替换成具体姓名,也别自己编一个;身份是**医生的助理**,不要改写成「客服/顾问」。
2. 时间一律占位:`【时间段1】【时间段2】【具体预约时间】` 原样保留,严禁替换成「周三上午」等具体时间、严禁加粗、严禁「已为您约好」式承诺。
3. 引导预约:企微里**不要用电话那种「您看哪个方便?」的口头二选一**——那是要对方当场答的。改成把两个时间段摆出来请他回复,例如「X医生【时间段1】和【时间段2】都有空,您回我一下方便的时间就行」。「X医生」用给定的诊断医生姓替换。 # ⛔⛔ 不许出现任何时间占位符
**不要写 `【时间段1】【时间段2】【具体预约时间】`,一个都不许出现。**
⛔ 也不要自己编具体时间(「周三上午」「本周末」),更不要「已为您约好」式承诺。
**为什么**:电话里客服是边说边填时间的;企微这条消息他是**整段复制直接发出去**的——
留个 `【时间段1】` 在里面,他要么忘了改直接发给患者(患者收到一句带方括号的乱码),
要么得先手动编辑一遍,而"可直接复制发送"当场就不成立了。
**那约时间怎么办**:把决定权交回给患者,用**不含任何具体时间**的邀约收尾,例如
「您方便的时候回我一下,我帮您安排李医生的号」「您看这周哪天方便,我这边给您留时间」。
⚠️ 说不清楚的就别说 —— 宁可只说「回我一下我帮您约」,也不要摆一个你并不知道的时间。
# 写的内容 # 写的内容
......
...@@ -43,7 +43,51 @@ export class WecomScriptStrategy { ...@@ -43,7 +43,51 @@ export class WecomScriptStrategy {
private readonly verifyCall: WecomVerifyCall, private readonly verifyCall: WecomVerifyCall,
) {} ) {}
async run(ctx: ScriptContext, runCtx: AiCallContext): Promise<WecomScriptResult> { /**
* 流式版:边跑边 yield 步骤事件,最后 yield 结果。
*
* ⭐ 事件形状**刻意与电话深度档对齐**(`{kind:'step', step, status, detail}`)——
* 前端那套「深度过程可见」的时间线组件(ScriptDeepProcess)因此原样复用,
* ⛔ 不必为企微再画一套。企微一轮要跑 60 秒上下,没有过程可见就是一个转圈的白屏。
* ⚠️ 这里**不推正文增量**:企微稿是一整块,逐字推过去除了闪没有别的意义
* (电话档推的是"段",那是有结构的)。
*/
async *runStream(
ctx: ScriptContext,
runCtx: AiCallContext,
): AsyncGenerator<
| { kind: 'step'; step: 'plan' | 'write' | 'verify' | 'repair'; status: 'running' | 'done'; detail?: { pass?: boolean; issuesCount?: number } }
| { kind: 'result'; result: WecomScriptResult }
> {
const emit: Array<{ step: 'plan' | 'write' | 'verify' | 'repair'; status: 'running' | 'done'; detail?: { pass?: boolean; issuesCount?: number } }> = [];
// 复用 run() 的全部逻辑,只是把它的步骤回调接出来 —— ⛔ 别把 run() 抄一遍改成生成器,
// 那样两条路径的兜底/闸门会各活各的,而它们必须一致。
const collect = (e: (typeof emit)[number]) => emit.push(e);
const runner = this.run(ctx, runCtx, collect);
// 边跑边把已产生的事件吐出去(轮询本地队列;步骤粒度秒级,这个精度足够)
let sent = 0;
let done = false;
const p = runner.finally(() => {
done = true;
});
while (!done) {
while (sent < emit.length) yield { kind: 'step', ...emit[sent++]! };
await new Promise((r) => setTimeout(r, 120));
}
while (sent < emit.length) yield { kind: 'step', ...emit[sent++]! };
yield { kind: 'result', result: await p };
}
async run(
ctx: ScriptContext,
runCtx: AiCallContext,
onStep?: (e: { step: 'plan' | 'write' | 'verify' | 'repair'; status: 'running' | 'done'; detail?: { pass?: boolean; issuesCount?: number } }) => void,
): Promise<WecomScriptResult> {
const step = (
s: 'plan' | 'write' | 'verify' | 'repair',
status: 'running' | 'done',
detail?: { pass?: boolean; issuesCount?: number },
) => onStep?.({ step: s, status, detail });
const steps: string[] = []; const steps: string[] = [];
let cost = 0; let cost = 0;
let promptTokens = 0; let promptTokens = 0;
...@@ -70,21 +114,25 @@ export class WecomScriptStrategy { ...@@ -70,21 +114,25 @@ export class WecomScriptStrategy {
// ── 步骤1:要点规划(best-effort —— 失败不阻断,让 write 直接按事实自己排)── // ── 步骤1:要点规划(best-effort —— 失败不阻断,让 write 直接按事实自己排)──
ensureLive(); ensureLive();
step('plan', 'running');
let plan: WecomPlanZ | undefined; let plan: WecomPlanZ | undefined;
try { try {
const r = await this.runner.run(this.planCall, ctx, runCtx); const r = await this.runner.run(this.planCall, ctx, runCtx);
acc(r); acc(r);
plan = r.output; plan = r.output;
steps.push('plan'); steps.push('plan');
step('plan', 'done');
} catch (err) { } catch (err) {
if (runCtx.signal?.aborted) throw err; if (runCtx.signal?.aborted) throw err;
this.logger.warn(`wecom plan 失败,跳过规划直接写: ${(err as Error).message}`); this.logger.warn(`wecom plan 失败,跳过规划直接写: ${(err as Error).message}`);
plan = { points: [] }; plan = { points: [] };
steps.push('plan:skip'); steps.push('plan:skip');
step('plan', 'done');
} }
// ── 步骤2:写(单块)── // ── 步骤2:写(单块)──
ensureLive(); ensureLive();
step('write', 'running');
let w; let w;
try { try {
w = await this.runner.run(this.writeCall, { ctx, plan }, runCtx); w = await this.runner.run(this.writeCall, { ctx, plan }, runCtx);
...@@ -96,25 +144,30 @@ export class WecomScriptStrategy { ...@@ -96,25 +144,30 @@ export class WecomScriptStrategy {
let draft = w.output; let draft = w.output;
let invocationId = w.invocationId; let invocationId = w.invocationId;
steps.push('write'); steps.push('write');
step('write', 'done');
// ── 步骤3:对抗校验 + 机器扫 ── // ── 步骤3:对抗校验 + 机器扫 ──
const issues: WecomVerifyZ['issues'] = machineScanIssues(draft); const issues: WecomVerifyZ['issues'] = machineScanIssues(draft);
ensureLive(); ensureLive();
step('verify', 'running');
try { try {
const v = await this.runner.run(this.verifyCall, { ctx, draft }, runCtx); const v = await this.runner.run(this.verifyCall, { ctx, draft }, runCtx);
acc(v); acc(v);
steps.push('verify'); steps.push('verify');
if (!v.output.pass) issues.push(...v.output.issues); if (!v.output.pass) issues.push(...v.output.issues);
step('verify', 'done', { pass: v.output.pass, issuesCount: issues.length });
} catch (err) { } catch (err) {
if (runCtx.signal?.aborted) throw err; if (runCtx.signal?.aborted) throw err;
this.logger.warn(`wecom verify 失败,仅依据机器扫: ${(err as Error).message}`); this.logger.warn(`wecom verify 失败,仅依据机器扫: ${(err as Error).message}`);
steps.push('verify:skip'); steps.push('verify:skip');
step('verify', 'done', { issuesCount: issues.length });
} }
// ── repair(≤1 轮)── // ── repair(≤1 轮)──
if (issues.length > 0) { if (issues.length > 0) {
ensureLive(); ensureLive();
this.logger.debug(`wecom repair: ${issues.length} 个 issue`); this.logger.debug(`wecom repair: ${issues.length} 个 issue`);
step('repair', 'running', { issuesCount: issues.length });
try { try {
const w2 = await this.runner.run( const w2 = await this.runner.run(
this.writeCall, this.writeCall,
...@@ -125,14 +178,17 @@ export class WecomScriptStrategy { ...@@ -125,14 +178,17 @@ export class WecomScriptStrategy {
draft = w2.output; draft = w2.output;
invocationId = w2.invocationId; invocationId = w2.invocationId;
steps.push('repair'); steps.push('repair');
step('repair', 'done');
} catch (err) { } catch (err) {
if (runCtx.signal?.aborted) throw err; if (runCtx.signal?.aborted) throw err;
return fail(`修订失败: ${(err as Error).message}`, invocationId); return fail(`修订失败: ${(err as Error).message}`, invocationId);
} }
// 终检:机器闸仍不过 → ⛔ **不放行**(对抗哲学:接地/安全宁可没有也不发错的) // 终检:机器闸仍不过 → ⛔ **不放行**(对抗哲学:接地/安全宁可没有也不发错的)。
const stillBad = machineSafetyScan(draft.markdown); // ⚠️ 必须复用 machineScanIssues(而不是只调 machineSafetyScan)——
// 占位符残留那条闸只在前者里,漏掉的话修订后仍带【时间段】会被直接放行。
const stillBad = machineScanIssues(draft);
if (stillBad.length > 0) { if (stillBad.length > 0) {
return fail(`修订后仍不过机器安全闸: ${stillBad.join(';')}`, invocationId); return fail(`修订后仍不过机器安全闸: ${stillBad.map((i) => i.problem).join(';')}`, invocationId);
} }
} }
...@@ -155,9 +211,29 @@ export class WecomScriptStrategy { ...@@ -155,9 +211,29 @@ export class WecomScriptStrategy {
* 而且它是"单一源"——两处各写一份的话,改了其中一处另一处就悄悄留在旧规则上。 * 而且它是"单一源"——两处各写一份的话,改了其中一处另一处就悄悄留在旧规则上。
*/ */
function machineScanIssues(draft: WecomWriteZ): WecomVerifyZ['issues'] { function machineScanIssues(draft: WecomWriteZ): WecomVerifyZ['issues'] {
return machineSafetyScan(draft.markdown).map((problem) => ({ const issues: WecomVerifyZ['issues'] = machineSafetyScan(draft.markdown).map((problem) => ({
section: '整体', section: '整体',
problem, problem,
fix: '按机器安全闸要求改掉该处(不报价 / 不承诺疗效 / 时间用【时间段】占位 / ≤18 不提拍片)', fix: '按机器安全闸要求改掉该处(不报价 / 不承诺疗效 / ≤18 不提拍片)',
})); }));
/**
* ⭐ 企微专有硬闸:**除【回访客服】外不许残留任何 `【】` 占位**。
*
* 电话稿留【时间段1】是对的(客服边打边填);企微这条消息是**整段复制直接发出去**的,
* 占位符会原样发到患者微信里 —— 要么客服忘了改直接发出去,要么他得先手动编辑一遍,
* 而"可直接复制发送"当场就不成立了。
* ⚠️ 只靠 prompt 拦不住(实测模型照着电话档的习惯写出来了),所以这里硬扫兜底。
*/
const leftovers = [...draft.markdown.matchAll(/【([^]*)】/g)]
.map((m) => m[0])
.filter((tag) => tag !== '【回访客服】');
if (leftovers.length) {
issues.push({
section: '整体',
problem: `残留占位符 ${[...new Set(leftovers)].join('、')} —— 企微是整段复制直发,这会原样发给患者`,
fix: '删掉占位符,改成不含具体时间的邀约,如「您方便的时候回我一下,我帮您安排」',
});
}
return issues;
} }
...@@ -36,18 +36,67 @@ export class WecomScriptOrchestrator { ...@@ -36,18 +36,67 @@ export class WecomScriptOrchestrator {
private readonly strategy: WecomScriptStrategy, private readonly strategy: WecomScriptStrategy,
) {} ) {}
/**
* 流式生成 —— yield 步骤事件,最后落库并 yield done。
*
* ⭐ 事件形状与电话深度档一致(`{type:'step'|'done'}`),前端整套复用:
* ScriptDeepProcess 时间线、停止按钮、AIStamp。⛔ 不为企微另造一套交互。
*/
async *generateStream(
planId: string,
options: { modelIdOverride?: string; signal?: AbortSignal } = {},
): AsyncGenerator<
| { type: 'step'; step: string; status: 'running' | 'done'; detail?: unknown }
| { type: 'done'; content: string; source: 'agent' | 'failed'; invocationId: string; failReason?: string }
> {
const { ctx, plan } = await this.load(planId);
const runCtx = this.runCtx(plan, options);
let result: WecomScriptGenerateResult | null = null;
for await (const ev of this.strategy.runStream(ctx, runCtx)) {
if (ev.kind === 'step') {
yield { type: 'step', step: ev.step, status: ev.status, detail: ev.detail };
} else {
result = await this.persist(plan, ev.result);
}
}
yield {
type: 'done',
content: result?.content ?? '',
source: result?.source ?? 'failed',
invocationId: result?.agentInvocationId ?? '',
...(result?.failReason ? { failReason: result.failReason } : {}),
};
}
async generate( async generate(
planId: string, planId: string,
options: { modelIdOverride?: string; signal?: AbortSignal } = {}, options: { modelIdOverride?: string; signal?: AbortSignal } = {},
): Promise<WecomScriptGenerateResult> { ): Promise<WecomScriptGenerateResult> {
// ⭐ 复用电话档的上下文装配(见类注释) const { ctx, plan } = await this.load(planId);
const r = await this.strategy.run(ctx, this.runCtx(plan, options));
return this.persist(plan, r);
}
/**
* ⭐ 上下文装配整段复用电话档的 `buildScriptInputForPlan`(见类注释)。
* ⛔ 别在这里另写一遍取数:两条链路对"患者事实"各有一套理解就一定会漂
* (一边加了字段另一边没加,表现是企微稿比电话稿少提一颗牙,还不报错)。
*/
private async load(planId: string) {
const ctx = await this.planScripts.buildScriptInputForPlan(planId); const ctx = await this.planScripts.buildScriptInputForPlan(planId);
const plan = await this.prisma.followupPlan.findUniqueOrThrow({ const plan = await this.prisma.followupPlan.findUniqueOrThrow({
where: { id: planId }, where: { id: planId },
select: { id: true, hostId: true, tenantId: true, patientId: true }, select: { id: true, hostId: true, tenantId: true, patientId: true },
}); });
return { ctx, plan };
}
const r = await this.strategy.run(ctx, { private runCtx(
plan: { id: string; hostId: string; tenantId: string; patientId: string },
options: { modelIdOverride?: string; signal?: AbortSignal },
) {
return {
hostId: plan.hostId, hostId: plan.hostId,
tenantId: plan.tenantId, tenantId: plan.tenantId,
linkedPatientId: plan.patientId, linkedPatientId: plan.patientId,
...@@ -56,10 +105,19 @@ export class WecomScriptOrchestrator { ...@@ -56,10 +105,19 @@ export class WecomScriptOrchestrator {
workflowRunId: randomUUID(), workflowRunId: randomUUID(),
bustCache: true, bustCache: true,
modelIdOverride: options.modelIdOverride, modelIdOverride: options.modelIdOverride,
evalMode: 'production', evalMode: 'production' as const,
signal: options.signal, signal: options.signal,
}); };
}
/**
* 落库 —— ⚠️ 失败**不写 ready**:企微稿是要原样发给患者的,
* 落一份套话让他复制发出去比没有更糟。失败就写 failed,前端显示"生成失败,请手写"。
*/
private async persist(
plan: { id: string; hostId: string; tenantId: string },
r: { markdown: string; source: 'agent' | 'failed'; invocationId: string; costYuan: number; stepsRun: string[]; failReason?: string },
): Promise<WecomScriptGenerateResult> {
const ok = r.source === 'agent' && r.markdown.trim().length > 0; const ok = r.source === 'agent' && r.markdown.trim().length > 0;
const row = await this.prisma.planScript.upsert({ const row = await this.prisma.planScript.upsert({
where: { planId_channel: { planId: plan.id, channel: 'wecom' } }, where: { planId_channel: { planId: plan.id, channel: 'wecom' } },
...@@ -81,11 +139,11 @@ export class WecomScriptOrchestrator { ...@@ -81,11 +139,11 @@ export class WecomScriptOrchestrator {
}, },
select: { id: true }, select: { id: true },
}); });
if (!ok) { if (!ok) {
this.logger.warn(`企微话术生成失败 plan=${planId}: ${r.failReason ?? '未知'};步骤=${r.stepsRun.join('→')}`); this.logger.warn(
`企微话术生成失败 plan=${plan.id}: ${r.failReason ?? '未知'};步骤=${r.stepsRun.join('→')}`,
);
} }
return { return {
planId: plan.id, planId: plan.id,
planScriptId: row.id, planScriptId: row.id,
......
...@@ -148,6 +148,37 @@ export class PlansAggregateController { ...@@ -148,6 +148,37 @@ export class PlansAggregateController {
* ⚠️ 失败**不兜底**:返回 source='failed' + failReason,前端如实显示。 * ⚠️ 失败**不兜底**:返回 source='failed' + failReason,前端如实显示。
* ⛔ 别回落成电话稿(口语分段的东西复制发给患者很怪,而客服不会注意到)。 * ⛔ 别回落成电话稿(口语分段的东西复制发给患者很怪,而客服不会注意到)。
*/ */
/**
* 流式生成企微话术(SSE)—— ⭐ 事件形状与电话档 `script:stream` 一致,
* 前端那套「深度过程可见」的时间线 / 停止 / AIStamp 因此原样复用。
* ⚠️ 只推**步骤**不推正文增量:企微稿是一整块,逐字推过去只是闪。
*/
@Get(':id/wecom-script:stream')
@RequirePermission(Permission.PLAN_VIEW_OWN)
@ApiOperation({ summary: '流式生成企微话术(SSE,只推步骤)' })
async streamWecomScript(
@TenantScope() scope: TenantScopeContext,
@CurrentUser() user: AuthenticatedUser,
@Param('id') planId: string,
@Query('model') model: string | undefined,
@Res() res: Response,
): Promise<void> {
// 认领闸 —— 在开 SSE 之前抛,让错误走正常 JSON envelope 而不是流里的 error 事件
await this.assertClaimed(scope, planId, user.sub);
const agent = resolveScriptAgent(user);
// eslint-disable-next-line @typescript-eslint/no-this-alias
const self = this;
await this.pipeSse(res, async function* (signal) {
for await (const ev of self.wecomScript.generateStream(planId, {
modelIdOverride: model,
signal,
})) {
// 落库存的是【回访客服】占位,推给前端前按当前登录人回填
yield ev.type === 'done' ? { ...ev, content: renderAgentIdentity(ev.content, agent) } : ev;
}
});
}
@Post(':id/wecom-script:regenerate') @Post(':id/wecom-script:regenerate')
@RequirePermission(Permission.PLAN_VIEW_OWN) @RequirePermission(Permission.PLAN_VIEW_OWN)
@ApiOperation({ summary: '生成 / 重新生成企微话术(一次性单块)' }) @ApiOperation({ summary: '生成 / 重新生成企微话术(一次性单块)' })
......
...@@ -268,10 +268,22 @@ export function PlanDetailApp({ ...@@ -268,10 +268,22 @@ export function PlanDetailApp({
// 流式 done / error 时弹 toast // 流式 done / error 时弹 toast
useEffect(() => { useEffect(() => {
if (streamState.status === 'done') { if (streamState.status === 'done') {
const cost = streamState.costYuan.toFixed(4); // 🔴 `costYuan` 可能缺省 —— 企微的 done 事件不带成本/token 字段(它只推步骤 + 正文),
// 直接 `.toFixed()` 会在生成完成那一刻整页崩(实测)。⛔ 别假设两条流的 done 形状一样。
const cost = streamState.costYuan?.toFixed(4);
const label = const label =
streamState.source === 'template_fallback' ? '模板兜底' : `AI 生成 · ¥${cost}`; streamState.source === 'failed'
showToast('emerald', '话术已重新生成', label); ? '生成失败,请重试或自己写一条'
: streamState.source === 'template_fallback'
? '模板兜底'
: cost
? `AI 生成 · ¥${cost}`
: 'AI 生成';
showToast(
streamState.source === 'failed' ? 'rose' : 'emerald',
streamState.source === 'failed' ? '生成失败' : '话术已重新生成',
label,
);
} else if (streamState.status === 'error') { } else if (streamState.status === 'error') {
showToast('rose', '生成失败', streamState.message.slice(0, 80)); showToast('rose', '生成失败', streamState.message.slice(0, 80));
} }
...@@ -772,6 +784,8 @@ export function PlanDetailApp({ ...@@ -772,6 +784,8 @@ export function PlanDetailApp({
streaming={isStreaming} streaming={isStreaming}
model={scriptModel} model={scriptModel}
tier={scriptTier} tier={scriptTier}
// 企微只有深度档 → 藏掉档位下拉(摆一个只有一个选项的下拉是误导)
hideTier={scriptChannel === 'wecom'}
onSelectModel={setScriptModel} onSelectModel={setScriptModel}
onSelectTier={setScriptTier} onSelectTier={setScriptTier}
onStop={() => { onStop={() => {
...@@ -779,9 +793,16 @@ export function PlanDetailApp({ ...@@ -779,9 +793,16 @@ export function PlanDetailApp({
showToast('slate', '已停止', '本次 AI 生成被中断'); showToast('slate', '已停止', '本次 AI 生成被中断');
}} }}
// 认领闸:重生成要真花 AI 钱,不该让没认领的人点(服务端同样会拒) // 认领闸:重生成要真花 AI 钱,不该让没认领的人点(服务端同样会拒)
// ⭐ 企微**沿用这一个入口**,⛔ 视图里不另造生成按钮 ——
// 两个"生成"按钮意味着两条路径、两套状态,而它们必然会不一致。
onRegen={() => { onRegen={() => {
if (!gateCheck()) return; if (!gateCheck()) return;
void regenerate(plan.id, { model: scriptModel, tier: scriptTier }); void regenerate(plan.id, {
model: scriptModel,
...(scriptChannel === 'wecom'
? { channel: 'wecom' as const }
: { tier: scriptTier }),
});
}} }}
/> />
{/* AI 时间戳 — 窄屏隐藏;未生成话术时不显示(无 generatedAt) */} {/* AI 时间戳 — 窄屏隐藏;未生成话术时不显示(无 generatedAt) */}
...@@ -794,7 +815,13 @@ export function PlanDetailApp({ ...@@ -794,7 +815,13 @@ export function PlanDetailApp({
: fmtRel(script.generatedAt) : fmtRel(script.generatedAt)
} }
source={ source={
streamState.status === 'done' ? streamState.source : script.source // ⚠️ 'failed' 只有企微会出现(它没有模板兜底),AIStamp 不认这个值 →
// 给 undefined 让它不显示来源标记,⛔ 别硬转成 'agent'(那是谎报)
streamState.status === 'done'
? streamState.source === 'failed'
? undefined
: streamState.source
: script.source
} }
/> />
)} )}
...@@ -818,11 +845,14 @@ export function PlanDetailApp({ ...@@ -818,11 +845,14 @@ export function PlanDetailApp({
)} )}
{scriptChannel === 'wecom' ? ( {scriptChannel === 'wecom' ? (
<WecomScriptView <WecomScriptView
planId={plan.id} // 流式期间/刚生成完用流里的正文,否则用聚合里的存量稿
streamedContent={
streamState.status === 'streaming' || streamState.status === 'done'
? (streamState.sections?.[0]?.markdown ?? '')
: ''
}
streaming={isStreaming}
script={data.wecomScript ?? null} script={data.wecomScript ?? null}
onGate={gateCheck}
onToast={showToast}
onRefresh={() => onRefreshAggregate?.()}
/> />
) : !isStreaming && !hasScriptContent ? ( ) : !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">
...@@ -2706,6 +2736,7 @@ function RegenBtn({ ...@@ -2706,6 +2736,7 @@ function RegenBtn({
onSelectTier, onSelectTier,
onStop, onStop,
onRegen, onRegen,
hideTier,
}: { }: {
streaming: boolean; streaming: boolean;
model: ScriptModel; model: ScriptModel;
...@@ -2714,6 +2745,13 @@ function RegenBtn({ ...@@ -2714,6 +2745,13 @@ function RegenBtn({
onSelectTier: (tier: ScriptTier) => void; onSelectTier: (tier: ScriptTier) => void;
onStop: () => void; onStop: () => void;
onRegen: () => void; onRegen: () => void;
/**
* 隐藏档位下拉 —— 企微用。
* ⚠️ 企微**只有深度档**(产品定:一条发出去收不回的消息,不给低质量档),
* 摆一个只有一个选项的下拉是误导。⛔ 但**别顺手把模型下拉也藏了**,
* 换模型对两个渠道都成立。
*/
hideTier?: boolean;
}) { }) {
const current = SCRIPT_MODELS.find((m) => m.key === model) ?? SCRIPT_MODELS[0]!; const current = SCRIPT_MODELS.find((m) => m.key === model) ?? SCRIPT_MODELS[0]!;
const currentTier = SCRIPT_TIERS.find((t) => t.key === tier) ?? SCRIPT_TIERS[0]!; const currentTier = SCRIPT_TIERS.find((t) => t.key === tier) ?? SCRIPT_TIERS[0]!;
...@@ -2725,7 +2763,8 @@ function RegenBtn({ ...@@ -2725,7 +2763,8 @@ function RegenBtn({
); );
return ( return (
<div className="inline-flex flex-none items-stretch rounded border border-slate-100 overflow-hidden"> <div className="inline-flex flex-none items-stretch rounded border border-slate-100 overflow-hidden">
{/* 档位选择(只选,不触发) */} {/* 档位选择(只选,不触发);企微只有深度档 → 不显示 */}
{!hideTier && (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild disabled={streaming}> <DropdownMenuTrigger asChild disabled={streaming}>
<button <button
...@@ -2748,6 +2787,7 @@ function RegenBtn({ ...@@ -2748,6 +2787,7 @@ function RegenBtn({
</DropdownMenuRadioGroup> </DropdownMenuRadioGroup>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
)}
{/* 模型选择(只选,不触发) */} {/* 模型选择(只选,不触发) */}
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild disabled={streaming}> <DropdownMenuTrigger asChild disabled={streaming}>
......
...@@ -51,10 +51,11 @@ export type ScriptStreamState = ...@@ -51,10 +51,11 @@ export type ScriptStreamState =
sections: ScriptSection[]; sections: ScriptSection[];
steps?: DeepStep[]; steps?: DeepStep[];
planScriptId: string | null; planScriptId: string | null;
source: 'agent' | 'template_fallback'; source: 'agent' | 'template_fallback' | 'failed';
costYuan: number; /** ⚠️ 企微的 done 不带成本/token(只推步骤+正文)→ 可选。⛔ 别在下游直接 .toFixed() */
promptTokens: number; costYuan?: number;
completionTokens: number; promptTokens?: number;
completionTokens?: number;
fallbackReason?: string; fallbackReason?: string;
} }
| { status: 'error'; message: string }; | { status: 'error'; message: string };
...@@ -62,7 +63,10 @@ export type ScriptStreamState = ...@@ -62,7 +63,10 @@ export type ScriptStreamState =
export interface UseScriptStream { export interface UseScriptStream {
state: ScriptStreamState; state: ScriptStreamState;
/** 触发流式重新生成 */ /** 触发流式重新生成 */
regenerate: (planId: string, options?: { model?: string; tier?: string }) => Promise<void>; regenerate: (
planId: string,
options?: { model?: string; tier?: string; channel?: 'phone' | 'wecom' },
) => Promise<void>;
/** 中途停掉(用户点暂停 / 离开页面) */ /** 中途停掉(用户点暂停 / 离开页面) */
abort: () => void; abort: () => void;
reset: () => void; reset: () => void;
...@@ -94,20 +98,30 @@ export function useScriptStream(): UseScriptStream { ...@@ -94,20 +98,30 @@ export function useScriptStream(): UseScriptStream {
abortRef.current = null; abortRef.current = null;
}, []); }, []);
const regenerate = useCallback(async (planId: string, options?: { model?: string; tier?: string }) => { const regenerate = useCallback(async (
planId: string,
options?: { model?: string; tier?: string; channel?: 'phone' | 'wecom' },
) => {
abortRef.current?.abort(); abortRef.current?.abort();
const controller = new AbortController(); const controller = new AbortController();
abortRef.current = controller; abortRef.current = controller;
/**
* ⭐ 企微走另一个端点,但**事件形状完全一致**(step / done),所以整套流程复用:
* 鉴权 + silent refresh、帧解析、步骤时间线、停止。
* ⚠️ 企微没有"档"(只有深度档),不带 tier 参数;也没有分段骨架
* —— 它是一整块,占位骨架会先闪出几个空段再被替换,比空白更难看。
*/
const wecom = options?.channel === 'wecom';
const url = new URL( const url = new URL(
`/pac/v1/plans/${encodeURIComponent(planId)}/script:stream`, `/pac/v1/plans/${encodeURIComponent(planId)}/${wecom ? 'wecom-script:stream' : 'script:stream'}`,
env.apiBaseUrl, env.apiBaseUrl,
); );
if (options?.model) url.searchParams.set('model', options.model); if (options?.model) url.searchParams.set('model', options.model);
if (options?.tier) url.searchParams.set('tier', options.tier); if (!wecom && options?.tier) url.searchParams.set('tier', options.tier);
// 占位 sections(分档骨架),避免第一帧到达前 UI 闪 // 占位 sections(分档骨架),避免第一帧到达前 UI 闪;企微无段 → 不铺骨架
setState({ status: 'streaming', sections: makeEmptySections(options?.tier) }); setState({ status: 'streaming', sections: wecom ? [] : makeEmptySections(options?.tier) });
try { try {
// SSE 走原生 fetch(api-client 不处理 stream),自己实现 silent refresh: // SSE 走原生 fetch(api-client 不处理 stream),自己实现 silent refresh:
...@@ -194,7 +208,14 @@ export function useScriptStream(): UseScriptStream { ...@@ -194,7 +208,14 @@ export function useScriptStream(): UseScriptStream {
} else if (evt.type === 'done') { } else if (evt.type === 'done') {
setState((prev) => ({ setState((prev) => ({
status: 'done', status: 'done',
sections: serverToClientSections(evt.sections), /**
* ⚠️ 企微的 done 带的是整块 `content`(它没有段);电话带 `sections`。
* 这里把企微那块包成**一段**,让下游(渲染 / 复制 / 状态)只认一种形状。
* ⛔ 别为此在下游到处判渠道 —— 判据散开之后总会漏一处。
*/
sections: evt.sections
? serverToClientSections(evt.sections)
: [{ id: 'wecom', label: '企微话术', durationHint: '', markdown: evt.content ?? '' }],
steps: prev.status === 'streaming' ? prev.steps : undefined, // 保留过程时间线供完成后回看 steps: prev.status === 'streaming' ? prev.steps : undefined, // 保留过程时间线供完成后回看
planScriptId: evt.planScriptId, planScriptId: evt.planScriptId,
source: evt.source, source: evt.source,
...@@ -245,7 +266,8 @@ interface SseDoneEvent { ...@@ -245,7 +266,8 @@ interface SseDoneEvent {
planScriptId: string | null; planScriptId: string | null;
agentInvocationId: string; agentInvocationId: string;
source: 'agent' | 'template_fallback'; source: 'agent' | 'template_fallback';
sections: ServerSection[]; /** ⚠️ 企微没有段 → 缺省;此时正文在 content 里(见上面 done 分支的包段逻辑) */
sections?: ServerSection[];
content: string; content: string;
costYuan: number; costYuan: number;
promptTokens: number; promptTokens: number;
......
'use client'; 'use client';
import { useState } from 'react'; import { useState } from 'react';
import { Check, Copy, Loader2, RefreshCw } from 'lucide-react'; import { Check, Copy } from 'lucide-react';
import { plansApi } from '@/components/plans/plans-api';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
/** /**
* 企微话术视图 —— **一整块可直接复制发送的消息**。 * 企微话术视图 —— **一整块可直接复制发送的消息**。
* *
* ⚠️ 步骤时间线(ScriptDeepProcess)由**外层统一渲染**(电话/企微共用那一处),
* ⛔ 本组件里不要再画一遍 —— 实测重复渲染成了两组一模一样的「规划大纲/撰写话术/安全自检」。
*
* ⚠️ 与电话稿的三个视图(伴飞/卡片/原文)不是同一套东西:那三个是"同一份分段稿的不同看法"; * ⚠️ 与电话稿的三个视图(伴飞/卡片/原文)不是同一套东西:那三个是"同一份分段稿的不同看法";
* 这里根本没有段,它就是一条消息。所以⛔ 不复用 `ScriptView`。 * 这里根本没有段,它就是一条消息。所以⛔ 不复用 `ScriptView`。
* *
* ⛔ **本组件不提供生成按钮** —— 生成走顶栏那一个入口(与电话稿同一个)。
* 两个"生成"按钮意味着两条路径、两套状态,而它们必然会不一致
* (点了这个转圈、点了那个也转圈,而流只有一条)。
*
* ⚠️ **失败态如实显示**,⛔ 不回落去显示电话稿 —— 电话稿是口语分段的, * ⚠️ **失败态如实显示**,⛔ 不回落去显示电话稿 —— 电话稿是口语分段的,
* 复制发给患者会很怪,而客服不会注意到自己发错了东西(见服务端 wecom.strategy 的说明) * 复制发给患者会很怪,而客服不会注意到自己发错了东西。
*/ */
export function WecomScriptView({ export function WecomScriptView({
planId,
script, script,
onGate, streamedContent,
onToast, streaming,
onRefresh,
}: { }: {
planId: string; /** 存量稿(聚合响应);只要 content + status */
/** 只要 content+status 两个字段;⛔ 别收紧成 PlanScript —— 上游是聚合响应的宽松形状 */
script: { content: string | null; status: string } | null; script: { content: string | null; status: string } | null;
/** 认领闸:生成要真花 AI 钱 */ /** 本次流式产出的正文(流中/刚完成时优先用它) */
onGate: () => boolean; streamedContent: string;
onToast: (kind: string, title: string, msg: string) => void; streaming: boolean;
onRefresh: () => void | Promise<void>;
}) { }) {
const [busy, setBusy] = useState(false);
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const content = script?.status === 'ready' ? (script.content ?? '') : ''; const stored = script?.status === 'ready' ? (script.content ?? '') : '';
const content = streamedContent || stored;
const generate = async () => { const failed = !streaming && !content && script?.status === 'failed';
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 () => { const copy = async () => {
if (!content) return; if (!content) return;
...@@ -60,54 +46,21 @@ export function WecomScriptView({ ...@@ -60,54 +46,21 @@ export function WecomScriptView({
// 按钮本身变成「已复制」已经是足够的反馈。 // 按钮本身变成「已复制」已经是足够的反馈。
setTimeout(() => setCopied(false), 2000); setTimeout(() => setCopied(false), 2000);
} catch { } catch {
onToast('amber', '复制失败', '请手动选中文本复制'); /* 剪贴板被拒(非 https / 无权限)→ 用户可以手动选中复制,不打断 */
} }
}; };
// ── 尚未生成 / 生成失败 ────────────────────────────────
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 ( return (
<div className="flex h-full flex-col gap-2"> <div className="flex h-full flex-col gap-2">
<div className="flex flex-none items-center justify-end gap-1.5"> {content ? (
<button <>
type="button" <div className="flex flex-none items-center justify-end">
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 <button
type="button" type="button"
onClick={copy} onClick={copy}
disabled={streaming}
className={cn( className={cn(
'inline-flex items-center gap-1 rounded-md px-2.5 py-1 text-[11px] font-medium transition-colors', 'inline-flex items-center gap-1 rounded-md px-2.5 py-1 text-[11px] font-medium transition-colors disabled:opacity-50',
copied ? 'bg-emerald-50 text-emerald-700' : 'bg-brand-600 text-white hover:bg-brand-700', copied ? 'bg-emerald-50 text-emerald-700' : 'bg-brand-600 text-white hover:bg-brand-700',
)} )}
> >
...@@ -117,12 +70,26 @@ export function WecomScriptView({ ...@@ -117,12 +70,26 @@ export function WecomScriptView({
</div> </div>
{/* {/*
⚠️ 用 `whitespace-pre-wrap` 原样呈现,⛔ 不过 markdown 渲染器: ⚠️ 用 `whitespace-pre-wrap` 原样呈现,⛔ 不过 markdown 渲染器:
客服复制的必须是**他看到的那些字**。经 markdown 渲染后,`**加粗**` 之类会变成样式, 客服复制的必须是**他看到的那些字**。经 markdown 渲染后 `**加粗**` 之类会变成样式,
复制出来却带着星号发给患者 —— 所见与所复制不一致,而他不会逐字校对。 复制出来却带着星号发给患者 —— 所见与所复制不一致,而他不会逐字校对。
*/} */}
<div className="min-h-0 flex-1 overflow-y-auto rounded-md bg-slate-50/70 p-3"> <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> <p className="whitespace-pre-wrap text-[13px] leading-relaxed text-slate-800">{content}</p>
</div> </div>
</>
) : (
!streaming && (
<div className="flex h-full min-h-[160px] flex-col items-center justify-center gap-2 text-center text-slate-400">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="h-8 w-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>
</div>
)
)}
</div> </div>
); );
} }
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