From 9c161db26bb5c5c77fb0e742f79a6f1ac6c2a4a0 Mon Sep 17 00:00:00 2001 From: WangDL Date: Tue, 19 May 2026 22:20:29 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20KnowledgeSource=20=E5=92=8C=20ImportCan?= =?UTF-8?q?didate=20=E6=A8=A1=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app.module.ts | 4 + .../import-candidate.controller.ts | 45 +++++++++++ .../import-candidate.module.ts | 13 ++++ .../import-candidate.repository.ts | 71 +++++++++++++++++ .../import-candidate.service.ts | 65 ++++++++++++++++ .../knowledge-source.controller.ts | 39 ++++++++++ .../knowledge-source.module.ts | 13 ++++ .../knowledge-source.repository.ts | 76 +++++++++++++++++++ .../knowledge-source.service.ts | 60 +++++++++++++++ 9 files changed, 386 insertions(+) create mode 100644 src/modules/import-candidate/import-candidate.controller.ts create mode 100644 src/modules/import-candidate/import-candidate.module.ts create mode 100644 src/modules/import-candidate/import-candidate.repository.ts create mode 100644 src/modules/import-candidate/import-candidate.service.ts create mode 100644 src/modules/knowledge-source/knowledge-source.controller.ts create mode 100644 src/modules/knowledge-source/knowledge-source.module.ts create mode 100644 src/modules/knowledge-source/knowledge-source.repository.ts create mode 100644 src/modules/knowledge-source/knowledge-source.service.ts diff --git a/src/app.module.ts b/src/app.module.ts index ac35796..42e941a 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -26,6 +26,8 @@ import { NotificationsModule } from './modules/notifications/notifications.modul import { FeedbackModule } from './modules/feedback/feedback.module'; import { FilesModule } from './modules/files/files.module'; import { WaitlistModule } from './modules/waitlist/waitlist.module'; +import { KnowledgeSourceModule } from './modules/knowledge-source/knowledge-source.module'; +import { ImportCandidateModule } from './modules/import-candidate/import-candidate.module'; import { JwtAuthGuard } from './common/guards/jwt-auth.guard'; import { RolesGuard } from './common/guards/roles.guard'; @@ -80,6 +82,8 @@ import appleConfig from './config/apple.config'; UsersModule, KnowledgeBaseModule, KnowledgeItemsModule, + KnowledgeSourceModule, + ImportCandidateModule, DocumentImportModule, LearningSessionModule, ActiveRecallModule, diff --git a/src/modules/import-candidate/import-candidate.controller.ts b/src/modules/import-candidate/import-candidate.controller.ts new file mode 100644 index 0000000..541ae29 --- /dev/null +++ b/src/modules/import-candidate/import-candidate.controller.ts @@ -0,0 +1,45 @@ +import { Controller, Get, Post, Patch, Body, Param } from '@nestjs/common'; +import { ApiTags, ApiOperation } from '@nestjs/swagger'; +import { ImportCandidateService } from './import-candidate.service'; + +@ApiTags('import-candidate') +@Controller() +export class ImportCandidateController { + constructor(private readonly service: ImportCandidateService) {} + + @Get('knowledge-sources/:sourceId/import-candidates') + @ApiOperation({ summary: '获取资料来源的候选知识点' }) + async findBySource(@Param('sourceId') sourceId: string) { + return this.service.findBySource(sourceId); + } + + @Get('import-candidates/:id') + @ApiOperation({ summary: '获取候选知识点详情' }) + async findOne(@Param('id') id: string) { + return this.service.findOne(id); + } + + @Patch('import-candidates/:id') + @ApiOperation({ summary: '编辑候选知识点' }) + async update(@Param('id') id: string, @Body() dto: any) { + return this.service.update(id, dto); + } + + @Post('import-candidates/:id/accept') + @ApiOperation({ summary: '接受候选知识点 → 生成 KnowledgeItem' }) + async accept(@Param('id') id: string) { + return this.service.accept(id); + } + + @Post('import-candidates/:id/reject') + @ApiOperation({ summary: '拒绝候选知识点' }) + async reject(@Param('id') id: string) { + return this.service.reject(id); + } + + @Post('import-candidates/batch-accept') + @ApiOperation({ summary: '批量接受候选知识点' }) + async batchAccept(@Body() dto: { sourceId: string }) { + return this.service.batchAccept(dto.sourceId); + } +} diff --git a/src/modules/import-candidate/import-candidate.module.ts b/src/modules/import-candidate/import-candidate.module.ts new file mode 100644 index 0000000..b9aa4bd --- /dev/null +++ b/src/modules/import-candidate/import-candidate.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { ImportCandidateController } from './import-candidate.controller'; +import { ImportCandidateService } from './import-candidate.service'; +import { ImportCandidateRepository } from './import-candidate.repository'; +import { KnowledgeItemsModule } from '../knowledge-items/knowledge-items.module'; + +@Module({ + imports: [KnowledgeItemsModule], + controllers: [ImportCandidateController], + providers: [ImportCandidateService, ImportCandidateRepository], + exports: [ImportCandidateService, ImportCandidateRepository], +}) +export class ImportCandidateModule {} diff --git a/src/modules/import-candidate/import-candidate.repository.ts b/src/modules/import-candidate/import-candidate.repository.ts new file mode 100644 index 0000000..e56a573 --- /dev/null +++ b/src/modules/import-candidate/import-candidate.repository.ts @@ -0,0 +1,71 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '../../infrastructure/database/prisma.service'; + +@Injectable() +export class ImportCandidateRepository { + constructor(private readonly prisma: PrismaService) {} + + async createMany(userId: string, knowledgeBaseId: string, sourceId: string, importId: string, candidates: Array<{ + title: string; + summary?: string; + content?: string; + tagsJson?: any; + recallQuestionsJson?: any; + sourceTextSnippet?: string; + sourceChunkIds?: any; + confidence?: number; + difficulty?: string; + orderIndex?: number; + }>) { + const data = candidates.map((c, i) => ({ + userId, + knowledgeBaseId, + sourceId, + importId, + title: c.title, + summary: c.summary, + content: c.content, + tagsJson: c.tagsJson, + recallQuestionsJson: c.recallQuestionsJson, + sourceTextSnippet: c.sourceTextSnippet, + sourceChunkIds: c.sourceChunkIds, + confidence: c.confidence ?? 0, + difficulty: c.difficulty, + orderIndex: c.orderIndex ?? i, + status: 'PENDING', + })); + return this.prisma.importCandidate.createMany({ data }); + } + + async findBySource(sourceId: string) { + return this.prisma.importCandidate.findMany({ + where: { sourceId }, + orderBy: { orderIndex: 'asc' }, + }); + } + + async findById(id: string) { + return this.prisma.importCandidate.findUnique({ where: { id } }); + } + + async updateStatus(id: string, status: string) { + return this.prisma.importCandidate.update({ + where: { id }, + data: { status }, + }); + } + + async batchAccept(ids: string[]) { + return this.prisma.importCandidate.updateMany({ + where: { id: { in: ids } }, + data: { status: 'ACCEPTED' }, + }); + } + + async update(id: string, data: { title?: string; summary?: string; content?: string; tagsJson?: any; recallQuestionsJson?: any }) { + return this.prisma.importCandidate.update({ + where: { id }, + data: { ...data, status: 'EDITED' }, + }); + } +} diff --git a/src/modules/import-candidate/import-candidate.service.ts b/src/modules/import-candidate/import-candidate.service.ts new file mode 100644 index 0000000..4749a24 --- /dev/null +++ b/src/modules/import-candidate/import-candidate.service.ts @@ -0,0 +1,65 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { ImportCandidateRepository } from './import-candidate.repository'; +import { KnowledgeItemsRepository } from '../knowledge-items/knowledge-items.repository'; + +@Injectable() +export class ImportCandidateService { + constructor( + private readonly repository: ImportCandidateRepository, + private readonly itemsRepo: KnowledgeItemsRepository, + ) {} + + async findBySource(sourceId: string) { + return this.repository.findBySource(sourceId); + } + + async findOne(id: string) { + const c = await this.repository.findById(id); + if (!c) throw new NotFoundException('候选知识点不存在'); + return c; + } + + async accept(id: string) { + const candidate = await this.repository.findById(id); + if (!candidate) throw new NotFoundException('候选知识点不存在'); + + await this.repository.updateStatus(id, 'ACCEPTED'); + + // 生成 KnowledgeItem + await this.itemsRepo.create(candidate.userId, candidate.knowledgeBaseId, { + title: candidate.title, + content: (candidate.content as string) ?? '', + itemType: 'ai_generated', + orderIndex: candidate.orderIndex, + }); + + return { status: 'ACCEPTED' }; + } + + async reject(id: string) { + const candidate = await this.repository.findById(id); + if (!candidate) throw new NotFoundException('候选知识点不存在'); + return this.repository.updateStatus(id, 'REJECTED'); + } + + async batchAccept(sourceId: string) { + const candidates = await this.repository.findBySource(sourceId); + const pending = candidates.filter(c => c.status === 'PENDING'); + + for (const c of pending) { + await this.accept(c.id); + } + + return { accepted: pending.length }; + } + + async update(id: string, dto: any) { + const candidate = await this.repository.findById(id); + if (!candidate) throw new NotFoundException('候选知识点不存在'); + return this.repository.update(id, dto); + } + + async createCandidates(userId: string, knowledgeBaseId: string, sourceId: string, importId: string, candidates: Array) { + return this.repository.createMany(userId, knowledgeBaseId, sourceId, importId, candidates); + } +} diff --git a/src/modules/knowledge-source/knowledge-source.controller.ts b/src/modules/knowledge-source/knowledge-source.controller.ts new file mode 100644 index 0000000..fd5f2df --- /dev/null +++ b/src/modules/knowledge-source/knowledge-source.controller.ts @@ -0,0 +1,39 @@ +import { Controller, Get, Post, Delete, Body, Param, Query } from '@nestjs/common'; +import { ApiTags, ApiOperation } from '@nestjs/swagger'; +import { KnowledgeSourceService } from './knowledge-source.service'; +import { CurrentUser } from '../../common/decorators/current-user.decorator'; +import type { UserPayload } from '../../common/types'; + +@ApiTags('knowledge-source') +@Controller('knowledge-bases/:kbId/sources') +export class KnowledgeSourceController { + constructor(private readonly service: KnowledgeSourceService) {} + + @Post() + @ApiOperation({ summary: '添加资料来源' }) + async addSource( + @CurrentUser() user: UserPayload, + @Param('kbId') kbId: string, + @Body() dto: any, + ) { + return this.service.addSource(user.id, kbId, dto); + } + + @Get() + @ApiOperation({ summary: '获取知识库的资料列表' }) + async findAll(@Param('kbId') kbId: string, @Query() pagination: any) { + return this.service.findByKnowledgeBase(kbId, pagination); + } + + @Get(':id') + @ApiOperation({ summary: '获取资料详情' }) + async findOne(@Param('id') id: string) { + return this.service.findOne(id); + } + + @Delete(':id') + @ApiOperation({ summary: '删除资料来源' }) + async remove(@CurrentUser() user: UserPayload, @Param('id') id: string) { + return this.service.remove(id); + } +} diff --git a/src/modules/knowledge-source/knowledge-source.module.ts b/src/modules/knowledge-source/knowledge-source.module.ts new file mode 100644 index 0000000..e23cead --- /dev/null +++ b/src/modules/knowledge-source/knowledge-source.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { KnowledgeSourceController } from './knowledge-source.controller'; +import { KnowledgeSourceService } from './knowledge-source.service'; +import { KnowledgeSourceRepository } from './knowledge-source.repository'; +import { DocumentImportModule } from '../document-import/document-import.module'; + +@Module({ + imports: [DocumentImportModule], + controllers: [KnowledgeSourceController], + providers: [KnowledgeSourceService, KnowledgeSourceRepository], + exports: [KnowledgeSourceService, KnowledgeSourceRepository], +}) +export class KnowledgeSourceModule {} diff --git a/src/modules/knowledge-source/knowledge-source.repository.ts b/src/modules/knowledge-source/knowledge-source.repository.ts new file mode 100644 index 0000000..5aa2389 --- /dev/null +++ b/src/modules/knowledge-source/knowledge-source.repository.ts @@ -0,0 +1,76 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '../../infrastructure/database/prisma.service'; + +@Injectable() +export class KnowledgeSourceRepository { + constructor(private readonly prisma: PrismaService) {} + + async create(userId: string, knowledgeBaseId: string, dto: { + fileId?: string; + type?: string; + title?: string; + originalFilename?: string; + mimeType?: string; + sizeBytes?: number; + originalObjectKey?: string; + }) { + return this.prisma.knowledgeSource.create({ + data: { + userId, + knowledgeBaseId, + fileId: dto.fileId, + type: dto.type ?? 'file', + title: dto.title ?? dto.originalFilename, + originalFilename: dto.originalFilename, + mimeType: dto.mimeType, + sizeBytes: dto.sizeBytes ?? 0, + originalObjectKey: dto.originalObjectKey, + parseStatus: 'pending', + indexStatus: 'pending', + learningStatus: 'pending', + }, + }); + } + + async findByKnowledgeBase(knowledgeBaseId: string, pagination: { page?: number; limit?: number }) { + const page = pagination.page ?? 1; + const limit = pagination.limit ?? 20; + return this.prisma.knowledgeSource.findMany({ + where: { knowledgeBaseId, deletedAt: null }, + skip: (page - 1) * limit, + take: limit, + orderBy: { createdAt: 'desc' }, + }); + } + + async findById(id: string) { + return this.prisma.knowledgeSource.findFirst({ where: { id, deletedAt: null } }); + } + + async softDelete(id: string) { + return this.prisma.knowledgeSource.update({ + where: { id }, + data: { deletedAt: new Date() }, + }); + } + + async updateParseStatus(id: string, parseStatus: string, data?: { textLength?: number; parsedObjectKey?: string; metadataObjectKey?: string; errorCode?: string; errorMessage?: string }) { + return this.prisma.knowledgeSource.update({ + where: { id }, + data: { parseStatus, ...data }, + }); + } + + async updateIndexStatus(id: string, indexStatus: string, errorCode?: string, errorMessage?: string) { + return this.prisma.knowledgeSource.update({ + where: { id }, + data: { indexStatus, errorCode, errorMessage }, + }); + } + + async countByKnowledgeBase(knowledgeBaseId: string) { + return this.prisma.knowledgeSource.count({ + where: { knowledgeBaseId, deletedAt: null }, + }); + } +} diff --git a/src/modules/knowledge-source/knowledge-source.service.ts b/src/modules/knowledge-source/knowledge-source.service.ts new file mode 100644 index 0000000..378733c --- /dev/null +++ b/src/modules/knowledge-source/knowledge-source.service.ts @@ -0,0 +1,60 @@ +import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; +import { KnowledgeSourceRepository } from './knowledge-source.repository'; +import { DocumentImportRepository } from '../document-import/document-import.repository'; + +@Injectable() +export class KnowledgeSourceService { + constructor( + private readonly repository: KnowledgeSourceRepository, + private readonly importRepo: DocumentImportRepository, + ) {} + + async addSource(userId: string, knowledgeBaseId: string, dto: { + fileId?: string; + type?: string; + title?: string; + originalFilename?: string; + mimeType?: string; + sizeBytes?: number; + originalObjectKey?: string; + }) { + const source = await this.repository.create(userId, knowledgeBaseId, dto); + + // 自动创建 import 任务 + await this.importRepo.create({ + userId, + knowledgeBaseId, + sourceId: source.id, + fileId: dto.fileId, + sourceType: dto.type ?? 'file', + sourceName: dto.originalFilename ?? dto.title ?? '', + status: 'QUEUED', + }); + + return source; + } + + async findByKnowledgeBase(knowledgeBaseId: string, pagination: { page?: number; limit?: number }) { + return this.repository.findByKnowledgeBase(knowledgeBaseId, pagination); + } + + async findOne(id: string) { + const source = await this.repository.findById(id); + if (!source) throw new NotFoundException('资料来源不存在'); + return source; + } + + async remove(id: string) { + const source = await this.repository.findById(id); + if (!source) throw new NotFoundException('资料来源不存在'); + return this.repository.softDelete(id); + } + + async updateParseStatus(id: string, parseStatus: string, data?: any) { + return this.repository.updateParseStatus(id, parseStatus, data); + } + + async updateIndexStatus(id: string, indexStatus: string, errorCode?: string, errorMessage?: string) { + return this.repository.updateIndexStatus(id, indexStatus, errorCode, errorMessage); + } +}