Compare commits

..

2 Commits

Author SHA1 Message Date
wangdl
0889e3af46 feat: knowledge generation workflow + drop import-candidate module + migrations
All checks were successful
Deploy API Server / build (push) Successful in 36s
Deploy API Server / deploy (push) Successful in 1m0s
2026-07-08 19:34:10 +08:00
wangdl
d23b9953ec fix: sessionsCount 修复 + 手动会话结束更新 DailyLearningActivity
- upsertFromReadingEvent 加 isNewSession 参数,material_opened 时 increment sessionsCount
- LearningSessionService.end() 调用 incrementDailySessions 更新日活会话计数

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-04 14:54:04 +08:00
75 changed files with 1624 additions and 1457 deletions

View File

@ -161,7 +161,6 @@ npm run start:worker
| `POST /internal/rag/jobs/:id/heartbeat` | 心跳 | | `POST /internal/rag/jobs/:id/heartbeat` | 心跳 |
| `POST /internal/rag/jobs/:id/status` | 更新状态 | | `POST /internal/rag/jobs/:id/status` | 更新状态 |
| `POST /internal/rag/chunks` | 批量保存 Chunk | | `POST /internal/rag/chunks` | 批量保存 Chunk |
| `POST /internal/rag/candidates` | 保存候选知识点 |
## 目录结构 ## 目录结构

View File

@ -0,0 +1,2 @@
ALTER TABLE `QuestionGenerationPlan`
CHANGE COLUMN `knowledgePointIds` `sourceIds` JSON NULL;

View File

@ -0,0 +1 @@
DROP TABLE `ImportCandidate`;

View File

