【问题】
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 已重启加载新版。
81 lines
3.6 KiB
JavaScript
81 lines
3.6 KiB
JavaScript
// 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)
|