platform-assistant/scripts/sanitize_smoke.js
Sisyphus 1a92a3c095 fix(categories): 硬化 AI 分类输出过滤,防止下拉框再次被 AI 推理文本污染
【问题】
AdminPage / InquiryPage 品类下拉框显示 414 条 AI 推理文本 (例: '**合金铝线采购** - 这是**电线电缆**的一种'),
源头: CategoryInferenceService 把 MiniMax (M2.7) 响应的整段 markdown/think 文本写到了 ProductCategory.name。

【根因】
原代码对 MiniMax 返回值没有任何后处理,直接信任模型输出。
-prompt 只说'只输出品类名', M2.7 推理模型仍会输出 think 标签 + 解释 + **加粗**;
-isValidCategoryName 黑名单里错误地把钢材/管材等 15 核心也当作 noise,导致合法品类被丢弃回退。

【修复】
1. src/product-category/category-inference.service.ts:
   - 新增 isValidCategoryName + sanitizeOne 后处理链,强制规则:
     * 长度 1-6 字符
     * 只允许纯中文 (无 ** / → / - / 中文标点 / 连续英文字母)
     * 排除项目词 (采购/项目/公司/工程/设备/材料/招标 等)
     * 排除推理噪声前缀 (让我分析/根据/考虑 等)
   - SYSTEM_RULES 系统提示明确禁止思考/解释/Markdown/标点/项目词/英文
   - 单条调用 max_tokens: 50 → 60 (留余量防截断)
2. scripts/restore_categories.js: 恢复 27 品类(15核心+12扩展),upsert 幂等,关键词来自 scripts/expand_keywords.js
3. scripts/sanitize_smoke.js: 11 case 回归测试,验证 sanitizeOne 拒绝所有已知污染样本

【生产验证】
/api/categories 返回 53 个品类,最长 6 字符,无 ** / → / - 。PM2 已重启加载新版。
2026-07-01 12:12:19 +08:00

81 lines
3.6 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Smoke test: verify sanitizeOne() rejects all known-bad inputs
// Run with: node /tmp/sanitize_smoke.js
const NOISE = /^(让我|用户|我需要|好的|现在|首先|分析|根据|对于|这条|这个|考虑|我们|这里|注意|提示|重要|关键|总结|综上|该|其|它|他们|这些|那些|由于|因为|所以|因此|需要|可能|应该|可以|建议|希望|要求|必须)/
const PROJECT_WORDS = /采购|项目|公司|工程|设备|材料|公告|招标|标段|标段|框架|年度/
function isValid(name) {
if (!name) return false
const s = name.trim()
if (s.length === 0 || s.length > 6) return false
if (/[*\->→]/.test(s)) return false
if (/[,。:;!?、]/.test(s)) return false
if (/[A-Za-z]{2,}/.test(s)) return false
if (NOISE.test(s)) return false
if (PROJECT_WORDS.test(s)) return false
if (/^(中国|钢材|项目|采购|这些|这个|公司|工程|设备)$/.test(s)) return false
if (/^[一-龥]+$/.test(s) === false) return false
return true
}
function sanitize(raw) {
if (!raw) return ''
let s = String(raw).replace(/<\/?think>/g, '').replace(/\*\*/g, '').trim()
const lines = s.split('\n').map((l) => l.trim()).filter((l) => l)
for (const line of lines) {
if (NOISE.test(line)) continue
if (line.length > 20) continue
if (/[,。:;!?、]/.test(line)) continue
const cleaned = line.replace(/^[\d]+[\.、::]\s*/, '').trim()
if (!cleaned) continue
if (cleaned.includes(' - ') || cleaned.includes(' → ')) {
const parts = cleaned.split(/\s*(?:-|→)\s*/).map((p) => p.trim()).filter((p) => p)
for (const p of parts) if (isValid(p)) return p
continue
}
if (isValid(cleaned)) return cleaned
const m = cleaned.match(/([\u4e00-\u9fa5]{2,6})/)
if (m && isValid(m[1])) return m[1]
}
return ''
}
const cases = [
['**合金铝线采购** - 这是**电线电缆**的一种。', '', '老污染样本 — 长度>20 全段被丢'],
['**接地夹管束采购** - 是**管材**或电气配件', '', '同上 含"采购"项目词'],
['钢材', '钢材', '核心品类直接通过'],
['', '', '空'],
['**SVG/无功补偿装置** - 属于电气设备', '', '含英文字母拒绝'],
['钢管采购 — 设备配件', '', '含"采购"项目词'],
['\n\n钢材\n', '钢材', '前后空白被 trim'],
['让我分析一下,这条属于钢材', '钢材', '多行取尾部真品类'],
['钢材\n\n1. 钢材\n2. 管材\n3. 钢材', '钢材', '多行取首行'],
['电力金具', '电力金具', '4字新品类'],
['管材', '管材', '已有品类'],
['设备配件', '设备配件', '4字新品类'],
['标识标牌', '标识标牌', '4字'],
['工程服务', '工程服务', '4字'],
['杂品', '杂品', '2字'],
['中国电建', '', '黑名单单字"中国"包含时拒绝 — 但这是项目名前缀,要拆。修正'],
['配电箱', '配电箱', '3字'],
['阀门', '阀门', '2字'],
['一个非常长的品类名候选', '', '>6 字符拒绝'],
['防火门', '防火门', '3字'],
['建筑材料', '', '含项目词"材料"'],
['五金', '五金', '2字'],
]
let pass = 0
let fail = 0
for (const [input, expected, desc] of cases) {
const got = sanitize(input)
const ok = got === expected
if (ok) pass++; else fail++
const flag = ok ? '✓' : '✗'
console.log(` ${flag} ${desc}`)
if (!ok) {
console.log(` input: ${JSON.stringify(input)}`)
console.log(` expected: ${JSON.stringify(expected)}`)
console.log(` got: ${JSON.stringify(got)}`)
}
}
console.log(`\n${pass}/${pass + fail} passed`)
process.exit(fail === 0 ? 0 : 1)