feat(M-AI-08-05): learning analysis projector + atomic idempotency
Some checks failed
Deploy API Server / build-and-unit (push) Failing after 27s
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

- 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:
wangdl 2026-06-22 21:44:29 +08:00
parent 06a0e21bbf
commit 9cf85023d1
2 changed files with 119 additions and 1 deletions

View File

@ -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: [

View 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; }
}