@ -0,0 +1,47 @@
-- CreateTable
CREATE TABLE `KnowledgeGenerationTask` (
`id` VARCHAR(191) NOT NULL,
`userId` VARCHAR(191) NOT NULL,
`knowledgeBaseId` VARCHAR(191) NOT NULL,
`sourceId` VARCHAR(191) NOT NULL,
`aiJobId` VARCHAR(191) NULL,
`requestKind` VARCHAR(32) NOT NULL DEFAULT 'manual_source',
`status` VARCHAR(32) NOT NULL DEFAULT 'queued',
`idempotencyKey` VARCHAR(255) NULL,
`knowledgeItemCount` INTEGER NOT NULL DEFAULT 0,
`errorCode` VARCHAR(100) NULL,
`errorMessage` TEXT NULL,
`requestedAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`startedAt` DATETIME(3) NULL,
`completedAt` DATETIME(3) NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
UNIQUE INDEX `KnowledgeGenerationTask_aiJobId_key`(`aiJobId`),
UNIQUE INDEX `KnowledgeGenerationTask_userId_sourceId_idempotencyKey_key`(`userId`, `sourceId`, `idempotencyKey`),
INDEX `KnowledgeGenerationTask_userId_status_idx`(`userId`, `status`),
INDEX `KnowledgeGenerationTask_knowledgeBaseId_status_idx`(`knowledgeBaseId`, `status`),
INDEX `KnowledgeGenerationTask_sourceId_status_idx`(`sourceId`, `status`),
INDEX `KnowledgeGenerationTask_sourceId_requestedAt_idx`(`sourceId`, `requestedAt`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- AddForeignKey
ALTER TABLE `KnowledgeGenerationTask`
ADD CONSTRAINT `KnowledgeGenerationTask_userId_fkey`
FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `KnowledgeGenerationTask`
ADD CONSTRAINT `KnowledgeGenerationTask_knowledgeBaseId_fkey`
FOREIGN KEY (`knowledgeBaseId`) REFERENCES `KnowledgeBase`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `KnowledgeGenerationTask`
ADD CONSTRAINT `KnowledgeGenerationTask_sourceId_fkey`
FOREIGN KEY (`sourceId`) REFERENCES `KnowledgeSource`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `KnowledgeGenerationTask`
ADD CONSTRAINT `KnowledgeGenerationTask_aiJobId_fkey`
FOREIGN KEY (`aiJobId`) REFERENCES `AiAnalysisJob`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;

View File

@ -0,0 +1,19 @@
ALTER TABLE `KnowledgeGenerationTask`
MODIFY `sourceId` VARCHAR(191) NULL;
ALTER TABLE `KnowledgeGenerationTask`
ADD COLUMN `chatSessionId` VARCHAR(191) NULL,
ADD COLUMN `chatMessageId` VARCHAR(191) NULL;
CREATE INDEX `KnowledgeGenerationTask_chatMessageId_status_idx`
ON `KnowledgeGenerationTask`(`chatMessageId`, `status`);
CREATE INDEX `KnowledgeGenerationTask_chatSessionId_requestedAt_idx`
ON `KnowledgeGenerationTask`(`chatSessionId`, `requestedAt`);
CREATE UNIQUE INDEX `KnowledgeGenerationTask_userId_chatMessageId_idempotencyKey_key`
ON `KnowledgeGenerationTask`(`userId`, `chatMessageId`, `idempotencyKey`);
ALTER TABLE `KnowledgeGenerationTask`
ADD CONSTRAINT `KnowledgeGenerationTask_chatMessageId_fkey`
FOREIGN KEY (`chatMessageId`) REFERENCES `ChatMessage`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;

View File

@ -57,7 +57,7 @@ model User {
aiUsageLogs AiUsageLog[] aiUsageLogs AiUsageLog[]
knowledgeSources KnowledgeSource[] knowledgeSources KnowledgeSource[]
knowledgeChunks KnowledgeChunk[] knowledgeChunks KnowledgeChunk[]
importCandidates ImportCandidate[] knowledgeGenerationTasks KnowledgeGenerationTask[]
readingEvents ReadingEvent[] readingEvents ReadingEvent[]
materialReadingProgresses MaterialReadingProgress[] materialReadingProgresses MaterialReadingProgress[]
temporaryReadingMaterials TemporaryReadingMaterial[] temporaryReadingMaterials TemporaryReadingMaterial[]
@ -202,8 +202,8 @@ model KnowledgeBase {
user User @relation(fields: [userId], references: [id]) user User @relation(fields: [userId], references: [id])
items KnowledgeItem[] items KnowledgeItem[]
sources KnowledgeSource[] sources KnowledgeSource[]
candidates ImportCandidate[]
chunks KnowledgeChunk[] chunks KnowledgeChunk[]
knowledgeGenerationTasks KnowledgeGenerationTask[]
focusItems FocusItem[] focusItems FocusItem[]
folders KnowledgeFolder[] folders KnowledgeFolder[]
subscriptions KnowledgeBaseSubscription[] subscriptions KnowledgeBaseSubscription[]
@ -397,7 +397,6 @@ model DocumentImport {
user User @relation(fields: [userId], references: [id]) user User @relation(fields: [userId], references: [id])
source KnowledgeSource? @relation(fields: [sourceId], references: [id]) source KnowledgeSource? @relation(fields: [sourceId], references: [id])
candidates ImportCandidate[]
@@index([userId]) @@index([userId])
@@index([status]) @@index([status])
@ -650,6 +649,7 @@ model AiJob {
results AiAnalysisResult[] results AiAnalysisResult[]
snapshot AiJobSnapshot? snapshot AiJobSnapshot?
artifacts AiJobArtifact[] artifacts AiJobArtifact[]
knowledgeGenerationTasks KnowledgeGenerationTask[]
usageLogs AiUsageLog[] usageLogs AiUsageLog[]
parentJob AiJob? @relation("JobChildren", fields: [parentJobId], references: [id], onDelete: SetNull, onUpdate: Cascade) parentJob AiJob? @relation("JobChildren", fields: [parentJobId], references: [id], onDelete: SetNull, onUpdate: Cascade)
children AiJob[] @relation("JobChildren") children AiJob[] @relation("JobChildren")
@ -1014,8 +1014,8 @@ model KnowledgeSource {
chunks KnowledgeChunk[] chunks KnowledgeChunk[]
items KnowledgeItem[] items KnowledgeItem[]
imports DocumentImport[] imports DocumentImport[]
knowledgeGenerationTasks KnowledgeGenerationTask[]
references SourceReference[] references SourceReference[]
candidates ImportCandidate[]
@@index([userId]) @@index([userId])
@@index([knowledgeBaseId]) @@index([knowledgeBaseId])
@ -1024,6 +1024,42 @@ model KnowledgeSource {
@@index([indexStatus]) @@index([indexStatus])
} }
model KnowledgeGenerationTask {
id String @id @default(cuid())
userId String
knowledgeBaseId String
sourceId String?
chatSessionId String?
chatMessageId String?
aiJobId String? @unique
requestKind String @default("manual_source") @db.VarChar(32)
status String @default("queued") @db.VarChar(32)
idempotencyKey String? @db.VarChar(255)
knowledgeItemCount Int @default(0)
errorCode String? @db.VarChar(100)
errorMessage String? @db.Text
requestedAt DateTime @default(now())
startedAt DateTime?
completedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id])
knowledgeBase KnowledgeBase @relation(fields: [knowledgeBaseId], references: [id])
source KnowledgeSource? @relation(fields: [sourceId], references: [id])
chatMessage ChatMessage? @relation(fields: [chatMessageId], references: [id])
aiJob AiJob? @relation(fields: [aiJobId], references: [id])
@@index([userId, status])
@@index([knowledgeBaseId, status])
@@index([sourceId, status])
@@index([chatMessageId, status])
@@index([sourceId, requestedAt])
@@index([chatSessionId, requestedAt])
@@unique([userId, sourceId, idempotencyKey])
@@unique([userId, chatMessageId, idempotencyKey])
}
model KnowledgeChunk { model KnowledgeChunk {
id String @id @default(cuid()) id String @id @default(cuid())
userId String userId String
@ -1071,37 +1107,6 @@ model SourceReference {
@@index([sourceId]) @@index([sourceId])
} }
model ImportCandidate {
id String @id @default(cuid())
userId String
knowledgeBaseId String
sourceId String
importId String
title String @db.VarChar(255)
summary String? @db.Text
content String? @db.LongText
tagsJson Json?
recallQuestionsJson Json?
sourceTextSnippet String? @db.Text
sourceChunkIds Json?
confidence Decimal @default(0) @db.Decimal(4, 3)
difficulty String? @db.VarChar(16)
orderIndex Int @default(0)
status String @default("PENDING") @db.VarChar(16)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id])
knowledgeBase KnowledgeBase @relation(fields: [knowledgeBaseId], references: [id])
source KnowledgeSource @relation(fields: [sourceId], references: [id])
import DocumentImport @relation(fields: [importId], references: [id])
@@index([userId])
@@index([sourceId])
@@index([importId])
@@index([status])
}
model BackupJob { model BackupJob {
id String @id @default(cuid()) id String @id @default(cuid())
type String @db.VarChar(16) type String @db.VarChar(16)
@ -1318,6 +1323,7 @@ model ChatMessage {
session ChatSession @relation(fields: [sessionId], references: [id]) session ChatSession @relation(fields: [sessionId], references: [id])
citations ChatCitation[] citations ChatCitation[]
knowledgeGenerationTasks KnowledgeGenerationTask[]
@@index([sessionId]) @@index([sessionId])
} }
@ -2337,7 +2343,7 @@ model QuestionGenerationPlan {
snapshotId String? snapshotId String?
targetType String? @db.VarChar(32) targetType String? @db.VarChar(32)
targetId String? @db.VarChar(255) targetId String? @db.VarChar(255)
knowledgePointIds Json? sourceIds Json?
questionTypes Json? questionTypes Json?
difficultyLevel String? @db.VarChar(32) difficultyLevel String? @db.VarChar(32)
count Int @default(5) count Int @default(5)

View File

@ -48,7 +48,6 @@ import { FilesModule } from './modules/files/files.module';
import { WaitlistModule } from './modules/waitlist/waitlist.module'; import { WaitlistModule } from './modules/waitlist/waitlist.module';
import { WorkspaceModule } from './modules/workspace/workspace.module'; import { WorkspaceModule } from './modules/workspace/workspace.module';
import { KnowledgeSourceModule } from './modules/knowledge-source/knowledge-source.module'; import { KnowledgeSourceModule } from './modules/knowledge-source/knowledge-source.module';
import { ImportCandidateModule } from './modules/import-candidate/import-candidate.module';
import { RagModule } from './modules/rag/rag.module'; import { RagModule } from './modules/rag/rag.module';
import { RagChatModule } from './modules/rag-chat/rag-chat.module'; import { RagChatModule } from './modules/rag-chat/rag-chat.module';
import { VectorModule } from './modules/vector/vector.module'; import { VectorModule } from './modules/vector/vector.module';
@ -147,7 +146,6 @@ import appleConfig from './config/apple.config';
KnowledgeBaseModule, KnowledgeBaseModule,
KnowledgeItemsModule, KnowledgeItemsModule,
KnowledgeSourceModule, KnowledgeSourceModule,
ImportCandidateModule,
DocumentImportModule, DocumentImportModule,
RagModule, RagModule,
RagChatModule, RagChatModule,

View File

@ -24,23 +24,19 @@ export class LocalStorageController {
@Res() res: Response, @Res() res: Response,
) { ) {
const decodedKey = decodeURIComponent(objectKey); const decodedKey = decodeURIComponent(objectKey);
const chunks: Buffer[] = [];
req.on('data', (chunk: Buffer) => chunks.push(chunk)); try {
req.on('end', () => { const body = req.body;
try { const buffer = Buffer.isBuffer(body)
const buffer = Buffer.concat(chunks); ? body
this.localProvider.saveFile(decodedKey, buffer); : Buffer.from(body ?? '');
res.status(200).json({ success: true, objectKey: decodedKey, size: buffer.length });
} catch (err: any) { this.localProvider.saveFile(decodedKey, buffer);
this.logger.error(`Upload failed: ${decodedKey}`, err.message); res.status(200).json({ success: true, objectKey: decodedKey, size: buffer.length });
res.status(500).json({ success: false, error: err.message }); } catch (err: any) {
} this.logger.error(`Upload failed: ${decodedKey}`, err.message);
});
req.on('error', (err: any) => {
this.logger.error(`Upload stream error: ${decodedKey}`, err.message);
res.status(500).json({ success: false, error: err.message }); res.status(500).json({ success: false, error: err.message });
}); }
} }
/** /**

View File

@ -8,6 +8,7 @@ import helmet from 'helmet';
import cookieParser from 'cookie-parser'; import cookieParser from 'cookie-parser';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { NestExpressApplication } from '@nestjs/platform-express'; import { NestExpressApplication } from '@nestjs/platform-express';
import express from 'express';
async function bootstrap() { async function bootstrap() {
const app = await NestFactory.create<NestExpressApplication>(AppModule); const app = await NestFactory.create<NestExpressApplication>(AppModule);
@ -29,6 +30,7 @@ async function bootstrap() {
maxAge: 86400, maxAge: 86400,
}); });
app.use('/api/storage/upload', express.raw({ type: '*/*', limit: '100mb' }));
app.useBodyParser('json', { limit: '10mb' }); app.useBodyParser('json', { limit: '10mb' });
const swaggerEnabled = !isProduction || configService.get('app.enableSwagger') === true; const swaggerEnabled = !isProduction || configService.get('app.enableSwagger') === true;

View File

@ -71,6 +71,31 @@ export class AdminKnowledgeController {
return { items: enriched, total, page: p, limit: l, totalPages: Math.ceil(total / l) }; return { items: enriched, total, page: p, limit: l, totalPages: Math.ceil(total / l) };
} }
// ── File verification (must be before :id to avoid route conflict) ──
@Get('verify-files')
@ApiOperation({ summary: '校验所有 source 文件是否存在' })
async verifyFiles() {
const sources = await this.prisma.knowledgeSource.findMany({
where: { deletedAt: null, originalObjectKey: { not: null } },
select: { id: true, originalObjectKey: true, originalFilename: true },
});
const results = await Promise.all(
sources.map(async (s) => {
try {
const info = await this.storage.verifyUpload(s.originalObjectKey!);
return { sourceId: s.id, filename: s.originalFilename, objectKey: s.originalObjectKey, exists: !!info };
} catch {
return { sourceId: s.id, filename: s.originalFilename, objectKey: s.originalObjectKey, exists: false };
}
}),
);
const missing = results.filter(r => !r.exists);
return { total: results.length, missing: missing.length, missingFiles: missing };
}
@Get(':id') @Get(':id')
@ApiOperation({ summary: '知识库详情' }) @ApiOperation({ summary: '知识库详情' })
async detail(@Param('id') id: string) { async detail(@Param('id') id: string) {
@ -112,42 +137,6 @@ export class AdminKnowledgeController {
}); });
} }
// ── File verification ──
@Get('verify-files')
@ApiOperation({ summary: '校验所有 source 文件是否存在' })
async verifyFiles() {
const sources = await this.prisma.knowledgeSource.findMany({
where: { deletedAt: null, originalObjectKey: { not: null } },
select: { id: true, originalObjectKey: true, originalFilename: true },
});
const results = await Promise.all(
sources.map(async (s) => {
try {
const info = await this.storage.verifyUpload(s.originalObjectKey!);
return { sourceId: s.id, filename: s.originalFilename, objectKey: s.originalObjectKey, exists: !!info };
} catch {
return { sourceId: s.id, filename: s.originalFilename, objectKey: s.originalObjectKey, exists: false };
}
}),
);
const missing = results.filter(r => !r.exists);
return { total: results.length, missing: missing.length, missingFiles: missing };
}
// ── Candidates ──
@Get('candidates')
@ApiOperation({ summary: '候选知识点列表' })
async candidates(@Query('status') status?: string, @Query('kbId') kbId?: string) {
const where: any = {};
if (status) where.status = status;
if (kbId) where.knowledgeBaseId = kbId;
return this.prisma.importCandidate.findMany({ where, orderBy: { createdAt: 'desc' }, take: 100 });
}
// ── Knowledge items ── // ── Knowledge items ──
@Get('items') @Get('items')

View File

@ -25,6 +25,8 @@ import { QuizGenerationExecutor } from './quiz-generation-executor';
import { QuizGenerationValidator } from './quiz-generation-validator'; import { QuizGenerationValidator } from './quiz-generation-validator';
import { LearningAnalysisExecutor } from './learning-analysis-executor'; import { LearningAnalysisExecutor } from './learning-analysis-executor';
import { LearningAnalysisValidator } from './learning-analysis-validator'; import { LearningAnalysisValidator } from './learning-analysis-validator';
import { KnowledgeGenerationExecutor } from './knowledge-generation-executor';
import type { KnowledgeGenerationSnapshot } from './knowledge-generation-snapshot-builder';
import { import {
AiJobExecutionEngine, AiJobExecutionEngine,
EngineJobContext, EngineJobContext,
@ -105,6 +107,7 @@ export class AiJobExecutionEngineImpl implements AiJobExecutionEngine {
private readonly quizValidator: QuizGenerationValidator, private readonly quizValidator: QuizGenerationValidator,
private readonly learningAnalysisExecutor: LearningAnalysisExecutor, private readonly learningAnalysisExecutor: LearningAnalysisExecutor,
private readonly learningAnalysisValidator: LearningAnalysisValidator, private readonly learningAnalysisValidator: LearningAnalysisValidator,
private readonly knowledgeGenerationExecutor: KnowledgeGenerationExecutor,
private readonly observability: ActiveRecallObservabilityService, private readonly observability: ActiveRecallObservabilityService,
private readonly feynmanObs: FeynmanObservabilityService, private readonly feynmanObs: FeynmanObservabilityService,
) {} ) {}
@ -286,6 +289,17 @@ export class AiJobExecutionEngineImpl implements AiJobExecutionEngine {
this.logger.warn(`Learning analysis validation failed for job=${aiJobId}: ${validationErr.message}`); this.logger.warn(`Learning analysis validation failed for job=${aiJobId}: ${validationErr.message}`);
throw validationErr; throw validationErr;
} }
} else if (job.jobType === 'knowledge_generation' && snapshot) {
const knowledgeGenerationSnapshot = snapshot as unknown as KnowledgeGenerationSnapshot;
response = await this.knowledgeGenerationExecutor.execute(
knowledgeGenerationSnapshot,
aiJobId,
);
parsedOutput = response.parsed;
this.logger.log(
`Knowledge Generation Executor completed: job=${aiJobId} ` +
`points=${(parsedOutput as any)?.knowledgePoints?.length ?? 0}`,
);
} else { } else {
// 默认路径:直接调用 AiGatewaysynthetic_job 等) // 默认路径:直接调用 AiGatewaysynthetic_job 等)
response = await this.aiGateway.generate( response = await this.aiGateway.generate(
@ -494,6 +508,18 @@ export class AiJobExecutionEngineImpl implements AiJobExecutionEngine {
} }
if (classified.retryable) { if (classified.retryable) {
if (job.jobType === 'knowledge_generation') {
await this.prisma.knowledgeGenerationTask.updateMany({
where: { aiJobId, status: 'running' },
data: { status: 'queued' },
});
if (job.targetId) {
await this.prisma.knowledgeSource.updateMany({
where: { id: job.targetId },
data: { learningStatus: 'queued' },
});
}
}
// 重试:先解锁回 queuedBullMQ retry → lockJob 可再次抢锁),然后抛给 BullMQ // 重试:先解锁回 queuedBullMQ retry → lockJob 可再次抢锁),然后抛给 BullMQ
await this.unlockForRetry(aiJobId); await this.unlockForRetry(aiJobId);
throw execErr; throw execErr;
@ -505,6 +531,23 @@ export class AiJobExecutionEngineImpl implements AiJobExecutionEngine {
publicErrorMessage: classified.publicMessage, publicErrorMessage: classified.publicMessage,
internalErrorMessage: execErr.message?.slice(0, 500), internalErrorMessage: execErr.message?.slice(0, 500),
}); });
if (job.jobType === 'knowledge_generation') {
await this.prisma.knowledgeGenerationTask.updateMany({
where: { aiJobId },
data: {
status: 'failed',
completedAt: new Date(),
errorCode: classified.errorCode,
errorMessage: execErr.message?.slice(0, 1000) ?? null,
},
});
if (job.targetId) {
await this.prisma.knowledgeSource.updateMany({
where: { id: job.targetId },
data: { learningStatus: 'failed' },
});
}
}
// markFailed 后不抛错 → BullMQ 认为 job 完成(不再重试) // markFailed 后不抛错 → BullMQ 认为 job 完成(不再重试)
return; return;
} }

View File

@ -44,6 +44,10 @@ import { LearningAnalysisSnapshotBuilder } from './learning-analysis-snapshot-bu
import { LearningAnalysisExecutor } from './learning-analysis-executor'; import { LearningAnalysisExecutor } from './learning-analysis-executor';
import { LearningAnalysisValidator } from './learning-analysis-validator'; import { LearningAnalysisValidator } from './learning-analysis-validator';
import { LearningAnalysisProjector } from './learning-analysis-projector'; import { LearningAnalysisProjector } from './learning-analysis-projector';
import { KnowledgeGenerationRegistrationService } from './knowledge-generation-registration.service';
import { KnowledgeGenerationSnapshotBuilder } from './knowledge-generation-snapshot-builder';
import { KnowledgeGenerationExecutor } from './knowledge-generation-executor';
import { KnowledgeGenerationProjector } from './knowledge-generation-projector';
import { import {
FeynmanBusinessValidator, FeynmanBusinessValidator,
FeynmanReferenceValidator, FeynmanReferenceValidator,
@ -96,7 +100,11 @@ import { AppConfigModule } from '../config/config.module';
LearningAnalysisExecutor, LearningAnalysisExecutor,
LearningAnalysisValidator, LearningAnalysisValidator,
LearningAnalysisProjector, LearningAnalysisProjector,
{ provide: RESULT_PROJECTORS, useFactory: (synthetic: SyntheticResultProjector, activeRecall: ActiveRecallProjector, feynman: FeynmanProjector, reviewCard: ReviewCardGenerationProjector, quiz: QuizGenerationProjector, learningAnalysis: LearningAnalysisProjector) => [synthetic, activeRecall, feynman, reviewCard, quiz, learningAnalysis], inject: [SyntheticResultProjector, ActiveRecallProjector, FeynmanProjector, ReviewCardGenerationProjector, QuizGenerationProjector, LearningAnalysisProjector] } as any, KnowledgeGenerationRegistrationService,
KnowledgeGenerationSnapshotBuilder,
KnowledgeGenerationExecutor,
KnowledgeGenerationProjector,
{ provide: RESULT_PROJECTORS, useFactory: (synthetic: SyntheticResultProjector, activeRecall: ActiveRecallProjector, feynman: FeynmanProjector, reviewCard: ReviewCardGenerationProjector, quiz: QuizGenerationProjector, learningAnalysis: LearningAnalysisProjector, knowledgeGeneration: KnowledgeGenerationProjector) => [synthetic, activeRecall, feynman, reviewCard, quiz, learningAnalysis, knowledgeGeneration], inject: [SyntheticResultProjector, ActiveRecallProjector, FeynmanProjector, ReviewCardGenerationProjector, QuizGenerationProjector, LearningAnalysisProjector, KnowledgeGenerationProjector] } as any,
{ provide: AI_JOB_EXECUTION_ENGINE, useExisting: AiJobExecutionEngineImpl }, { provide: AI_JOB_EXECUTION_ENGINE, useExisting: AiJobExecutionEngineImpl },
], ],
exports: [ exports: [
@ -110,6 +118,7 @@ import { AppConfigModule } from '../config/config.module';
FeynmanObservabilityService, FeynmanObservabilityService,
QuizGenerationSnapshotBuilder, QuizGenerationSnapshotBuilder,
LearningAnalysisSnapshotBuilder, LearningAnalysisSnapshotBuilder,
KnowledgeGenerationSnapshotBuilder,
AI_JOB_EXECUTION_ENGINE, AI_JOB_EXECUTION_ENGINE,
], ],
}) })

View File

@ -0,0 +1,52 @@
import { Injectable, Logger } from '@nestjs/common';
import { PrismaService } from '../../infrastructure/database/prisma.service';
import { KnowledgeImportWorkflow } from '../ai/workflows/knowledge-import.workflow';
import type { KnowledgeGenerationSnapshot } from './knowledge-generation-snapshot-builder';
@Injectable()
export class KnowledgeGenerationExecutor {
private readonly logger = new Logger(KnowledgeGenerationExecutor.name);
constructor(
private readonly prisma: PrismaService,
private readonly workflow: KnowledgeImportWorkflow,
) {}
async execute(snapshot: KnowledgeGenerationSnapshot, aiJobId: string) {
const { taskId, sourceId, knowledgeBaseId, userId, rawText, sourceName } = snapshot.snapshot;
const txOps = [
this.prisma.knowledgeGenerationTask.updateMany({
where: { id: taskId, aiJobId, status: { in: ['queued', 'running'] } },
data: {
status: 'running',
startedAt: new Date(),
errorCode: null,
errorMessage: null,
},
}),
];
if (sourceId) {
txOps.push(
this.prisma.knowledgeSource.updateMany({
where: { id: sourceId, knowledgeBaseId, userId },
data: { learningStatus: 'running' },
}),
);
}
await this.prisma.$transaction(txOps);
const result = await this.workflow.execute({
userId,
rawText,
sourceName,
});
this.logger.log(
`Knowledge Generation Executor completed: job=${aiJobId} ` +
`task=${taskId} source=${sourceId ?? 'n/a'} points=${result.knowledgePoints?.length ?? 0}`,
);
return { parsed: result };
}
}

View File

@ -0,0 +1,59 @@
import type { JobDefinition } from './job-definition.types';
export const KNOWLEDGE_GENERATION_JOB_DEFINITION: JobDefinition = {
jobType: 'knowledge_generation',
metadata: {
label: 'Knowledge Generation',
description:
'Generate knowledge items from a manually triggered knowledge source using ' +
'previously parsed KnowledgeChunk content.',
domain: 'generation',
version: '1.0.0',
},
queue: {
queueName: 'ai-background',
defaultPriority: 10,
},
execution: {
timeoutMs: 180_000,
maxRetries: 3,
retryBackoff: { type: 'exponential', delay: 1000 },
cancellable: true,
abortStrategy: 'fail',
},
input: {
schemaVersion: 'knowledge-generation-v1',
},
output: {
schemaVersion: 'knowledge-import-v1',
},
prompt: {
promptKey: 'knowledge-import',
promptVersion: '1.0.0',
},
model: {
modelTier: 'cheap',
modelProvider: 'deepseek',
modelName: 'deepseek-v4-flash',
maxTokens: 4096,
},
credential: {
allowedModes: ['platform_key'],
defaultMode: 'platform_key',
},
projectorKey: 'knowledge_generation_projector',
security: {
contentSafetyCheck: true,
outputRedaction: false,
},
};

View File

@ -0,0 +1,123 @@
import { Injectable, Logger } from '@nestjs/common';
import type { Prisma } from '@prisma/client';
import {
ResultProjector,
ProjectionContext,
ArtifactReference,
} from './result-projector.interface';
import type { KnowledgeGenerationSnapshot } from './knowledge-generation-snapshot-builder';
@Injectable()
export class KnowledgeGenerationProjector implements ResultProjector {
readonly key = 'knowledge_generation_projector';
private readonly logger = new Logger(KnowledgeGenerationProjector.name);
async project(
tx: Prisma.TransactionClient,
context: ProjectionContext,
): Promise<ArtifactReference[]> {
const { job, validatedOutput } = context;
const snapshot = context.snapshot as KnowledgeGenerationSnapshot;
const taskId = snapshot.snapshot.taskId;
const sourceId = snapshot.snapshot.sourceId;
const knowledgeBaseId = snapshot.snapshot.knowledgeBaseId;
const requestKind = snapshot.snapshot.requestKind;
const chatMessageId = snapshot.snapshot.chatMessageId;
const sourceTitle = snapshot.snapshot.sourceTitle;
const existingArtifacts = await tx.aiJobArtifact.findMany({
where: { jobId: job.id },
orderBy: { ordinal: 'asc' },
});
if (existingArtifacts.length > 0) {
this.logger.log(
`Knowledge Generation Projector: returning ${existingArtifacts.length} existing artifact(s) for job=${job.id}`,
);
return existingArtifacts.map((artifact) => ({
artifactType: artifact.artifactType,
artifactId: artifact.artifactId,
ordinal: artifact.ordinal,
}));
}
const points = Array.isArray((validatedOutput as any)?.knowledgePoints)
? (validatedOutput as any).knowledgePoints
: [];
const validPoints = points.filter((point: any) => point?.title && point?.content);
const orderAgg = await tx.knowledgeItem.aggregate({
where: { knowledgeBaseId, deletedAt: null },
_max: { orderIndex: true },
});
let nextOrderIndex = (orderAgg._max.orderIndex ?? 0) + 1;
let ordinal = 0;
const artifacts: ArtifactReference[] = [];
for (const point of validPoints) {
const item = await tx.knowledgeItem.create({
data: {
userId: job.userId,
knowledgeBaseId,
title: point.title,
content: point.content,
itemType: requestKind === 'manual_chat_message' ? 'note' : 'lesson',
sourceId,
sourceType: requestKind === 'manual_chat_message' ? 'ai_chat' : null,
sourceRef: requestKind === 'manual_chat_message' ? chatMessageId : sourceId,
sourceTitleSnapshot: requestKind === 'manual_chat_message' ? sourceTitle : null,
sourceSnippetSnapshot:
requestKind === 'manual_chat_message'
? point.content?.slice(0, 1000) ?? null
: null,
orderIndex: point.suggestedOrder ?? nextOrderIndex,
},
});
nextOrderIndex += 1;
await tx.aiJobArtifact.create({
data: {
jobId: job.id,
artifactType: 'KnowledgeItem',
artifactId: item.id,
ordinal,
},
});
artifacts.push({
artifactType: 'KnowledgeItem',
artifactId: item.id,
ordinal,
});
ordinal += 1;
}
if (validPoints.length > 0) {
await tx.knowledgeBase.update({
where: { id: knowledgeBaseId },
data: { itemCount: { increment: validPoints.length } },
});
}
await tx.knowledgeGenerationTask.update({
where: { id: taskId },
data: {
status: 'completed',
knowledgeItemCount: validPoints.length,
completedAt: new Date(),
errorCode: null,
errorMessage: null,
},
});
if (sourceId) {
await tx.knowledgeSource.update({
where: { id: sourceId },
data: { learningStatus: 'completed' },
});
}
this.logger.log(
`Knowledge Generation Projector completed: job=${job.id} task=${taskId} items=${validPoints.length}`,
);
return artifacts;
}
}

View File

@ -0,0 +1,27 @@
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { JobDefinitionRegistry, DuplicateJobTypeError } from './job-definition-registry';
import { KNOWLEDGE_GENERATION_JOB_DEFINITION } from './knowledge-generation-job-definition';
@Injectable()
export class KnowledgeGenerationRegistrationService implements OnModuleInit {
private readonly logger = new Logger(KnowledgeGenerationRegistrationService.name);
constructor(private readonly registry: JobDefinitionRegistry) {}
onModuleInit(): void {
try {
this.registry.register(KNOWLEDGE_GENERATION_JOB_DEFINITION);
this.logger.log(
`Knowledge Generation Job Definition registered: ` +
`jobType="${KNOWLEDGE_GENERATION_JOB_DEFINITION.jobType}" ` +
`queue="${KNOWLEDGE_GENERATION_JOB_DEFINITION.queue.queueName}"`,
);
} catch (err: unknown) {
if (err instanceof DuplicateJobTypeError) {
this.logger.log('Knowledge Generation Job Definition already registered');
} else {
throw err;
}
}
}
}

View File

@ -0,0 +1,252 @@
import { Injectable, Logger, BadRequestException, ForbiddenException, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../../infrastructure/database/prisma.service';
import { JobDefinitionRegistry } from './job-definition-registry';
const SNAPSHOT_SCHEMA_VERSION = 'knowledge-generation-v1';
const MAX_RAW_TEXT_LENGTH = 12_000;
export interface KnowledgeGenerationSnapshot {
schemaVersion: string;
snapshot: {
userId: string;
taskId: string;
targetType: string;
targetId: string;
knowledgeBaseId: string;
requestKind: string;
sourceId: string | null;
chatSessionId: string | null;
chatMessageId: string | null;
sourceTitle: string;
sourceName: string;
chunkCount: number;
rawText: string;
promptKey: string;
promptVersion: string;
modelTier: string;
inputSchemaVersion: string;
outputSchemaVersion: string;
createdAt: string;
};
}
export interface KnowledgeGenerationSnapshotInput {
userId: string;
knowledgeBaseId: string;
taskId: string;
sourceId?: string | null;
chatSessionId?: string | null;
chatMessageId?: string | null;
requestKind?: string;
}
@Injectable()
export class KnowledgeGenerationSnapshotBuilder {
private readonly logger = new Logger(KnowledgeGenerationSnapshotBuilder.name);
constructor(
private readonly prisma: PrismaService,
private readonly registry: JobDefinitionRegistry,
) {}
async build(input: KnowledgeGenerationSnapshotInput): Promise<KnowledgeGenerationSnapshot> {
const def = this.registry.get('knowledge_generation');
if (input.requestKind === 'manual_chat_message') {
return this.buildFromChatMessage(input, def);
}
if (!input.sourceId) {
throw new BadRequestException('sourceId is required for source knowledge generation');
}
return this.buildFromSource(input, def);
}
private async buildFromSource(
input: KnowledgeGenerationSnapshotInput,
def: ReturnType<JobDefinitionRegistry['get']>,
): Promise<KnowledgeGenerationSnapshot> {
const sourceId = input.sourceId;
if (!sourceId) {
throw new BadRequestException('sourceId is required for source knowledge generation');
}
const source = await this.prisma.knowledgeSource.findUnique({
where: { id: sourceId },
select: {
id: true,
userId: true,
knowledgeBaseId: true,
title: true,
originalFilename: true,
parseStatus: true,
deletedAt: true,
},
});
if (!source || source.deletedAt) {
throw new NotFoundException(`KnowledgeSource ${input.sourceId} not found`);
}
if (source.userId !== input.userId) {
throw new ForbiddenException(`KnowledgeSource ${input.sourceId} does not belong to user ${input.userId}`);
}
if (source.knowledgeBaseId !== input.knowledgeBaseId) {
throw new BadRequestException('Knowledge source does not belong to the specified knowledge base');
}
if (source.parseStatus !== 'completed') {
throw new BadRequestException('Knowledge source parse is not completed');
}
const chunks = await this.prisma.knowledgeChunk.findMany({
where: { sourceId },
select: {
content: true,
chunkIndex: true,
pageNumber: true,
sectionTitle: true,
},
orderBy: { chunkIndex: 'asc' },
});
if (chunks.length === 0) {
throw new BadRequestException('Knowledge source has no parsed chunks');
}
let totalLength = 0;
const rawTextParts: string[] = [];
for (const chunk of chunks) {
const headerBits = [
chunk.pageNumber != null ? `Page ${chunk.pageNumber}` : '',
chunk.sectionTitle ?? '',
].filter(Boolean);
const normalized = (chunk.content ?? '').trim();
if (!normalized) continue;
const block = `${headerBits.length > 0 ? `[${headerBits.join(' | ')}]\n` : ''}${normalized}`;
const room = MAX_RAW_TEXT_LENGTH - totalLength;
if (room <= 0) break;
if (block.length <= room) {
rawTextParts.push(block);
totalLength += block.length + 2;
continue;
}
rawTextParts.push(block.slice(0, room));
totalLength = MAX_RAW_TEXT_LENGTH;
break;
}
const rawText = rawTextParts.join('\n\n').trim();
if (!rawText) {
throw new BadRequestException('Knowledge source chunks are empty after normalization');
}
const snapshot: KnowledgeGenerationSnapshot = {
schemaVersion: SNAPSHOT_SCHEMA_VERSION,
snapshot: {
userId: input.userId,
taskId: input.taskId,
targetType: 'knowledge_source',
targetId: source.id,
knowledgeBaseId: source.knowledgeBaseId,
requestKind: input.requestKind ?? 'manual_source',
sourceId: source.id,
chatSessionId: null,
chatMessageId: null,
sourceTitle: source.title ?? source.originalFilename ?? '',
sourceName: source.originalFilename ?? source.title ?? '',
chunkCount: chunks.length,
rawText,
promptKey: def.prompt.promptKey,
promptVersion: def.prompt.promptVersion,
modelTier: def.model.modelTier,
inputSchemaVersion: SNAPSHOT_SCHEMA_VERSION,
outputSchemaVersion: def.output.schemaVersion,
createdAt: new Date().toISOString().replace(/\.\d{3}Z$/, 'Z'),
},
};
this.logger.log(
`Built Knowledge Generation snapshot for source=${source.id} ` +
`task=${input.taskId} chunks=${chunks.length} rawTextLength=${rawText.length}`,
);
return snapshot;
}
private async buildFromChatMessage(
input: KnowledgeGenerationSnapshotInput,
def: ReturnType<JobDefinitionRegistry['get']>,
): Promise<KnowledgeGenerationSnapshot> {
if (!input.chatMessageId) {
throw new BadRequestException('chatMessageId is required for chat knowledge generation');
}
const message = await this.prisma.chatMessage.findUnique({
where: { id: input.chatMessageId },
select: {
id: true,
sessionId: true,
role: true,
content: true,
session: {
select: {
id: true,
userId: true,
title: true,
scopeType: true,
parentKnowledgeBaseId: true,
knowledgeBaseId: true,
},
},
},
});
if (!message) {
throw new NotFoundException(`ChatMessage ${input.chatMessageId} not found`);
}
if (message.session.userId !== input.userId) {
throw new ForbiddenException(`ChatMessage ${input.chatMessageId} does not belong to user ${input.userId}`);
}
if (message.role !== 'ai') {
throw new BadRequestException('Only AI chat messages can be converted into knowledge');
}
const sessionKnowledgeBaseId = message.session.parentKnowledgeBaseId ?? message.session.knowledgeBaseId;
if (sessionKnowledgeBaseId !== input.knowledgeBaseId) {
throw new BadRequestException('Chat message does not belong to the specified knowledge base');
}
const rawText = (message.content ?? '').trim().slice(0, MAX_RAW_TEXT_LENGTH);
if (!rawText) {
throw new BadRequestException('Chat message content is empty after normalization');
}
const sourceTitle = message.session.title?.trim() || 'AI 对话';
const snapshot: KnowledgeGenerationSnapshot = {
schemaVersion: SNAPSHOT_SCHEMA_VERSION,
snapshot: {
userId: input.userId,
taskId: input.taskId,
targetType: 'chat_message',
targetId: message.id,
knowledgeBaseId: input.knowledgeBaseId,
requestKind: input.requestKind ?? 'manual_chat_message',
sourceId: null,
chatSessionId: message.sessionId,
chatMessageId: message.id,
sourceTitle,
sourceName: sourceTitle,
chunkCount: 1,
rawText,
promptKey: def.prompt.promptKey,
promptVersion: def.prompt.promptVersion,
modelTier: def.model.modelTier,
inputSchemaVersion: SNAPSHOT_SCHEMA_VERSION,
outputSchemaVersion: def.output.schemaVersion,
createdAt: new Date().toISOString().replace(/\.\d{3}Z$/, 'Z'),
},
};
this.logger.log(
`Built Knowledge Generation snapshot for chatMessage=${message.id} ` +
`task=${input.taskId} rawTextLength=${rawText.length}`,
);
return snapshot;
}
}

View File

@ -18,14 +18,14 @@ function validSnapshot() {
submissionId: 'sub-001', submissionId: 'sub-001',
knowledgeBaseTitle: 'Test KB', knowledgeBaseTitle: 'Test KB',
knowledgeBaseDescription: 'A test kb', knowledgeBaseDescription: 'A test kb',
knowledgeItems: [ sourceIds: ['src-1', 'src-2'],
{ id: 'ki-1', title: 'Item 1', content: 'Content 1', summary: 'S1' }, sourceChunks: [
{ id: 'ki-2', title: 'Item 2', content: 'Content 2', summary: 'S2' }, { id: 'kc-1', sourceId: 'src-1', pageNumber: 1, sectionTitle: 'Intro', content: 'Content 1' },
{ id: 'kc-2', sourceId: 'src-2', pageNumber: 2, sectionTitle: 'Body', content: 'Content 2' },
], ],
questionCount: 3, questionCount: 3,
questionTypes: ['choice', 'judge'], questionTypes: ['choice', 'judge'],
difficultyLevel: 'medium', difficultyLevel: 'medium',
knowledgePointIds: [],
promptKey: 'quiz-generation', promptKey: 'quiz-generation',
promptVersion: '1.0.0', promptVersion: '1.0.0',
modelTier: 'primary', modelTier: 'primary',
@ -86,12 +86,12 @@ describe('QuizGenerationExecutor', () => {
expect(call.promptVersion).toBe('1.0.0'); expect(call.promptVersion).toBe('1.0.0');
}); });
it('user message 包含知识点内容', async () => { it('user message 包含资料分块内容', async () => {
gateway.generate.mockResolvedValue({ parsed: validOutput(), usage: {} }); gateway.generate.mockResolvedValue({ parsed: validOutput(), usage: {} });
await executor.execute(validSnapshot(), 180000); await executor.execute(validSnapshot(), 180000);
const msg = gateway.generate.mock.calls[0][0].messages[0].content; const msg = gateway.generate.mock.calls[0][0].messages[0].content;
expect(msg).toContain('Test KB'); expect(msg).toContain('Test KB');
expect(msg).toContain('Item 1'); expect(msg).toContain('kc-1');
expect(msg).toContain('Content 1'); expect(msg).toContain('Content 1');
expect(msg).toContain('生成 3 道测验题目'); expect(msg).toContain('生成 3 道测验题目');
expect(msg).toContain('choice、judge'); expect(msg).toContain('choice、judge');
@ -102,7 +102,7 @@ describe('QuizGenerationExecutor', () => {
gateway.generate.mockResolvedValue({ parsed: validOutput(), usage: {} }); gateway.generate.mockResolvedValue({ parsed: validOutput(), usage: {} });
await executor.execute(validSnapshot(), 120000); await executor.execute(validSnapshot(), 120000);
expect(gateway.generate.mock.calls[0][0].maxTokens).toBe(4096); expect(gateway.generate.mock.calls[0][0].maxTokens).toBe(4096);
expect(gateway.generate.mock.calls[1]?.[1]).toBeUndefined(); // timeout is second arg expect(gateway.generate.mock.calls[0][1]).toBe(120000);
}); });
it('Provider 失败 → 错误传播', async () => { it('Provider 失败 → 错误传播', async () => {

View File

@ -21,10 +21,16 @@ export class QuizGenerationExecutor {
async execute(snapshot: QuizGenerationSnapshot, timeoutMs: number) { async execute(snapshot: QuizGenerationSnapshot, timeoutMs: number) {
const s = snapshot.snapshot; const s = snapshot.snapshot;
// 构造知识点列表文本 const chunksText = s.sourceChunks
const itemsText = s.knowledgeItems .map((chunk, i) =>
.map((item, i) => [
`【知识点${i + 1}${item.title}\n${item.content}`, `【资料分块${i + 1}`,
`chunkId=${chunk.id}`,
`sourceId=${chunk.sourceId}`,
chunk.sectionTitle ? `section=${chunk.sectionTitle}` : '',
chunk.pageNumber != null ? `page=${chunk.pageNumber}` : '',
chunk.content,
].filter(Boolean).join('\n'),
) )
.join('\n\n'); .join('\n\n');
@ -33,16 +39,18 @@ export class QuizGenerationExecutor {
`【知识库】${s.knowledgeBaseTitle}`, `【知识库】${s.knowledgeBaseTitle}`,
s.knowledgeBaseDescription ? `描述:${s.knowledgeBaseDescription}` : '', s.knowledgeBaseDescription ? `描述:${s.knowledgeBaseDescription}` : '',
'', '',
itemsText, chunksText,
'', '',
`请为以上知识点生成 ${s.questionCount} 道测验题目。`, '请严格依据以上资料原文生成测验题目,不要使用外部知识补充事实。',
`请为以上资料生成 ${s.questionCount} 道测验题目。`,
`题型:${typeDesc}`, `题型:${typeDesc}`,
`难度:${s.difficultyLevel}`, `难度:${s.difficultyLevel}`,
'如能定位题目依据,请返回 sourceBlockIds对应 chunkId。',
].filter(Boolean).join('\n'); ].filter(Boolean).join('\n');
this.logger.log( this.logger.log(
`Quiz Generation Executor calling AI: userId=${s.userId} ` + `Quiz Generation Executor calling AI: userId=${s.userId} ` +
`kb=${s.targetId} items=${s.knowledgeItems.length} ` + `kb=${s.targetId} chunks=${s.sourceChunks.length} sources=${s.sourceIds.length} ` +
`questionCount=${s.questionCount} types=${s.questionTypes.join(',')} ` + `questionCount=${s.questionCount} types=${s.questionTypes.join(',')} ` +
`difficulty=${s.difficultyLevel} modelTier=${s.modelTier} timeoutMs=${timeoutMs}`, `difficulty=${s.difficultyLevel} modelTier=${s.modelTier} timeoutMs=${timeoutMs}`,
); );

View File

@ -87,15 +87,16 @@ describe('QuizGenerationSnapshotBuilder', () => {
}; };
const mockItems = [ const mockItems = [
{ id: 'ki-1', title: 'Item 1', content: 'Content of item 1', summary: 'Summary 1' }, { id: 'kc-1', sourceId: 'src-1', content: 'Content of chunk 1', pageNumber: 1, sectionTitle: 'Section 1' },
{ id: 'ki-2', title: 'Item 2', content: 'Content of item 2', summary: 'Summary 2' }, { id: 'kc-2', sourceId: 'src-1', content: 'Content of chunk 2', pageNumber: 2, sectionTitle: 'Section 2' },
{ id: 'ki-3', title: 'Item 3', content: 'C'.repeat(2500), summary: 'Summary 3' }, { id: 'kc-3', sourceId: 'src-2', content: 'C'.repeat(2500), pageNumber: 3, sectionTitle: 'Section 3' },
]; ];
beforeEach(async () => { beforeEach(async () => {
prisma = { prisma = {
knowledgeBase: { findUnique: jest.fn() }, knowledgeBase: { findUnique: jest.fn() },
knowledgeItem: { findMany: jest.fn() }, knowledgeSource: { findMany: jest.fn() },
knowledgeChunk: { findMany: jest.fn() },
}; };
registry = { get: jest.fn().mockReturnValue(QUIZ_GENERATION_JOB_DEFINITION) }; registry = { get: jest.fn().mockReturnValue(QUIZ_GENERATION_JOB_DEFINITION) };
@ -114,7 +115,8 @@ describe('QuizGenerationSnapshotBuilder', () => {
describe('build', () => { describe('build', () => {
it('构建有效快照', async () => { it('构建有效快照', async () => {
prisma.knowledgeBase.findUnique.mockResolvedValue(mockKb); prisma.knowledgeBase.findUnique.mockResolvedValue(mockKb);
prisma.knowledgeItem.findMany.mockResolvedValue(mockItems); prisma.knowledgeSource.findMany.mockResolvedValue([{ id: 'src-1' }, { id: 'src-2' }]);
prisma.knowledgeChunk.findMany.mockResolvedValue(mockItems);
const snapshot = await builder.build({ const snapshot = await builder.build({
userId: 'u-001', userId: 'u-001',
@ -131,14 +133,16 @@ describe('QuizGenerationSnapshotBuilder', () => {
expect(snapshot.snapshot.questionCount).toBe(3); expect(snapshot.snapshot.questionCount).toBe(3);
expect(snapshot.snapshot.questionTypes).toEqual(['choice', 'judge']); expect(snapshot.snapshot.questionTypes).toEqual(['choice', 'judge']);
expect(snapshot.snapshot.difficultyLevel).toBe('medium'); expect(snapshot.snapshot.difficultyLevel).toBe('medium');
expect(snapshot.snapshot.knowledgeItems).toHaveLength(3); expect(snapshot.snapshot.sourceIds).toEqual(['src-1', 'src-2']);
expect(snapshot.snapshot.sourceChunks).toHaveLength(3);
expect(snapshot.snapshot.promptKey).toBe('quiz-generation'); expect(snapshot.snapshot.promptKey).toBe('quiz-generation');
expect(snapshot.snapshot.modelTier).toBe('primary'); expect(snapshot.snapshot.modelTier).toBe('primary');
}); });
it('prompt/model 值来自 Definition单一事实来源', async () => { it('prompt/model 值来自 Definition单一事实来源', async () => {
prisma.knowledgeBase.findUnique.mockResolvedValue(mockKb); prisma.knowledgeBase.findUnique.mockResolvedValue(mockKb);
prisma.knowledgeItem.findMany.mockResolvedValue([mockItems[0]]); prisma.knowledgeSource.findMany.mockResolvedValue([{ id: 'src-1' }]);
prisma.knowledgeChunk.findMany.mockResolvedValue([mockItems[0]]);
registry.get.mockReturnValue({ registry.get.mockReturnValue({
...QUIZ_GENERATION_JOB_DEFINITION, ...QUIZ_GENERATION_JOB_DEFINITION,
prompt: { promptKey: 'custom-quiz', promptVersion: '2.0' }, prompt: { promptKey: 'custom-quiz', promptVersion: '2.0' },
@ -157,7 +161,8 @@ describe('QuizGenerationSnapshotBuilder', () => {
it('默认值questionCount=5, types=全类型, difficulty=medium', async () => { it('默认值questionCount=5, types=全类型, difficulty=medium', async () => {
prisma.knowledgeBase.findUnique.mockResolvedValue(mockKb); prisma.knowledgeBase.findUnique.mockResolvedValue(mockKb);
prisma.knowledgeItem.findMany.mockResolvedValue([mockItems[0]]); prisma.knowledgeSource.findMany.mockResolvedValue([{ id: 'src-1' }]);
prisma.knowledgeChunk.findMany.mockResolvedValue([mockItems[0]]);
const snapshot = await builder.build({ const snapshot = await builder.build({
userId: 'u-001', userId: 'u-001',
@ -171,9 +176,10 @@ describe('QuizGenerationSnapshotBuilder', () => {
expect(snapshot.snapshot.difficultyLevel).toBe('medium'); expect(snapshot.snapshot.difficultyLevel).toBe('medium');
}); });
it('content 截断到 2000 字符', async () => { it('chunk content 截断到 2000 字符', async () => {
prisma.knowledgeBase.findUnique.mockResolvedValue(mockKb); prisma.knowledgeBase.findUnique.mockResolvedValue(mockKb);
prisma.knowledgeItem.findMany.mockResolvedValue([mockItems[2]]); // 2500 chars prisma.knowledgeSource.findMany.mockResolvedValue([{ id: 'src-2' }]);
prisma.knowledgeChunk.findMany.mockResolvedValue([mockItems[2]]); // 2500 chars
const snapshot = await builder.build({ const snapshot = await builder.build({
userId: 'u-001', userId: 'u-001',
@ -182,7 +188,7 @@ describe('QuizGenerationSnapshotBuilder', () => {
submissionId: 'sub-001', submissionId: 'sub-001',
}); });
expect(snapshot.snapshot.knowledgeItems[0].content.length).toBeLessThanOrEqual(2000); expect(snapshot.snapshot.sourceChunks[0].content.length).toBeLessThanOrEqual(2000);
}); });
it('knowledgeBase 不存在 → NotFoundException', async () => { it('knowledgeBase 不存在 → NotFoundException', async () => {
@ -235,7 +241,16 @@ describe('QuizGenerationSnapshotBuilder', () => {
it('空知识库 → BadRequestException', async () => { it('空知识库 → BadRequestException', async () => {
prisma.knowledgeBase.findUnique.mockResolvedValue(mockKb); prisma.knowledgeBase.findUnique.mockResolvedValue(mockKb);
prisma.knowledgeItem.findMany.mockResolvedValue([]); prisma.knowledgeSource.findMany.mockResolvedValue([]);
await expect(
builder.build({ userId: 'u-001', targetType: 'knowledge_base', targetId: 'kb-001', submissionId: 'sub-001' }),
).rejects.toThrow(BadRequestException);
});
it('无分块内容 → BadRequestException', async () => {
prisma.knowledgeBase.findUnique.mockResolvedValue(mockKb);
prisma.knowledgeSource.findMany.mockResolvedValue([{ id: 'src-1' }]);
prisma.knowledgeChunk.findMany.mockResolvedValue([]);
await expect( await expect(
builder.build({ userId: 'u-001', targetType: 'knowledge_base', targetId: 'kb-001', submissionId: 'sub-001' }), builder.build({ userId: 'u-001', targetType: 'knowledge_base', targetId: 'kb-001', submissionId: 'sub-001' }),
).rejects.toThrow(BadRequestException); ).rejects.toThrow(BadRequestException);
@ -243,7 +258,8 @@ describe('QuizGenerationSnapshotBuilder', () => {
it('Snapshot 不含敏感字段', async () => { it('Snapshot 不含敏感字段', async () => {
prisma.knowledgeBase.findUnique.mockResolvedValue(mockKb); prisma.knowledgeBase.findUnique.mockResolvedValue(mockKb);
prisma.knowledgeItem.findMany.mockResolvedValue([mockItems[0]]); prisma.knowledgeSource.findMany.mockResolvedValue([{ id: 'src-1' }]);
prisma.knowledgeChunk.findMany.mockResolvedValue([mockItems[0]]);
const snapshot = await builder.build({ const snapshot = await builder.build({
userId: 'u-001', targetType: 'knowledge_base', targetId: 'kb-001', submissionId: 'sub-001', userId: 'u-001', targetType: 'knowledge_base', targetId: 'kb-001', submissionId: 'sub-001',
@ -262,7 +278,8 @@ describe('QuizGenerationSnapshotBuilder', () => {
describe('computeHash', () => { describe('computeHash', () => {
it('相同输入 → 相同 hash', async () => { it('相同输入 → 相同 hash', async () => {
prisma.knowledgeBase.findUnique.mockResolvedValue(mockKb); prisma.knowledgeBase.findUnique.mockResolvedValue(mockKb);
prisma.knowledgeItem.findMany.mockResolvedValue([mockItems[0]]); prisma.knowledgeSource.findMany.mockResolvedValue([{ id: 'src-1' }]);
prisma.knowledgeChunk.findMany.mockResolvedValue([mockItems[0]]);
const s1 = await builder.build({ userId: 'u-001', targetType: 'knowledge_base', targetId: 'kb-001', submissionId: 'sub-001' }); const s1 = await builder.build({ userId: 'u-001', targetType: 'knowledge_base', targetId: 'kb-001', submissionId: 'sub-001' });
const s2 = await builder.build({ userId: 'u-001', targetType: 'knowledge_base', targetId: 'kb-001', submissionId: 'sub-001' }); const s2 = await builder.build({ userId: 'u-001', targetType: 'knowledge_base', targetId: 'kb-001', submissionId: 'sub-001' });
@ -272,10 +289,11 @@ describe('QuizGenerationSnapshotBuilder', () => {
it('不同输入 → 不同 hash', async () => { it('不同输入 → 不同 hash', async () => {
prisma.knowledgeBase.findUnique.mockResolvedValue(mockKb); prisma.knowledgeBase.findUnique.mockResolvedValue(mockKb);
prisma.knowledgeItem.findMany.mockResolvedValueOnce([mockItems[0]]); prisma.knowledgeSource.findMany.mockResolvedValue([{ id: 'src-1' }, { id: 'src-2' }]);
prisma.knowledgeChunk.findMany.mockResolvedValueOnce([mockItems[0]]);
const s1 = await builder.build({ userId: 'u-001', targetType: 'knowledge_base', targetId: 'kb-001', submissionId: 'sub-001' }); const s1 = await builder.build({ userId: 'u-001', targetType: 'knowledge_base', targetId: 'kb-001', submissionId: 'sub-001' });
prisma.knowledgeItem.findMany.mockResolvedValueOnce([mockItems[0], mockItems[1]]); prisma.knowledgeChunk.findMany.mockResolvedValueOnce([mockItems[0], mockItems[1]]);
const s2 = await builder.build({ userId: 'u-001', targetType: 'knowledge_base', targetId: 'kb-001', submissionId: 'sub-002' }); const s2 = await builder.build({ userId: 'u-001', targetType: 'knowledge_base', targetId: 'kb-001', submissionId: 'sub-002' });
expect(builder.computeHash(s1)).not.toBe(builder.computeHash(s2)); expect(builder.computeHash(s1)).not.toBe(builder.computeHash(s2));
@ -283,7 +301,8 @@ describe('QuizGenerationSnapshotBuilder', () => {
it('hash 长度 16hex 格式', async () => { it('hash 长度 16hex 格式', async () => {
prisma.knowledgeBase.findUnique.mockResolvedValue(mockKb); prisma.knowledgeBase.findUnique.mockResolvedValue(mockKb);
prisma.knowledgeItem.findMany.mockResolvedValue([mockItems[0]]); prisma.knowledgeSource.findMany.mockResolvedValue([{ id: 'src-1' }]);
prisma.knowledgeChunk.findMany.mockResolvedValue([mockItems[0]]);
const snapshot = await builder.build({ userId: 'u-001', targetType: 'knowledge_base', targetId: 'kb-001', submissionId: 'sub-001' }); const snapshot = await builder.build({ userId: 'u-001', targetType: 'knowledge_base', targetId: 'kb-001', submissionId: 'sub-001' });
const hash = builder.computeHash(snapshot); const hash = builder.computeHash(snapshot);

View File

@ -12,7 +12,7 @@ import { JobDefinitionRegistry } from './job-definition-registry';
* *
* *
* 1. knowledgeBase * 1. knowledgeBase
* 2. + * 2. +
* 3. questionCount / questionTypes / difficultyLevel * 3. questionCount / questionTypes / difficultyLevel
* 4. JobDefinitionRegistry prompt/model * 4. JobDefinitionRegistry prompt/model
* 5. * 5.
@ -26,8 +26,8 @@ import { JobDefinitionRegistry } from './job-definition-registry';
* *
* Snapshot Schemaquiz-generation-v1 * Snapshot Schemaquiz-generation-v1
* userId, targetType, targetId, submissionId, * userId, targetType, targetId, submissionId,
* knowledgeBaseTitle/Description, knowledgeItems (truncated), * knowledgeBaseTitle/Description, sourceIds, sourceChunks (truncated),
* questionCount, questionTypes, difficultyLevel, knowledgePointIds, * questionCount, questionTypes, difficultyLevel,
* promptKey, promptVersion, modelTier, * promptKey, promptVersion, modelTier,
* inputSchemaVersion, outputSchemaVersion, createdAt * inputSchemaVersion, outputSchemaVersion, createdAt
*/ */
@ -44,10 +44,10 @@ const VALID_DIFFICULTY_LEVELS = ['easy', 'medium', 'hard'] as const;
const MIN_QUESTION_COUNT = 1; const MIN_QUESTION_COUNT = 1;
const MAX_QUESTION_COUNT = 50; const MAX_QUESTION_COUNT = 50;
/** 知识点内容截断长度 */ /** 资料分块内容截断长度 */
const MAX_ITEM_CONTENT_LENGTH = 2000; const MAX_CHUNK_CONTENT_LENGTH = 2000;
/** 知识点最大加载数量 */ /** 资料分块最大加载数量 */
const MAX_KNOWLEDGE_ITEMS = 50; const MAX_SOURCE_CHUNKS = 50;
export interface QuizGenerationSnapshot { export interface QuizGenerationSnapshot {
schemaVersion: string; schemaVersion: string;
@ -58,16 +58,17 @@ export interface QuizGenerationSnapshot {
submissionId: string; submissionId: string;
knowledgeBaseTitle: string; knowledgeBaseTitle: string;
knowledgeBaseDescription: string | null; knowledgeBaseDescription: string | null;
knowledgeItems: Array<{ sourceIds: string[];
sourceChunks: Array<{
id: string; id: string;
title: string; sourceId: string;
content: string; // truncated to MAX_ITEM_CONTENT_LENGTH pageNumber: number | null;
summary: string; sectionTitle: string;
content: string; // truncated to MAX_CHUNK_CONTENT_LENGTH
}>; }>;
questionCount: number; questionCount: number;
questionTypes: string[]; questionTypes: string[];
difficultyLevel: string; difficultyLevel: string;
knowledgePointIds: string[];
promptKey: string; promptKey: string;
promptVersion: string; promptVersion: string;
modelTier: string; modelTier: string;
@ -85,7 +86,7 @@ export interface QuizGenerationSnapshotInput {
questionCount?: number; questionCount?: number;
questionTypes?: string[]; questionTypes?: string[];
difficultyLevel?: string; difficultyLevel?: string;
knowledgePointIds?: string[]; sourceIds?: string[];
} }
@Injectable() @Injectable()
@ -122,7 +123,7 @@ export class QuizGenerationSnapshotBuilder {
const questionCount = this.normalizeQuestionCount(input.questionCount); const questionCount = this.normalizeQuestionCount(input.questionCount);
const questionTypes = this.normalizeQuestionTypes(input.questionTypes); const questionTypes = this.normalizeQuestionTypes(input.questionTypes);
const difficultyLevel = this.normalizeDifficultyLevel(input.difficultyLevel); const difficultyLevel = this.normalizeDifficultyLevel(input.difficultyLevel);
const knowledgePointIds = input.knowledgePointIds ?? []; const sourceIds = input.sourceIds ?? [];
// 4. 加载并校验 knowledgeBase // 4. 加载并校验 knowledgeBase
const kb = await this.prisma.knowledgeBase.findUnique({ const kb = await this.prisma.knowledgeBase.findUnique({
@ -137,40 +138,62 @@ export class QuizGenerationSnapshotBuilder {
); );
} }
// 5. 加载知识点(限制数量 + 截断内容) // 5. 加载资料来源,再读取分块快照
const where: any = { const sourceWhere: any = {
knowledgeBaseId: input.targetId, knowledgeBaseId: input.targetId,
deletedAt: null, deletedAt: null,
status: 'active',
}; };
if (knowledgePointIds.length > 0) { if (sourceIds.length > 0) {
where.id = { in: knowledgePointIds }; sourceWhere.id = { in: sourceIds };
} }
const items = await this.prisma.knowledgeItem.findMany({ const sources = await this.prisma.knowledgeSource.findMany({
where, where: sourceWhere,
select: { select: {
id: true, id: true,
title: true,
content: true,
summary: true,
}, },
orderBy: { orderIndex: 'asc' }, orderBy: { createdAt: 'asc' },
take: MAX_KNOWLEDGE_ITEMS,
}); });
if (items.length === 0) { if (sources.length === 0) {
throw new BadRequestException( throw new BadRequestException(
'Knowledge base has no active knowledge items', 'Knowledge base has no active sources',
); );
} }
// 截断内容(最小化快照) const effectiveSourceIds = sources.map((source) => source.id);
const knowledgeItems = items.map((item) => ({
id: item.id, const chunks = await this.prisma.knowledgeChunk.findMany({
title: item.title, where: {
content: (item.content ?? '').slice(0, MAX_ITEM_CONTENT_LENGTH), knowledgeBaseId: input.targetId,
summary: item.summary ?? '', sourceId: { in: effectiveSourceIds },
},
select: {
id: true,
sourceId: true,
content: true,
pageNumber: true,
sectionTitle: true,
},
orderBy: [
{ sourceId: 'asc' },
{ chunkIndex: 'asc' },
],
take: MAX_SOURCE_CHUNKS,
});
if (chunks.length === 0) {
throw new BadRequestException(
'Knowledge base has no source chunks',
);
}
const sourceChunks = chunks.map((chunk) => ({
id: chunk.id,
sourceId: chunk.sourceId,
pageNumber: chunk.pageNumber ?? null,
sectionTitle: chunk.sectionTitle ?? '',
content: (chunk.content ?? '').slice(0, MAX_CHUNK_CONTENT_LENGTH),
})); }));
// 6. 构建快照 // 6. 构建快照
@ -184,11 +207,11 @@ export class QuizGenerationSnapshotBuilder {
submissionId: input.submissionId, submissionId: input.submissionId,
knowledgeBaseTitle: kb.title, knowledgeBaseTitle: kb.title,
knowledgeBaseDescription: kb.description ?? null, knowledgeBaseDescription: kb.description ?? null,
knowledgeItems, sourceIds: effectiveSourceIds,
sourceChunks,
questionCount, questionCount,
questionTypes, questionTypes,
difficultyLevel, difficultyLevel,
knowledgePointIds,
promptKey: def.prompt.promptKey, promptKey: def.prompt.promptKey,
promptVersion: def.prompt.promptVersion, promptVersion: def.prompt.promptVersion,
modelTier: def.model.modelTier, modelTier: def.model.modelTier,
@ -200,7 +223,7 @@ export class QuizGenerationSnapshotBuilder {
this.logger.log( this.logger.log(
`Built Quiz Generation snapshot for kb=${input.targetId} ` + `Built Quiz Generation snapshot for kb=${input.targetId} ` +
`userId=${input.userId} items=${knowledgeItems.length} ` + `userId=${input.userId} chunks=${sourceChunks.length} sources=${effectiveSourceIds.length} ` +
`questionCount=${questionCount} types=${questionTypes.join(',')} ` + `questionCount=${questionCount} types=${questionTypes.join(',')} ` +
`difficulty=${difficultyLevel}`, `difficulty=${difficultyLevel}`,
); );

View File

@ -14,6 +14,7 @@ function dto(overrides?: any) {
questionCount: 3, questionCount: 3,
questionTypes: ['choice', 'judge'], questionTypes: ['choice', 'judge'],
difficultyLevel: 'medium', difficultyLevel: 'medium',
sourceIds: ['src-1', 'src-2'],
...overrides, ...overrides,
}; };
} }
@ -147,7 +148,8 @@ describe('QuizExecutionRouter', () => {
it('submissionId 可由 contentKey 验证', async () => { it('submissionId 可由 contentKey 验证', async () => {
// Build expected key manually // Build expected key manually
const types = ['choice', 'judge'].sort().join(','); const types = ['choice', 'judge'].sort().join(',');
const normalized = ['knowledge_base', 'kb-001', '3', types, 'medium'].join('|'); const sourceIds = ['src-1', 'src-2'].sort().join(',');
const normalized = ['knowledge_base', 'kb-001', '3', types, 'medium', sourceIds].join('|');
const expectedHash = crypto.createHash('sha256').update(normalized).digest('hex').substring(0, 24); const expectedHash = crypto.createHash('sha256').update(normalized).digest('hex').substring(0, 24);
await router.generateQuiz('u1', dto()); await router.generateQuiz('u1', dto());

View File

@ -82,7 +82,7 @@ export class QuizExecutionRouter {
questionCount: dto.questionCount, questionCount: dto.questionCount,
questionTypes: dto.questionTypes, questionTypes: dto.questionTypes,
difficultyLevel: dto.difficultyLevel, difficultyLevel: dto.difficultyLevel,
knowledgePointIds: dto.knowledgePointIds, sourceIds: dto.sourceIds,
}; };
const snapshot = await this.snapshotBuilder.build(snapshotInput); const snapshot = await this.snapshotBuilder.build(snapshotInput);
@ -126,12 +126,14 @@ export class QuizExecutionRouter {
*/ */
private buildContentKey(dto: CreateAnalysisJobDto): string { private buildContentKey(dto: CreateAnalysisJobDto): string {
const types = (dto.questionTypes ?? []).slice().sort().join(','); const types = (dto.questionTypes ?? []).slice().sort().join(',');
const sourceIds = (dto.sourceIds ?? []).slice().sort().join(',');
const normalized = [ const normalized = [
dto.targetType, dto.targetType,
dto.targetId, dto.targetId,
String(dto.questionCount ?? 5), String(dto.questionCount ?? 5),
types || 'choice,fill,judge', types || 'choice,fill,judge',
dto.difficultyLevel ?? 'medium', dto.difficultyLevel ?? 'medium',
sourceIds || 'all_sources',
].join('|'); ].join('|');
return crypto.createHash('sha256').update(normalized).digest('hex').substring(0, 24); return crypto.createHash('sha256').update(normalized).digest('hex').substring(0, 24);
} }

View File

@ -84,12 +84,14 @@ export class CreateAnalysisJobDto {
// Quiz-specific (API-AI-023) // Quiz-specific (API-AI-023)
@IsOptional() @IsInt() @Min(1) @Max(50) questionCount?: number; @IsOptional() @IsInt() @Min(1) @Max(50) questionCount?: number;
@IsOptional() @IsArray() @IsString({ each: true }) questionTypes?: string[]; @IsOptional() @IsArray() @IsString({ each: true }) questionTypes?: string[];
@IsOptional() @IsArray() @IsString({ each: true }) sourceIds?: string[];
// Flashcard-specific (API-AI-024) // Flashcard-specific (API-AI-024)
@IsOptional() @IsInt() @Min(1) @Max(100) cardCount?: number; @IsOptional() @IsInt() @Min(1) @Max(100) cardCount?: number;
// Shared by quiz & flashcard // Shared by quiz & flashcard
@IsOptional() @IsIn(VALID_DIFFICULTY_LEVELS) difficultyLevel?: string; @IsOptional() @IsIn(VALID_DIFFICULTY_LEVELS) difficultyLevel?: string;
// Deprecated: retained for legacy flashcard / older quiz clients.
@IsOptional() @IsArray() @IsString({ each: true }) knowledgePointIds?: string[]; @IsOptional() @IsArray() @IsString({ each: true }) knowledgePointIds?: string[];
} }

View File

@ -172,7 +172,7 @@ describe('UserAiService.createAnalysisJob', () => {
questionCount: 10, questionCount: 10,
difficultyLevel: 'medium', difficultyLevel: 'medium',
questionTypes: ['choice', 'judge'], questionTypes: ['choice', 'judge'],
knowledgePointIds: ['kp1', 'kp2'], sourceIds: ['src1', 'src2'],
}; };
it('creates QuestionGenerationPlan for quiz_generation', async () => { it('creates QuestionGenerationPlan for quiz_generation', async () => {
@ -188,7 +188,7 @@ describe('UserAiService.createAnalysisJob', () => {
count: 10, count: 10,
difficultyLevel: 'medium', difficultyLevel: 'medium',
questionTypes: ['choice', 'judge'], questionTypes: ['choice', 'judge'],
knowledgePointIds: ['kp1', 'kp2'], sourceIds: ['src1', 'src2'],
status: 'pending', status: 'pending',
}), }),
})); }));
@ -210,7 +210,7 @@ describe('UserAiService.createAnalysisJob', () => {
await service.createAnalysisJob('u1', { jobType: 'quiz_generation', targetType: 'knowledge_base', targetId: 'kb1' }); await service.createAnalysisJob('u1', { jobType: 'quiz_generation', targetType: 'knowledge_base', targetId: 'kb1' });
expect(prisma.questionGenerationPlan.create).toHaveBeenCalledWith(expect.objectContaining({ expect(prisma.questionGenerationPlan.create).toHaveBeenCalledWith(expect.objectContaining({
data: expect.objectContaining({ count: 5, questionTypes: [], knowledgePointIds: [] }), data: expect.objectContaining({ count: 5, questionTypes: [], sourceIds: [] }),
})); }));
}); });

View File

@ -301,6 +301,7 @@ export class UserAiService {
// 10. Create per-type plan record // 10. Create per-type plan record
let planId: string | undefined; let planId: string | undefined;
if (dto.jobType === 'quiz_generation') { if (dto.jobType === 'quiz_generation') {
const quizSourceIds = dto.sourceIds ?? [];
const plan = await this.prisma.questionGenerationPlan.create({ const plan = await this.prisma.questionGenerationPlan.create({
data: { data: {
userId, userId,
@ -308,7 +309,7 @@ export class UserAiService {
snapshotId: snapshot.id, snapshotId: snapshot.id,
targetType: dto.targetType, targetType: dto.targetType,
targetId: dto.targetId, targetId: dto.targetId,
knowledgePointIds: (dto.knowledgePointIds ?? []) as any, sourceIds: quizSourceIds as any,
questionTypes: (dto.questionTypes ?? []) as any, questionTypes: (dto.questionTypes ?? []) as any,
difficultyLevel: dto.difficultyLevel ?? null, difficultyLevel: dto.difficultyLevel ?? null,
count: dto.questionCount ?? 5, count: dto.questionCount ?? 5,

View File

@ -9,6 +9,7 @@ import { PrismaService } from '../../infrastructure/database/prisma.service';
import { ActiveRecallAnalysisWorkflow } from './workflows/active-recall-analysis.workflow'; import { ActiveRecallAnalysisWorkflow } from './workflows/active-recall-analysis.workflow';
import { FeynmanEvaluationWorkflow } from './workflows/feynman-evaluation.workflow'; import { FeynmanEvaluationWorkflow } from './workflows/feynman-evaluation.workflow';
import { KnowledgeImportWorkflow } from './workflows/knowledge-import.workflow'; import { KnowledgeImportWorkflow } from './workflows/knowledge-import.workflow';
import { QuizGenerationWorkflow } from './workflows/quiz-generation.workflow';
import { ReviewCardGenerationWorkflow } from './workflows/review-card-generation.workflow'; import { ReviewCardGenerationWorkflow } from './workflows/review-card-generation.workflow';
import { LearningTrendWorkflow } from './workflows/learning-trend.workflow'; import { LearningTrendWorkflow } from './workflows/learning-trend.workflow';
import { AdminAiGatewayController } from './ai.controller'; import { AdminAiGatewayController } from './ai.controller';
@ -75,9 +76,10 @@ import type { AiProvider } from './providers/ai-provider.interface';
ActiveRecallAnalysisWorkflow, ActiveRecallAnalysisWorkflow,
FeynmanEvaluationWorkflow, FeynmanEvaluationWorkflow,
KnowledgeImportWorkflow, KnowledgeImportWorkflow,
QuizGenerationWorkflow,
ReviewCardGenerationWorkflow, ReviewCardGenerationWorkflow,
LearningTrendWorkflow, LearningTrendWorkflow,
], ],
exports: [AiGatewayService, ActiveRecallAnalysisWorkflow, FeynmanEvaluationWorkflow, KnowledgeImportWorkflow, ReviewCardGenerationWorkflow, LearningTrendWorkflow], exports: [AiGatewayService, ActiveRecallAnalysisWorkflow, FeynmanEvaluationWorkflow, KnowledgeImportWorkflow, QuizGenerationWorkflow, ReviewCardGenerationWorkflow, LearningTrendWorkflow],
}) })
export class AiModule {} export class AiModule {}

View File

@ -1,6 +1,6 @@
export const QUIZ_GENERATION_SYSTEM_PROMPT = `你是一位教育测验设计专家,擅长根据知识点内容创建高质量的测验题目。 export const QUIZ_GENERATION_SYSTEM_PROMPT = `你是一位教育测验设计专家,擅长根据学习资料原文创建高质量的测验题目。
1. 1.
@ -21,9 +21,11 @@ export const QUIZ_GENERATION_SYSTEM_PROMPT = `你是一位教育测验设计专
- questions - questions
- quizTitle50 - quizTitle50
- quizDescription - quizDescription
- sourceBlockIds chunkId
- - 使
-
- -
- -
- -

View File

@ -0,0 +1,88 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { PrismaService } from '../../../infrastructure/database/prisma.service';
import { AiGatewayService } from '../gateway/ai-gateway.service';
import { QuizGenerationResultSchema } from '../prompts/schemas/quiz-generation.schema';
import type { QuizGenerationResult } from '../prompts/schemas/quiz-generation.schema';
export interface QuizGenerationInput {
userId: string;
knowledgeBaseId: string;
sourceIds: string[];
questionCount: number;
}
@Injectable()
export class QuizGenerationWorkflow {
constructor(
private readonly gateway: AiGatewayService,
private readonly prisma: PrismaService,
) {}
async execute(input: QuizGenerationInput): Promise<QuizGenerationResult> {
const chunks = await this.prisma.knowledgeChunk.findMany({
where: {
knowledgeBaseId: input.knowledgeBaseId,
sourceId: { in: input.sourceIds },
},
select: {
id: true,
content: true,
pageNumber: true,
sectionTitle: true,
},
orderBy: [
{ sourceId: 'asc' },
{ chunkIndex: 'asc' },
],
take: 50,
});
if (chunks.length === 0) {
throw new BadRequestException('资料内容为空,无法生成题目');
}
const maxChars = 12000;
const sourceBlocks: string[] = [];
let currentLength = 0;
for (const chunk of chunks) {
const block = [
`[chunkId=${chunk.id}]`,
chunk.sectionTitle ? `section=${chunk.sectionTitle}` : '',
chunk.pageNumber != null ? `page=${chunk.pageNumber}` : '',
chunk.content,
]
.filter(Boolean)
.join('\n');
if (currentLength + block.length > maxChars && sourceBlocks.length > 0) {
break;
}
sourceBlocks.push(block);
currentLength += block.length;
}
const userMessage = [
`请根据以下学习资料生成 ${input.questionCount} 道测验题目。`,
'',
'题目必须以资料原文为依据,不依赖外部知识。',
'如果能定位题目的资料依据,请在 sourceBlockIds 中返回对应的 chunkId。',
'',
'【资料原文分块】',
sourceBlocks.join('\n\n'),
].join('\n');
const response = await this.gateway.generate({
feature: 'quiz-generation',
userId: input.userId,
tier: 'primary',
promptKey: 'quiz-generation',
promptVersion: '1.0.0',
messages: [{ role: 'user', content: userMessage }],
outputSchema: QuizGenerationResultSchema,
});
return response.parsed as QuizGenerationResult;
}
}

View File

@ -1,6 +1,7 @@
import { Controller, Get, Post, Param, Query, Body, UseGuards, BadRequestException } from '@nestjs/common'; import { Controller, Get, Post, Delete, Param, Query, Body, UseGuards, BadRequestException } from '@nestjs/common';
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger'; import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
import { PrismaService } from '../../infrastructure/database/prisma.service'; import { PrismaService } from '../../infrastructure/database/prisma.service';
import { StorageService } from '../../infrastructure/storage/storage.service';
import { enrichWithNames } from '../../common/helpers/name-resolver'; import { enrichWithNames } from '../../common/helpers/name-resolver';
import { AdminAuthGuard } from '../../common/guards/admin-auth.guard'; import { AdminAuthGuard } from '../../common/guards/admin-auth.guard';
import { AdminRolesGuard } from '../../common/guards/admin-roles.guard'; import { AdminRolesGuard } from '../../common/guards/admin-roles.guard';
@ -12,7 +13,10 @@ import type { AdminRole } from '../../common/types/admin-role.enum';
@UseGuards(AdminAuthGuard, AdminRolesGuard) @UseGuards(AdminAuthGuard, AdminRolesGuard)
@ApiBearerAuth() @ApiBearerAuth()
export class AdminImportsController { export class AdminImportsController {
constructor(private readonly prisma: PrismaService) {} constructor(
private readonly prisma: PrismaService,
private readonly storage: StorageService,
) {}
@Get() @Get()
@AdminRoles('ADMIN' as AdminRole) @AdminRoles('ADMIN' as AdminRole)
@ -136,4 +140,34 @@ export class AdminImportsController {
return { success: true, updated: updated.length, skipped, skippedCount: skipped.length }; return { success: true, updated: updated.length, skipped, skippedCount: skipped.length };
} }
@Delete(':id')
@AdminRoles('ADMIN' as AdminRole)
@ApiOperation({ summary: '删除导入记录(同时删除关联的 Source 和文件)' })
async remove(@Param('id') id: string) {
const job = await this.prisma.documentImport.findUnique({ where: { id } });
if (!job) throw new BadRequestException('导入任务不存在');
// 删除关联的 KnowledgeSource含存储文件
if (job.sourceId) {
const source = await this.prisma.knowledgeSource.findFirst({
where: { id: job.sourceId, deletedAt: null },
});
if (source?.originalObjectKey) {
try { await this.storage.deleteObject(source.originalObjectKey); } catch {}
}
await this.prisma.knowledgeSource.updateMany({
where: { id: job.sourceId },
data: { deletedAt: new Date() },
});
}
// 清理关联数据
await this.prisma.importStepLog.deleteMany({ where: { importId: id } });
// 删除导入记录
await this.prisma.documentImport.delete({ where: { id } });
return { success: true, deletedId: id, deletedSource: !!job.sourceId };
}
} }

