feat: 品类方案C - AI分类 + 关键词表反哺
- 新增 ProductCategory 表(schema + migration + seed 15个品类) - 新增 product-category CRUD API(GET/POST/PUT/DELETE /api/categories) - 新增 CategoryInferenceService:关键词匹配优先,失败时调 DeepSeek AI 分类,结果反写品类表 - 更新 CrawlerService:采集入库时使用 batchInfer 替代硬编码 inferCategory - 更新 LeadService:品类筛选改为 DB 关键词匹配,移除硬编码 CATEGORY_KEYWORDS - 修复 LeadModule/CrawlerModule 依赖关系,避免循环依赖
This commit is contained in:
parent
761200a70c
commit
58ac140802
@ -0,0 +1,12 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "ProductCategory" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"keywords" TEXT NOT NULL,
|
||||||
|
"isAIGenerated" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" DATETIME NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "ProductCategory_name_key" ON "ProductCategory"("name");
|
||||||
@ -230,3 +230,12 @@ model Integration {
|
|||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model ProductCategory {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
name String @unique // 品类名, 如 "电线电缆"
|
||||||
|
keywords String // JSON 数组: ["电缆","电线","导线"]
|
||||||
|
isAIGenerated Boolean @default(false) // 是否由 AI 自动分类生成
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|||||||
@ -262,6 +262,33 @@ async function main() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
console.log('Seed data created')
|
console.log('Seed data created')
|
||||||
|
|
||||||
|
const categories = [
|
||||||
|
{ name: '防火门', keywords: ['防火门', '防火卷帘', '防火窗', '钢质防火', '木质防火'] },
|
||||||
|
{ name: '配电箱', keywords: ['配电箱', '配电柜', '配电盘', '照明配电箱', '动力配电箱', '电表箱'] },
|
||||||
|
{ name: '电线电缆', keywords: ['电线', '电缆', '导线', '电力线', '控制电缆', '光缆', '架空线'] },
|
||||||
|
{ name: '阀门', keywords: ['阀门', '闸阀', '球阀', '蝶阀', '截止阀', '止回阀', '调节阀'] },
|
||||||
|
{ name: '钢材', keywords: ['钢材', '钢管', '钢板', '钢筋', '型钢', '槽钢', '角钢', '工字钢', 'H型钢', '螺纹钢'] },
|
||||||
|
{ name: '涂料', keywords: ['涂料', '油漆', '防腐漆', '防火涂料', '乳胶漆', '防水涂料'] },
|
||||||
|
{ name: '混凝土', keywords: ['混凝土', '商混', '商品砼', '水泥', '砂浆', '砼'] },
|
||||||
|
{ name: '变压器', keywords: ['变压器', '箱变', '干式变', '油浸变', '电力变压器'] },
|
||||||
|
{ name: '水泵', keywords: ['水泵', '离心泵', '潜污泵', '消防泵', '排污泵', '循环泵'] },
|
||||||
|
{ name: '风机', keywords: ['风机', '通风机', '排风机', '轴流风机', '离心风机'] },
|
||||||
|
{ name: '管材', keywords: ['管材', '管道', 'PVC管', 'PE管', '镀锌管', '无缝管', '螺旋管', '给水管'] },
|
||||||
|
{ name: '开关', keywords: ['开关', '断路器', '接触器', '继电器', '熔断器', '隔离开关'] },
|
||||||
|
{ name: '灯具', keywords: ['灯具', '照明灯', '路灯', '防爆灯', 'LED灯', '太阳能灯'] },
|
||||||
|
{ name: '仪表', keywords: ['仪表', '仪器', '传感器', '变送器', '流量计', '压力表', '温度计'] },
|
||||||
|
{ name: '电机', keywords: ['电机', '电动机', '减速机', '减速器', '变频电机'] },
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const cat of categories) {
|
||||||
|
await prisma.productCategory.upsert({
|
||||||
|
where: { name: cat.name },
|
||||||
|
update: { keywords: JSON.stringify(cat.keywords) },
|
||||||
|
create: { name: cat.name, keywords: JSON.stringify(cat.keywords) },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
console.log('ProductCategory seed data created')
|
||||||
}
|
}
|
||||||
|
|
||||||
main()
|
main()
|
||||||
|
|||||||
@ -13,6 +13,7 @@ import { AuthModule } from './auth/auth.module'
|
|||||||
import { UsersModule } from './users/users.module'
|
import { UsersModule } from './users/users.module'
|
||||||
import { AdminModule } from './admin/admin.module'
|
import { AdminModule } from './admin/admin.module'
|
||||||
import { IntegrationModule } from './integration/integration.module'
|
import { IntegrationModule } from './integration/integration.module'
|
||||||
|
import { ProductCategoryModule } from './product-category/product-category.module'
|
||||||
import { HealthController } from './common/health.controller'
|
import { HealthController } from './common/health.controller'
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
@ -31,6 +32,7 @@ import { HealthController } from './common/health.controller'
|
|||||||
CrawlerModule,
|
CrawlerModule,
|
||||||
AuditLogModule,
|
AuditLogModule,
|
||||||
IntegrationModule,
|
IntegrationModule,
|
||||||
|
ProductCategoryModule,
|
||||||
],
|
],
|
||||||
controllers: [HealthController],
|
controllers: [HealthController],
|
||||||
})
|
})
|
||||||
|
|||||||
@ -1,12 +1,13 @@
|
|||||||
import { Module } from '@nestjs/common'
|
import { Module } from '@nestjs/common'
|
||||||
import { ScheduleModule } from '@nestjs/schedule'
|
import { ScheduleModule } from '@nestjs/schedule'
|
||||||
import { PrismaModule } from '../prisma/prisma.module'
|
import { PrismaModule } from '../prisma/prisma.module'
|
||||||
|
import { ProductCategoryModule } from '../product-category/product-category.module'
|
||||||
import { CrawlerService } from './crawler.service'
|
import { CrawlerService } from './crawler.service'
|
||||||
import { CrawlerController } from './crawler.controller'
|
import { CrawlerController } from './crawler.controller'
|
||||||
import { NotesExtractorService } from './notes-extractor.service'
|
import { NotesExtractorService } from './notes-extractor.service'
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PrismaModule, ScheduleModule.forRoot()],
|
imports: [PrismaModule, ScheduleModule.forRoot(), ProductCategoryModule],
|
||||||
controllers: [CrawlerController],
|
controllers: [CrawlerController],
|
||||||
providers: [CrawlerService, NotesExtractorService],
|
providers: [CrawlerService, NotesExtractorService],
|
||||||
exports: [NotesExtractorService],
|
exports: [NotesExtractorService],
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import { execSync } from 'child_process'
|
|||||||
import * as path from 'path'
|
import * as path from 'path'
|
||||||
import * as fs from 'fs'
|
import * as fs from 'fs'
|
||||||
import { PrismaService } from '../prisma/prisma.service'
|
import { PrismaService } from '../prisma/prisma.service'
|
||||||
import { inferCategory } from '../lead/lead.service'
|
import { CategoryInferenceService } from '../product-category/category-inference.service'
|
||||||
|
|
||||||
const SCRIPTS_DIR = path.resolve(__dirname, '../../../../../scripts')
|
const SCRIPTS_DIR = path.resolve(__dirname, '../../../../../scripts')
|
||||||
const TENDER_FETCHER_DIR = path.join(SCRIPTS_DIR, 'tender-fetcher')
|
const TENDER_FETCHER_DIR = path.join(SCRIPTS_DIR, 'tender-fetcher')
|
||||||
@ -30,6 +30,7 @@ export class CrawlerService implements OnModuleInit {
|
|||||||
constructor(
|
constructor(
|
||||||
private prisma: PrismaService,
|
private prisma: PrismaService,
|
||||||
private schedulerRegistry: SchedulerRegistry,
|
private schedulerRegistry: SchedulerRegistry,
|
||||||
|
private categoryInference: CategoryInferenceService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async onModuleInit() {
|
async onModuleInit() {
|
||||||
@ -118,8 +119,13 @@ export class CrawlerService implements OnModuleInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let successCount = 0
|
let successCount = 0
|
||||||
for (const r of results) {
|
|
||||||
if (!r.noticeId) continue
|
const validResults = results.filter(r => r.noticeId)
|
||||||
|
const categoryMap = await this.categoryInference.batchInfer(
|
||||||
|
validResults.map(r => ({ id: r.noticeId, title: r.title })),
|
||||||
|
)
|
||||||
|
|
||||||
|
for (const r of validResults) {
|
||||||
try {
|
try {
|
||||||
await this.prisma.lead.upsert({
|
await this.prisma.lead.upsert({
|
||||||
where: {
|
where: {
|
||||||
@ -131,7 +137,7 @@ export class CrawlerService implements OnModuleInit {
|
|||||||
deadline: r.deadline,
|
deadline: r.deadline,
|
||||||
detailUrl: r.detailUrl,
|
detailUrl: r.detailUrl,
|
||||||
publishTime: r.publishTime || null,
|
publishTime: r.publishTime || null,
|
||||||
category: inferCategory(r.title),
|
category: categoryMap.get(r.noticeId) || '',
|
||||||
...(r.amount ? { amount: r.amount } : {}),
|
...(r.amount ? { amount: r.amount } : {}),
|
||||||
...(r.region ? { region: r.region } : {}),
|
...(r.region ? { region: r.region } : {}),
|
||||||
...(r.regionDetail ? { regionDetail: r.regionDetail } : {}),
|
...(r.regionDetail ? { regionDetail: r.regionDetail } : {}),
|
||||||
@ -148,7 +154,7 @@ export class CrawlerService implements OnModuleInit {
|
|||||||
amount: r.amount || '待确认',
|
amount: r.amount || '待确认',
|
||||||
status: '待复核',
|
status: '待复核',
|
||||||
level: 'C',
|
level: 'C',
|
||||||
category: inferCategory(r.title),
|
category: categoryMap.get(r.noticeId) || '',
|
||||||
region: r.region || '',
|
region: r.region || '',
|
||||||
regionDetail: r.regionDetail || '',
|
regionDetail: r.regionDetail || '',
|
||||||
summary: `来源: ${r.source}\n公告ID: ${r.noticeId}\n链接: ${r.detailUrl}`,
|
summary: `来源: ${r.source}\n公告ID: ${r.noticeId}\n链接: ${r.detailUrl}`,
|
||||||
|
|||||||
166
apps/api/src/lead/category-inference.service.ts
Normal file
166
apps/api/src/lead/category-inference.service.ts
Normal file
@ -0,0 +1,166 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common'
|
||||||
|
import { PrismaService } from '../prisma/prisma.service'
|
||||||
|
import { ProductCategoryService } from '../product-category/product-category.service'
|
||||||
|
|
||||||
|
const DEEPSEEK_ENDPOINT = 'https://api.deepseek.com/v1/chat/completions'
|
||||||
|
const DEEPSEEK_MODEL = 'deepseek-chat'
|
||||||
|
const DEEPSEEK_TIMEOUT_MS = 30000
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CategoryInferenceService {
|
||||||
|
private readonly logger = new Logger(CategoryInferenceService.name)
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private prisma: PrismaService,
|
||||||
|
private categoryService: ProductCategoryService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async infer(title: string, summary?: string | null): Promise<string> {
|
||||||
|
if (!title) return ''
|
||||||
|
|
||||||
|
const keywordResult = await this.categoryService.matchByKeywords(title)
|
||||||
|
if (keywordResult) return keywordResult.name
|
||||||
|
|
||||||
|
const apiKey = process.env.DEEPSEEK_API_KEY
|
||||||
|
if (!apiKey) {
|
||||||
|
this.logger.warn('DEEPSEEK_API_KEY 未配置, 无法 AI 分类')
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const aiCategory = await this.callDeepSeek(title, summary, apiKey)
|
||||||
|
if (aiCategory) {
|
||||||
|
await this.categoryService.addKeywordToCategory(aiCategory, this.extractMainKeyword(title))
|
||||||
|
this.logger.log(`AI 分类: "${title.slice(0, 40)}..." → ${aiCategory}`)
|
||||||
|
return aiCategory
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
this.logger.warn(`AI 分类失败: ${e.message}`)
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
async batchInfer(leads: Array<{ id: string; title: string; summary?: string | null }>): Promise<Map<string, string>> {
|
||||||
|
const result = new Map<string, string>()
|
||||||
|
const needsInference = []
|
||||||
|
|
||||||
|
for (const lead of leads) {
|
||||||
|
if (!lead.title) { result.set(lead.id, ''); continue }
|
||||||
|
const keywordResult = await this.categoryService.matchByKeywords(lead.title)
|
||||||
|
if (keywordResult) {
|
||||||
|
result.set(lead.id, keywordResult.name)
|
||||||
|
} else {
|
||||||
|
needsInference.push(lead)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (needsInference.length === 0) return result
|
||||||
|
|
||||||
|
const apiKey = process.env.DEEPSEEK_API_KEY
|
||||||
|
if (!apiKey) {
|
||||||
|
for (const lead of needsInference) result.set(lead.id, '')
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
const BATCH_PROMPT = `你是建材工业品分类助手。根据采购公告标题,从以下已有品类中选择最匹配的一个。如果都不匹配,返回一个新的简短品类名(2-4个中文字)。
|
||||||
|
|
||||||
|
已有品类:{{CATEGORIES}}
|
||||||
|
|
||||||
|
返回格式:只返回品类名,不要其他文字。`
|
||||||
|
|
||||||
|
const categories = await this.categoryService.findAll()
|
||||||
|
const catNames = categories.map(c => c.name).join('、')
|
||||||
|
const systemPrompt = BATCH_PROMPT.replace('{{CATEGORIES}}', catNames)
|
||||||
|
|
||||||
|
const BATCH_SIZE = 20
|
||||||
|
for (let i = 0; i < needsInference.length; i += BATCH_SIZE) {
|
||||||
|
const batch = needsInference.slice(i, i + BATCH_SIZE)
|
||||||
|
const items = batch.map((l, idx) => `${idx + 1}. ${l.title}`).join('\n')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const r = await fetch(DEEPSEEK_ENDPOINT, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${apiKey}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: DEEPSEEK_MODEL,
|
||||||
|
messages: [
|
||||||
|
{ role: 'system', content: systemPrompt },
|
||||||
|
{ role: 'user', content: items },
|
||||||
|
],
|
||||||
|
temperature: 0.1,
|
||||||
|
max_tokens: 500,
|
||||||
|
}),
|
||||||
|
signal: AbortSignal.timeout(DEEPSEEK_TIMEOUT_MS),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!r.ok) {
|
||||||
|
const errText = await r.text().catch(() => '')
|
||||||
|
this.logger.warn(`DeepSeek batch API ${r.status}: ${errText.slice(0, 200)}`)
|
||||||
|
for (const lead of batch) result.set(lead.id, '')
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await r.json() as { choices?: Array<{ message?: { content?: string } }> }
|
||||||
|
const content = data.choices?.[0]?.message?.content || ''
|
||||||
|
const lines = content.split('\n').map(l => l.trim()).filter(l => l.length > 0)
|
||||||
|
|
||||||
|
for (let j = 0; j < batch.length; j++) {
|
||||||
|
const catName = lines[j]?.replace(/^\d+[\.\、]\s*/, '').trim() || ''
|
||||||
|
if (catName && catName.length <= 10) {
|
||||||
|
result.set(batch[j].id, catName)
|
||||||
|
await this.categoryService.addKeywordToCategory(catName, this.extractMainKeyword(batch[j].title))
|
||||||
|
} else {
|
||||||
|
result.set(batch[j].id, '')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
this.logger.warn(`AI batch 分类失败: ${e.message}`)
|
||||||
|
for (const lead of batch) result.set(lead.id, '')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
private async callDeepSeek(title: string, summary: string | null | undefined, apiKey: string): Promise<string> {
|
||||||
|
const existingCats = (await this.categoryService.findAll()).map(c => c.name).join('、')
|
||||||
|
const userContent = `标题: ${title}\n${summary ? `摘要: ${summary.slice(0, 500)}` : ''}`
|
||||||
|
|
||||||
|
const r = await fetch(DEEPSEEK_ENDPOINT, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${apiKey}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: DEEPSEEK_MODEL,
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: 'system',
|
||||||
|
content: `你是建材工业品分类助手。根据采购公告标题,从以下已有品类中选择最匹配的一个。如果都不匹配,返回一个新的简短品类名(2-4个中文字)。\n已有品类:${existingCats}\n\n返回格式:只返回品类名,不要其他文字。`,
|
||||||
|
},
|
||||||
|
{ role: 'user', content: userContent },
|
||||||
|
],
|
||||||
|
temperature: 0.1,
|
||||||
|
max_tokens: 50,
|
||||||
|
}),
|
||||||
|
signal: AbortSignal.timeout(DEEPSEEK_TIMEOUT_MS),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!r.ok) {
|
||||||
|
const errText = await r.text().catch(() => '')
|
||||||
|
throw new Error(`DeepSeek API ${r.status}: ${errText.slice(0, 200)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await r.json() as { choices?: Array<{ message?: { content?: string } }> }
|
||||||
|
return data.choices?.[0]?.message?.content?.trim() || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractMainKeyword(title: string): string {
|
||||||
|
const match = title.match(/([\u4e00-\u9fa5]{2,6})采购/)
|
||||||
|
return match ? match[1] : title.slice(0, 20)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -2,12 +2,13 @@ import { Module } from '@nestjs/common'
|
|||||||
import { PrismaModule } from '../prisma/prisma.module'
|
import { PrismaModule } from '../prisma/prisma.module'
|
||||||
import { IntegrationModule } from '../integration/integration.module'
|
import { IntegrationModule } from '../integration/integration.module'
|
||||||
import { CrawlerModule } from '../crawler/crawler.module'
|
import { CrawlerModule } from '../crawler/crawler.module'
|
||||||
|
import { ProductCategoryModule } from '../product-category/product-category.module'
|
||||||
import { LeadService } from './lead.service'
|
import { LeadService } from './lead.service'
|
||||||
import { LeadController } from './lead.controller'
|
import { LeadController } from './lead.controller'
|
||||||
import { LeadNotesController } from './lead-notes.controller'
|
import { LeadNotesController } from './lead-notes.controller'
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PrismaModule, IntegrationModule, CrawlerModule],
|
imports: [PrismaModule, IntegrationModule, CrawlerModule, ProductCategoryModule],
|
||||||
controllers: [LeadController, LeadNotesController],
|
controllers: [LeadController, LeadNotesController],
|
||||||
providers: [LeadService],
|
providers: [LeadService],
|
||||||
exports: [LeadService],
|
exports: [LeadService],
|
||||||
|
|||||||
@ -1,36 +1,8 @@
|
|||||||
import { Injectable, NotFoundException, Logger, ServiceUnavailableException } from '@nestjs/common'
|
import { Injectable, NotFoundException, Logger, ServiceUnavailableException } from '@nestjs/common'
|
||||||
import { PrismaService } from '../prisma/prisma.service'
|
import { PrismaService } from '../prisma/prisma.service'
|
||||||
import { IntegrationService } from '../integration/integration.service'
|
import { IntegrationService } from '../integration/integration.service'
|
||||||
|
import { ProductCategoryService } from '../product-category/product-category.service'
|
||||||
// 品类 → 标题关键词映射(筛选 + 入库均使用)
|
import { CategoryInferenceService } from './category-inference.service'
|
||||||
export const CATEGORY_KEYWORDS: Record<string, string[]> = {
|
|
||||||
'防火门': ['防火门', '防火卷帘', '防火窗', '钢质防火', '木质防火'],
|
|
||||||
'配电箱': ['配电箱', '配电柜', '配电盘', '照明配电箱', '动力配电箱', '电表箱'],
|
|
||||||
'电线电缆': ['电线', '电缆', '导线', '电力线', '控制电缆', '光缆', '架空线'],
|
|
||||||
'阀门': ['阀门', '闸阀', '球阀', '蝶阀', '截止阀', '止回阀', '调节阀'],
|
|
||||||
'钢材': ['钢材', '钢管', '钢板', '钢筋', '型钢', '槽钢', '角钢', '工字钢', 'H型钢', '螺纹钢'],
|
|
||||||
'涂料': ['涂料', '油漆', '防腐漆', '防火涂料', '乳胶漆', '防水涂料'],
|
|
||||||
'混凝土': ['混凝土', '商混', '商品砼', '水泥', '砂浆', '砼'],
|
|
||||||
'变压器': ['变压器', '箱变', '干式变', '油浸变', '电力变压器'],
|
|
||||||
'水泵': ['水泵', '离心泵', '潜污泵', '消防泵', '排污泵', '循环泵'],
|
|
||||||
'风机': ['风机', '通风机', '排风机', '轴流风机', '离心风机'],
|
|
||||||
'管材': ['管材', '管道', 'PVC管', 'PE管', '镀锌管', '无缝管', '螺旋管', '给水管'],
|
|
||||||
'开关': ['开关', '断路器', '接触器', '继电器', '熔断器', '隔离开关'],
|
|
||||||
'灯具': ['灯具', '照明灯', '路灯', '防爆灯', 'LED灯', '太阳能灯'],
|
|
||||||
'仪表': ['仪表', '仪器', '传感器', '变送器', '流量计', '压力表', '温度计'],
|
|
||||||
'电机': ['电机', '电动机', '减速机', '减速器', '变频电机'],
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 根据标题推断品类,未匹配返回空字符串 */
|
|
||||||
export function inferCategory(title: string): string {
|
|
||||||
if (!title) return ''
|
|
||||||
for (const [cat, keywords] of Object.entries(CATEGORY_KEYWORDS)) {
|
|
||||||
for (const kw of keywords) {
|
|
||||||
if (title.includes(kw)) return cat
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class LeadService {
|
export class LeadService {
|
||||||
@ -39,6 +11,8 @@ export class LeadService {
|
|||||||
constructor(
|
constructor(
|
||||||
private prisma: PrismaService,
|
private prisma: PrismaService,
|
||||||
private integrationService: IntegrationService,
|
private integrationService: IntegrationService,
|
||||||
|
private categoryService: ProductCategoryService,
|
||||||
|
private categoryInference: CategoryInferenceService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async findAll(params?: { status?: string; level?: string; search?: string; star?: string; source?: string; category?: string; assignedTo?: string; timeRange?: string }) {
|
async findAll(params?: { status?: string; level?: string; search?: string; star?: string; source?: string; category?: string; assignedTo?: string; timeRange?: string }) {
|
||||||
@ -59,8 +33,9 @@ export class LeadService {
|
|||||||
}
|
}
|
||||||
if (params?.source) where.source = params.source
|
if (params?.source) where.source = params.source
|
||||||
if (params?.category) {
|
if (params?.category) {
|
||||||
const keywords = CATEGORY_KEYWORDS[params.category]
|
const categoryRecord = await this.prisma.productCategory.findUnique({ where: { name: params.category } })
|
||||||
if (keywords) {
|
if (categoryRecord) {
|
||||||
|
const keywords: string[] = JSON.parse(categoryRecord.keywords)
|
||||||
where.OR = keywords.map(kw => ({ title: { contains: kw } }))
|
where.OR = keywords.map(kw => ({ title: { contains: kw } }))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
27
apps/api/src/product-category/product-category.controller.ts
Normal file
27
apps/api/src/product-category/product-category.controller.ts
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import { Controller, Get, Post, Put, Delete, Body, Param } from '@nestjs/common'
|
||||||
|
import { ProductCategoryService } from './product-category.service'
|
||||||
|
|
||||||
|
@Controller('api/categories')
|
||||||
|
export class ProductCategoryController {
|
||||||
|
constructor(private readonly categoryService: ProductCategoryService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
findAll() {
|
||||||
|
return this.categoryService.findAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
create(@Body() body: { name: string; keywords: string[] }) {
|
||||||
|
return this.categoryService.create(body.name, body.keywords)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put(':id')
|
||||||
|
update(@Param('id') id: string, @Body() body: { name: string; keywords: string[] }) {
|
||||||
|
return this.categoryService.update(id, body.name, body.keywords)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
remove(@Param('id') id: string) {
|
||||||
|
return this.categoryService.remove(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
13
apps/api/src/product-category/product-category.module.ts
Normal file
13
apps/api/src/product-category/product-category.module.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import { Module } from '@nestjs/common'
|
||||||
|
import { PrismaModule } from '../prisma/prisma.module'
|
||||||
|
import { ProductCategoryService } from './product-category.service'
|
||||||
|
import { ProductCategoryController } from './product-category.controller'
|
||||||
|
import { CategoryInferenceService } from '../lead/category-inference.service'
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [PrismaModule],
|
||||||
|
providers: [ProductCategoryService, CategoryInferenceService],
|
||||||
|
controllers: [ProductCategoryController],
|
||||||
|
exports: [ProductCategoryService, CategoryInferenceService],
|
||||||
|
})
|
||||||
|
export class ProductCategoryModule {}
|
||||||
72
apps/api/src/product-category/product-category.service.ts
Normal file
72
apps/api/src/product-category/product-category.service.ts
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common'
|
||||||
|
import { PrismaService } from '../prisma/prisma.service'
|
||||||
|
|
||||||
|
export interface CategoryMatch {
|
||||||
|
name: string
|
||||||
|
matchedBy: 'keyword' | 'ai' | null
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ProductCategoryService {
|
||||||
|
private readonly logger = new Logger(ProductCategoryService.name)
|
||||||
|
|
||||||
|
constructor(private prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async findAll() {
|
||||||
|
return this.prisma.productCategory.findMany({ orderBy: { name: 'asc' } })
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(name: string, keywords: string[]) {
|
||||||
|
return this.prisma.productCategory.create({
|
||||||
|
data: { name, keywords: JSON.stringify(keywords) },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, name: string, keywords: string[]) {
|
||||||
|
return this.prisma.productCategory.update({
|
||||||
|
where: { id },
|
||||||
|
data: { name, keywords: JSON.stringify(keywords) },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(id: string) {
|
||||||
|
return this.prisma.productCategory.delete({ where: { id } })
|
||||||
|
}
|
||||||
|
|
||||||
|
async matchByKeywords(title: string): Promise<CategoryMatch | null> {
|
||||||
|
const categories = await this.prisma.productCategory.findMany()
|
||||||
|
for (const cat of categories) {
|
||||||
|
const keywords: string[] = JSON.parse(cat.keywords)
|
||||||
|
for (const kw of keywords) {
|
||||||
|
if (title.includes(kw)) {
|
||||||
|
return { name: cat.name, matchedBy: 'keyword' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
async addKeywordToCategory(categoryName: string, keyword: string) {
|
||||||
|
const cat = await this.prisma.productCategory.findUnique({ where: { name: categoryName } })
|
||||||
|
if (!cat) {
|
||||||
|
await this.prisma.productCategory.create({
|
||||||
|
data: {
|
||||||
|
name: categoryName,
|
||||||
|
keywords: JSON.stringify([keyword]),
|
||||||
|
isAIGenerated: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
this.logger.log(`AI 新建品类: ${categoryName} (关键词: ${keyword})`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const keywords: string[] = JSON.parse(cat.keywords)
|
||||||
|
if (!keywords.includes(keyword)) {
|
||||||
|
keywords.push(keyword)
|
||||||
|
await this.prisma.productCategory.update({
|
||||||
|
where: { id: cat.id },
|
||||||
|
data: { keywords: JSON.stringify(keywords) },
|
||||||
|
})
|
||||||
|
this.logger.log(`AI 补充品类 ${categoryName} 关键词: ${keyword}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user