Commit d5132fca by luoqi

fix(用词): 确认单上「无主」→「无专属」—— 同一张卡上两个词指同一件事

按《分配助手》那份 artifact 里的几条边界逐条扫代码,八项里七项干净,
只揪出这一处,但它就在主管眼皮底下:

  确认单段落: 「候选 N 人里 X 人有专属,Y 人**无主**」
  同卡片按钮: 「换**无专属客服**的患者补上」

产品早就定过统一用「无专属」,`assignment-facts`(喂模型那份)也已经改对、
还带着守卫注释「 别写「无主」—— 界面上的按钮文案就是「无专属客服的患者」」,
唯独 `pendingNote` 这段**界面卡片文案**没跟上(改了 3 处 + 1 处变量注释)。
顺带修 `assignment-confirm-sheet` 里一条引错按钮名的注释。

️ 保留 1420 行那条 —— 它复述的是 2026-08-06 那个 bug 的历史现场
  (当时卡片确实写着"19 人无主"),改了反而失真。

■ 新增《用词一致性》守卫:会到达人或模型的字里不许出现废弃词(无主 / 时效)
  ·  语义不同的四类文件豁免(临床禁忌有效期、话术语气紧迫)
  · ️ 扫描器**跟踪块注释状态**, 不只看行首 —— `/* */` 与 JSX `{/* */}`
    中间那些行不以 `*` 开头,只判行首会把历史记录当活文案报出来(第一版就这么假红过)
  · ️ 已做变异校验:往 assignment-facts 里塞一个「无主」立刻变红
  · 带一条"扫到了 >200 个文件"的自检,防止路径写错导致整条测试假绿

■ 同批审计中确认干净的(artifact 的边界表逐条)
  · 系统提示词五层:0 处工具名 / 参数名
  · 语气层:0 处事实 / 工具名 / 界面部件名(`3.5 天` 是标点示范,VOICE 注释明确允许)
  · 工具描述:0 处正面例句(那条踩过:模型把例句逐字念给主管听)
  · 工具返回值:`assignment-facts` 的「只给事实、不给成品句子」边界完好
  · 提示词版本 assistant@2026-08-16-b 与最后一次提示词改动对得上
  · 术语「时效」只剩临床禁忌那三处(刻意排除)
  · 标签改预计算后,工具描述里无残留旧口径

