工单质量是一个真实的需求:描述写得清不清楚、处理结果是不是一句「已处理」、分类选没选对。

第一版的做法很直接:在提交按钮前面加一道质检闸。规则不过,就不让提交。

后来这道闸被摘掉了,改成了一个纯只读的事后抽检面板。这篇讲为什么,以及事后抽检要做对的几件事。

为什么摘掉提交前的闸

核心是一句话:质检是主管视角的事后评分,不是挡住正在干活的人。

两个位置的差别不在规则本身,在误伤的代价:

提交前拦截事后抽检
规则误判一次一个正在处理客户问题的人被卡住,要去猜规则想要什么主管看到一条可疑记录,点开看一眼就知道是误判
谁承担代价一线,在最忙的时候主管,在复盘的时候
规则的性质必须接近 100% 准确才能用有误报也有价值

而质检规则天然是概率性的——「处理结果太笼统」这种判断,规则只能近似。概率性的规则放在一个要求确定性的位置上,结果只能是:要么把阈值调得很松(那拦不住什么),要么一线天天被误伤(然后他们会学会绕过它)。

(规则本身后来没有浪费:它被拼进了 AI 写单的 prompt 里,作为「写的时候就照着这个写」的约束——从「事后拦」变成了「事前教」。)

判定分三层,只做了前两层

层判什么靠什么成本
L1 规则口水话、笼统的处理结果、缺必填信息、分类过于泛化本地规则函数0
L2 一致性某个字段与这个客户的真实属性是否相符本地查表0
L3 语义描述是否讲清根因、处理结果是否对得上问题大模型每条一次调用

L3 暂时没做——一次抽检几百条,每条一次模型调用,成本和耗时都不划算,而前两层已经能挑出大部分问题。

L2 最容易出错:推断不能用来判人

「这个客户的真实属性」有两个来源:

  • 例外表:主管逐个核查过的确定事实;
  • 推断:从客户所属的类别推出来的——而类别和属性不是一一对应的。

规则是:只有来源是例外表时,不符才判为错误;来源是推断时,最多给一个提醒。

拿推断去判人,必然制造冤假错案——被判错的那个人是对的,是你的推断错了。

判定的严厉程度,不能超过判据的可信度。 这是整个功能里最该想清楚的一条。

抽样必须可复现

质检是抽样的:某人某段时间里的全部工单,抽 N 条。

随机抽样必须可复现——同一个时间区间、同一个人,重复抽必然抽到同一批。否则被抽查的人完全可以质疑:「你多抽几次,总能抽到我错的那条。」整个质检结论就失去了公信力。

做法是用确定性的伪随机数,种子取「日期区间 + 被抽查人 id」:

src/audit/seeded-sample.ts
/** 字符串 → 32 位种子(FNV-1a) */
function hashSeed(s: string): number {
let h = 0x811c9dc5;
for (let i = 0; i < s.length; i++) {
h ^= s.charCodeAt(i);
h = Math.imul(h, 0x01000193);
}
return h >>> 0;
}
/** mulberry32:小而快的确定性 PRNG */
function mulberry32(seed: number): () => number {
let a = seed >>> 0;
return () => {
a = (a + 0x6d2b79f5) >>> 0;
let t = a;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
/**
* 可复现抽样:同一个 seed 永远抽到同一批。
* 种子建议用「日期区间|被抽查人 id」——同一天同一个人重复抽,结果必然相同。
*/
export function seededSample<T>(items: readonly T[], n: number, seed: string): T[] {
if (items.length <= n) return [...items]; // 池子不够就全给
const rand = mulberry32(hashSeed(seed));
const arr = [...items];
// 部分 Fisher–Yates:只洗前 n 个位置
for (let i = 0; i < n; i++) {
const j = i + Math.floor(rand() * (arr.length - i));
[arr[i], arr[j]] = [arr[j], arr[i]];
}
return arr.slice(0, n);
}

测过的性质:同种子两次结果完全一致、不同的人结果不同、无重复、池子小于 N 时全给、不改原数组;两万个种子下,每个元素被抽中的次数偏离均值不到 10%。

Math.random() 在这里是错的选择——不是因为它不够随机,是因为它不可复现。

两个会被误读的数字

① 人员列表上的条数不是真实总数。 列出「这段时间有产出的人」时,只翻了有限几页(单日全量就上千条,全翻既慢又没必要)。那个数字只用来让人勾选抽谁,真实条数以逐人查询的结果为准。

面板上必须把这个区别写出来——不写,它会被当成产能数据读,然后有人拿它去比较谁干得多。

② 过滤失效时要放弃,不是给结果。 取数用的列表接口,写错的过滤条件会被静默忽略、返回全量(细节在另一篇)。质检的取数一样要过「过滤生效自检」——日期条件必须真的生效、结果必须显著小于全量,否则放弃这次抽检。

长任务:port 长连接,离开即中止

一次抽检要跑几十到几百条、分钟级,要逐条回进度、要能中止。

所以用 chrome.tabs.connect 建一条长连接:引擎在 content(要调接口、要读页面上的分类树),UI 在侧栏。

离开面板、关掉侧栏 = port 断开 = 引擎中止。 不留一个没人看的后台任务在那里继续跑、继续打接口。

导出 CSV 的四个细节

结果导出成 CSV,不上云——质检结果是敏感信息,留在主管自己的机器上。

  1. 表头带质检标准的版本号。 报表要能自证「按哪一版标准评的」——否则过几个月,没人说得清某份结果用的是什么口径;
  2. 公式注入防护:以 = + - @ 开头的值前面加一个单引号。否则 Excel 会把工单文本当公式执行——而工单文本是客户写的;
  3. 必须带 UTF-8 BOM,否则 Excel 打开中文是乱码;
  4. 下载必须在 content 侧触发。侧栏是独立文档,它自己造的 blob URL 下载会被沙箱挡住。
function csvCell(v: string): string {
let s = v ?? '';
if (/^[=+\-@]/.test(s)) s = `'${s}`; // 防公式注入
return /[",\r\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
}
const csv = '\uFEFF' + rows.map((r) => r.map(csvCell).join(',')).join('\r\n'); // BOM + CRLF

纯只读是一个设计约束,不是巧合

这个模块不改任何工单、不提交、不发消息、不调模型。它是整个扩展里最「无害」的长任务。

这不是因为功能简单,是刻意的:质检的结论要被信任,它本身就不能有任何副作用。一个会改数据的质检工具,它的结论永远可以被质疑「是不是你改出来的」。


回头看,这件事最重要的决定是把它从提交按钮前面挪开。

同一套规则,挡在一线面前是一道需要绕过的墙;放在主管手里是一份可以讨论的报告。规则没变,变的是它出现的时刻和面对的人。