diff --git a/src/modules/ai-runtime/ai-runtime.module.ts b/src/modules/ai-runtime/ai-runtime.module.ts index efd80a7..4e60707 100644 --- a/src/modules/ai-runtime/ai-runtime.module.ts +++ b/src/modules/ai-runtime/ai-runtime.module.ts @@ -1,6 +1,7 @@ import { Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import { PrismaModule } from '../../infrastructure/database/prisma.module'; +import { AiJobModule } from '../ai-job/ai-job.module'; import { UserAiController } from './user-ai.controller'; import { UserAiService } from './user-ai.service'; import { CredentialEncryptionService } from './credential-encryption.service'; @@ -12,11 +13,12 @@ import { SnapshotBuilderService } from './snapshot-builder.service'; import { PriorityRulesService } from './priority-rules.service'; import { SnapshotCleanupService } from './snapshot-cleanup.service'; import { JobReaperService } from './job-reaper.service'; +import { QuizExecutionRouter } from './quiz-execution-router'; @Module({ - imports: [ConfigModule, PrismaModule], + imports: [ConfigModule, PrismaModule, AiJobModule], controllers: [UserAiController, RuntimeInternalController], - providers: [UserAiService, CredentialEncryptionService, RuntimeInternalService, UserAiQuotaService, PlatformBudgetService, SnapshotBuilderService, PriorityRulesService, SnapshotCleanupService, JobReaperService], + providers: [UserAiService, CredentialEncryptionService, RuntimeInternalService, UserAiQuotaService, PlatformBudgetService, SnapshotBuilderService, PriorityRulesService, SnapshotCleanupService, JobReaperService, QuizExecutionRouter], exports: [UserAiService, CredentialEncryptionService, RuntimeInternalService, UserAiQuotaService, PlatformBudgetService, SnapshotBuilderService, PriorityRulesService, SnapshotCleanupService, JobReaperService], }) export class AiRuntimeModule {} diff --git a/src/modules/ai-runtime/quiz-execution-router.ts b/src/modules/ai-runtime/quiz-execution-router.ts new file mode 100644 index 0000000..ed04423 --- /dev/null +++ b/src/modules/ai-runtime/quiz-execution-router.ts @@ -0,0 +1,101 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { FeatureFlagService } from '../config/feature-flag.service'; +import { QuizGenerationSnapshotBuilder } from '../ai-job/quiz-generation-snapshot-builder'; +import type { QuizGenerationSnapshotInput } from '../ai-job/quiz-generation-snapshot-builder'; +import { AiJobCreationService } from '../ai-job/ai-job-creation.service'; +import { UserAiService } from './user-ai.service'; +import type { CreateAnalysisJobDto } from './user-ai.dto'; + +/** + * M-AI-07-05: Quiz Execution Router + * + * 根据 QUIZ_GENERATION_ENGINE_MODE Feature Flag 路由 Quiz 生成请求。 + * - 'legacy' → UserAiService.createAnalysisJob() + * - 'unified' → SnapshotBuilder → AiJobCreationService + */ + +const FLAG_NAME = 'QUIZ_GENERATION_ENGINE_MODE'; + +@Injectable() +export class QuizExecutionRouter { + private readonly logger = new Logger(QuizExecutionRouter.name); + + constructor( + private readonly featureFlag: FeatureFlagService, + private readonly snapshotBuilder: QuizGenerationSnapshotBuilder, + private readonly creationService: AiJobCreationService, + private readonly legacyService: UserAiService, + ) {} + + async generateQuiz(userId: string, dto: CreateAnalysisJobDto) { + // 1. 判断模式 + const useUnified = await this.shouldUseUnified(userId); + + if (!useUnified) { + this.logger.log(`QUIZ_GENERATION_ENGINE_MODE=legacy for userId=${userId}`); + return this.legacyService.createAnalysisJob(userId, dto); + } + + // ═════════════════════════════════════════════════════════ + // Unified 路径 + // ═════════════════════════════════════════════════════════ + + this.logger.log(`QUIZ_GENERATION_ENGINE_MODE=unified for userId=${userId}`); + + // 2. 确定 submissionId(幂等键来源) + const submissionId = dto.idempotencyKey ?? + `${dto.targetType}:${dto.targetId}:${Date.now().toString(36)}`; + + // 3. 构建 Snapshot + const snapshotInput: QuizGenerationSnapshotInput = { + userId, + targetType: dto.targetType, + targetId: dto.targetId, + submissionId, + questionCount: dto.questionCount, + questionTypes: dto.questionTypes, + difficultyLevel: dto.difficultyLevel, + knowledgePointIds: dto.knowledgePointIds, + }; + + const snapshot = await this.snapshotBuilder.build(snapshotInput); + + // 4. 幂等键 + const idempotencyKey = dto.idempotencyKey ?? `quiz-generation:${submissionId}`; + + // 5. 通过 AiJobCreationService 创建 Job(原子:Job + Snapshot + Outbox) + const job = await this.creationService.createJob({ + userId, + jobType: 'quiz_generation', + triggerType: 'user_api', + targetType: dto.targetType, + targetId: dto.targetId, + idempotencyKey, + retrySnapshotContent: snapshot as unknown as Record, + }); + + this.logger.log( + `Quiz Unified: jobId=${job.id} userId=${userId} ` + + `targetType=${dto.targetType} targetId=${dto.targetId} ` + + `questionCount=${snapshot.snapshot.questionCount}`, + ); + + // 6. 返回兼容响应 + return { + jobId: job.id, + status: 'queued', + engineMode: 'unified' as const, + lifecycleStatus: 'queued', + planId: undefined, // Unified 不创建 QuestionGenerationPlan + }; + } + + private async shouldUseUnified(userId: string): Promise { + try { + return await this.featureFlag.isEnabled(FLAG_NAME, userId); + } catch (err: any) { + this.logger.warn(`FeatureFlag query failed, falling back to legacy: ${err.message}`); + return false; + } + } +} diff --git a/src/modules/ai-runtime/user-ai.controller.spec.ts b/src/modules/ai-runtime/user-ai.controller.spec.ts index aaf8106..0863d37 100644 --- a/src/modules/ai-runtime/user-ai.controller.spec.ts +++ b/src/modules/ai-runtime/user-ai.controller.spec.ts @@ -3,6 +3,7 @@ import { UserAiController } from './user-ai.controller'; describe('UserAiController', () => { let controller: UserAiController; let mockService: any; + let mockQuizRouter: any; beforeEach(() => { mockService = { @@ -35,7 +36,10 @@ describe('UserAiController', () => { getFlashcard: jest.fn(), listFlashcards: jest.fn(), }; - controller = new UserAiController(mockService); + mockQuizRouter = { + generateQuiz: jest.fn(), + }; + controller = new UserAiController(mockService, mockQuizRouter); }); const req = (id = 'u1') => ({ user: { id } }) as any; @@ -136,12 +140,22 @@ describe('UserAiController', () => { // ═══════════════════════════════════════════════════════════════════ describe('Analysis Jobs', () => { - it('POST jobs creates analysis job', async () => { + it('quiz_generation routes through QuizExecutionRouter', async () => { const dto = { jobType: 'quiz_generation', targetType: 'knowledge_base', targetId: 'kb1' }; - mockService.createAnalysisJob.mockResolvedValue({ jobId: 'j1' }); + mockQuizRouter.generateQuiz.mockResolvedValue({ jobId: 'j1', status: 'queued' }); + const result = await controller.createAnalysisJob(req(), dto as any); + expect(mockQuizRouter.generateQuiz).toHaveBeenCalledWith('u1', dto); + expect(mockService.createAnalysisJob).not.toHaveBeenCalled(); + expect(result).toEqual({ jobId: 'j1', status: 'queued' }); + }); + + it('non-quiz jobType calls legacy service', async () => { + const dto = { jobType: 'learning_state_analysis', targetType: 'knowledge_base', targetId: 'kb1' }; + mockService.createAnalysisJob.mockResolvedValue({ jobId: 'j2' }); const result = await controller.createAnalysisJob(req(), dto as any); expect(mockService.createAnalysisJob).toHaveBeenCalledWith('u1', dto); - expect(result).toEqual({ jobId: 'j1' }); + expect(mockQuizRouter.generateQuiz).not.toHaveBeenCalled(); + expect(result).toEqual({ jobId: 'j2' }); }); it('POST jobs/:jobId/cancel cancels job', async () => { diff --git a/src/modules/ai-runtime/user-ai.controller.ts b/src/modules/ai-runtime/user-ai.controller.ts index 9556350..7e3a6ea 100644 --- a/src/modules/ai-runtime/user-ai.controller.ts +++ b/src/modules/ai-runtime/user-ai.controller.ts @@ -1,10 +1,14 @@ import { Controller, Get, Put, Post, Delete, Param, Body, Req, Query } from '@nestjs/common'; import { UserAiService } from './user-ai.service'; +import { QuizExecutionRouter } from './quiz-execution-router'; import { SaveLearningProfileDto, UpdateAiSettingsDto, CreateCredentialDto, UpdateCredentialDto, CreateAnalysisJobDto } from './user-ai.dto'; @Controller('ai') export class UserAiController { - constructor(private readonly service: UserAiService) {} + constructor( + private readonly service: UserAiService, + private readonly quizRouter: QuizExecutionRouter, + ) {} // ── Profile ── @@ -62,6 +66,9 @@ export class UserAiController { @Post('jobs') async createAnalysisJob(@Req() req: any, @Body() dto: CreateAnalysisJobDto) { + if (dto.jobType === 'quiz_generation') { + return this.quizRouter.generateQuiz(req.user.id, dto); + } return this.service.createAnalysisJob(req.user.id, dto); }