feat(M-AI-07-05): quiz generation execution router + controller wiring
- QuizExecutionRouter: QUIZ_GENERATION_ENGINE_MODE flag routing - legacy: UserAiService.createAnalysisJob() - unified: SnapshotBuilder + AiJobCreationService.createJob() - Controller: quiz_generation routes through Router - Response compat: legacy fields + optional engineMode/lifecycleStatus Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
a59446266a
commit
40e772cc3a
@ -1,6 +1,7 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { ConfigModule } from '@nestjs/config';
|
import { ConfigModule } from '@nestjs/config';
|
||||||
import { PrismaModule } from '../../infrastructure/database/prisma.module';
|
import { PrismaModule } from '../../infrastructure/database/prisma.module';
|
||||||
|
import { AiJobModule } from '../ai-job/ai-job.module';
|
||||||
import { UserAiController } from './user-ai.controller';
|
import { UserAiController } from './user-ai.controller';
|
||||||
import { UserAiService } from './user-ai.service';
|
import { UserAiService } from './user-ai.service';
|
||||||
import { CredentialEncryptionService } from './credential-encryption.service';
|
import { CredentialEncryptionService } from './credential-encryption.service';
|
||||||
@ -12,11 +13,12 @@ import { SnapshotBuilderService } from './snapshot-builder.service';
|
|||||||
import { PriorityRulesService } from './priority-rules.service';
|
import { PriorityRulesService } from './priority-rules.service';
|
||||||
import { SnapshotCleanupService } from './snapshot-cleanup.service';
|
import { SnapshotCleanupService } from './snapshot-cleanup.service';
|
||||||
import { JobReaperService } from './job-reaper.service';
|
import { JobReaperService } from './job-reaper.service';
|
||||||
|
import { QuizExecutionRouter } from './quiz-execution-router';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [ConfigModule, PrismaModule],
|
imports: [ConfigModule, PrismaModule, AiJobModule],
|
||||||
controllers: [UserAiController, RuntimeInternalController],
|
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],
|
exports: [UserAiService, CredentialEncryptionService, RuntimeInternalService, UserAiQuotaService, PlatformBudgetService, SnapshotBuilderService, PriorityRulesService, SnapshotCleanupService, JobReaperService],
|
||||||
})
|
})
|
||||||
export class AiRuntimeModule {}
|
export class AiRuntimeModule {}
|
||||||
|
|||||||
101
src/modules/ai-runtime/quiz-execution-router.ts
Normal file
101
src/modules/ai-runtime/quiz-execution-router.ts
Normal file
@ -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<string, unknown>,
|
||||||
|
});
|
||||||
|
|
||||||
|
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<boolean> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -3,6 +3,7 @@ import { UserAiController } from './user-ai.controller';
|
|||||||
describe('UserAiController', () => {
|
describe('UserAiController', () => {
|
||||||
let controller: UserAiController;
|
let controller: UserAiController;
|
||||||
let mockService: any;
|
let mockService: any;
|
||||||
|
let mockQuizRouter: any;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mockService = {
|
mockService = {
|
||||||
@ -35,7 +36,10 @@ describe('UserAiController', () => {
|
|||||||
getFlashcard: jest.fn(),
|
getFlashcard: jest.fn(),
|
||||||
listFlashcards: 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;
|
const req = (id = 'u1') => ({ user: { id } }) as any;
|
||||||
@ -136,12 +140,22 @@ describe('UserAiController', () => {
|
|||||||
// ═══════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
describe('Analysis Jobs', () => {
|
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' };
|
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);
|
const result = await controller.createAnalysisJob(req(), dto as any);
|
||||||
expect(mockService.createAnalysisJob).toHaveBeenCalledWith('u1', dto);
|
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 () => {
|
it('POST jobs/:jobId/cancel cancels job', async () => {
|
||||||
|
|||||||
@ -1,10 +1,14 @@
|
|||||||
import { Controller, Get, Put, Post, Delete, Param, Body, Req, Query } from '@nestjs/common';
|
import { Controller, Get, Put, Post, Delete, Param, Body, Req, Query } from '@nestjs/common';
|
||||||
import { UserAiService } from './user-ai.service';
|
import { UserAiService } from './user-ai.service';
|
||||||
|
import { QuizExecutionRouter } from './quiz-execution-router';
|
||||||
import { SaveLearningProfileDto, UpdateAiSettingsDto, CreateCredentialDto, UpdateCredentialDto, CreateAnalysisJobDto } from './user-ai.dto';
|
import { SaveLearningProfileDto, UpdateAiSettingsDto, CreateCredentialDto, UpdateCredentialDto, CreateAnalysisJobDto } from './user-ai.dto';
|
||||||
|
|
||||||
@Controller('ai')
|
@Controller('ai')
|
||||||
export class UserAiController {
|
export class UserAiController {
|
||||||
constructor(private readonly service: UserAiService) {}
|
constructor(
|
||||||
|
private readonly service: UserAiService,
|
||||||
|
private readonly quizRouter: QuizExecutionRouter,
|
||||||
|
) {}
|
||||||
|
|
||||||
// ── Profile ──
|
// ── Profile ──
|
||||||
|
|
||||||
@ -62,6 +66,9 @@ export class UserAiController {
|
|||||||
|
|
||||||
@Post('jobs')
|
@Post('jobs')
|
||||||
async createAnalysisJob(@Req() req: any, @Body() dto: CreateAnalysisJobDto) {
|
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);
|
return this.service.createAnalysisJob(req.user.id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user