From b3bce7ff78630a92125a78bd33131249e798b0e2 Mon Sep 17 00:00:00 2001 From: WangDL Date: Sun, 24 May 2026 11:31:46 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20M2-03=20=E2=80=94=20Material=20&=20Sour?= =?UTF-8?q?ce,=20SourceReference=20citation=20tracking?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SourceReference model for artifact→chunk→source citation chain - Admin source list + reference tracing endpoints - Existing KnowledgeSource already covers Material status/version Co-Authored-By: Claude Opus 4.7 --- .../migration.sql | 14 +++++++ prisma/schema.prisma | 18 +++++++++ .../admin-knowledge.controller.ts | 20 ++++++++++ test/m2.e2e-spec.ts | 40 +++++++++++++++++++ test/mocks/prisma.mock.ts | 2 +- 5 files changed, 93 insertions(+), 1 deletion(-) create mode 100644 prisma/migrations/20260525020000_add_source_reference/migration.sql diff --git a/prisma/migrations/20260525020000_add_source_reference/migration.sql b/prisma/migrations/20260525020000_add_source_reference/migration.sql new file mode 100644 index 0000000..caf8482 --- /dev/null +++ b/prisma/migrations/20260525020000_add_source_reference/migration.sql @@ -0,0 +1,14 @@ +CREATE TABLE IF NOT EXISTS `SourceReference` ( + `id` VARCHAR(191) NOT NULL, + `sourceId` VARCHAR(191) NOT NULL, + `chunkId` VARCHAR(191) NULL, + `artifactType` VARCHAR(32) NOT NULL, + `artifactId` VARCHAR(100) NOT NULL, + `pageNumber` INT NULL, + `sectionTitle` VARCHAR(500) NULL, + `excerptText` VARCHAR(2000) NULL, + `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + INDEX `SourceReference_artifactType_artifactId_idx`(`artifactType`, `artifactId`), + INDEX `SourceReference_sourceId_idx`(`sourceId`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 3b67a01..9f03b3f 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -738,6 +738,24 @@ model KnowledgeChunk { @@index([externalVectorId]) } +model SourceReference { + id String @id @default(cuid()) + sourceId String + chunkId String? + artifactType String @db.VarChar(32) + artifactId String @db.VarChar(100) + pageNumber Int? + sectionTitle String? @db.VarChar(500) + excerptText String? @db.VarChar(2000) + createdAt DateTime @default(now()) + + source KnowledgeSource @relation(fields: [sourceId], references: [id]) + chunk KnowledgeChunk? @relation(fields: [chunkId], references: [id]) + + @@index([artifactType, artifactId]) + @@index([sourceId]) +} + model ImportCandidate { id String @id @default(cuid()) userId String diff --git a/src/modules/admin-knowledge/admin-knowledge.controller.ts b/src/modules/admin-knowledge/admin-knowledge.controller.ts index bc86b7f..cb37447 100644 --- a/src/modules/admin-knowledge/admin-knowledge.controller.ts +++ b/src/modules/admin-knowledge/admin-knowledge.controller.ts @@ -47,4 +47,24 @@ export class AdminKnowledgeController { await this.prisma.knowledgeBase.update({ where: { id }, data: { deletedAt: new Date() } }); return { success: true }; } + + @Get(':id/sources') + @ApiOperation({ summary: '知识库资料来源列表' }) + async sources(@Param('id') id: string) { + return this.prisma.knowledgeSource.findMany({ + where: { knowledgeBaseId: id, deletedAt: null }, + orderBy: { createdAt: 'desc' }, + take: 50, + }); + } + + @Get('sources/:sourceId/references') + @ApiOperation({ summary: 'Source 引用追踪' }) + async sourceReferences(@Param('sourceId') sourceId: string) { + return this.prisma.sourceReference.findMany({ + where: { sourceId }, + orderBy: { createdAt: 'desc' }, + take: 50, + }); + } } diff --git a/test/m2.e2e-spec.ts b/test/m2.e2e-spec.ts index 82ef423..9176e42 100644 --- a/test/m2.e2e-spec.ts +++ b/test/m2.e2e-spec.ts @@ -145,4 +145,44 @@ describe('M2 E2E Tests', () => { expect(res.body.data).toHaveProperty('total'); }); }); + + // ══════════════════════════════════════════════ + // M2-03: Material & Source + // ══════════════════════════════════════════════ + describe('M2-03 Material & Source', () => { + let token: string; + beforeAll(async () => { token = await loginAdmin(); }); + + it('POST /api/knowledge-bases/:kbId/sources → 201 add source', async () => { + const kb = await request(app.getHttpServer()) + .post('/api/knowledge-bases') + .send({ title: 'Source Test KB', description: 'test' }); + const kbId = kb.body?.data?.id; + if (!kbId) return; + + const res = await request(app.getHttpServer()) + .post(`/api/knowledge-bases/${kbId}/sources`) + .send({ type: 'file', title: 'Test PDF', originalFilename: 'test.pdf', mimeType: 'application/pdf' }) + .expect([200, 201]); + expect(res.body.data).toHaveProperty('id'); + }); + + it('GET /admin-api/knowledge-bases/:id/sources → 200 source list', async () => { + if (!token) return; + const res = await request(app.getHttpServer()) + .get('/admin-api/knowledge-bases/kb-test/sources') + .set('Authorization', `Bearer ${token}`) + .expect(200); + expect(Array.isArray(res.body.data)).toBe(true); + }); + + it('GET /admin-api/knowledge-bases/sources/:sourceId/references → 200', async () => { + if (!token) return; + const res = await request(app.getHttpServer()) + .get('/admin-api/knowledge-bases/sources/src-test/references') + .set('Authorization', `Bearer ${token}`) + .expect(200); + expect(Array.isArray(res.body.data)).toBe(true); + }); + }); }); diff --git a/test/mocks/prisma.mock.ts b/test/mocks/prisma.mock.ts index d5943d8..7704439 100644 --- a/test/mocks/prisma.mock.ts +++ b/test/mocks/prisma.mock.ts @@ -91,7 +91,7 @@ const modelNames = [ 'userMembership', 'quotaUsage', 'costDailySummary', 'secretRecord', 'secretAccessLog', 'modelRoute', 'providerConfig', 'fallbackEvent', 'violationRecord', 'contentReport', 'userDevice', 'accountDeletionRequest', - 'workspace', 'knowledgeFolder', + 'workspace', 'knowledgeFolder', 'sourceReference', ] for (const name of modelNames) {