验证:tsc(service + web) 通过;jest 87 套 1354 例全过;eslint 干净。
parent 50f342b3
...@@ -595,12 +595,12 @@ export class AssignmentProposalService { ...@@ -595,12 +595,12 @@ export class AssignmentProposalService {
(ownership.topOwners.length (ownership.topOwners.length
? `\n候选 ${ownership.withOwner + ownership.free} 人里 ${ownership.withOwner} 人有专属` + ? `\n候选 ${ownership.withOwner + ownership.free} 人里 ${ownership.withOwner} 人有专属` +
`(${ownership.topOwners.map((o) => `${o.name ?? o.userId} ${o.n} `).join('、')})` + `(${ownership.topOwners.map((o) => `${o.name ?? o.userId} ${o.n} `).join('、')})` +
`,${ownership.free} 人无。` `,${ownership.free} 人无专属。`
: '') + : '') +
// ⭐ 只说"能重新排一版",⛔ 不说名次、不说顶几个 —— // ⭐ 只说"能重新排一版",⛔ 不说名次、不说顶几个 ——
// 产品:「排名其实不重要,重要的是运营策略(一批人有相同特征)」。 // 产品:「排名其实不重要,重要的是运营策略(一批人有相同特征)」。
(canRefill (canRefill
? `\n卡片上有个「重新排一版」—— 我把池子里**无的都换进来**重挑这 ${target} 人,` + ? `\n卡片上有个「重新排一版」—— 我把池子里**无专属的都换进来**重挑这 ${target} 人,` +
`能排好的会多一些,一条专属关系还是不动。要不要换由您点。` `能排好的会多一些,一条专属关系还是不动。要不要换由您点。`
: '') : '')
: '', : '',
...@@ -615,7 +615,7 @@ export class AssignmentProposalService { ...@@ -615,7 +615,7 @@ export class AssignmentProposalService {
// ⚠️ 中文标点**一律全角**(,;:())—— 这段是直接摆给主管看的, // ⚠️ 中文标点**一律全角**(,;:())—— 这段是直接摆给主管看的,
// 和系统提示词里对模型的要求同一条规矩。半角混在中文里挤成一团, // 和系统提示词里对模型的要求同一条规矩。半角混在中文里挤成一团,
// 而句号又是全角,一行里宽窄不一,读起来发涩。 // 而句号又是全角,一行里宽窄不一,读起来发涩。
: `**已重挑这 ${target} 人**:池子里 ${freeInBatch} 位无的全换进来了,` + : `**已重挑这 ${target} 人**:池子里 ${freeInBatch} 位无专属的全换进来了,` +
`其余按优先级用有专属的补齐。\n` + `其余按优先级用有专属的补齐。\n` +
`排好 ${items.placed.length} 人` + `排好 ${items.placed.length} 人` +
(pending.length > 0 (pending.length > 0
...@@ -1145,7 +1145,7 @@ export function placeAgents( ...@@ -1145,7 +1145,7 @@ export function placeAgents(
agents.length > 0 ? Math.max(1, Math.ceil((inHandSum + chosen.length) / agents.length)) : 0; agents.length > 0 ? Math.max(1, Math.ceil((inHandSum + chosen.length) / agents.length)) : 0;
const placed: PlacedItem[] = []; const placed: PlacedItem[] = [];
const free: typeof chosen = []; // 无 / 专属已离岗 const free: typeof chosen = []; // 无专属 / 专属已离岗
const held: typeof chosen = []; // 有主,但专属这轮的份额已满 → 第三趟进「待分配」 const held: typeof chosen = []; // 有主,但专属这轮的份额已满 → 第三趟进「待分配」
let unplaced = 0; let unplaced = 0;
......
import fs from 'node:fs';
import path from 'node:path';
/**
* 用词一致性守卫 —— 主管在**同一张确认单**上不该看到两个词指同一件事。
*
* 🔴 2026-08-17 审计发现:确认单段落里写「N 人无主」,而同一张卡片上的按钮写
* 「换无专属客服的患者补上」。产品早就定过统一用「无专属」,`assignment-facts`
* (喂模型那份)也带着守卫注释,唯独界面文案没跟上。
*
* ⚠️ 这类漂移不会报错、测试也不会红 —— 只有主管一眼看出"这俩是不是两回事"。
* ⇒ 用规则钉死:**会到达人或模型的字**里不许再出现废弃词。
*/
const ROOTS = ['src', '../pac-web/src', '../../packages/types/src'];
/** 废弃词 → 该用的词 */
const RETIRED: Array<[RegExp, string]> = [
[/无主/, '无专属'],
[/时效/, '时限'],
];
/** ⛔ 语义不同的例外(临床禁忌有效期 / 话术语气紧迫),⛔ 别连坐 */
const EXEMPT = /clinical-signals|persona-feature-specs|signal-extractor|contraindication|shared\/tone/;
function walk(dir: string, out: string[] = []): string[] {
if (!fs.existsSync(dir)) return out;
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
const p = path.join(dir, e.name);
if (e.isDirectory()) {
if (!/node_modules|dist|\.next/.test(p)) walk(p, out);
} else if (/\.tsx?$/.test(e.name)) out.push(p);
}
return out;
}
/**
* 只看**会到达人或模型**的行 —— 注释里的沿革记录保留(那是历史,改了反而失真)。
*
* ⚠️ 必须**跟踪块注释状态**,⛔ 不能只看行首:`/* … *\/` 和 JSX 的 `{/* … *\/}`
* 中间那些行不以 `*` 开头,只判行首会把历史记录当成活文案报出来(第一版就是这么假红的)。
*/
function liveLines(file: string): Array<{ n: number; text: string }> {
const out: Array<{ n: number; text: string }> = [];
let inBlock = false;
fs.readFileSync(file, 'utf8')
.split('\n')
.forEach((text, i) => {
const t = text.trim();
const opens = (t.match(/\/\*/g) ?? []).length;
const closes = (t.match(/\*\//g) ?? []).length;
const wasInBlock = inBlock;
if (opens > closes) inBlock = true;
else if (closes > opens) inBlock = false;
if (wasInBlock || inBlock || t.startsWith('*') || t.startsWith('//') || opens > 0) return;
out.push({ n: i + 1, text });
});
return out;
}
describe('用词一致性 —— 废弃词不许出现在会被看见的字里', () => {
const files = ROOTS.flatMap((r) => walk(path.join(__dirname, '..', r))).filter(
(f) => !EXEMPT.test(f) && !f.includes('wording-consistency.spec'),
);
it('扫到了文件(否则这条测试是假绿)', () => {
expect(files.length).toBeGreaterThan(200);
});
for (const [bad, good] of RETIRED) {
it(`⛔ 不再出现「${bad.source}」—— 产品定的是「${good}」`, () => {
const hits: string[] = [];
for (const f of files) {
for (const { n, text } of liveLines(f)) {
if (bad.test(text)) hits.push(`${path.basename(f)}:${n} ${text.trim().slice(0, 80)}`);
}
}
expect(hits).toEqual([]);
});
}
});
...@@ -1715,7 +1715,7 @@ export function AssignmentConfirmSheet({ ...@@ -1715,7 +1715,7 @@ export function AssignmentConfirmSheet({
⚠️ ⛔ 不写名次/优先级:产品判定「排名不重要,重要的是这批人共享同一组特征」。 ⚠️ ⛔ 不写名次/优先级:产品判定「排名不重要,重要的是这批人共享同一组特征」。
*/} */}
{/* ⛔ 这里原来有一个"重排凑满"按钮 —— 2026-08-13 去掉: {/* ⛔ 这里原来有一个"重排凑满"按钮 —— 2026-08-13 去掉:
待分配已经是引导节点,同一个动作在节点里叫「换无患者补上」 待分配已经是引导节点,同一个动作在节点里叫「换无专属客服的患者补上」
(intent `pending.refill`,由 use-assistant-chat 执行)。 (intent `pending.refill`,由 use-assistant-chat 执行)。
同一个动作两个入口必然漂,而且主管要在两处之间猜哪个是哪个。 */} 同一个动作两个入口必然漂,而且主管要在两处之间猜哪个是哪个。 */}
</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