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())
|
||||
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')
|
||||
|
||||
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()
|
||||
|
||||
@ -13,6 +13,7 @@ import { AuthModule } from './auth/auth.module'
|
||||
import { UsersModule } from './users/users.module'
|
||||
import { AdminModule } from './admin/admin.module'
|
||||
import { IntegrationModule } from './integration/integration.module'
|
||||
import { ProductCategoryModule } from './product-category/product-category.module'
|
||||
import { HealthController } from './common/health.controller'
|
||||
|
||||
@Module({
|
||||
@ -31,6 +32,7 @@ import { HealthController } from './common/health.controller'
|
||||
CrawlerModule,
|
||||
AuditLogModule,
|
||||
IntegrationModule,
|
||||
ProductCategoryModule,
|
||||
],
|
||||
controllers: [HealthController],
|
||||
})
|
||||
|
||||
@ -1,12 +1,13 @@
|
||||
import { Module } from '@nestjs/common'
|
||||
import { ScheduleModule } from '@nestjs/schedule'
|
||||
import { PrismaModule } from '../prisma/prisma.module'
|
||||
import { ProductCategoryModule } from '../product-category/product-category.module'
|
||||
import { CrawlerService } from './crawler.service'
|
||||
import { CrawlerController } from './crawler.controller'
|
||||
import { NotesExtractorService } from './notes-extractor.service'
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, ScheduleModule.forRoot()],
|
||||
imports: [PrismaModule, ScheduleModule.forRoot(), ProductCategoryModule],
|
||||
controllers: [CrawlerController],
|
||||
providers: [CrawlerService, NotesExtractorService],
|
||||
exports: [NotesExtractorService],
|
||||
|
||||
@ -5,7 +5,7 @@ import { execSync } from 'child_process'
|
||||
import * as path from 'path'
|
||||
import * as fs from 'fs'
|
||||
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 TENDER_FETCHER_DIR = path.join(SCRIPTS_DIR, 'tender-fetcher')
|
||||
@ -30,6 +30,7 @@ export class CrawlerService implements OnModuleInit {
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private schedulerRegistry: SchedulerRegistry,
|
||||
private categoryInference: CategoryInferenceService,
|
||||
) {}
|
||||
|
||||
async onModuleInit() {
|
||||
@ -118,8 +119,13 @@ export class CrawlerService implements OnModuleInit {
|
||||
}
|
||||
|
||||
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 {
|
||||
await this.prisma.lead.upsert({
|
||||
where: {
|
||||
@ -131,7 +137,7 @@ export class CrawlerService implements OnModuleInit {
|
||||
deadline: r.deadline,
|
||||
detailUrl: r.detailUrl,
|
||||
publishTime: r.publishTime || null,
|
||||
category: inferCategory(r.title),
|
||||
category: categoryMap.get(r.noticeId) || '',
|
||||
...(r.amount ? { amount: r.amount } : {}),
|
||||
...(r.region ? { region: r.region } : {}),
|
||||
...(r.regionDetail ? { regionDetail: r.regionDetail } : {}),
|
||||
@ -148,7 +154,7 @@ export class CrawlerService implements OnModuleInit {
|
||||
amount: r.amount || '待确认',
|
||||
status: '待复核',
|
||||
level: 'C',
|
||||
category: inferCategory(r.title),
|
||||
category: categoryMap.get(r.noticeId) || '',
|
||||
region: r.region || '',
|
||||
regionDetail: r.regionDetail || '',
|
||||
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 { IntegrationModule } from '../integration/integration.module'
|
||||
import { CrawlerModule } from '../crawler/crawler.module'
|
||||
import { ProductCategoryModule } from '../product-category/product-category.module'
|
||||
import { LeadService } from './lead.service'
|
||||
import { LeadController } from './lead.controller'
|
||||
import { LeadNotesController } from './lead-notes.controller'
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, IntegrationModule, CrawlerModule],
|
||||
imports: [PrismaModule, IntegrationModule, CrawlerModule, ProductCategoryModule],
|
||||
controllers: [LeadController, LeadNotesController],
|
||||
providers: [LeadService],
|
||||
exports: [LeadService],
|
||||
|
||||
@ -1,36 +1,8 @@
|
||||
import { Injectable, NotFoundException, Logger, ServiceUnavailableException } from '@nestjs/common'
|
||||
import { PrismaService } from '../prisma/prisma.service'
|
||||
import { IntegrationService } from '../integration/integration.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 ''
|
||||
}
|
||||
import { ProductCategoryService } from '../product-category/product-category.service'
|
||||
import { CategoryInferenceService } from './category-inference.service'
|
||||
|
||||
@Injectable()
|
||||
export class LeadService {
|
||||
@ -39,6 +11,8 @@ export class LeadService {
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
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 }) {
|
||||
@ -59,8 +33,9 @@ export class LeadService {
|
||||
}
|
||||
if (params?.source) where.source = params.source
|
||||
if (params?.category) {
|
||||
const keywords = CATEGORY_KEYWORDS[params.category]
|
||||
if (keywords) {
|
||||
const categoryRecord = await this.prisma.productCategory.findUnique({ where: { name: params.category } })
|
||||
if (categoryRecord) {
|
||||
const keywords: string[] = JSON.parse(categoryRecord.keywords)
|
||||
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