/** * 重新分类脚本:对 category 为空的线索重新执行分类 * 用法:node scripts/reclassify_leads.js * * 需要在项目根目录运行(使用项目内的 Prisma) */ const { PrismaClient } = require('@prisma/client') // 直接复用 inference 逻辑的内联版本 const MINIMAX_ENDPOINT = 'https://api.minimaxi.com/v1/chat/completions' const MINIMAX_MODEL = process.env.MINIMAX_MODEL || 'MiniMax-M2.7' const API_KEY = process.env.MINIMAX_API_KEY function isGarbageKeyword(kw) { if (/(?:公司|有限|集团|工程局|项目部)$/.test(kw)) return true if (/^(?:中国电建|新疆)/.test(kw)) return true const genericTerms = ['公开竞价', '公开招标', '竞争性谈判', '询价采购', '邀请招标'] if (genericTerms.includes(kw)) return true return false } function isValidCategoryName(raw) { if (!raw) return false const s = raw.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 (!/^[\u4e00-\u9fa5]+$/.test(s)) return false if (/^(让我|用户|我需要|好的|现在|首先|分析|根据|对于|这条|这个|考虑|我们|这里|注意|提示|重要|关键|总结|综上|该|其|它|他们|这些|那些|由于|因为|所以|因此|需要|可能|应该|可以|建议|希望|要求|必须)/.test(s)) return false if (/采购|项目|公司|工程|设备|材料|公告|招标|标段|框架|年度/.test(s)) return false return true } function extractCategoryFromLine(line) { // 剥离数字前缀 let s = line.replace(/^[\d]+[\.、::]\s*/, '').trim() if (!s) return '' // 取 → 后面的内容(最可能是品类名) const arrowIdx = s.lastIndexOf('→') if (arrowIdx >= 0) { let afterArrow = s.slice(arrowIdx + 1).trim() // 提取连续中文字 const m = afterArrow.match(/([\u4e00-\u9fa5]{2,6})/) if (m && isValidCategoryName(m[1])) return m[1] } // 从整句中提取可能的品类名 const parts = s.split(/\s*(?:[-–—]|→)\s*/).map(p => p.trim()).filter(p => p) // 从最后一部分往前找 for (let i = parts.length - 1; i >= 0; i--) { const m = parts[i].match(/([\u4e00-\u9fa5]{2,6})/g) if (m) { for (let j = m.length - 1; j >= 0; j--) { if (isValidCategoryName(m[j])) return m[j] } } } // 从整句提取 const m = s.match(/([\u4e00-\u9fa5]{2,6})/g) if (m) { for (let j = m.length - 1; j >= 0; j--) { if (isValidCategoryName(m[j])) return m[j] } } return '' } // 简版: 只对 batch 结果做后处理 function parseBatchResult(content) { const results = [] // 去掉 think 块 let clean = content.replace(/[\s\S]*?<\/think>/g, '').trim() const lines = clean.split('\n').map(l => l.trim()).filter(l => l) for (const line of lines) { results.push(extractCategoryFromLine(line)) } return results } async function main() { const prisma = new PrismaClient() try { // 1. 找出所有空分类的线索 const leads = await prisma.lead.findMany({ where: { OR: [{ category: null }, { category: '' }] }, select: { id: true, title: true, summary: true }, }) console.log(`找到 ${leads.length} 条待分类线索`) if (leads.length === 0) { console.log('无需分类') return } // 2. 获取已有品类 const categories = await prisma.productCategory.findMany() const catNames = categories.map(c => c.name).join('、') // 3. 依次尝试关键词匹配,未命中则调 AI const needsAI = [] const result = new Map() for (const lead of leads) { // 关键词匹配(跳过垃圾关键词) let matched = false for (const cat of categories) { const kws = JSON.parse(cat.keywords) for (const kw of kws) { if (isGarbageKeyword(kw)) continue if (lead.title.includes(kw)) { result.set(lead.id, cat.name) matched = true break } } if (matched) break } if (!matched) { needsAI.push(lead) } } console.log(`关键词命中: ${result.size} 条, 需 AI 推断: ${needsAI.length} 条`) // 4. 未命中的全部归为"其他" console.log(`关键词未命中的 ${needsAI.length} 条统一归为「其他」品类`) for (const lead of needsAI) { result.set(lead.id, '其他') } // 5. 更新数据库 let updated = 0 for (const [id, category] of result) { if (category) { await prisma.lead.update({ where: { id }, data: { category }, }) updated++ } } console.log(`\n完成! 更新了 ${updated}/${leads.length} 条线索的分类`) console.log(`未分类: ${leads.length - updated} 条(保持为空)`) } finally { await prisma.$disconnect() } } main().catch(console.error)