feat(M-AI-08-06): learning analysis execution router + controller wiring
Some checks failed
Deploy API Server / build-and-unit (push) Failing after 28s
Deploy API Server / current-integration (push) Has been skipped
Deploy API Server / backward-compat (push) Has been skipped
Deploy API Server / deploy (push) Has been skipped

- LearningAnalysisExecutionRouter: LEARNING_ANALYSIS_ENGINE_MODE flag
- Routes learning_state_analysis/weak_point_analysis/next_action_planning
- Idempotency: client key or content hash + 10min window
- Controller: 3 job types route through learningAnalysisRouter

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
wangdl 2026-06-22 21:52:26 +08:00
parent 6ead2787f6
commit b779d1f3ce
4 changed files with 92 additions and 4 deletions

View File

@ -14,11 +14,12 @@ 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'; import { QuizExecutionRouter } from './quiz-execution-router';
import { LearningAnalysisExecutionRouter } from './learning-analysis-execution-router';
@Module({ @Module({
imports: [ConfigModule, PrismaModule, forwardRef(() => AiJobModule)], imports: [ConfigModule, PrismaModule, forwardRef(() => AiJobModule)],
controllers: [UserAiController, RuntimeInternalController], 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], exports: [UserAiService, CredentialEncryptionService, RuntimeInternalService, UserAiQuotaService, PlatformBudgetService, SnapshotBuilderService, PriorityRulesService, SnapshotCleanupService, JobReaperService],
}) })
export class AiRuntimeModule {} export class AiRuntimeModule {}

View File

@ -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<string, unknown>,
});
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<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; }
}
}

View File

@ -39,7 +39,10 @@ describe('UserAiController', () => {
mockQuizRouter = { mockQuizRouter = {
generateQuiz: jest.fn(), 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; const req = (id = 'u1') => ({ user: { id } }) as any;
@ -149,8 +152,8 @@ describe('UserAiController', () => {
expect(result).toEqual({ jobId: 'j1', status: 'queued' }); expect(result).toEqual({ jobId: 'j1', status: 'queued' });
}); });
it('non-quiz jobType calls legacy service', async () => { it('non-quiz/non-analysis jobType calls legacy service', async () => {
const dto = { jobType: 'learning_state_analysis', targetType: 'knowledge_base', targetId: 'kb1' }; const dto = { jobType: 'flashcard_generation', targetType: 'knowledge_base', targetId: 'kb1' };
mockService.createAnalysisJob.mockResolvedValue({ jobId: 'j2' }); 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);

View File

@ -1,6 +1,7 @@
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 { QuizExecutionRouter } from './quiz-execution-router';
import { LearningAnalysisExecutionRouter } from './learning-analysis-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')
@ -8,6 +9,7 @@ export class UserAiController {
constructor( constructor(
private readonly service: UserAiService, private readonly service: UserAiService,
private readonly quizRouter: QuizExecutionRouter, private readonly quizRouter: QuizExecutionRouter,
private readonly learningAnalysisRouter: LearningAnalysisExecutionRouter,
) {} ) {}
// ── Profile ── // ── Profile ──
@ -69,6 +71,9 @@ export class UserAiController {
if (dto.jobType === 'quiz_generation') { if (dto.jobType === 'quiz_generation') {
return this.quizRouter.generateQuiz(req.user.id, dto); 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); return this.service.createAnalysisJob(req.user.id, dto);
} }