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 <noreply@anthropic.com>
This commit is contained in:
parent
06a0e21bbf
commit
9cf85023d1
@ -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: [
|
||||
|
||||
116
src/modules/ai-job/learning-analysis-projector.ts
Normal file
116
src/modules/ai-job/learning-analysis-projector.ts
Normal file
@ -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<ArtifactReference[]> {
|
||||
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<void> {
|
||||
try { await tx.aiJobArtifact.create({ data: { jobId, artifactType, artifactId, ordinal } }); }
|
||||
catch (err: any) { if (err?.code === 'P2002') return; throw err; }
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user