From 9cf85023d1a5dcfd994154405d2c9646ddaf359c Mon Sep 17 00:00:00 2001 From: wangdl Date: Mon, 22 Jun 2026 21:44:29 +0800 Subject: [PATCH] feat(M-AI-08-05): learning analysis projector + atomic idempotency - LearningAnalysisProjector: AiLearningAnalysis + WeakPointCandidate + Recommendation - All in one transaction via ProjectionExecutor - Entry idempotency via Artifact check + P2002 fallback - Artifact types: learning_analysis, weak_point, recommendation - Registered in RESULT_PROJECTORS factory Co-Authored-By: Claude --- src/modules/ai-job/ai-job.module.ts | 4 +- .../ai-job/learning-analysis-projector.ts | 116 ++++++++++++++++++ 2 files changed, 119 insertions(+), 1 deletion(-) create mode 100644 src/modules/ai-job/learning-analysis-projector.ts diff --git a/src/modules/ai-job/ai-job.module.ts b/src/modules/ai-job/ai-job.module.ts index 8299354..e77de65 100644 --- a/src/modules/ai-job/ai-job.module.ts +++ b/src/modules/ai-job/ai-job.module.ts @@ -43,6 +43,7 @@ import { LearningAnalysisRegistrationService } from './learning-analysis-registr import { LearningAnalysisSnapshotBuilder } from './learning-analysis-snapshot-builder'; import { LearningAnalysisExecutor } from './learning-analysis-executor'; import { LearningAnalysisValidator } from './learning-analysis-validator'; +import { LearningAnalysisProjector } from './learning-analysis-projector'; import { FeynmanBusinessValidator, FeynmanReferenceValidator, @@ -94,7 +95,8 @@ import { AppConfigModule } from '../config/config.module'; LearningAnalysisSnapshotBuilder, LearningAnalysisExecutor, LearningAnalysisValidator, - { provide: RESULT_PROJECTORS, useFactory: (synthetic: SyntheticResultProjector, activeRecall: ActiveRecallProjector, feynman: FeynmanProjector, reviewCard: ReviewCardGenerationProjector, quiz: QuizGenerationProjector) => [synthetic, activeRecall, feynman, reviewCard, quiz], inject: [SyntheticResultProjector, ActiveRecallProjector, FeynmanProjector, ReviewCardGenerationProjector, QuizGenerationProjector] } as any, + 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, { provide: AI_JOB_EXECUTION_ENGINE, useExisting: AiJobExecutionEngineImpl }, ], exports: [ diff --git a/src/modules/ai-job/learning-analysis-projector.ts b/src/modules/ai-job/learning-analysis-projector.ts new file mode 100644 index 0000000..4a34ccd --- /dev/null +++ b/src/modules/ai-job/learning-analysis-projector.ts @@ -0,0 +1,116 @@ +import { Injectable, Logger } from '@nestjs/common'; +import type { Prisma } from '@prisma/client'; +import { ResultProjector, ProjectionContext, ArtifactReference } from './result-projector.interface'; + +@Injectable() +export class LearningAnalysisProjector implements ResultProjector { + readonly key = 'learning_analysis_projector'; + private readonly logger = new Logger(LearningAnalysisProjector.name); + + async project(tx: Prisma.TransactionClient, context: ProjectionContext): Promise { + const { job, validatedOutput, snapshot } = context; + let ordinal = 0; + const artifacts: ArtifactReference[] = []; + + // Entry idempotency + const existing = await tx.aiJobArtifact.findMany({ where: { jobId: job.id }, orderBy: { ordinal: 'asc' } }); + if (existing.length > 0) { + this.logger.log(`LearningAnalysis Projector: returning ${existing.length} existing artifact(s) for job=${job.id}`); + return existing.map(a => ({ artifactType: a.artifactType, artifactId: a.artifactId, ordinal: a.ordinal })); + } + + const output = validatedOutput as any; + const snap = snapshot?.snapshot || {}; + + // ── 1. AiLearningAnalysis ── + const analysis = await tx.aiLearningAnalysis.create({ + data: { + userId: job.userId, + jobId: job.id, + snapshotId: job.snapshotId || null, + targetType: job.targetType || 'knowledge_base', + targetId: job.targetId || 'unknown', + learningState: output.insufficientData ? 'insufficient' : 'analyzed', + summary: output.summary || null, + riskLevel: this.computeRiskLevel(output.risks || []), + confidence: output.confidence ?? null, + evidence: (output.strengths || []).concat(output.weaknesses || []) as any, + nextActionIds: (output.recommendations || []).map((r: any) => r.actionType) as any, + promptVersion: job.promptVersion || null, + schemaVersion: job.outputSchemaVersion || null, + }, + }); + + await upsertArtifact(tx, job.id, 'learning_analysis', analysis.id, ordinal); + artifacts.push({ artifactType: 'learning_analysis', artifactId: analysis.id, ordinal: ordinal++ }); + + this.logger.log(`LearningAnalysis Projector: AiLearningAnalysis ${analysis.id} written for job=${job.id}`); + + // ── 2. WeakPointCandidate x N ── + for (const w of output.weaknesses || []) { + const candidate = await tx.weakPointCandidate.create({ + data: { + userId: job.userId, + jobId: job.id, + snapshotId: job.snapshotId || null, + targetType: job.targetType || 'knowledge_base', + targetId: job.targetId || 'unknown', + knowledgePointId: w.knowledgePointId || null, + title: w.title, + reason: w.description || null, + confidence: output.confidence ?? null, + evidence: w.evidenceRefs || null, + status: 'active', + }, + }); + + await upsertArtifact(tx, job.id, 'weak_point', candidate.id, ordinal); + artifacts.push({ artifactType: 'weak_point', artifactId: candidate.id, ordinal: ordinal++ }); + } + + if ((output.weaknesses || []).length > 0) { + this.logger.log(`LearningAnalysis Projector: ${output.weaknesses.length} WeakPointCandidate(s) written for job=${job.id}`); + } + + // ── 3. NextActionRecommendation x N ── + for (const r of output.recommendations || []) { + const rec = await tx.nextActionRecommendation.create({ + data: { + userId: job.userId, + jobId: job.id, + snapshotId: job.snapshotId || null, + actionType: r.actionType || 'general', + targetType: r.targetType || null, + targetId: r.targetId || null, + title: r.title, + reason: r.reason || null, + priority: r.priority ?? 0, + estimatedMinutes: r.estimatedMinutes ?? null, + status: 'active', + }, + }); + + await upsertArtifact(tx, job.id, 'recommendation', rec.id, ordinal); + artifacts.push({ artifactType: 'recommendation', artifactId: rec.id, ordinal: ordinal++ }); + } + + if ((output.recommendations || []).length > 0) { + this.logger.log(`LearningAnalysis Projector: ${output.recommendations.length} Recommendation(s) written for job=${job.id}`); + } + + this.logger.log(`LearningAnalysis Projector: ${artifacts.length} artifact(s) total for job=${job.id}`); + return artifacts; + } + + private computeRiskLevel(risks: any[]): string | null { + if (!risks || risks.length === 0) return null; + if (risks.some((r: any) => r.severity === 'high')) return 'high'; + if (risks.some((r: any) => r.severity === 'medium')) return 'medium'; + return 'low'; + } +} + +async function upsertArtifact(tx: Prisma.TransactionClient, jobId: string, artifactType: string, artifactId: string, ordinal: number): Promise { + try { await tx.aiJobArtifact.create({ data: { jobId, artifactType, artifactId, ordinal } }); } + catch (err: any) { if (err?.code === 'P2002') return; throw err; } +}