Commit a67c3f0c by luoqi

feat: 企微话术复制埋点 + 修复路由遮蔽(反馈按钮一直在偷偷重新生成话术)

═ 埋点 ═
plan_event_logs 新增事件 script_copy,reason 列存渠道(wecom / phone,都登记进
PlanEventReason —— 那一列的注释写死了"不要在调用处随手写字符串")。

为什么值得记:企微稿的正常用法就是复制出去发给患者,复制那一下之后客服就离开
PAC 了 —— 这是我们能观测到的**最接近"真的用了"**的信号。生成完没人复制 =
生成了但没人用,那是产品问题不是模型问题,而这件事此前完全看不见。

口径(回归里锁着,这两条改错不会有任何编译错误):
· byHuman=false —— 复制 ≠ 联系了患者(复制完可能没发)。算进 HUMAN_TOUCH_EVENTS
  的话「处理过的患者」会静默膨胀成"复制一下也算",与 view 同一类错误。
· holdsPatient=false —— 可能发生在未认领的单上,不参与归属区间。
端点不加认领闸(同 :id/view):主管浏览时也能复制,加闸统计直接偏。

═ 顺带修掉两个真 bug ═
1) 🔴 路由遮蔽 —— **已有的话术 👍👎 反馈按钮从来没生效过**。
   Express 把 ':id/script:regenerate' 里的 :regenerate 当路径参数,模式实际是
   「字面量 script + 参数」,于是 /script-feedback 和 /script-copy 全部命中它
   (参数 = '-feedback' / '-copy')。点一次反馈 = 真的重新生成一次话术:
   花 AI 钱 + 覆盖已有稿,而前端拿到 200、界面一切正常,**不报任何错**。
   治标:字面量路由挪到冒号路由之前(Express 按声明顺序匹配)。
   治本是把 :verb 改成 /verb,会动前端与文档契约,没在这一刀做。
   新增 route-shadowing.spec 用文本扫描锁住顺序 —— 这个 bug 恰恰不会在类型
   或运行时暴露,只能这么防。已验证:把 script-copy 挪回去,测试立刻红。

2) 复制按钮在 http / 宿主 iframe 下**点了毫无反应**。
   navigator.clipboard.writeText 在非 https 或 iframe 里直接 NotAllowedError,
   而原写法把 setCopied 放在它后面 → 不报错、不变文案,客服会以为按钮坏了。
   加 execCommand 回落路径。
    埋点挪到最前面,与剪贴板成败**解耦** —— 记的是"点了复制"这个意图。
   剪贴板被拒时客服往往改成手动选中复制(真的用了),埋点不该跟着一起丢。

