工单质量是一个真实的需求:描述写得清不清楚、处理结果是不是一句「已处理」、分类选没选对。
第一版的做法很直接:在提交按钮前面加一道质检闸。规则不过,就不让提交。
后来这道闸被摘掉了,改成了一个纯只读的事后抽检面板。这篇讲为什么,以及事后抽检要做对的几件事。
为什么摘掉提交前的闸
核心是一句话:质检是主管视角的事后评分,不是挡住正在干活的人。
两个位置的差别不在规则本身,在误伤的代价:
| 提交前拦截 | 事后抽检 | |
|---|---|---|
| 规则误判一次 | 一个正在处理客户问题的人被卡住,要去猜规则想要什么 | 主管看到一条可疑记录,点开看一眼就知道是误判 |
| 谁承担代价 | 一线,在最忙的时候 | 主管,在复盘的时候 |
| 规则的性质 | 必须接近 100% 准确才能用 | 有误报也有价值 |
而质检规则天然是概率性的——「处理结果太笼统」这种判断,规则只能近似。概率性的规则放在一个要求确定性的位置上,结果只能是:要么把阈值调得很松(那拦不住什么),要么一线天天被误伤(然后他们会学会绕过它)。
(规则本身后来没有浪费:它被拼进了 AI 写单的 prompt 里,作为「写的时候就照着这个写」的约束——从「事后拦」变成了「事前教」。)
判定分三层,只做了前两层
| 层 | 判什么 | 靠什么 | 成本 |
|---|---|---|---|
| L1 规则 | 口水话、笼统的处理结果、缺必填信息、分类过于泛化 | 本地规则函数 | 0 |
| L2 一致性 | 某个字段与这个客户的真实属性是否相符 | 本地查表 | 0 |
| L3 语义 | 描述是否讲清根因、处理结果是否对得上问题 | 大模型 | 每条一次调用 |
L3 暂时没做——一次抽检几百条,每条一次模型调用,成本和耗时都不划算,而前两层已经能挑出大部分问题。
L2 最容易出错:推断不能用来判人
「这个客户的真实属性」有两个来源:
- 例外表:主管逐个核查过的确定事实;
- 推断:从客户所属的类别推出来的——而类别和属性不是一一对应的。
规则是:只有来源是例外表时,不符才判为错误;来源是推断时,最多给一个提醒。
拿推断去判人,必然制造冤假错案——被判错的那个人是对的,是你的推断错了。
判定的严厉程度,不能超过判据的可信度。 这是整个功能里最该想清楚的一条。
抽样必须可复现
质检是抽样的:某人某段时间里的全部工单,抽 N 条。
随机抽样必须可复现——同一个时间区间、同一个人,重复抽必然抽到同一批。否则被抽查的人完全可以质疑:「你多抽几次,总能抽到我错的那条。」整个质检结论就失去了公信力。
做法是用确定性的伪随机数,种子取「日期区间 + 被抽查人 id」:
/** 字符串 → 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,不上云——质检结果是敏感信息,留在主管自己的机器上。
- 表头带质检标准的版本号。 报表要能自证「按哪一版标准评的」——否则过几个月,没人说得清某份结果用的是什么口径;
- 公式注入防护:以
=+-@开头的值前面加一个单引号。否则 Excel 会把工单文本当公式执行——而工单文本是客户写的; - 必须带 UTF-8 BOM,否则 Excel 打开中文是乱码;
- 下载必须在 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纯只读是一个设计约束,不是巧合
这个模块不改任何工单、不提交、不发消息、不调模型。它是整个扩展里最「无害」的长任务。
这不是因为功能简单,是刻意的:质检的结论要被信任,它本身就不能有任何副作用。一个会改数据的质检工具,它的结论永远可以被质疑「是不是你改出来的」。
回头看,这件事最重要的决定是把它从提交按钮前面挪开。
同一套规则,挡在一线面前是一道需要绕过的墙;放在主管手里是一份可以讨论的报告。规则没变,变的是它出现的时刻和面对的人。