- HANDOVER.md: 完整交接文档(架构/数据模型/API/业务流/已知坑/工作区状态/待办) - PRD-智能比价助手.md: 比价功能产品需求文档(供外部平台参考) - 数据维护脚本: 清理AI垃圾品类SQL、修复损坏标题SQL、线索重分类/重抓脚本
154 lines
4.8 KiB
JavaScript
154 lines
4.8 KiB
JavaScript
/**
|
||
* 重新分类脚本:对 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(/<think>[\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)
|