实测:点复制 → script_copy | wecom | 操作人 832 | 带批次号 t 落库。
1024 tests,两个 tsc + next build 干净。
parent dc6f9469
......@@ -9,7 +9,13 @@ import {
} from '@nestjs/common';
import type { Response } from 'express';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { ApiCode, Permission, PlanEventType, SubmitRecallFeedbackRequestSchema } from '@pac/types';
import {
ApiCode,
Permission,
PlanEventReason,
PlanEventType,
SubmitRecallFeedbackRequestSchema,
} from '@pac/types';
import { z } from 'zod';
import { RequirePermission } from '../../common/decorators/permissions.decorator';
import { BizError } from '../../common/errors/biz-error';
......@@ -42,6 +48,11 @@ const ScriptFeedbackSchema = z.object({
feedback: z.enum(['up', 'down']),
});
/// 复制话术埋点的入参 —— 渠道闭集,⛔ 不收裸字符串(reason 列要能直接 group by)
const ScriptCopySchema = z.object({
channel: z.enum(['wecom', 'phone']),
});
/**
* PlansAggregateController — 生产路径的 plan 详情聚合 + AI 资产再生成端点。
*
......@@ -179,6 +190,99 @@ export class PlansAggregateController {
});
}
// ═══════════════════════════════════════════════════════════════════
// 🔴🔴 **字面量路由必须声明在 `xxx:verb` 路由之前** —— 顺序即语义,别重排。
//
// Express 把 `:id/script:regenerate` 里的 `:regenerate` 当成**路径参数**:
// 模式实际是「字面量 `script` + 参数」,于是 `/script-feedback`、`/script-copy`
// 这些以 `script` 开头的路径**全部命中它**(参数 = `-feedback` / `-copy`)。
//
// 实测后果(2026-08-04 发现):话术 👍👎 反馈按钮**从来没生效过** ——
// 每点一次实际是在**重新生成话术**(真花 AI 钱 + 覆盖已有稿),而前端拿到 200
// 看起来一切正常。同一个坑把新加的复制埋点也吞了。
//
// ⚠️ 治标是这里的顺序,治本是把 `:verb` 风格的路径改成 `/verb`(会动前端与文档契约)。
// ⛔ 在此之前:任何以 `script` / `wecom-script` 开头的**新字面量路由,必须加在这一段里**。
// ═══════════════════════════════════════════════════════════════════
/**
* 反馈某 plan 当前话术(对应 plan_scripts.agent_invocation_id)up/down。
* 同一 invocation 反复点会覆盖(再点 down 覆盖 up,以最新为准)。
* 没找到 planScript / agentInvocationId → 404(避免无 invocation 的 plan 接受反馈)。
*
* 后续可加:summary feedback(对称端点)/ 聚合 dashboard(up/down 比 + 命中 fallback 时禁止评)。
*/
@Post(':id/script-feedback')
@RequirePermission(Permission.PLAN_VIEW_OWN)
@ApiOperation({ summary: '对当前话术 thumbs up/down 反馈("本段是否好用")' })
async submitScriptFeedback(
@TenantScope() scope: TenantScopeContext,
@CurrentUser() user: AuthenticatedUser,
@Param('id') planId: string,
@Body() body: unknown,
) {
await this.assertClaimed(scope, planId, user.sub); // 认领闸
const { feedback } = ScriptFeedbackSchema.parse(body);
const script = await this.prisma.planScript.findFirst({
where: { planId, hostId: scope.hostId, tenantId: scope.tenantId },
});
if (!script?.agentInvocationId) {
return { ok: false as const, reason: 'no_invocation' };
}
await this.prisma.agentInvocation.update({
where: { id: script.agentInvocationId },
data: {
userFeedback: feedback,
userFeedbackAt: new Date(),
userFeedbackBy: user.sub,
},
});
return { ok: true as const, invocationId: script.agentInvocationId, feedback };
}
/**
* 上报「复制了参考话术」—— 点一次记一条。
*
* ⭐ 为什么值得记:企微稿的正常用法就是**复制出去发给患者**,复制那一下之后客服就离开 PAC 了。
* 这是我们能观测到的**最接近"真的用了"**的信号 —— 生成完没人复制 = 生成了但没人用,
* 那是产品问题不是模型问题,而现在这件事完全看不见。
*
* ⚠️ **不加认领闸**(同 `:id/view`):主管在池子里浏览时也能复制,加了闸这个埋点
* 就只剩已认领的,统计直接偏。
* ⚠️ 埋点是**尽力而为的旁路**:找不到 plan 静默返回,⛔ 不能因为它失败而打断复制这个动作
* —— 前端那边也不 await(见 wecom-script-view)。
*/
@Post(':id/script-copy')
@RequirePermission(Permission.PLAN_VIEW_OWN)
@ApiOperation({ summary: '上报复制参考话术(埋点)—— 每点一次「复制」记一条' })
async reportScriptCopy(
@TenantScope() scope: TenantScopeContext,
@CurrentUser() user: AuthenticatedUser,
@Param('id') planId: string,
@Body() body: unknown,
) {
const { channel } = ScriptCopySchema.parse(body);
const plan = await this.prisma.followupPlan.findFirst({
where: { id: planId, hostId: scope.hostId, tenantId: scope.tenantId },
select: { id: true, hostId: true, tenantId: true, patientId: true, assignmentId: true },
});
if (!plan) return { ok: false as const, reason: 'not_found' };
await recordPlanEvent(this.prisma, {
hostId: plan.hostId,
tenantId: plan.tenantId,
planId: plan.id,
patientId: plan.patientId,
event: PlanEventType.SCRIPT_COPY,
// 渠道进 reason 列(登记过的枚举,可直接 group by),⛔ 不塞 details
reason: channel === 'wecom' ? PlanEventReason.COPY_WECOM : PlanEventReason.COPY_PHONE,
actorUserId: user.sub,
// ⚠️ 复制**不改变归属**,但要记是哪一批的单 —— 否则"这批的话术有没有被用"answer 不了。
// 与 release/auto_release 同一条口径:此刻取是对的(覆盖只发生在下次分配)。
assignmentId: plan.assignmentId,
});
return { ok: true as const };
}
@Post(':id/wecom-script:regenerate')
@RequirePermission(Permission.PLAN_VIEW_OWN)
@ApiOperation({ summary: '生成 / 重新生成企微话术(一次性单块)' })
......@@ -282,41 +386,6 @@ export class PlansAggregateController {
// 话术 / 摘要 thumbs up/down 反馈("本段是否好用")
// ─────────────────────────────────────────────
/**
* 反馈某 plan 当前话术(对应 plan_scripts.agent_invocation_id)up/down。
* 同一 invocation 反复点会覆盖(再点 down 覆盖 up,以最新为准)。
* 没找到 planScript / agentInvocationId → 404(避免无 invocation 的 plan 接受反馈)。
*
* 后续可加:summary feedback(对称端点)/ 聚合 dashboard(up/down 比 + 命中 fallback 时禁止评)。
*/
@Post(':id/script-feedback')
@RequirePermission(Permission.PLAN_VIEW_OWN)
@ApiOperation({ summary: '对当前话术 thumbs up/down 反馈("本段是否好用")' })
async submitScriptFeedback(
@TenantScope() scope: TenantScopeContext,
@CurrentUser() user: AuthenticatedUser,
@Param('id') planId: string,
@Body() body: unknown,
) {
await this.assertClaimed(scope, planId, user.sub); // 认领闸
const { feedback } = ScriptFeedbackSchema.parse(body);
const script = await this.prisma.planScript.findFirst({
where: { planId, hostId: scope.hostId, tenantId: scope.tenantId },
});
if (!script?.agentInvocationId) {
return { ok: false as const, reason: 'no_invocation' };
}
await this.prisma.agentInvocation.update({
where: { id: script.agentInvocationId },
data: {
userFeedback: feedback,
userFeedbackAt: new Date(),
userFeedbackBy: user.sub,
},
});
return { ok: true as const, invocationId: script.agentInvocationId, feedback };
}
@Post(':id/view')
@RequirePermission(Permission.PLAN_VIEW_OWN)
@ApiOperation({
......
......@@ -144,6 +144,18 @@ describe('PlanEventType / PLAN_EVENT_META — 扩展性约束', () => {
);
});
test('⭐⭐ 复制话术不算「处理过患者」—— 复制 ≠ 联系了患者', () => {
// 复制完可能压根没发出去。算进 HUMAN_TOUCH_EVENTS 的话,「处理过的患者」
// 会静默膨胀成"复制一下也算" —— 与 view 同一类错误,而它是 filter 派生的,
// 改错**不会有任何编译错误**。
expect(PLAN_EVENT_META.script_copy.byHuman).toBe(false);
expect(HUMAN_TOUCH_EVENTS).not.toContain(PlanEventType.SCRIPT_COPY);
});
test('⭐ 复制话术不参与归属区间(可能发生在未认领的单上)', () => {
expect(PLAN_EVENT_META.script_copy.holdsPatient).toBe(false);
});
test('归属类事件的 holdsPatient 口径正确(算归属区间用)', () => {
expect(PLAN_EVENT_META.claim.holdsPatient).toBe(true);
expect(PLAN_EVENT_META.assign.holdsPatient).toBe(true);
......
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
/**
* 🔴 路由遮蔽 —— `xxx:verb` 风格的路径会把同前缀的字面量路由**整个吞掉**。
*
* Express 把 `:id/script:regenerate` 里的 `:regenerate` 当**路径参数**,
* 模式实际是「字面量 `script` + 参数」。于是:
* POST /plans/x/script-feedback → 命中 script:regenerate(参数 = `-feedback`)
* POST /plans/x/script-copy → 同上(参数 = `-copy`)
*
* 实测(2026-08-04):话术 👍👎 反馈按钮**从来没生效过** —— 每点一次实际在重新生成话术
* (真花 AI 钱 + 覆盖已有稿),而前端拿到 200、界面一切正常,**不会有任何报错**。
*
* 治标是声明顺序(字面量在前),治本是把 `:verb` 改成 `/verb`。在改之前,这个 spec
* 锁住顺序 —— 它是纯文本检查,因为这个 bug 恰恰不会在类型或运行时暴露。
*/
const CONTROLLER = join(
__dirname,
'../src/modules/plan-aggregate/plans-aggregate.controller.ts',
);
/** 取出所有 @Post/@Get 装饰器里的路径,按**声明顺序** */
function declaredRoutes(): Array<{ method: string; path: string; at: number }> {
const src = readFileSync(CONTROLLER, 'utf-8');
const out: Array<{ method: string; path: string; at: number }> = [];
const re = /@(Post|Get)\(\s*'([^']+)'\s*\)/g;
let m: RegExpExecArray | null;
while ((m = re.exec(src)) !== null) {
out.push({ method: m[1]!, path: m[2]!, at: m.index });
}
return out;
}
/** `a` 会不会把 `b` 吞掉:a 形如 `前缀:参数`,而 b 以同一个前缀开头 */
function shadows(a: string, b: string): boolean {
const i = a.indexOf(':', a.indexOf('/') + 1); // 跳过开头的 :id
if (i === -1) return false;
const literal = a.slice(0, i); // 如 ':id/script'
if (!literal.includes('/')) return false;
// b 必须以同一段字面量开头,且后面还有内容(那部分会被当成参数吃掉)
return b !== a && b.startsWith(literal) && b.length > literal.length && !b.slice(literal.length).startsWith('/');
}
describe('路由遮蔽 —— 字面量路由必须声明在 `:verb` 路由之前', () => {
test('⭐⭐ 任何被 `:verb` 路由遮蔽的字面量路由,都必须声明得更早', () => {
const routes = declaredRoutes();
const problems: string[] = [];
for (const colonRoute of routes) {
if (!shadows(colonRoute.path, colonRoute.path)) {
// colonRoute 自身含 `:verb` 才有遮蔽能力
}
for (const literal of routes) {
if (literal.method !== colonRoute.method) continue;
if (!shadows(colonRoute.path, literal.path)) continue;
if (literal.at > colonRoute.at) {
problems.push(
`${literal.method} '${literal.path}' 声明在 '${colonRoute.path}' 之后 → 会被它整个吞掉(永远进不来)`,
);
}
}
}
expect(problems).toEqual([]);
});
test('⭐ 已知的两条:script-feedback / script-copy 都排在 script:regenerate 之前', () => {
const routes = declaredRoutes();
const at = (p: string) => routes.find((r) => r.path === p)?.at ?? -1;
expect(at(':id/script-feedback')).toBeGreaterThan(-1);
expect(at(':id/script-copy')).toBeGreaterThan(-1);
expect(at(':id/script-feedback')).toBeLessThan(at(':id/script:regenerate'));
expect(at(':id/script-copy')).toBeLessThan(at(':id/script:regenerate'));
});
});
......@@ -845,6 +845,7 @@ export function PlanDetailApp({
)}
{scriptChannel === 'wecom' ? (
<WecomScriptView
planId={plan.id}
// 流式期间/刚生成完用流里的正文,否则用聚合里的存量稿
streamedContent={
streamState.status === 'streaming' || streamState.status === 'done'
......
......@@ -2,6 +2,7 @@
import { useState } from 'react';
import { Check, Copy } from 'lucide-react';
import { plansApi } from '@/components/plans/plans-api';
import { cn } from '@/lib/utils';
/**
......@@ -21,10 +22,12 @@ import { cn } from '@/lib/utils';
* 复制发给患者会很怪,而客服不会注意到自己发错了东西。
*/
export function WecomScriptView({
planId,
script,
streamedContent,
streaming,
}: {
planId: string;
/** 存量稿(聚合响应);只要 content + status */
script: { content: string | null; status: string } | null;
/** 本次流式产出的正文(流中/刚完成时优先用它) */
......@@ -37,17 +40,57 @@ export function WecomScriptView({
const content = streamedContent || stored;
const failed = !streaming && !content && script?.status === 'failed';
/**
* 写剪贴板 —— 两条路,⛔ 别只用 `navigator.clipboard`。
*
* 🔴 实测:PAC 跑在 `http://` 或**嵌在宿主 iframe** 里时,`clipboard.writeText`
* 直接 `NotAllowedError`。原来的写法把 `setCopied` 也放在它后面,于是复制按钮
* **点了毫无反应**(不报错、不变文案)—— 客服会以为按钮坏了,而这正是企微稿的主路径。
* ⇒ 失败回落到 `execCommand('copy')`(老 API,http/iframe 下仍可用)。
*/
const writeClipboard = async (text: string): Promise<boolean> => {
try {
await navigator.clipboard.writeText(text);
return true;
} catch {
/* 落到下面的回落路径 */
}
try {
const ta = document.createElement('textarea');
ta.value = text;
// 移出视口 + readOnly:避免闪一下输入框、避免移动端弹键盘
ta.style.cssText = 'position:fixed;left:-9999px;top:0;opacity:0';
ta.readOnly = true;
document.body.appendChild(ta);
ta.select();
ta.setSelectionRange(0, text.length);
const ok = document.execCommand('copy');
document.body.removeChild(ta);
return ok;
} catch {
return false;
}
};
const copy = async () => {
if (!content) return;
try {
await navigator.clipboard.writeText(content);
/**
* ⭐ 埋点在**最前面**,与剪贴板成没成功**解耦**。
*
* 记的是「客服点了复制」这个**意图** —— 复制 = 我们能观测到的最接近"真的用了"
* 的信号(复制完他就离开 PAC 去发微信了;生成完没人点 = 生成了但没人用)。
* ⛔ 别挪到 `writeClipboard` 成功之后:剪贴板在 http/iframe 下会被拒,
* 而那时客服往往改成手动选中复制 —— 真的用了,我们却一条都没记到。
* ⚠️ **不 await、失败静默**:埋点是旁路,不能拖慢或打断复制这个动作。
*/
void plansApi.reportScriptCopy(planId, 'wecom').catch(() => {});
const ok = await writeClipboard(content);
if (!ok) return; // 两条路都失败 → 保持原样,客服可以手动选中复制
setCopied(true);
// 2 秒后复原 —— ⛔ 不弹 toast:复制是高频动作,每次弹一条是噪音,
// 按钮本身变成「已复制」已经是足够的反馈。
setTimeout(() => setCopied(false), 2000);
} catch {
/* 剪贴板被拒(非 https / 无权限)→ 用户可以手动选中复制,不打断 */
}
};
return (
......
......@@ -143,6 +143,18 @@ export const plansApi = {
{},
),
/**
* 上报「复制了参考话术」(埋点)。
*
* ⚠️ **尽力而为的旁路**:调用方不要 await、失败也不要提示 ——
* 复制这个动作本身已经完成了(剪贴板先写),为一条埋点打断它是本末倒置。
*/
reportScriptCopy: (planId: string, channel: 'wecom' | 'phone') =>
api.post<{ ok: boolean; reason?: string }>(
`/pac/v1/plans/${encodeURIComponent(planId)}/script-copy`,
{ channel },
),
/** W4 末:话术 thumbs up/down 反馈("本段是否好用")
* - POST /pac/v1/plans/:id/script-feedback { feedback: 'up'|'down' }
* - 写入 agent_invocations.user_feedback;同 invocation 反复点会覆盖以最新为准 */
......
......@@ -987,6 +987,16 @@ export const PlanEventType = {
/// 三种入口全收:直接进/刷新 · 患者列表切换 · /plans 落地自动跳转;
/// 同一 planId 每次进入记一次(前端 usePlanAggregate 去重守卫为界),手动刷新不重复计。
VIEW: 'view',
/**
* 复制了参考话术(点「复制」按钮一次记一条)。`reason` 列存渠道:`wecom` / `phone`。
*
* ⭐ 为什么值得单独记:企微稿的正常用法就是**复制出去发给患者**,而那一下之后
* 客服就离开 PAC 了 —— 这是我们能观测到的**最接近"真的用了"**的信号
* (话术生成完没人复制 = 生成了但没人用,那是产品问题,不是模型问题)。
* ⚠️ 它**不是**「联系了患者」:复制完可能没发。所以 `byHuman: false` ——
* ⛔ 别把它算进"客服处理过这个患者",那个口径会静默膨胀成"复制一下也算"。
*/
SCRIPT_COPY: 'script_copy',
} as const;
export type PlanEventType = (typeof PlanEventType)[keyof typeof PlanEventType];
......@@ -1006,7 +1016,8 @@ export const PLAN_EVENT_META: Record<
PlanEventType,
{
labelZh: string;
group: 'ownership' | 'feedback' | 'view';
/** ownership 归属变更 / feedback 质量反馈 / view 浏览埋点 / usage 产物被用了 */
group: 'ownership' | 'feedback' | 'view' | 'usage';
byHuman: boolean;
holdsPatient: boolean;
}
......@@ -1018,6 +1029,10 @@ export const PLAN_EVENT_META: Record<
feedback: { labelZh: '召回反馈', group: 'feedback', byHuman: true, holdsPatient: true },
// ⚠️ byHuman: false —— 浏览是人做的,但**不算"处理过"**(见上方字段说明)。别改成 true。
view: { labelZh: '查看详情', group: 'view', byHuman: false, holdsPatient: false },
// ⚠️ byHuman: false —— 复制 ≠ 联系了患者(复制完可能没发)。理由同 view,别改成 true。
// ⚠️ holdsPatient: false —— 复制可能发生在未认领的单上(主管浏览时也能复制),
// 跟归属无关,不参与归属区间计算。
script_copy: { labelZh: '复制话术', group: 'usage', byHuman: false, holdsPatient: false },
};
/// 「该患者被客服处理过」的事件集合 —— 统计口径收口在这里,避免各处各写一套。
......@@ -1042,6 +1057,11 @@ export const PlanEventReason = {
UP: 'up',
/// 召回不准 👎
DOWN: 'down',
// ── script_copy 事件:复制的是哪个渠道的话术 ──
/// 企微稿(整段复制发给患者 —— 这是它的正常用法)
COPY_WECOM: 'wecom',
/// 电话稿(目前界面上没有复制入口,先登记,免得将来加了又随手写字符串)
COPY_PHONE: 'phone',
// ── auto_release 事件(系统收走归属,无 actor)──
/// 超过 recycle_at 被 cron 回收(自动回收开关开启时才可能出现)
TIMEOUT: 'timeout',
......
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