View File

@ -71,7 +71,7 @@ export class DocumentImportRepository {
const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000); const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000);
return this.prisma.documentImport.findMany({ return this.prisma.documentImport.findMany({
where: { where: {
status: { in: ['CLAIMED', 'DOWNLOADING', 'PARSING', 'OCR_PROCESSING', 'VISION_PROCESSING', 'CLEANING', 'CHUNKING', 'EMBEDDING', 'INDEXING', 'GENERATING_CANDIDATES'] }, status: { in: ['CLAIMED', 'DOWNLOADING', 'PARSING', 'OCR_PROCESSING', 'VISION_PROCESSING', 'CLEANING', 'CHUNKING', 'EMBEDDING', 'INDEXING'] },
heartbeatAt: { lt: fiveMinutesAgo }, heartbeatAt: { lt: fiveMinutesAgo },
}, },
}); });

View File

@ -1,45 +0,0 @@
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);
}
}

View File

@ -1,13 +0,0 @@
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 {}

View File

@ -1,71 +0,0 @@
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' },
});
}
}

View File

@ -1,87 +0,0 @@
import { Injectable, NotFoundException, Optional } from '@nestjs/common';
import { ImportCandidateRepository } from './import-candidate.repository';
import { KnowledgeItemsRepository } from '../knowledge-items/knowledge-items.repository';
import { ContentSafetyService } from '../content-safety/content-safety.service';
@Injectable()
export class ImportCandidateService {
constructor(
private readonly repository: ImportCandidateRepository,
private readonly itemsRepo: KnowledgeItemsRepository,
@Optional() private readonly safety?: ContentSafetyService,
) {}
private async checkSafety(title: string, content: string, userId: string): Promise<boolean> {
if (!this.safety) return true;
const check = await this.safety.check(title + ' ' + (content || '').slice(0, 200), { userId, contentType: 'candidate' });
return check.safe;
}
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('候选知识点不存在');
// Content safety check before accepting
const safe = await this.checkSafety(candidate.title, candidate.content || '', candidate.userId);
if (!safe) return { status: 'BLOCKED', reason: '内容安全审核未通过' };
await this.repository.updateStatus(id, 'ACCEPTED');
// 生成 KnowledgeItem关联到来源
const sourceId = (candidate as any).sourceId ?? null;
await this.itemsRepo.create(candidate.userId, candidate.knowledgeBaseId, {
title: candidate.title,
content: (candidate.content as string) ?? '',
itemType: 'ai_generated',
orderIndex: candidate.orderIndex,
sourceId,
sourceRef: sourceId,
});
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<any>) {
// Filter out unsafe candidates
const safeCandidates: any[] = [];
for (const c of candidates) {
const safe = await this.checkSafety(c.title || '', c.content || '', userId);
if (safe) safeCandidates.push(c);
}
if (safeCandidates.length === 0) return { created: 0, filtered: candidates.length };
return this.repository.createMany(userId, knowledgeBaseId, sourceId, importId, safeCandidates);
}
}

