diff --git a/src/modules/ai-runtime/ai-runtime.module.ts b/src/modules/ai-runtime/ai-runtime.module.ts index cf903d0..1fbfb09 100644 --- a/src/modules/ai-runtime/ai-runtime.module.ts +++ b/src/modules/ai-runtime/ai-runtime.module.ts @@ -14,11 +14,12 @@ import { PriorityRulesService } from './priority-rules.service'; import { SnapshotCleanupService } from './snapshot-cleanup.service'; import { JobReaperService } from './job-reaper.service'; import { QuizExecutionRouter } from './quiz-execution-router'; +import { LearningAnalysisExecutionRouter } from './learning-analysis-execution-router'; @Module({ imports: [ConfigModule, PrismaModule, forwardRef(() => AiJobModule)], controllers: [UserAiController, RuntimeInternalController], - providers: [UserAiService, CredentialEncryptionService, RuntimeInternalService, UserAiQuotaService, PlatformBudgetService, SnapshotBuilderService, PriorityRulesService, SnapshotCleanupService, JobReaperService, QuizExecutionRouter], + providers: [UserAiService, CredentialEncryptionService, RuntimeInternalService, UserAiQuotaService, PlatformBudgetService, SnapshotBuilderService, PriorityRulesService, SnapshotCleanupService, JobReaperService, QuizExecutionRouter, LearningAnalysisExecutionRouter], exports: [UserAiService, CredentialEncryptionService, RuntimeInternalService, UserAiQuotaService, PlatformBudgetService, SnapshotBuilderService, PriorityRulesService, SnapshotCleanupService, JobReaperService], }) export class AiRuntimeModule {} diff --git a/src/modules/ai-runtime/learning-analysis-execution-router.ts b/src/modules/ai-runtime/learning-analysis-execution-router.ts new file mode 100644 index 0000000..589f589 --- /dev/null +++ b/src/modules/ai-runtime/learning-analysis-execution-router.ts @@ -0,0 +1,79 @@ +import { Injectable, Logger } from '@nestjs/common'; +import * as crypto from 'crypto'; +import { FeatureFlagService } from '../config/feature-flag.service'; +import { LearningAnalysisSnapshotBuilder } from '../ai-job/learning-analysis-snapshot-builder'; +import { AiJobCreationService } from '../ai-job/ai-job-creation.service'; +import { UserAiService } from './user-ai.service'; +import type { CreateAnalysisJobDto } from './user-ai.dto'; + +const FLAG_NAME = 'LEARNING_ANALYSIS_ENGINE_MODE'; + +@Injectable() +export class LearningAnalysisExecutionRouter { + private readonly logger = new Logger(LearningAnalysisExecutionRouter.name); + + constructor( + private readonly featureFlag: FeatureFlagService, + private readonly snapshotBuilder: LearningAnalysisSnapshotBuilder, + private readonly creationService: AiJobCreationService, + private readonly legacyService: UserAiService, + ) {} + + async analyze(userId: string, dto: CreateAnalysisJobDto) { + const useUnified = await this.shouldUseUnified(userId); + + if (!useUnified) { + this.logger.log(`LEARNING_ANALYSIS_ENGINE_MODE=legacy for userId=${userId}`); + return this.legacyService.createAnalysisJob(userId, dto); + } + + this.logger.log(`LEARNING_ANALYSIS_ENGINE_MODE=unified for userId=${userId}`); + + // Idempotency: client-provided key or deterministic content hash + window + let effectiveId: string; + if (dto.idempotencyKey) { + effectiveId = dto.idempotencyKey; + } else { + const contentKey = crypto.createHash('sha256') + .update([dto.targetType, dto.targetId].join('|')) + .digest('hex').substring(0, 24); + const windowTs = Math.floor(Date.now() / (10 * 60 * 1000)); + effectiveId = `${contentKey}:w${windowTs}`; + } + + const snapshot = await this.snapshotBuilder.build({ + userId, + triggerType: 'manual', + operationId: effectiveId, + }); + + const idempotencyKey = `learning-analysis:manual:${effectiveId}`; + + const job = await this.creationService.createJob({ + userId, + jobType: 'learning_analysis', + triggerType: 'user_api', + targetType: dto.targetType, + targetId: dto.targetId, + idempotencyKey, + retrySnapshotContent: snapshot as unknown as Record, + }); + + this.logger.log( + `Learning Analysis Unified: jobId=${job.id} userId=${userId} ` + + `targetType=${dto.targetType} targetId=${dto.targetId}`, + ); + + return { + jobId: job.id, + status: 'queued', + engineMode: 'unified' as const, + lifecycleStatus: 'queued', + }; + } + + 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 0863d37..3eda66e 100644 --- a/src/modules/ai-runtime/user-ai.controller.spec.ts +++ b/src/modules/ai-runtime/user-ai.controller.spec.ts @@ -39,7 +39,10 @@ describe('UserAiController', () => { mockQuizRouter = { generateQuiz: jest.fn(), }; - controller = new UserAiController(mockService, mockQuizRouter); + const mockLearningAnalysisRouter = { + analyze: jest.fn(), + }; + controller = new UserAiController(mockService, mockQuizRouter, mockLearningAnalysisRouter); }); const req = (id = 'u1') => ({ user: { id } }) as any; @@ -149,8 +152,8 @@ describe('UserAiController', () => { 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' }; + it('non-quiz/non-analysis jobType calls legacy service', async () => { + const dto = { jobType: 'flashcard_generation', 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); diff --git a/src/modules/ai-runtime/user-ai.controller.ts b/src/modules/ai-runtime/user-ai.controller.ts index 7e3a6ea..24f1b28 100644 --- a/src/modules/ai-runtime/user-ai.controller.ts +++ b/src/modules/ai-runtime/user-ai.controller.ts @@ -1,6 +1,7 @@ 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 { LearningAnalysisExecutionRouter } from './learning-analysis-execution-router'; import { SaveLearningProfileDto, UpdateAiSettingsDto, CreateCredentialDto, UpdateCredentialDto, CreateAnalysisJobDto } from './user-ai.dto'; @Controller('ai') @@ -8,6 +9,7 @@ export class UserAiController { constructor( private readonly service: UserAiService, private readonly quizRouter: QuizExecutionRouter, + private readonly learningAnalysisRouter: LearningAnalysisExecutionRouter, ) {} // ── Profile ── @@ -69,6 +71,9 @@ export class UserAiController { if (dto.jobType === 'quiz_generation') { return this.quizRouter.generateQuiz(req.user.id, dto); } + if (['learning_state_analysis', 'weak_point_analysis', 'next_action_planning'].includes(dto.jobType)) { + return this.learningAnalysisRouter.analyze(req.user.id, dto); + } return this.service.createAnalysisJob(req.user.id, dto); }