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 { LearningAnalysisSnapshotBuilder } from './learning-analysis-snapshot-builder';
|
||||||
import { LearningAnalysisExecutor } from './learning-analysis-executor';
|
import { LearningAnalysisExecutor } from './learning-analysis-executor';
|
||||||
import { LearningAnalysisValidator } from './learning-analysis-validator';
|
import { LearningAnalysisValidator } from './learning-analysis-validator';
|
||||||
|
import { LearningAnalysisProjector } from './learning-analysis-projector';
|
||||||
import {
|
import {
|
||||||
FeynmanBusinessValidator,
|
FeynmanBusinessValidator,
|
||||||
FeynmanReferenceValidator,
|
FeynmanReferenceValidator,
|
||||||
@ -94,7 +95,8 @@ import { AppConfigModule } from '../config/config.module';
|
|||||||
LearningAnalysisSnapshotBuilder,
|
LearningAnalysisSnapshotBuilder,
|
||||||
LearningAnalysisExecutor,
|
LearningAnalysisExecutor,
|
||||||
LearningAnalysisValidator,
|
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 },
|
{ provide: AI_JOB_EXECUTION_ENGINE, useExisting: AiJobExecutionEngineImpl },
|
||||||
],
|
],
|
||||||
exports: [
|
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