View File

@ -47,11 +47,14 @@ export class KnowledgeItemsRepository {
async create(userId: string, knowledgeBaseId: string, dto: { async create(userId: string, knowledgeBaseId: string, dto: {
title?: string; title?: string;
content?: string; content?: string;
summary?: string;
parentId?: string; parentId?: string;
itemType?: string; itemType?: string;
sourceId?: string; sourceId?: string;
sourceType?: string; sourceType?: string;
sourceRef?: string; sourceRef?: string;
sourceTitleSnapshot?: string;
sourceSnippetSnapshot?: string;
durationSeconds?: number; durationSeconds?: number;
fileSize?: number; fileSize?: number;
orderIndex?: number; orderIndex?: number;
@ -63,11 +66,14 @@ export class KnowledgeItemsRepository {
knowledgeBaseId, knowledgeBaseId,
title: dto.title ?? '', title: dto.title ?? '',
content: dto.content ?? '', content: dto.content ?? '',
summary: dto.summary ?? null,
parentId: dto.parentId ?? null, parentId: dto.parentId ?? null,
itemType: dto.itemType ?? 'lesson', itemType: dto.itemType ?? 'lesson',
sourceType, sourceType,
sourceId: dto.sourceId ?? null, sourceId: dto.sourceId ?? null,
sourceRef: dto.sourceRef ?? null, sourceRef: dto.sourceRef ?? null,
sourceTitleSnapshot: dto.sourceTitleSnapshot ?? null,
sourceSnippetSnapshot: dto.sourceSnippetSnapshot ?? null,
durationSeconds: dto.durationSeconds ?? 0, durationSeconds: dto.durationSeconds ?? 0,
fileSize: dto.fileSize ?? null, fileSize: dto.fileSize ?? null,
orderIndex: dto.orderIndex ?? 0, orderIndex: dto.orderIndex ?? 0,

View File

@ -0,0 +1,14 @@
import { ApiProperty } from '@nestjs/swagger';
import { ArrayMinSize, IsArray, IsString } from 'class-validator';
export class GenerateKnowledgeBatchDto {
@ApiProperty({
description: '要批量生成知识点的资料来源 ID 列表',
type: [String],
example: ['source_1', 'source_2'],
})
@IsArray()
@ArrayMinSize(1)
@IsString({ each: true })
sourceIds!: string[];
}

View File

@ -3,6 +3,7 @@ import { ApiTags, ApiOperation } from '@nestjs/swagger';
import { KnowledgeSourceService } from './knowledge-source.service'; import { KnowledgeSourceService } from './knowledge-source.service';
import { CurrentUser } from '../../common/decorators/current-user.decorator'; import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { AddSourceDto } from './dto/add-source.dto'; import { AddSourceDto } from './dto/add-source.dto';
import { GenerateKnowledgeBatchDto } from './dto/generate-knowledge-batch.dto';
import type { UserPayload } from '../../common/types'; import type { UserPayload } from '../../common/types';
@ApiTags('knowledge-source') @ApiTags('knowledge-source')
@ -26,12 +27,36 @@ export class KnowledgeSourceController {
return this.service.findByKnowledgeBase(kbId, pagination); return this.service.findByKnowledgeBase(kbId, pagination);
} }
@Get('knowledge-generation-tasks')
@ApiOperation({ summary: '获取知识库资料最新知识点生成任务列表' })
async knowledgeGenerationTasks(
@CurrentUser() user: UserPayload,
@Param('kbId') kbId: string,
@Query('sourceIds') sourceIds?: string,
) {
const parsed = (sourceIds ?? '')
.split(',')
.map((id) => id.trim())
.filter(Boolean);
return this.service.findLatestKnowledgeGenerationTasks(kbId, user.id, parsed);
}
@Get(':id') @Get(':id')
@ApiOperation({ summary: '获取资料详情' }) @ApiOperation({ summary: '获取资料详情' })
async findOne(@Param('id') id: string) { async findOne(@Param('id') id: string) {
return this.service.findOne(id); return this.service.findOne(id);
} }
@Get(':id/knowledge-generation-task')
@ApiOperation({ summary: '获取资料最新知识点生成任务' })
async latestKnowledgeGenerationTask(
@CurrentUser() user: UserPayload,
@Param('kbId') kbId: string,
@Param('id') id: string,
) {
return this.service.findLatestKnowledgeGenerationTask(kbId, id, user.id);
}
@Post(':id/parse') @Post(':id/parse')
@ApiOperation({ summary: '手动触发资料解析' }) @ApiOperation({ summary: '手动触发资料解析' })
async triggerParse( async triggerParse(
@ -52,6 +77,16 @@ export class KnowledgeSourceController {
return this.service.generateKnowledgePoints(kbId, id, user.id); return this.service.generateKnowledgePoints(kbId, id, user.id);
} }
@Post('generate-knowledge-batch')
@ApiOperation({ summary: '批量提交知识点生成任务' })
async generateKnowledgeBatch(
@CurrentUser() user: UserPayload,
@Param('kbId') kbId: string,
@Body() dto: GenerateKnowledgeBatchDto,
) {
return this.service.generateKnowledgePointsBatch(kbId, dto.sourceIds ?? [], user.id);
}
@Delete(':id') @Delete(':id')
@ApiOperation({ summary: '删除资料来源' }) @ApiOperation({ summary: '删除资料来源' })
async remove(@CurrentUser() user: UserPayload, @Param('id') id: string) { async remove(@CurrentUser() user: UserPayload, @Param('id') id: string) {

View File

@ -4,10 +4,10 @@ import { KnowledgeSourceService } from './knowledge-source.service';
import { KnowledgeSourceRepository } from './knowledge-source.repository'; import { KnowledgeSourceRepository } from './knowledge-source.repository';
import { DocumentImportModule } from '../document-import/document-import.module'; import { DocumentImportModule } from '../document-import/document-import.module';
import { FilesModule } from '../files/files.module'; import { FilesModule } from '../files/files.module';
import { KnowledgeItemsModule } from '../knowledge-items/knowledge-items.module'; import { AiJobModule } from '../ai-job/ai-job.module';
@Module({ @Module({
imports: [DocumentImportModule, FilesModule, KnowledgeItemsModule], imports: [DocumentImportModule, FilesModule, AiJobModule],
controllers: [KnowledgeSourceController], controllers: [KnowledgeSourceController],
providers: [KnowledgeSourceService, KnowledgeSourceRepository], providers: [KnowledgeSourceService, KnowledgeSourceRepository],
exports: [KnowledgeSourceService, KnowledgeSourceRepository], exports: [KnowledgeSourceService, KnowledgeSourceRepository],

View File

@ -102,6 +102,13 @@ export class KnowledgeSourceRepository {
}); });
} }
async updateLearningStatus(id: string, learningStatus: string) {
return this.prisma.knowledgeSource.update({
where: { id },
data: { learningStatus },
});
}
async countByKnowledgeBase(knowledgeBaseId: string) { async countByKnowledgeBase(knowledgeBaseId: string) {
return this.prisma.knowledgeSource.count({ return this.prisma.knowledgeSource.count({
where: { knowledgeBaseId, deletedAt: null }, where: { knowledgeBaseId, deletedAt: null },

View File

@ -5,9 +5,9 @@ import { QueueService } from '../../infrastructure/queue/queue.service';
import { RedisService } from '../../infrastructure/redis/redis.service'; import { RedisService } from '../../infrastructure/redis/redis.service';
import { FilesService } from '../files/files.service'; import { FilesService } from '../files/files.service';
import { StorageService } from '../../infrastructure/storage/storage.service'; import { StorageService } from '../../infrastructure/storage/storage.service';
import { KnowledgeItemsRepository } from '../knowledge-items/knowledge-items.repository'; import { PrismaService } from '../../infrastructure/database/prisma.service';
import { KnowledgeImportWorkflow } from '../ai/workflows/knowledge-import.workflow'; import { AiJobCreationService } from '../ai-job/ai-job-creation.service';
import { KnowledgeGenerationSnapshotBuilder } from '../ai-job/knowledge-generation-snapshot-builder';
@Injectable() @Injectable()
export class KnowledgeSourceService { export class KnowledgeSourceService {
@ -18,9 +18,9 @@ export class KnowledgeSourceService {
private readonly redis: RedisService, private readonly redis: RedisService,
private readonly filesService: FilesService, private readonly filesService: FilesService,
@Optional() private readonly storage?: StorageService, @Optional() private readonly storage?: StorageService,
@Optional() private readonly knowledgeItemsRepo?: KnowledgeItemsRepository, @Optional() private readonly aiJobCreationService?: AiJobCreationService,
@Optional() private readonly knowledgeImportWorkflow?: KnowledgeImportWorkflow, @Optional() private readonly knowledgeGenerationSnapshotBuilder?: KnowledgeGenerationSnapshotBuilder,
@Optional() private readonly prisma?: PrismaService,
) {} ) {}
async addSource(userId: string, knowledgeBaseId: string, dto: { async addSource(userId: string, knowledgeBaseId: string, dto: {
@ -32,8 +32,19 @@ export class KnowledgeSourceService {
sizeBytes?: number; sizeBytes?: number;
originalObjectKey?: string; originalObjectKey?: string;
}) { }) {
// 校验文件确实已上传(防止 DB 有记录但文件不存在)
if (dto.originalObjectKey && this.storage) {
const info = await this.storage.verifyUpload(dto.originalObjectKey);
if (!info) {
throw new BadRequestException('文件上传未完成或已失效,请重新上传');
}
}
const source = await this.repository.create(userId, knowledgeBaseId, dto); const source = await this.repository.create(userId, knowledgeBaseId, dto);
// 更新知识库 updatedAt
await this.touchKnowledgeBase(knowledgeBaseId);
// 自动创建 import 任务并入队 // 自动创建 import 任务并入队
const importJob = await this.importRepo.create({ const importJob = await this.importRepo.create({
userId, userId,
@ -75,63 +86,160 @@ export class KnowledgeSourceService {
async remove(userId: string, id: string) { async remove(userId: string, id: string) {
const source = await this.repository.findById(id); const source = await this.repository.findById(id);
if (!source) throw new NotFoundException('资料来源不存在'); if (!source) throw new NotFoundException('资料来源不存在');
// 删除关联的 UploadedFile 记录
if (source.fileId) { if (source.fileId) {
await this.filesService.deleteFile(userId, source.fileId); await this.filesService.deleteFile(userId, source.fileId);
} }
// 删除存储中的文件(本地/COS
if (source.originalObjectKey && this.storage) {
try {
await this.storage.deleteObject(source.originalObjectKey);
} catch {
// 文件可能已经不存在,忽略
}
}
// 更新知识库 updatedAt
await this.touchKnowledgeBase(source.knowledgeBaseId);
return this.repository.softDelete(id); return this.repository.softDelete(id);
} }
/** 更新父知识库的 updatedAt */
private async touchKnowledgeBase(knowledgeBaseId: string) {
if (!this.prisma) return;
try {
await this.prisma.knowledgeBase.update({
where: { id: knowledgeBaseId },
data: { updatedAt: new Date() },
});
} catch {
// 知识库可能已被删除,忽略
}
}
/** AI brain
* Python RAG Worker + AI Gateway /** 手动触发 AI 知识点生成(统一改为异步任务) */
*/
async generateKnowledgePoints(knowledgeBaseId: string, sourceId: string, userId: string) { async generateKnowledgePoints(knowledgeBaseId: string, sourceId: string, userId: string) {
const source = await this.repository.findById(sourceId); return this.createKnowledgeGenerationTask(knowledgeBaseId, sourceId, userId);
if (!source) throw new NotFoundException('资料来源不存在'); }
const objectKey = source.originalObjectKey; async generateKnowledgePointsBatch(knowledgeBaseId: string, sourceIds: string[], userId: string) {
if (!objectKey) throw new BadRequestException('文件不存在,无法生成知识点'); if (!Array.isArray(sourceIds) || sourceIds.length === 0) {
throw new BadRequestException('请选择至少一个资料来源');
if (!this.storage || !this.knowledgeItemsRepo || !this.knowledgeImportWorkflow) {
throw new BadRequestException('知识点生成服务未就绪');
} }
const buf = await this.storage.getObject(objectKey); const uniqueSourceIds = [...new Set(sourceIds)];
if (!buf) throw new BadRequestException('无法读取文件内容'); const tasks: Array<{
taskId: string;
sourceId: string | null;
aiJobId: string | null;
status: string;
knowledgeItemCount: number;
errorCode: string | null;
errorMessage: string | null;
message: string;
}> = [];
for (const sourceId of uniqueSourceIds) {
tasks.push(await this.createKnowledgeGenerationTask(knowledgeBaseId, sourceId, userId));
}
const text = buf.toString('utf-8'); return {
if (!text.trim()) throw new BadRequestException('文件内容为空,无法生成知识点'); knowledgeBaseId,
totalCount: tasks.length,
queuedCount: tasks.filter((task) => task.status === 'queued').length,
waitingForParseCount: tasks.filter((task) => task.status === 'waiting_for_parse').length,
runningCount: tasks.filter((task) => task.status === 'running').length,
tasks,
message: `已提交 ${tasks.length} 个知识点生成任务`,
};
}
const result = await this.knowledgeImportWorkflow.execute({ async findLatestKnowledgeGenerationTask(knowledgeBaseId: string, sourceId: string, userId: string) {
userId, const source = await this.repository.findById(sourceId);
rawText: text, if (!source || source.userId !== userId || source.knowledgeBaseId !== knowledgeBaseId) {
sourceName: source.originalFilename ?? source.title ?? '', throw new NotFoundException('资料来源不存在');
}
if (!this.prisma) {
throw new BadRequestException('知识点生成任务服务未就绪');
}
const task = await this.prisma.knowledgeGenerationTask.findFirst({
where: { userId, knowledgeBaseId, sourceId },
orderBy: { requestedAt: 'desc' },
}); });
if (!result?.knowledgePoints?.length) { return task ? this.buildTaskResponse(task, 'ok') : null;
return { items: [], count: 0, sourceId, message: 'AI 未识别到可提取的知识点' }; }
async findLatestKnowledgeGenerationTasks(knowledgeBaseId: string, userId: string, sourceIds?: string[]) {
if (!this.prisma) {
throw new BadRequestException('知识点生成任务服务未就绪');
} }
const items: any[] = []; const where: any = { userId, knowledgeBaseId };
for (let i = 0; i < result.knowledgePoints.length; i++) { if (sourceIds?.length) {
const kp = result.knowledgePoints[i]; where.sourceId = { in: sourceIds };
const item = await this.knowledgeItemsRepo.create(userId, knowledgeBaseId, { }
title: kp.title,
content: kp.content, const tasks = await this.prisma.knowledgeGenerationTask.findMany({
itemType: 'lesson', where,
orderIndex: kp.suggestedOrder ?? i + 1, orderBy: [{ sourceId: 'asc' }, { requestedAt: 'desc' }],
});
const latestBySource = new Map<string, any>();
for (const task of tasks) {
if (!task.sourceId) {
continue;
}
if (!latestBySource.has(task.sourceId)) {
latestBySource.set(task.sourceId, this.buildTaskResponse(task, 'ok'));
}
}
return {
knowledgeBaseId,
items: Array.from(latestBySource.values()),
};
}
async dispatchWaitingKnowledgeGenerationTasks(sourceId: string) {
if (!this.prisma) return { dispatchedCount: 0 };
const source = await this.repository.findById(sourceId);
if (!source || source.parseStatus !== 'completed') {
return { dispatchedCount: 0 };
}
const chunkCount = await this.prisma.knowledgeChunk.count({ where: { sourceId } });
if (chunkCount === 0) {
return { dispatchedCount: 0 };
}
const waitingTasks = await this.prisma.knowledgeGenerationTask.findMany({
where: {
sourceId, sourceId,
sourceRef: sourceId, status: 'waiting_for_parse',
}); aiJobId: null,
items.push(item); },
orderBy: { requestedAt: 'asc' },
});
let dispatchedCount = 0;
for (const task of waitingTasks) {
await this.dispatchKnowledgeGenerationTask(task.id);
dispatchedCount += 1;
} }
return { items, count: items.length, sourceId }; return { dispatchedCount };
} }
async triggerParse(sourceId: string, userId: string) { async triggerParse(sourceId: string, userId: string) {
const source = await this.repository.findById(sourceId); const source = await this.repository.findById(sourceId);
if (!source) throw new NotFoundException('资料来源不存在'); if (!source) throw new NotFoundException('资料来源不存在');
if (source.userId !== userId) throw new NotFoundException('资料来源不存在');
// 创建新的 import 任务 // 创建新的 import 任务
const importJob = await this.importRepo.create({ const importJob = await this.importRepo.create({
@ -167,4 +275,127 @@ export class KnowledgeSourceService {
async updateIndexStatus(id: string, indexStatus: string, errorCode?: string, errorMessage?: string) { async updateIndexStatus(id: string, indexStatus: string, errorCode?: string, errorMessage?: string) {
return this.repository.updateIndexStatus(id, indexStatus, errorCode, errorMessage); return this.repository.updateIndexStatus(id, indexStatus, errorCode, errorMessage);
} }
private async dispatchKnowledgeGenerationTask(taskId: string) {
if (!this.prisma || !this.aiJobCreationService || !this.knowledgeGenerationSnapshotBuilder) {
throw new BadRequestException('知识点生成任务服务未就绪');
}
const task = await this.prisma.knowledgeGenerationTask.findUnique({
where: { id: taskId },
});
if (!task) {
throw new NotFoundException('知识点生成任务不存在');
}
if (task.aiJobId) {
return task;
}
if (!task.sourceId) {
throw new BadRequestException('资料来源任务缺少 sourceId');
}
const snapshot = await this.knowledgeGenerationSnapshotBuilder.build({
userId: task.userId,
knowledgeBaseId: task.knowledgeBaseId,
sourceId: task.sourceId,
taskId: task.id,
requestKind: task.requestKind,
});
const aiJob = await this.aiJobCreationService.createJob({
userId: task.userId,
jobType: 'knowledge_generation',
triggerType: 'user_api',
targetType: 'knowledge_source',
targetId: task.sourceId,
idempotencyKey: `knowledge-generation:${task.id}`,
retrySnapshotContent: snapshot as unknown as Record<string, unknown>,
});
const updatedTask = await this.prisma.knowledgeGenerationTask.update({
where: { id: task.id },
data: {
aiJobId: aiJob.id,
status: 'queued',
errorCode: null,
errorMessage: null,
},
});
await this.repository.updateLearningStatus(task.sourceId, 'queued');
return updatedTask;
}
private async createKnowledgeGenerationTask(knowledgeBaseId: string, sourceId: string, userId: string) {
const source = await this.repository.findById(sourceId);
if (!source) throw new NotFoundException('资料来源不存在');
if (source.userId !== userId) {
throw new NotFoundException('资料来源不存在');
}
if (source.knowledgeBaseId !== knowledgeBaseId) {
throw new BadRequestException('资料来源不属于当前知识库');
}
if (!this.prisma || !this.aiJobCreationService || !this.knowledgeGenerationSnapshotBuilder) {
throw new BadRequestException('知识点生成任务服务未就绪');
}
const activeTask = await this.prisma.knowledgeGenerationTask.findFirst({
where: {
userId,
sourceId,
status: { in: ['waiting_for_parse', 'queued', 'running'] },
},
orderBy: { requestedAt: 'desc' },
});
if (activeTask) {
return this.buildTaskResponse(activeTask, '知识点生成任务已存在');
}
const chunkCount = await this.prisma.knowledgeChunk.count({
where: { sourceId },
});
const readyForDispatch = source.parseStatus === 'completed' && chunkCount > 0;
const nextStatus = readyForDispatch ? 'queued' : 'waiting_for_parse';
const task = await this.prisma.knowledgeGenerationTask.create({
data: {
userId,
knowledgeBaseId,
sourceId,
requestKind: 'manual_source',
status: nextStatus,
idempotencyKey: `manual:${sourceId}:${Date.now()}`,
},
});
await this.repository.updateLearningStatus(sourceId, nextStatus);
if (readyForDispatch) {
const dispatched = await this.dispatchKnowledgeGenerationTask(task.id);
return this.buildTaskResponse(dispatched, '知识点生成任务已入队');
}
return this.buildTaskResponse(task, '资料解析未完成,任务已等待解析结果');
}
private buildTaskResponse(task: {
id: string;
sourceId: string | null;
aiJobId?: string | null;
status: string;
knowledgeItemCount?: number;
errorCode?: string | null;
errorMessage?: string | null;
}, message: string) {
return {
taskId: task.id,
sourceId: task.sourceId ?? null,
aiJobId: task.aiJobId ?? null,
status: task.status,
knowledgeItemCount: task.knowledgeItemCount ?? 0,
errorCode: task.errorCode ?? null,
errorMessage: task.errorMessage ?? null,
message,
};
}
} }

View File

@ -12,8 +12,20 @@ export class GrowthService {
@Optional() private readonly eventBus?: EventBusService, @Optional() private readonly eventBus?: EventBusService,
) {} ) {}
/** Calculate streak from daily activity */ /**
async getStreak(userId: string): Promise<{ currentStreak: number; longestStreak: number }> { * Calculate streak from daily activity.
*
* activityDate is stored as UTC midnight representing the client's local date
* (see computeActivityDate in LearningActivityRepository). "Today" / "yesterday"
* must be computed with the same timezone offset to be consistent.
*
* @param clientTimezoneOffsetMinutes client offset from UTC in minutes.
* Used to determine "today" consistently with how activityDate was computed.
*/
async getStreak(
userId: string,
clientTimezoneOffsetMinutes?: number,
): Promise<{ currentStreak: number; longestStreak: number }> {
const activities = await this.prisma.dailyLearningActivity.findMany({ const activities = await this.prisma.dailyLearningActivity.findMany({
where: { userId }, where: { userId },
orderBy: { activityDate: 'desc' }, orderBy: { activityDate: 'desc' },
@ -23,6 +35,7 @@ export class GrowthService {
if (activities.length === 0) return { currentStreak: 0, longestStreak: 0 }; if (activities.length === 0) return { currentStreak: 0, longestStreak: 0 };
// Deduplicate + collect dates in descending order (most recent first)
const seen = new Set<string>(); const seen = new Set<string>();
const dates: string[] = []; const dates: string[] = [];
for (const a of activities) { for (const a of activities) {
@ -30,24 +43,54 @@ export class GrowthService {
if (!seen.has(d)) { seen.add(d); dates.push(d); } if (!seen.has(d)) { seen.add(d); dates.push(d); }
} }
let currentStreak = dates.length > 0 ? 1 : 0; // ── Build "today" / "yesterday" using the same timezone offset as activityDate ──
let longestStreak = currentStreak; const offsetMs = (clientTimezoneOffsetMinutes ?? 0) * 60 * 1000;
let streak = currentStreak; const nowLocal = new Date(Date.now() + offsetMs);
const todayStr = new Date(Date.UTC(
nowLocal.getUTCFullYear(), nowLocal.getUTCMonth(), nowLocal.getUTCDate(),
)).toISOString().slice(0, 10);
const yesterdayLocal = new Date(Date.now() + offsetMs - 86_400_000);
const yesterdayStr = new Date(Date.UTC(
yesterdayLocal.getUTCFullYear(), yesterdayLocal.getUTCMonth(), yesterdayLocal.getUTCDate(),
)).toISOString().slice(0, 10);
for (let i = 1; i < dates.length; i++) { // ── Compute longestStreak: scan every consecutive run across full history ──
const prev = new Date(dates[i - 1]); let longestStreak = 0;
const curr = new Date(dates[i]); let runLength = 0;
const diffDays = Math.round((prev.getTime() - curr.getTime()) / 86400000); for (let i = 0; i < dates.length; i++) {
if (diffDays === 1) { if (i === 0) {
streak++; runLength = 1;
longestStreak = Math.max(longestStreak, streak);
} else if (diffDays === 0) {
// same day, skip
} else { } else {
break; // gap found — stop counting current streak const prev = new Date(dates[i - 1]);
const curr = new Date(dates[i]);
const diffDays = Math.round((prev.getTime() - curr.getTime()) / 86_400_000);
if (diffDays === 1) {
runLength++;
} else if (diffDays === 0) {
continue; // same day — skip
} else {
longestStreak = Math.max(longestStreak, runLength);
runLength = 1;
}
}
}
longestStreak = Math.max(longestStreak, runLength);
// ── Compute currentStreak: only valid when dates[0] is today or yesterday ──
let currentStreak = 0;
if (dates[0] === todayStr || dates[0] === yesterdayStr) {
currentStreak = 1;
for (let i = 1; i < dates.length; i++) {
const prev = new Date(dates[i - 1]);
const curr = new Date(dates[i]);
const diffDays = Math.round((prev.getTime() - curr.getTime()) / 86_400_000);
if (diffDays === 1) {
currentStreak++;
} else {
break; // gap → streak ends
}
} }
} }
currentStreak = streak;
// Publish event for downstream consumers // Publish event for downstream consumers
try { this.eventBus?.publish(new StreakUpdatedEvent(userId, currentStreak, longestStreak)); } catch {} try { this.eventBus?.publish(new StreakUpdatedEvent(userId, currentStreak, longestStreak)); } catch {}

View File

@ -38,8 +38,12 @@ export class LearningActivityController {
@Get('streak') @Get('streak')
@ApiOperation({ summary: '获取连续学习天数' }) @ApiOperation({ summary: '获取连续学习天数' })
async getStreak(@CurrentUser() user: UserPayload) { async getStreak(
return this.growth.getStreak(String(user?.id || 'anonymous')); @CurrentUser() user: UserPayload,
@Query('tzOffset') tzOffset?: string,
) {
const offset = tzOffset ? parseInt(tzOffset, 10) : undefined;
return this.growth.getStreak(String(user?.id || 'anonymous'), offset);
} }
@Get('recommendations') @Get('recommendations')

View File

@ -27,9 +27,10 @@ export class LearningActivityRepository {
activeSecondsDelta: number; activeSecondsDelta: number;
isNewMaterial?: boolean; isNewMaterial?: boolean;
isMarkedRead?: boolean; isMarkedRead?: boolean;
isNewSession?: boolean;
}) { }) {
const activityDate = this.computeActivityDate(data.clientTimestampMs, data.clientTimezoneOffsetMinutes); const activityDate = this.computeActivityDate(data.clientTimestampMs, data.clientTimezoneOffsetMinutes);
const { userId, activeSecondsDelta, isNewMaterial, isMarkedRead } = data; const { userId, activeSecondsDelta, isNewMaterial, isMarkedRead, isNewSession } = data;
return tx.dailyLearningActivity.upsert({ return tx.dailyLearningActivity.upsert({
where: { userId_activityDate: { userId, activityDate } }, where: { userId_activityDate: { userId, activityDate } },
@ -40,16 +41,28 @@ export class LearningActivityRepository {
readingSeconds: activeSecondsDelta, readingSeconds: activeSecondsDelta,
materialsReadCount: isNewMaterial ? 1 : 0, materialsReadCount: isNewMaterial ? 1 : 0,
markedReadCount: isMarkedRead ? 1 : 0, markedReadCount: isMarkedRead ? 1 : 0,
sessionsCount: isNewSession ? 1 : 0,
}, },
update: { update: {
durationSeconds: { increment: activeSecondsDelta }, durationSeconds: { increment: activeSecondsDelta },
readingSeconds: { increment: activeSecondsDelta }, readingSeconds: { increment: activeSecondsDelta },
materialsReadCount: isNewMaterial ? { increment: 1 } : undefined, materialsReadCount: isNewMaterial ? { increment: 1 } : undefined,
markedReadCount: isMarkedRead ? { increment: 1 } : undefined, markedReadCount: isMarkedRead ? { increment: 1 } : undefined,
sessionsCount: isNewSession ? { increment: 1 } : undefined,
}, },
}); });
} }
/** Increment sessionsCount for a given day (called from manual session end). */
async incrementDailySessions(userId: string, activityDate: Date) {
const date = new Date(Date.UTC(activityDate.getUTCFullYear(), activityDate.getUTCMonth(), activityDate.getUTCDate()));
return this.prisma.dailyLearningActivity.upsert({
where: { userId_activityDate: { userId, activityDate: date } },
create: { userId, activityDate: date, sessionsCount: 1 },
update: { sessionsCount: { increment: 1 } },
}).catch(() => {});
}
/** Compute local date from timestamp and timezone offset. */ /** Compute local date from timestamp and timezone offset. */
private computeActivityDate(timestampMs: bigint, offsetMinutes: number | null): Date { private computeActivityDate(timestampMs: bigint, offsetMinutes: number | null): Date {
const offsetMs = (offsetMinutes ?? 0) * 60 * 1000; const offsetMs = (offsetMinutes ?? 0) * 60 * 1000;

View File

@ -1,6 +1,7 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { PrismaModule } from '../../infrastructure/database/prisma.module'; import { PrismaModule } from '../../infrastructure/database/prisma.module';
import { AiRuntimeModule } from '../ai-runtime/ai-runtime.module'; import { AiRuntimeModule } from '../ai-runtime/ai-runtime.module';
import { LearningActivityModule } from '../learning-activity/learning-activity.module';
import { LearningSessionController } from './learning-session.controller'; import { LearningSessionController } from './learning-session.controller';
import { AdminLearningController } from './admin-learning.controller'; import { AdminLearningController } from './admin-learning.controller';
import { AdminLearningService } from './admin-learning.service'; import { AdminLearningService } from './admin-learning.service';
@ -8,7 +9,7 @@ import { LearningSessionService } from './learning-session.service';
import { LearningSessionRepository } from './learning-session.repository'; import { LearningSessionRepository } from './learning-session.repository';
@Module({ @Module({
imports: [PrismaModule, AiRuntimeModule], imports: [PrismaModule, AiRuntimeModule, LearningActivityModule],
controllers: [LearningSessionController, AdminLearningController], controllers: [LearningSessionController, AdminLearningController],
providers: [AdminLearningService, LearningSessionService, LearningSessionRepository], providers: [AdminLearningService, LearningSessionService, LearningSessionRepository],
exports: [LearningSessionService, LearningSessionRepository], exports: [LearningSessionService, LearningSessionRepository],

View File

@ -1,9 +1,13 @@
import { Injectable, NotFoundException } from '@nestjs/common'; import { Injectable, NotFoundException } from '@nestjs/common';
import { LearningSessionRepository } from './learning-session.repository'; import { LearningSessionRepository } from './learning-session.repository';
import { LearningActivityRepository } from '../learning-activity/learning-activity.repository';
@Injectable() @Injectable()
export class LearningSessionService { export class LearningSessionService {
constructor(private readonly repository: LearningSessionRepository) {} constructor(
private readonly repository: LearningSessionRepository,
private readonly activityRepo: LearningActivityRepository,
) {}
async start(userId: string, dto: any) { async start(userId: string, dto: any) {
return this.repository.create(userId, dto); return this.repository.create(userId, dto);
@ -12,6 +16,10 @@ export class LearningSessionService {
async end(id: string) { async end(id: string) {
const session = await this.repository.end(id); const session = await this.repository.end(id);
if (!session) throw new NotFoundException('会话不存在'); if (!session) throw new NotFoundException('会话不存在');
// 更新 DailyLearningActivity 的会话计数
if (session.userId) {
this.activityRepo.incrementDailySessions(session.userId, session.startedAt ?? new Date());
}
return session; return session;
} }

View File

@ -2,8 +2,10 @@ import { Module } from '@nestjs/common';
import { QuizController } from './quiz.controller'; import { QuizController } from './quiz.controller';
import { QuizService } from './quiz.service'; import { QuizService } from './quiz.service';
import { PrismaService } from '../../infrastructure/database/prisma.service'; import { PrismaService } from '../../infrastructure/database/prisma.service';
import { AiModule } from '../ai/ai.module';
@Module({ @Module({
imports: [AiModule],
controllers: [QuizController], controllers: [QuizController],
providers: [QuizService, PrismaService], providers: [QuizService, PrismaService],
}) })

View File

@ -1,76 +1,83 @@
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { PrismaService } from '../../infrastructure/database/prisma.service'; import { PrismaService } from '../../infrastructure/database/prisma.service';
import { QuizGenerationWorkflow } from '../ai/workflows/quiz-generation.workflow';
import { isValidQuestionType, gradeAnswer, type QuestionType } from './quiz-type.contract'; import { isValidQuestionType, gradeAnswer, type QuestionType } from './quiz-type.contract';
@Injectable() @Injectable()
export class QuizService { export class QuizService {
constructor(private readonly prisma: PrismaService) {} constructor(
private readonly prisma: PrismaService,
private readonly quizWorkflow: QuizGenerationWorkflow,
) {}
async create(userId: string, dto: { knowledgeBaseId: string; title?: string; sourceType?: string; sourceId?: string; questionCount?: number }) { async create(userId: string, dto: { knowledgeBaseId: string; title?: string; sourceType?: string; sourceId?: string; questionCount?: number }) {
const count = dto.questionCount ?? 5; const count = dto.questionCount ?? 5;
const kb = await this.prisma.knowledgeBase.findUnique({ where: { id: dto.knowledgeBaseId } }); const kb = await this.prisma.knowledgeBase.findUnique({ where: { id: dto.knowledgeBaseId } });
if (!kb || kb.deletedAt) throw new NotFoundException('知识库不存在'); if (!kb || kb.deletedAt) throw new NotFoundException('知识库不存在');
const sourceType = dto.sourceType ?? 'kb';
let sourceIds: string[] = [];
if (sourceType === 'source') {
if (!dto.sourceId) {
throw new BadRequestException('sourceId 不能为空');
}
const source = await this.prisma.knowledgeSource.findFirst({
where: {
id: dto.sourceId,
knowledgeBaseId: dto.knowledgeBaseId,
deletedAt: null,
},
select: { id: true },
});
if (!source) throw new NotFoundException('资料不存在');
sourceIds = [source.id];
} else {
const sources = await this.prisma.knowledgeSource.findMany({
where: { knowledgeBaseId: dto.knowledgeBaseId, deletedAt: null },
select: { id: true },
});
sourceIds = sources.map((source) => source.id);
}
if (sourceIds.length === 0) {
throw new BadRequestException('知识库中没有资料,无法生成测验');
}
const result = await this.quizWorkflow.execute({
userId,
knowledgeBaseId: dto.knowledgeBaseId,
sourceIds,
questionCount: count,
});
const quiz = await this.prisma.quiz.create({ const quiz = await this.prisma.quiz.create({
data: { data: {
userId, knowledgeBaseId: dto.knowledgeBaseId, userId,
title: dto.title || `${kb.title} - 自测`, knowledgeBaseId: dto.knowledgeBaseId,
sourceType: dto.sourceType ?? 'kb', sourceId: dto.sourceId ?? null, title: dto.title || result.quizTitle || `${kb.title} - 自测`,
questionCount: count, description: result.quizDescription ?? null,
sourceType,
sourceId: sourceType === 'source' ? dto.sourceId ?? null : null,
questionCount: result.questions.length,
}, },
}); });
// Generate questions from KB items await this.prisma.quizQuestion.createMany({
const items = await this.prisma.knowledgeItem.findMany({ data: result.questions.map((question, index) => ({
where: { knowledgeBaseId: dto.knowledgeBaseId, deletedAt: null }, quizId: quiz.id,
orderBy: { updatedAt: 'desc' }, take: count * 2, type: question.type,
stem: question.stem,
options: question.options ?? undefined,
answer: question.answer,
explanation: question.explanation ?? '',
sourceBlockIds: question.sourceBlockIds ?? undefined,
orderIndex: index,
})),
}); });
if (items.length === 0) throw new BadRequestException('知识库中没有知识点,无法生成测验');
const shuffled = items.sort(() => Math.random() - 0.5).slice(0, count);
const questions: any[] = [];
for (let i = 0; i < shuffled.length; i++) {
const item = shuffled[i];
const otherItems = items.filter(x => x.id !== item.id);
// Alternate question types
const types = ['choice', 'fill', 'judge'];
const qType = types[i % 3];
let stem = '', options: string[] = [], answer = '';
if (qType === 'choice') {
stem = `${item.title} 是什么?`;
const correct = item.content?.slice(0, 80) ?? '正确答案';
options = [correct];
for (const o of otherItems.slice(0, 3)) {
options.push(o.content?.slice(0, 80) ?? '其他选项');
}
options = options.sort(() => Math.random() - 0.5);
answer = String(options.indexOf(correct));
} else if (qType === 'fill') {
const words = (item.content ?? '').split(/[,。;\s]+/).filter(w => w.length >= 3);
const blank = words.length > 0 ? words[Math.floor(Math.random() * words.length)] : '关键概念';
stem = `${item.title}:请填写缺失的关键词。${(item.content ?? '').replace(blank, '____')}`;
answer = blank;
} else {
const isCorrect = Math.random() > 0.5;
stem = `关于「${item.title}」,以下说法是否正确?${isCorrect ? item.content?.slice(0, 100) ?? '正确描述' : '错误描述'}`;
answer = String(isCorrect);
}
questions.push({
quizId: quiz.id, type: qType, stem,
options: options.length > 0 ? options : undefined,
answer, explanation: item.content?.slice(0, 200) ?? '',
orderIndex: i,
});
}
await this.prisma.quizQuestion.createMany({ data: questions });
return this.prisma.quiz.findUnique({ where: { id: quiz.id }, include: { questions: { orderBy: { orderIndex: 'asc' } } } }); return this.prisma.quiz.findUnique({ where: { id: quiz.id }, include: { questions: { orderBy: { orderIndex: 'asc' } } } });
} }

View File

@ -68,6 +68,21 @@ export class RagChatController {
return this.svc.getMessages(String(user.id), id); return this.svc.getMessages(String(user.id), id);
} }
@Post('sessions/:sessionId/messages/:messageId/save-as-knowledge')
@ApiOperation({ summary: '将 AI 对话消息保存为知识点' })
async saveMessageAsKnowledge(
@CurrentUser() user: UserPayload,
@Param('sessionId') sessionId: string,
@Param('messageId') messageId: string,
@Body() dto?: {
knowledgeBaseId?: string;
title?: string;
summary?: string | null;
},
) {
return this.svc.saveMessageAsKnowledge(String(user.id), sessionId, messageId, dto ?? {});
}
@Post('sessions/:id/messages') @Post('sessions/:id/messages')
@ApiOperation({ summary: '发送消息(同步)' }) @ApiOperation({ summary: '发送消息(同步)' })
async sendMessage(@CurrentUser() user: UserPayload, @Param('id') id: string, @Body() dto: { content: string }) { async sendMessage(@CurrentUser() user: UserPayload, @Param('id') id: string, @Body() dto: { content: string }) {

View File

@ -5,9 +5,10 @@ import { RagChatService } from './rag-chat.service';
import { PrismaService } from '../../infrastructure/database/prisma.service'; import { PrismaService } from '../../infrastructure/database/prisma.service';
import { AiModule } from '../ai/ai.module'; import { AiModule } from '../ai/ai.module';
import { MembershipModule } from '../membership/membership.module'; import { MembershipModule } from '../membership/membership.module';
import { AiJobModule } from '../ai-job/ai-job.module';
@Module({ @Module({
imports: [AiModule, MembershipModule], imports: [AiModule, MembershipModule, AiJobModule],
controllers: [RagChatController, AdminRagChatController], controllers: [RagChatController, AdminRagChatController],
providers: [RagChatService, PrismaService], providers: [RagChatService, PrismaService],
exports: [RagChatService], exports: [RagChatService],

View File

@ -5,6 +5,8 @@ import { AiGatewayService } from '../ai/gateway/ai-gateway.service';
import { StorageService } from '../../infrastructure/storage/storage.service'; import { StorageService } from '../../infrastructure/storage/storage.service';
import { RagChatOutputSchema } from '../ai/prompts/schemas/rag-chat.schema'; import { RagChatOutputSchema } from '../ai/prompts/schemas/rag-chat.schema';
import type { StreamChunk } from '../ai/providers/ai-provider.interface'; import type { StreamChunk } from '../ai/providers/ai-provider.interface';
import { AiJobCreationService } from '../ai-job/ai-job-creation.service';
import { KnowledgeGenerationSnapshotBuilder } from '../ai-job/knowledge-generation-snapshot-builder';
const MAX_CONTEXT_CHARS = 4000; const MAX_CONTEXT_CHARS = 4000;
@ -33,6 +35,8 @@ export class RagChatService {
@Optional() private readonly safety?: ContentSafetyService, @Optional() private readonly safety?: ContentSafetyService,
@Optional() private readonly aiGateway?: AiGatewayService, @Optional() private readonly aiGateway?: AiGatewayService,
@Optional() private readonly storage?: StorageService, @Optional() private readonly storage?: StorageService,
@Optional() private readonly aiJobCreationService?: AiJobCreationService,
@Optional() private readonly knowledgeGenerationSnapshotBuilder?: KnowledgeGenerationSnapshotBuilder,
) {} ) {}
async createSession(userId: string, params: CreateSessionParams): Promise<CreateSessionResult> { async createSession(userId: string, params: CreateSessionParams): Promise<CreateSessionResult> {
@ -137,6 +141,97 @@ export class RagChatService {
}); });
} }
async saveMessageAsKnowledge(
userId: string,
sessionId: string,
messageId: string,
dto: { knowledgeBaseId?: string; title?: string; summary?: string | null },
) {
const session = await this.prisma.chatSession.findUnique({ where: { id: sessionId } });
if (!session || session.userId !== userId) throw new NotFoundException('对话不存在');
const message = await this.prisma.chatMessage.findUnique({
where: { id: messageId },
include: { citations: true },
});
if (!message || message.sessionId !== sessionId) {
throw new NotFoundException('消息不存在');
}
if (message.role !== 'ai') {
throw new BadRequestException('仅支持保存 AI 回复消息');
}
const knowledgeBaseId = dto.knowledgeBaseId || session.parentKnowledgeBaseId || session.knowledgeBaseId;
if (!knowledgeBaseId) {
throw new BadRequestException('当前对话未绑定知识库,无法保存为知识点');
}
const kb = await this.prisma.knowledgeBase.findUnique({ where: { id: knowledgeBaseId } });
if (!kb || kb.userId !== userId || kb.deletedAt) {
throw new NotFoundException('知识库不存在');
}
if (!this.aiJobCreationService || !this.knowledgeGenerationSnapshotBuilder) {
throw new BadRequestException('知识点生成任务服务未就绪');
}
const activeTask = await this.prisma.knowledgeGenerationTask.findFirst({
where: {
userId,
knowledgeBaseId,
chatMessageId: message.id,
status: { in: ['queued', 'running'] },
},
orderBy: { requestedAt: 'desc' },
});
if (activeTask) {
return this.buildKnowledgeTaskResponse(activeTask, '知识点生成任务已存在');
}
const task = await this.prisma.knowledgeGenerationTask.create({
data: {
userId,
knowledgeBaseId,
chatSessionId: session.id,
chatMessageId: message.id,
requestKind: 'manual_chat_message',
status: 'queued',
idempotencyKey: `manual-chat:${message.id}:${Date.now()}`,
},
});
const snapshot = await this.knowledgeGenerationSnapshotBuilder.build({
userId,
knowledgeBaseId,
taskId: task.id,
chatSessionId: session.id,
chatMessageId: message.id,
requestKind: 'manual_chat_message',
});
const aiJob = await this.aiJobCreationService.createJob({
userId,
jobType: 'knowledge_generation',
triggerType: 'user_api',
targetType: 'chat_message',
targetId: message.id,
idempotencyKey: `knowledge-generation:${task.id}`,
retrySnapshotContent: snapshot as unknown as Record<string, unknown>,
});
const updatedTask = await this.prisma.knowledgeGenerationTask.update({
where: { id: task.id },
data: {
aiJobId: aiJob.id,
status: 'queued',
errorCode: null,
errorMessage: null,
},
});
return this.buildKnowledgeTaskResponse(updatedTask, 'AI 对话知识点生成任务已入队');
}
async sendMessage(userId: string, sessionId: string, content: string) { async sendMessage(userId: string, sessionId: string, content: string) {
const session = await this.prisma.chatSession.findUnique({ where: { id: sessionId } }); const session = await this.prisma.chatSession.findUnique({ where: { id: sessionId } });
if (!session || session.userId !== userId) throw new NotFoundException('对话不存在'); if (!session || session.userId !== userId) throw new NotFoundException('对话不存在');
@ -217,6 +312,38 @@ export class RagChatService {
return { message: aiMsg, citations }; return { message: aiMsg, citations };
} }
private deriveKnowledgeTitle(content: string) {
const firstLine = content
.split('\n')
.map((line) => line.trim())
.find(Boolean) ?? '';
return firstLine.replace(/^[-*#\s]+/, '').slice(0, 40);
}
private buildKnowledgeTaskResponse(task: {
id: string;
sourceId?: string | null;
chatMessageId?: string | null;
aiJobId?: string | null;
status: string;
knowledgeItemCount?: number;
errorCode?: string | null;
errorMessage?: string | null;
}, message: string) {
return {
success: true,
taskId: task.id,
sourceId: task.sourceId ?? null,
chatMessageId: task.chatMessageId ?? null,
aiJobId: task.aiJobId ?? null,
status: task.status,
knowledgeItemCount: task.knowledgeItemCount ?? 0,
errorCode: task.errorCode ?? null,
errorMessage: task.errorMessage ?? null,
message,
};
}
async *sendMessageStream(userId: string, sessionId: string, content: string): AsyncGenerator<StreamChunk, void, undefined> { async *sendMessageStream(userId: string, sessionId: string, content: string): AsyncGenerator<StreamChunk, void, undefined> {
const session = await this.prisma.chatSession.findUnique({ where: { id: sessionId } }); const session = await this.prisma.chatSession.findUnique({ where: { id: sessionId } });
if (!session || session.userId !== userId) { if (!session || session.userId !== userId) {

View File

@ -2,7 +2,7 @@ import { Controller, Get, Post, Body, Param, UseGuards } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger'; import { ApiTags } from '@nestjs/swagger';
import { DocumentImportRepository } from '../document-import/document-import.repository'; import { DocumentImportRepository } from '../document-import/document-import.repository';
import { KnowledgeSourceRepository } from '../knowledge-source/knowledge-source.repository'; import { KnowledgeSourceRepository } from '../knowledge-source/knowledge-source.repository';
import { ImportCandidateRepository } from '../import-candidate/import-candidate.repository'; import { KnowledgeSourceService } from '../knowledge-source/knowledge-source.service';
import { PrismaService } from '../../infrastructure/database/prisma.service'; import { PrismaService } from '../../infrastructure/database/prisma.service';
import { StorageService } from '../../infrastructure/storage/storage.service'; import { StorageService } from '../../infrastructure/storage/storage.service';
import { InternalAuthGuard } from '../../common/guards/internal-auth.guard'; import { InternalAuthGuard } from '../../common/guards/internal-auth.guard';
@ -14,7 +14,7 @@ export class InternalRagController {
constructor( constructor(
private readonly importRepo: DocumentImportRepository, private readonly importRepo: DocumentImportRepository,
private readonly sourceRepo: KnowledgeSourceRepository, private readonly sourceRepo: KnowledgeSourceRepository,
private readonly candidateRepo: ImportCandidateRepository, private readonly knowledgeSourceService: KnowledgeSourceService,
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly storage: StorageService, private readonly storage: StorageService,
) {} ) {}
@ -117,38 +117,10 @@ export class InternalRagController {
where: { id: sourceId }, where: { id: sourceId },
data: { textLength: totalChars, parseStatus: 'completed', indexStatus: 'completed', updatedAt: new Date() }, data: { textLength: totalChars, parseStatus: 'completed', indexStatus: 'completed', updatedAt: new Date() },
}); });
await this.knowledgeSourceService.dispatchWaitingKnowledgeGenerationTasks(sourceId);
} }
} }
} }
return { success: true, count: chunks.length }; return { success: true, count: chunks.length };
} }
@Post('candidates')
async saveCandidates(
@Body() body: {
userId: string;
knowledgeBaseId: string;
sourceId: string;
importId: string;
candidates: any[];
},
) {
await this.candidateRepo.createMany(
body.userId,
body.knowledgeBaseId,
body.sourceId,
body.importId,
body.candidates || [],
);
// 更新 source 的学习状态
if (body.sourceId && body.candidates?.length > 0) {
await this.prisma.knowledgeSource.updateMany({
where: { id: body.sourceId },
data: { learningStatus: 'completed', updatedAt: new Date() },
});
}
return { success: true, count: body.candidates?.length || 0 };
}
} }

View File

@ -2,11 +2,10 @@ import { Module } from '@nestjs/common';
import { InternalRagController } from './internal-rag.controller'; import { InternalRagController } from './internal-rag.controller';
import { DocumentImportModule } from '../document-import/document-import.module'; import { DocumentImportModule } from '../document-import/document-import.module';
import { KnowledgeSourceModule } from '../knowledge-source/knowledge-source.module'; import { KnowledgeSourceModule } from '../knowledge-source/knowledge-source.module';
import { ImportCandidateModule } from '../import-candidate/import-candidate.module';
import { StorageModule } from '../../infrastructure/storage/storage.module'; import { StorageModule } from '../../infrastructure/storage/storage.module';
@Module({ @Module({
imports: [DocumentImportModule, KnowledgeSourceModule, ImportCandidateModule, StorageModule], imports: [DocumentImportModule, KnowledgeSourceModule, StorageModule],
controllers: [InternalRagController], controllers: [InternalRagController],
}) })
export class RagModule {} export class RagModule {}

View File

@ -148,6 +148,7 @@ export class ReadingEventProcessorService {
activeSecondsDelta: validated.activeSecondsDelta, activeSecondsDelta: validated.activeSecondsDelta,
isNewMaterial: validated.eventType === 'material_opened', isNewMaterial: validated.eventType === 'material_opened',
isMarkedRead: validated.eventType === 'marked_as_read', isMarkedRead: validated.eventType === 'marked_as_read',
isNewSession: validated.eventType === 'material_opened',
}); });
// 5d. Write LearningRecord (first open / closed / marked read) // 5d. Write LearningRecord (first open / closed / marked read)

View File

@ -4,8 +4,6 @@ import { Job } from 'bullmq';
import { QUEUE_DOCUMENT_IMPORT } from '../infrastructure/queue/queue.service'; import { QUEUE_DOCUMENT_IMPORT } from '../infrastructure/queue/queue.service';
import { workerOptionsFor } from '../infrastructure/queue/queue-definitions'; import { workerOptionsFor } from '../infrastructure/queue/queue-definitions';
import { DocumentImportRepository } from '../modules/document-import/document-import.repository'; import { DocumentImportRepository } from '../modules/document-import/document-import.repository';
import { KnowledgeItemsRepository } from '../modules/knowledge-items/knowledge-items.repository';
import { KnowledgeImportWorkflow } from '../modules/ai/workflows/knowledge-import.workflow';
import { RedisService } from '../infrastructure/redis/redis.service'; import { RedisService } from '../infrastructure/redis/redis.service';
@Processor(QUEUE_DOCUMENT_IMPORT, workerOptionsFor(QUEUE_DOCUMENT_IMPORT)) @Processor(QUEUE_DOCUMENT_IMPORT, workerOptionsFor(QUEUE_DOCUMENT_IMPORT))
@ -14,8 +12,6 @@ export class DocumentImportWorker extends WorkerHost {
constructor( constructor(
private readonly repository: DocumentImportRepository, private readonly repository: DocumentImportRepository,
private readonly knowledgeItemsRepo: KnowledgeItemsRepository,
private readonly workflow: KnowledgeImportWorkflow,
private readonly redis: RedisService, private readonly redis: RedisService,
) { ) {
super(); super();
@ -29,7 +25,7 @@ export class DocumentImportWorker extends WorkerHost {
rawText?: string; rawText?: string;
fileName?: string; fileName?: string;
}>) { }>) {
const { importId, userId, knowledgeBaseId, rawText, fileName } = job.data; const { importId, rawText } = job.data;
// Resolve sourceId: from job data first, then fallback to DocumentImport record // Resolve sourceId: from job data first, then fallback to DocumentImport record
let sourceId = job.data.sourceId; let sourceId = job.data.sourceId;
if (!sourceId) { if (!sourceId) {
@ -40,46 +36,21 @@ export class DocumentImportWorker extends WorkerHost {
try { try {
if (!rawText) { if (!rawText) {
// 文件导入PDF/Office 等),由 Python RAG Worker 处理 // 文件导入PDF/Office 等)由 Python RAG Worker 处理。
// BullMQ Worker 只处理纯文本导入
this.logger.log(`Skipping file import ${importId} — delegated to Python RAG Worker`); this.logger.log(`Skipping file import ${importId} — delegated to Python RAG Worker`);
return; return;
} }
await this.repository.updateStatus(importId, 'processing'); // 纯文本/链接导入不再自动生成知识点。
await this.redis.set(`job:document-import:${importId}:status`, 'parsing', 86400); // 知识点统一由用户在资料页手动触发生成。
await this.redis.set(`job:document-import:${importId}:progress`, '25', 86400); await this.repository.updateStatus(importId, 'completed', {
await this.redis.set(`job:document-import:${importId}:message`, 'AI 正在分析文本,提取知识点...', 86400); step: 'COMPLETED',
progress: 100,
const result = await this.workflow.execute({
userId,
rawText,
sourceName: fileName,
}); });
await this.redis.set(`job:document-import:${importId}:status`, 'saving', 86400);
await this.redis.set(`job:document-import:${importId}:progress`, '80', 86400);
await this.redis.set(`job:document-import:${importId}:message`, `正在保存 ${result.knowledgePoints.length} 个知识点...`, 86400);
if (knowledgeBaseId && result.knowledgePoints.length > 0) {
for (let i = 0; i < result.knowledgePoints.length; i++) {
const kp = result.knowledgePoints[i];
await this.knowledgeItemsRepo.create(userId, knowledgeBaseId, {
title: kp.title,
content: kp.content,
itemType: 'lesson',
orderIndex: kp.suggestedOrder ?? i + 1,
sourceId: sourceId,
sourceRef: sourceId,
});
}
}
await this.repository.updateStatus(importId, 'completed');
await this.redis.set(`job:document-import:${importId}:status`, 'completed', 86400); await this.redis.set(`job:document-import:${importId}:status`, 'completed', 86400);
await this.redis.set(`job:document-import:${importId}:progress`, '100', 86400); await this.redis.set(`job:document-import:${importId}:progress`, '100', 86400);
await this.redis.set(`job:document-import:${importId}:message`, `成功提取 ${result.knowledgePoints.length} 个知识点`, 86400); await this.redis.set(`job:document-import:${importId}:message`, '导入完成,未自动生成知识点', 86400);
this.logger.log(`Document import job ${job.id} completed: ${result.knowledgePoints.length} knowledge points`); this.logger.log(`Document import job ${job.id} completed without auto knowledge generation`);
} catch (err: any) { } catch (err: any) {
this.logger.error(`Document import job ${job.id} failed: ${err.message}`); this.logger.error(`Document import job ${job.id} failed: ${err.message}`);
await this.repository.updateStatus(importId, 'failed'); await this.repository.updateStatus(importId, 'failed');

View File

@ -313,15 +313,6 @@ describe('M2 E2E Tests', () => {
let token: string; let token: string;
beforeAll(async () => { token = await loginAdmin(); }); beforeAll(async () => { token = await loginAdmin(); });
it('GET /admin-api/knowledge-bases/candidates → candidate list', async () => {
if (!token) return;
const res = await request(app.getHttpServer())
.get('/admin-api/knowledge-bases/candidates')
.set('Authorization', `Bearer ${token}`)
.expect(200);
expect(Array.isArray(res.body.data)).toBe(true);
});
it('GET /admin-api/knowledge-bases/chunks → chunk list', async () => { it('GET /admin-api/knowledge-bases/chunks → chunk list', async () => {
if (!token) return; if (!token) return;
const res = await request(app.getHttpServer()) const res = await request(app.getHttpServer())

View File

@ -83,7 +83,7 @@ const modelNames = [
'activeRecallAnswer', 'aiAnalysisJob', 'aiAnalysisResult', 'focusItem', 'activeRecallAnswer', 'aiAnalysisJob', 'aiAnalysisResult', 'focusItem',
'reviewCard', 'reviewLog', 'reviewPlan', 'dailyLearningActivity', 'reviewCard', 'reviewLog', 'reviewPlan', 'dailyLearningActivity',
'notification', 'feedback', 'aiUsageLog', 'waitlistEntry', 'appChangelog', 'notification', 'feedback', 'aiUsageLog', 'waitlistEntry', 'appChangelog',
'knowledgeSource', 'knowledgeChunk', 'importCandidate', 'backupJob', 'knowledgeSource', 'knowledgeChunk', 'backupJob',
'adminUser', 'adminSession', 'adminAuditLog', 'membershipPlan', 'adminUser', 'adminSession', 'adminAuditLog', 'membershipPlan',
'adminConversation', 'adminMessage', 'adminCostItem', 'appConfig', 'adminConversation', 'adminMessage', 'adminCostItem', 'appConfig',
'featureFlag', 'configChangeLog', 'securityEvent', 'sensitiveWord', 'featureFlag', 'configChangeLog', 'securityEvent', 'sensitiveWord',

View File

@ -1,46 +0,0 @@
# 刑法总论 - 犯罪构成要件
## 犯罪构成四要件理论
### 一、犯罪客体
刑法所保护而被犯罪行为所侵害的社会关系。
- 一般客体:一切犯罪共同侵犯的社会关系
- 同类客体:某一类犯罪共同侵犯的社会关系
- 直接客体:具体犯罪直接侵犯的社会关系
### 二、犯罪客观方面
1. 危害行为(作为和不作为)
2. 危害结果
3. 因果关系
4. 犯罪的时间、地点、方法
不作为犯罪的条件:
- 有作为义务(法律规定、职务要求、法律行为、先行行为)
- 有能力履行义务
- 不履行义务造成危害结果
### 三、犯罪主体
**刑事责任年龄**
- 不满12周岁不负刑事责任
- 12-14周岁犯故意杀人、故意伤害致人重伤或死亡等严重罪行经最高检核准追诉
- 14-16周岁对八种严重犯罪负刑事责任故意杀人、故意伤害致人重伤或死亡、强奸、抢劫、贩卖毒品、放火、爆炸、投放危险物质
- 16周岁以上完全刑事责任能力
**刑事责任能力**
- 精神病人在不能辨认或控制自己行为时造成危害结果,不负刑事责任
- 间歇性精神病人在精神正常时犯罪,应负刑事责任
- 醉酒的人犯罪,应负刑事责任
### 四、犯罪主观方面
1. 故意(直接故意、间接故意)
2. 过失(疏忽大意的过失、过于自信的过失)
3. 目的和动机
## 正当防卫与紧急避险
### 正当防卫
为了国家、公共利益、本人或他人的人身财产免受正在进行的不法侵害而采取的制止行为。对正在进行行凶、杀人、抢劫、强奸、绑架等严重危及人身安全的暴力犯罪,采取防卫行为造成不法侵害人伤亡的,不负刑事责任(特殊防卫)。
### 紧急避险
为了使国家、公共利益、本人或他人的合法权益免受正在发生的危险,不得已采取的避险行为。不适用于职务上、业务上负有特定责任的人。

View File

@ -1,54 +0,0 @@
# 法理学 - 法的概念与渊源
## 法的概念
法是由国家制定或认可的,以权利义务为主要内容,由国家强制力保证实施的社会规范。
### 法的本质
1. 法是统治阶级意志的体现
2. 法的发展最终由经济基础决定
3. 法的内容受社会物质生活条件制约
### 法的特征
1. 法是调整行为的社会规范
2. 法是由国家制定或认可的社会规范
3. 法是以权利义务为内容的社会规范
4. 法是由国家强制力保障实施的社会规范
## 法的渊源
### 当代中国法的正式渊源
1. 宪法
2. 法律(全国人大及其常委会制定)
3. 行政法规(国务院制定)
4. 地方性法规(地方人大及其常委会制定)
5. 自治条例和单行条例
6. 规章(部门规章、地方政府规章)
7. 特别行政区法律
8. 国际条约和惯例
### 当代中国法的非正式渊源
1. 习惯
2. 判例
3. 政策
4. 道德
## 法的效力
### 法的效力层次
1. 上位法优于下位法
2. 特别法优于一般法
3. 新法优于旧法
### 法的效力范围
1. 对人的效力:属地主义、属人主义、保护主义、折中主义
2. 时间效力:法的生效时间、法的失效时间、法的溯及力
3. 空间效力:域内效力、域外效力
## 法律规则与法律原则
### 法律规则
指具体规定人们权利义务以及相应法律后果的行为准则。由假定条件、行为模式和法律后果三要素构成。
### 法律原则
法律原则是可以作为众多法律规则之基础或本源的综合性、稳定性的原理和准则。如罪刑法定原则、诚实信用原则。

View File

@ -1,40 +0,0 @@
# 民事诉讼法 - 诉讼主体与管辖
## 民事诉讼的基本原则
1. 诉讼权利平等原则
2. 同等原则与对等原则
3. 法院调解自愿和合法原则
4. 辩论原则
5. 处分原则
6. 诚实信用原则
7. 检察监督原则
## 诉讼主体
### 原告与被告
以自己的名义请求法院保护其合法权益而提起诉讼的人及其相对方。诉讼权利能力始于出生,终于死亡。法人从成立时具有诉讼权利能力。
### 共同诉讼
当事人一方或双方为二人以上,诉讼标的是共同的,或诉讼标的是同一种类、人民法院认为可以合并审理且当事人同意的。
### 第三人
1. 有独立请求权的第三人:对原被告争议的诉讼标的有独立的请求权,以起诉方式参加诉讼
2. 无独立请求权的第三人:虽对诉讼标的无独立请求权,但案件处理结果同他有法律上的利害关系
### 诉讼代理人
1. 法定代理人:无诉讼行为能力人由其监护人作为法定代理人代为诉讼
2. 委托代理人:当事人、法定代理人可以委托一至二人作为诉讼代理人
## 管辖
### 级别管辖
基层人民法院管辖第一审民事案件,但法律另有规定的除外。中级法院管辖重大涉外案件、在本辖区有重大影响的案件。
### 地域管辖
- 一般地域管辖:被告住所地法院
- 特殊地域管辖:合同纠纷—被告住所地或合同履行地;侵权纠纷—侵权行为地或被告住所地
- 专属管辖:不动产纠纷—不动产所在地法院;港口作业纠纷—港口所在地法院;继承遗产纠纷—被继承人死亡时住所地或主要遗产所在地法院
### 协议管辖
合同或其他财产权益纠纷的当事人可以书面协议选择与争议有实际联系的地点的法院管辖。

View File

@ -1,58 +0,0 @@
# 宪法学 - 国家制度与公民基本权利
## 宪法基本原则
1. 人民主权原则
2. 基本人权原则
3. 权力制约原则
4. 法治原则
## 国家基本制度
### 国家性质
中华人民共和国是工人阶级领导的、以工农联盟为基础的人民民主专政的社会主义国家。
### 政权组织形式
人民代表大会制度。全国人民代表大会是最高国家权力机关,其常设机关是全国人民代表大会常务委员会。
### 选举制度
- 普遍性原则18周岁以上公民除依法被剥夺政治权利者外享有选举权
- 平等性原则:一人一票
- 直接选举与间接选举并用原则:县乡两级人大代表直接选举,市级以上间接选举
- 秘密投票原则
### 民族区域自治制度
各少数民族聚居的地方实行区域自治,设立自治机关,行使自治权。
### 特别行政区制度
香港特别行政区和澳门特别行政区是中华人民共和国不可分离的部分,实行"一国两制"方针,享有高度自治权。
### 基层群众自治制度
城市和农村按居民居住地区设立的居民委员会和村民委员会是基层群众性自治组织。
## 公民的基本权利
### 平等权
中华人民共和国公民在法律面前一律平等。
### 政治权利
1. 选举权和被选举权
2. 言论、出版、集会、结社、游行、示威的自由
### 宗教信仰自由
每个公民有信仰宗教的自由,也有不信仰宗教的自由。国家保护正常的宗教活动。
### 人身自由
1. 人身自由不受侵犯
2. 人格尊严不受侵犯
3. 住宅不受侵犯
4. 通信自由和通信秘密受法律的保护
### 社会经济权利
1. 劳动的权利和义务
2. 休息权
3. 获得物质帮助的权利(年老、疾病或者丧失劳动能力时)
4. 受教育权
### 监督权
公民对任何国家机关和国家工作人员有提出批评和建议的权利;对任何国家机关和国家工作人员的违法失职行为有申诉、控告或检举的权利。

View File

@ -1,54 +0,0 @@
# 知识产权法 - 著作权与专利权
## 著作权(版权)
### 著作权的保护对象
著作权保护文学、艺术和科学领域内具有独创性并能以一定形式表现的智力成果,包括:
1. 文字作品
2. 口述作品
3. 音乐、戏剧、曲艺、舞蹈、杂技艺术作品
4. 美术、建筑作品
5. 摄影作品
6. 视听作品
7. 工程设计图、产品设计图、地图、示意图等图形作品和模型作品
8. 计算机软件
9. 符合作品特征的其他智力成果
### 著作权的内容
**人身权**(不可转让):发表权、署名权、修改权、保护作品完整权
**财产权**(可许可、转让):复制权、发行权、出租权、展览权、表演权、放映权、广播权、信息网络传播权、摄制权、改编权、翻译权、汇编权等
### 著作权的保护期限
- 人身权(署名权、修改权、保护作品完整权):永久保护
- 发表权和财产权作者终生加死后50年
### 合理使用制度
在特定情形下,可以不经著作权人许可、不向其支付报酬使用作品,但应指明作者姓名和作品名称、不得影响作品的正常使用、不得不合理损害著作权人的合法利益。例如:
1. 为个人学习、研究或者欣赏
2. 为介绍、评论或说明而适当引用
3. 为报道新闻
4. 为学校课堂教学或科学研究,翻译或少量复制
## 专利权
### 专利的类型
1. 发明专利保护期20年
2. 实用新型专利保护期10年
3. 外观设计专利保护期15年
### 授予专利权的条件
1. 新颖性:不属于现有技术
2. 创造性:与现有技术相比有突出的实质性特点和显著进步
3. 实用性:能够制造或使用并产生积极效果
### 不授予专利权的情形
1. 科学发现
2. 智力活动的规则和方法
3. 疾病的诊断和治疗方法
4. 动物和植物品种(其生产方法可获专利)
5. 原子核变换方法及用该方法获得的物质
6. 对平面印刷品的图案、色彩或者二者的结合作出的主要起标识作用的设计
### 专利侵权行为
未经专利权人许可实施其专利即构成侵权。为生产经营目的制造、使用、许诺销售、销售、进口其专利产品,或使用其专利方法等。

View File

@ -1,59 +0,0 @@
# 婚姻家庭法 - 夫妻财产与离婚
## 婚姻的成立条件
1. 男女双方完全自愿
2. 达到法定婚龄男22岁女20岁
3. 双方均无配偶
4. 不属于直系血亲和三代以内旁系血亲
5. 未患有医学上认为不应当结婚的疾病
## 无效婚姻与可撤销婚姻
### 无效婚姻
1. 重婚
2. 有禁止结婚的亲属关系
3. 未到法定婚龄
### 可撤销婚姻
1. 因胁迫结婚的,受胁迫的一方可以向人民法院请求撤销婚姻
2. 一方患有重大疾病,在结婚登记前未如实告知另一方的
## 夫妻财产制度
### 法定夫妻财产制(婚后共同制)
夫妻在婚姻关系存续期间所得的下列财产,为夫妻共同财产:
1. 工资、奖金、劳务报酬
2. 生产、经营、投资的收益
3. 知识产权的收益
4. 继承或受赠的财产(遗嘱或赠与合同确定只归一方的除外)
5. 其他应当归共同所有的财产
### 夫妻个人财产
1. 一方的婚前财产
2. 一方因受到人身损害获得的赔偿或补偿
3. 遗嘱或赠与合同中确定只归一方的财产
4. 一方专用的生活用品
5. 其他应当归一方的财产
### 约定财产制
男女双方可以约定婚姻关系存续期间所得的财产及婚前财产归各自所有、共同所有或部分各自所有、部分共同所有。
## 离婚
### 协议离婚
自愿离婚的双方到婚姻登记机关申请离婚经30日冷静期后领取离婚证。
### 诉讼离婚
人民法院审理离婚案件,应当进行调解。感情确已破裂,调解无效的,应准予离婚。
### 离婚损害赔偿
有下列情形之一导致离婚的,无过错方有权请求损害赔偿:
1. 重婚
2. 与他人同居
3. 实施家庭暴力
4. 虐待、遗弃家庭成员
5. 有其他重大过错
### 子女抚养
离婚后父母对子女仍有抚养、教育、保护的权利和义务。不满两周岁的子女以由母亲直接抚养为原则。已满两周岁的子女,协商不成时由法院根据最有利于未成年子女的原则判决。子女已满八周岁的,应当尊重其真实意愿。

View File

@ -1,50 +0,0 @@
# 商法 - 公司法基本制度
## 公司类型
1. 有限责任公司
2. 股份有限公司
3. 一人有限责任公司
4. 国有独资公司
5. 上市公司
## 公司的设立
### 有限责任公司的设立条件
1. 股东符合法定人数50人以下
2. 有符合公司章程规定的全体股东认缴的出资额(无最低注册资本限制)
3. 股东共同制定公司章程
4. 有公司名称,建立符合有限责任公司要求的组织机构
5. 有公司住所
### 股份有限公司的设立条件
1. 发起人符合法定人数2人以上200人以下
2. 有符合公司章程规定的全体发起人认购的股本总额或募集的实收股本总额
3. 股份发行、筹办事项符合法律规定
4. 发起人制订公司章程(募集设立的须经创立大会通过)
5. 有公司名称,建立符合股份有限公司要求的组织机构
6. 有公司住所
## 公司治理结构
### 股东(大)会
公司的权力机构。行使下列职权:
1. 决定公司的经营方针和投资计划
2. 选举和更换董事、监事
3. 审议批准董事会、监事会报告
4. 审议批准公司的年度财务预算方案、决算方案
5. 审议批准公司的利润分配方案和弥补亏损方案
6. 对公司增加或者减少注册资本作出决议
7. 对发行公司债券作出决议
8. 对公司合并、分立、解散、清算或者变更公司形式作出决议
9. 修改公司章程
### 董事会
公司的经营决策机构。有限责任公司设3-13名董事股份有限公司设5-19名董事。
### 监事会
公司的监督机构。有限责任公司设监事会其成员不得少于3人。
## 公司人格否认(揭开公司面纱)
公司股东滥用公司法人独立地位和股东有限责任,逃避债务,严重损害公司债权人利益的,应当对公司债务承担连带责任。

View File

@ -1,51 +0,0 @@
# 行政法与行政诉讼法 - 行政行为
## 行政法的基本原则
1. 依法行政原则
2. 行政合理性原则
3. 正当程序原则
4. 信赖保护原则
5. 高效便民原则
6. 权责统一原则
## 具体行政行为
### 行政许可
行政机关根据公民、法人或者其他组织的申请,经依法审查,准予其从事特定活动的行为。
### 行政处罚
种类:警告、罚款、没收违法所得、没收非法财物、责令停产停业、暂扣或吊销许可证、暂扣或吊销执照、行政拘留等。
行政处罚的实施机关:
1. 行政机关
2. 法律、法规授权的组织
3. 行政机关委托的组织
### 行政强制
包括行政强制措施(查封、扣押、冻结等)和行政强制执行(加处罚款、划拨存款、拍卖等)。
## 行政复议
### 复议范围
公民、法人或者其他组织认为具体行政行为侵犯其合法权益的,可以自知道该具体行政行为之日起六十日内提出行政复议申请。
### 复议机关
1. 对县级以上地方各级人民政府工作部门的具体行政行为不服的,由申请人选择,可以向该部门的本级人民政府申请行政复议,也可以向上一级主管部门申请行政复议
2. 对地方各级人民政府的具体行政行为不服的,向上一级地方人民政府申请行政复议
3. 对国务院部门或省、自治区、直辖市人民政府的具体行政行为不服的,向作出该具体行政行为的国务院部门或省、自治区、直辖市人民政府申请行政复议
### 复议决定
复议机关应当自受理申请之日起六十日内作出行政复议决定。种类:维持、撤销、变更、确认违法、责令履行等。
## 行政诉讼
### 受案范围
公民、法人或者其他组织认为行政机关和行政机关工作人员的行政行为侵犯其合法权益,有权向人民法院提起诉讼。
### 管辖
- 级别管辖:基层人民法院管辖第一审行政案件
- 地域管辖:最初作出行政行为的行政机关所在地人民法院管辖
### 举证责任
被告对作出的行政行为负有举证责任,应当提供作出该行政行为的证据和所依据的规范性文件。在诉讼过程中,被告及其诉讼代理人不得自行向原告、第三人和证人收集证据。

View File

@ -1,42 +0,0 @@
# 物权法 - 所有权与担保物权
## 物权法定原则
物权的种类和内容,由法律规定。当事人不得自行创设法律未规定的物权类型。
## 所有权
### 所有权的权能
1. 占有权能 — 对物的实际控制
2. 使用权能 — 按照物的性质和用途加以利用
3. 收益权能 — 收取物的天然孳息和法定孳息
4. 处分权能 — 对物进行事实上或法律上的处置
### 共有
- 按份共有:共有人按照各自的份额对共有财产分享权利、分担义务
- 共同共有:共有人对共有财产不分份额地共同享有权利、承担义务
### 善意取得制度
构成要件:
1. 受让人受让该不动产或者动产时是善意的
2. 以合理的价格转让
3. 转让的不动产或者动产依照法律规定应当登记的已经登记,不需要登记的已经交付给受让人
法律效果:受让人取得该不动产或者动产的所有权,原所有权人有权向无处分权人请求损害赔偿。
## 担保物权
### 抵押权
不转移占有的担保物权。抵押财产包括:建筑物、建设用地使用权、海域使用权、生产设备、原材料、半成品、产品、正在建造的建筑物、船舶、航空器、交通运输工具等。
### 质权
转移占有的担保物权。包括动产质权和权利质权。
### 留置权
债务人不履行到期债务,债权人可以留置已经合法占有的债务人的动产,并有权就该动产优先受偿。
### 担保物权的清偿顺序
同一财产上设立多个担保物权的:
1. 留置权优先于抵押权、质权
2. 登记的抵押权按登记先后顺序
3. 已登记的优先于未登记的

View File

@ -1,71 +0,0 @@
# 法律职业道德与司法制度
## 法律职业道德
### 法官职业道德
1. 忠诚司法事业
2. 保证司法公正
3. 确保司法廉洁
4. 坚持司法为民
5. 维护司法形象
### 律师职业道德
1. 忠于宪法和法律
2. 诚实守信、勤勉尽责
3. 依照事实和法律维护当事人合法权益
4. 维护法律正确实施,维护社会公平正义
5. 注重职业修养,珍惜职业声誉
6. 保守执业活动中知悉的国家秘密、商业秘密和个人隐私
7. 尊重同行、公平竞争、同业互助
### 检察官职业道德
1. 坚持忠诚品格
2. 坚持公正理念
3. 坚持清廉操守
4. 坚持文明规范
## 律师制度
### 律师执业条件
1. 拥护中华人民共和国宪法
2. 通过国家统一法律职业资格考试
3. 在律师事务所实习满一年
4. 品行良好
### 律师的执业范围
1. 接受委托担任代理人参加诉讼
2. 接受委托担任辩护人
3. 接受委托提供非诉讼法律服务
4. 解答法律咨询、代写法律文书
5. 担任法律顾问
### 法律援助
有下列情形之一的,当事人因经济困难没有委托代理人的,可以申请法律援助:
1. 请求国家赔偿
2. 请求给予社会保险待遇或最低生活保障待遇
3. 请求给付抚恤金、救济金
4. 请求给付赡养费、抚养费、扶养费
5. 请求支付劳动报酬
6. 主张见义勇为行为产生的民事权益
## 司法制度
### 审判制度
人民法院是国家审判机关。包括最高人民法院、地方各级人民法院和专门人民法院。实行两审终审制。
### 检察制度
人民检察院是国家的法律监督机关。行使下列职权:
1. 对危害国家安全案等重大犯罪案件行使检察权
2. 对直接受理的刑事案件进行侦查
3. 对公安机关侦查的案件进行审查
4. 对刑事案件提起公诉
5. 对诉讼活动实行法律监督
## 国家统一法律职业资格考试
### 报考条件
1. 具有中华人民共和国国籍
2. 拥护中华人民共和国宪法,享有选举权和被选举权
3. 具有良好的政治、业务素质和道德品行
4. 具有完全民事行为能力
5. 具备全日制普通高等学校法学类本科学历并获得学士及以上学位,或非法学类本科及以上学历并获得法律硕士、法学硕士及以上学位,或全日制非法学类本科及以上学历并获得相应学位且从事法律工作满三年

View File

@ -1,40 +0,0 @@
# 刑法分论 - 常见罪名解析
## 侵犯人身权利罪
### 故意杀人罪
非法故意剥夺他人生命。最高可判处死刑。
### 故意伤害罪
非法故意损害他人身体健康。致人重伤的,处三年以上十年以下有期徒刑;致人死亡或以特别残忍手段致人重伤造成严重残疾的,处十年以上有期徒刑、无期徒刑或死刑。
### 强奸罪
以暴力、胁迫或者其他手段强奸妇女。奸淫不满14周岁幼女的以强奸论从重处罚。
### 非法拘禁罪
非法剥夺他人人身自由。具有殴打、侮辱情节或致人重伤、死亡的,从重处罚。
## 侵犯财产罪
### 抢劫罪
以暴力、胁迫或者其他方法抢劫公私财物。入户抢劫、在公共交通工具上抢劫、抢劫银行、多次抢劫或抢劫数额巨大、抢劫致人重伤或死亡、冒充军警人员抢劫、持枪抢劫、抢劫军用物资或抢险救灾救济物资的,处十年以上有期徒刑、无期徒刑或死刑。
### 盗窃罪
盗窃公私财物,数额较大的,或者多次盗窃、入户盗窃、携带凶器盗窃、扒窃的。
### 诈骗罪
以非法占有为目的,用虚构事实或隐瞒真相的方法,骗取数额较大的公私财物。诈骗公私财物价值三千元至一万元以上、三万元至十万元以上、五十万元以上的,分别认定为"数额较大"、"数额巨大"、"数额特别巨大"。
### 侵占罪
将代为保管的他人财物非法占为己有,数额较大,拒不退还的。
### 职务侵占罪
公司、企业或其他单位的工作人员,利用职务便利,将本单位财物非法占为己有,数额较大的。
## 贪污贿赂罪
### 贪污罪
国家工作人员利用职务便利,侵吞、窃取、骗取或以其他手段非法占有公共财物。
### 受贿罪
国家工作人员利用职务便利,索取他人财物,或非法收受他人财物为他人谋取利益。

View File

@ -1,53 +0,0 @@
# 经济法 - 反垄断法与反不正当竞争
## 反垄断法
### 垄断行为
1. 经营者达成垄断协议
2. 经营者滥用市场支配地位
3. 具有或者可能具有排除、限制竞争效果的经营者集中
### 垄断协议
禁止具有竞争关系的经营者达成下列垄断协议:
1. 固定或者变更商品价格
2. 限制商品的生产数量或者销售数量
3. 分割销售市场或者原材料采购市场
4. 限制购买新技术、新设备或者限制开发新技术、新产品
5. 联合抵制交易
### 滥用市场支配地位
具有市场支配地位的经营者禁止下列行为:
1. 以不公平的高价销售商品或以不公平的低价购买商品
2. 没有正当理由以低于成本的价格销售商品
3. 没有正当理由拒绝与交易相对人进行交易
4. 没有正当理由限定交易相对人只能与其进行交易
### 经营者集中
符合申报标准的经营者集中应当事先向国务院反垄断执法机构申报,未申报的不得实施集中。
## 反不正当竞争法
### 不正当竞争行为
1. 混淆行为:擅自使用与他人有一定影响的商品名称、包装、装潢等相同或近似的标识
2. 商业贿赂:采用财物或者其他手段贿赂以谋取交易机会或竞争优势
3. 虚假宣传:对商品的性能、功能、质量等作虚假或者引人误解的商业宣传
4. 侵犯商业秘密:盗窃、贿赂、欺诈、胁迫或其他不正当手段获取权利人的商业秘密
5. 不正当有奖销售:所设奖的种类、兑奖条件等有奖销售信息不明确,影响兑奖
6. 商业诋毁:编造、传播虚假信息或误导性信息,损害竞争对手的商业信誉、商品声誉
7. 网络不正当竞争:利用技术手段影响用户选择,妨碍其他经营者合法提供的网络产品或服务正常运行
## 消费者权益保护法
### 消费者的权利
1. 安全权
2. 知情权
3. 自主选择权
4. 公平交易权
5. 依法求偿权
6. 结社权
7. 获得知识权
8. 受尊重权
9. 监督权
### 惩罚性赔偿
经营者提供商品或者服务有欺诈行为的,应当按消费者要求增加赔偿其受到的损失,增加赔偿的金额为消费者购买商品的价款或接受服务的费用的三倍;增加赔偿的金额不足五百元的,为五百元。

View File

@ -1,43 +0,0 @@
# 继承法 - 法定继承与遗嘱继承
## 继承开始
继承从被继承人死亡时开始。相互有继承关系的数人在同一事件中死亡,难以确定死亡时间的,推定没有其他继承人的人先死亡。都有其他继承人的,辈份不同的推定长辈先死亡,辈份相同的推定同时死亡。
## 法定继承
### 法定继承人的范围和顺序
第一顺序:配偶、子女、父母
第二顺序:兄弟姐妹、祖父母、外祖父母
继承开始后由第一顺序继承人继承,第二顺序继承人不继承;没有第一顺序继承人继承的,由第二顺序继承人继承。
子女包括婚生子女、非婚生子女、养子女和有扶养关系的继子女。父母包括生父母、养父母和有扶养关系的继父母。
### 代位继承
被继承人的子女先于被继承人死亡的,由被继承人子女的直系晚辈血亲代位继承。被继承人的兄弟姐妹先于被继承人死亡的,由被继承人兄弟姐妹的子女代位继承。
### 遗产分配原则
同一顺序继承人继承遗产的份额一般应当均等。对生活有特殊困难又缺乏劳动能力的继承人应当予以照顾。对被继承人尽了主要扶养义务或与被继承人共同生活的继承人可以多分。有扶养能力和条件的继承人不尽扶养义务的,应当不分或少分。
## 遗嘱继承和遗赠
### 遗嘱的形式
1. 自书遗嘱(亲笔书写、签名、注明年月日)
2. 代书遗嘱(两名以上见证人,一人代书,注明日期并签名)
3. 打印遗嘱(遗嘱人和见证人在每一页签名注日期)
4. 录音录像遗嘱(记录姓名或肖像及日期)
5. 口头遗嘱(危急情况下,两名见证人,危机解除后失效)
6. 公证遗嘱(公证机构办理)
### 遗嘱见证人的限制
以下人员不能作为遗嘱见证人:
1. 无民事行为能力人、限制民事行为能力人
2. 继承人、受遗赠人
3. 与继承人、受遗赠人有利害关系的人
### 特留份制度
遗嘱应当为缺乏劳动能力又没有生活来源的继承人保留必要的遗产份额。
### 遗赠扶养协议
遗赠人与扶养人签订的、由扶养人承担遗赠人生养死葬义务、享有受遗赠权利的协议。遗赠扶养协议的法律效力优先于遗嘱和法定继承。

View File

@ -1,40 +0,0 @@
# 民事诉讼法 - 证据制度与证明责任
## 民事诉讼证据的种类
1. 当事人的陈述
2. 书证
3. 物证
4. 视听资料
5. 电子数据
6. 证人证言
7. 鉴定意见
8. 勘验笔录
## 证明责任分配
### 一般规则:谁主张、谁举证
当事人对自己提出的主张,有责任提供证据。
### 举证责任倒置的几种情形
1. 因新产品制造方法发明专利引起的专利侵权诉讼,由制造同样产品的单位或者个人对其产品制造方法不同于专利方法承担举证责任
2. 高度危险作业致人损害的侵权诉讼,由加害人就受害人故意造成损害的事实承担举证责任
3. 因环境污染引起的损害赔偿诉讼,由加害人就法律规定的免责事由及其行为与损害结果之间不存在因果关系承担举证责任
4. 建筑物或者其他设施以及建筑物上的搁置物、悬挂物发生倒塌、脱落、坠落致人损害的侵权诉讼,由所有人或者管理人对其无过错承担举证责任
5. 饲养动物致人损害的侵权诉讼,由动物饲养人或者管理人就受害人有过错或者第三人有过错承担举证责任
6. 因缺陷产品致人损害的侵权诉讼,由产品的生产者就法律规定的免责事由承担举证责任
7. 因共同危险行为致人损害的侵权诉讼,由实施危险行为的人就其行为与损害结果之间不存在因果关系承担举证责任
8. 因医疗行为引起的侵权诉讼,由医疗机构就医疗行为与损害结果之间不存在因果关系及不存在医疗过错承担举证责任
## 证据的审核认定
审判人员应当依照法定程序,全面、客观地审核证据。对以严重侵害他人合法权益、违反法律禁止性规定或者严重违背公序良俗的方法形成或者获取的证据,不得作为认定案件事实的根据。
## 无需举证证明的事实
1. 当事人自认的事实(诉讼过程中的明示自认)
2. 众所周知的事实
3. 根据法律规定推定的事实
4. 根据已知事实和日常生活经验法则能推定出的另一事实
5. 已为人民法院发生法律效力的裁判所确认的事实
6. 已为有效公证文书所证明的事实

View File

@ -1,41 +0,0 @@
# 合同法 - 合同订立与违约责任
## 合同的订立
### 要约
要约是希望与他人订立合同的意思表示。构成要件:
1. 内容具体确定
2. 经受要约人承诺,要约人即受该意思表示约束
要约邀请(要约引诱):寄送的价目表、拍卖公告、招标公告、招股说明书、商业广告等。
### 承诺
承诺是受要约人同意要约的意思表示。承诺生效时合同成立。
### 合同成立的时间
1. 采用合同书形式订立合同的,自当事人均签名、盖章或者按指印时合同成立
2. 采用信件、数据电文等形式订立合同的,可以在合同成立之前要求签订确认书,签订确认书时合同成立
## 合同的效力
合同生效的一般要件:
1. 行为人具有相应的民事行为能力
2. 意思表示真实
3. 不违反法律、行政法规的强制性规定,不违背公序良俗
## 违约责任
### 违约责任的承担方式
1. 继续履行
2. 采取补救措施(修理、更换、重作、退货、减少价款或报酬等)
3. 赔偿损失
4. 支付违约金
5. 定金罚则
### 违约责任的免责事由
1. 不可抗力(不能预见、不能避免、不能克服的客观情况)
2. 因对方原因造成的违约
3. 约定的免责条款(但造成对方人身损害、故意或重大过失造成财产损失的免责条款无效)
### 违约金
实际损失低于违约金的,可以请求适当减少;实际损失高于违约金的,可以请求适当增加。违约金过分高于造成的损失的,人民法院或仲裁机构可以根据当事人的请求予以适当减少。

View File

@ -1,44 +0,0 @@
# 刑事诉讼法 - 基本原则与程序
## 刑事诉讼的基本原则
1. 侦查权、检察权、审判权由专门机关依法行使原则
2. 人民法院、人民检察院依法独立行使职权原则
3. 依靠群众原则
4. 以事实为根据,以法律为准绳原则
5. 对一切公民在适用法律上一律平等原则
6. 分工负责、互相配合、互相制约原则
7. 人民检察院依法对刑事诉讼实行法律监督原则
8. 使用本民族语言进行诉讼原则
9. 审判公开原则
10. 犯罪嫌疑人、被告人有权获得辩护原则
11. 未经人民法院依法判决不得确定有罪原则(无罪推定)
## 刑事诉讼的阶段
### 一、立案
公安机关或检察院发现犯罪事实或犯罪嫌疑人,应立案侦查。
### 二、侦查
1. 讯问犯罪嫌疑人
2. 询问证人、被害人
3. 勘验、检查
4. 搜查
5. 查封、扣押物证、书证
6. 鉴定
7. 技术侦查措施
8. 通缉
侦查羁押期限一般不超过2个月。案情复杂、期限届满不能终结的可经批准延长。
### 三、审查起诉
检察院对公安机关移送起诉的案件进行审查作出起诉或不起诉决定。审查起诉期限一般为1个月重大复杂案件可延长15日。
### 四、审判
1. 第一审程序(普通程序、简易程序、速裁程序)
2. 第二审程序(上诉、抗诉)
3. 死刑复核程序
4. 审判监督程序(再审)
### 五、执行
生效判决、裁定的执行。死刑由最高人民法院核准后执行。

View File

@ -1,38 +0,0 @@
# 侵权责任法 - 归责原则与特殊侵权
## 侵权责任的构成要件
1. 加害行为(作为或不作为)
2. 损害事实(人身损害、财产损害、精神损害)
3. 因果关系(加害行为与损害事实之间存在引起与被引起的关系)
4. 主观过错(故意或过失)
## 归责原则
### 过错责任原则
一般侵权责任以过错为构成要件。谁主张谁举证。
### 过错推定原则
法律推定加害人有过错,由加害人举证自己无过错方可免责。适用范围:无民事行为能力人在教育机构受到人身损害、医疗机构违反诊疗规范、高度危险物致害等。
### 无过错责任原则
不论加害人有无过错,均须承担责任。适用范围:
1. 高度危险作业致害
2. 环境污染致害
3. 饲养动物致害
4. 缺陷产品致害
5. 机动车与非机动车驾驶人、行人之间的交通事故
### 公平责任原则
双方均无过错时,根据实际情况由双方分担损失。
## 特殊侵权责任
### 用人者责任
用人单位的工作人员因执行工作任务造成他人损害的,由用人单位承担侵权责任。
### 网络侵权责任
网络用户利用网络侵害他人民事权益的,权利人有权通知网络服务提供者采取删除、屏蔽、断开链接等必要措施。通知应当包括构成侵权的初步证据及权利人的真实身份信息。
### 安全保障义务
宾馆、商场、银行、车站、机场、体育场馆、娱乐场所等经营场所、公共场所的经营者、管理者未尽到安全保障义务,造成他人损害的,应当承担侵权责任。

View File

@ -1,48 +0,0 @@
# 劳动法 - 劳动合同与劳动争议
## 劳动合同
### 劳动合同的订立
建立劳动关系应当订立书面劳动合同。已建立劳动关系未同时订立书面劳动合同的,应当自用工之日起一个月内订立书面劳动合同。
用人单位自用工之日起超过一个月不满一年未与劳动者订立书面劳动合同的,应向劳动者每月支付两倍的工资。
### 劳动合同的类型
1. 固定期限劳动合同
2. 无固定期限劳动合同连续工作满10年或连续订立两次固定期限合同后
3. 以完成一定工作任务为期限的劳动合同
### 试用期
- 劳动合同期限3个月以上不满1年的试用期不超过1个月
- 1年以上不满3年的试用期不超过2个月
- 3年以上固定期限或无固定期限的试用期不超过6个月
- 以完成一定工作任务为期限或劳动合同期限不满3个月的不得约定试用期
### 劳动合同的解除
**劳动者单方解除**提前30日书面通知试用期提前3日。用人单位有违法情形的劳动者可立即解除。
**用人单位单方解除**(需支付经济补偿):
1. 劳动者患病或非因工负伤,医疗期满后不能从事原工作
2. 劳动者不能胜任工作,经培训或调岗后仍不能胜任
3. 客观情况重大变化致合同无法履行
**用人单位即时解除**(无需支付经济补偿):
1. 试用期被证明不符合录用条件
2. 严重违反用人单位规章制度
3. 严重失职、营私舞弊造成重大损害
4. 劳动者同时与其他单位建立劳动关系且拒不改正
5. 被追究刑事责任
### 经济补偿金
按劳动者在本单位工作的年限,每满一年支付一个月工资。六个月以上不满一年的按一年计算,不满六个月的支付半个月工资。
## 劳动争议解决
### 解决途径
1. 协商
2. 调解
3. 仲裁(前置程序,一般须先仲裁才能诉讼)
4. 诉讼
### 劳动争议仲裁时效
劳动争议申请仲裁的时效期间为一年,从当事人知道或应当知道其权利被侵害之日起计算。