feat(M-AI-08-03): learning analysis prompt + schema + executor + engine dispatch
Resolves A1/A2: learning-analysis prompt and Zod output schema created. - Prompt: 6 analysis dimensions, evidence-required, no medical diagnosis - Schema: strengths/weaknesses/trends/risks/recommendations with evidenceRefs - Executor: AiGateway only, constructs user message from snapshot metrics - Engine dispatch for learning_analysis job type Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
d537ff3bd0
commit
1bb131a1bc
@ -14,6 +14,7 @@ import { ReviewCardGenerationExecutor } from './review-card-generation-executor'
|
||||
import { ReviewCardGenerationValidator } from './review-card-generation-validator';
|
||||
import { QuizGenerationExecutor } from './quiz-generation-executor';
|
||||
import { QuizGenerationValidator } from './quiz-generation-validator';
|
||||
import { LearningAnalysisExecutor } from './learning-analysis-executor';
|
||||
import { PrismaService } from '../../infrastructure/database/prisma.service';
|
||||
import { JobLockConflictError, JobAlreadyTerminalError } from './ai-job.errors';
|
||||
|
||||
@ -96,6 +97,7 @@ describe('AiJobExecutionEngineImpl', () => {
|
||||
{ provide: ReviewCardGenerationValidator, useValue: { validate: jest.fn() } },
|
||||
{ provide: QuizGenerationExecutor, useValue: { execute: jest.fn() } },
|
||||
{ provide: QuizGenerationValidator, useValue: { validate: jest.fn() } },
|
||||
{ provide: LearningAnalysisExecutor, useValue: { execute: jest.fn() } },
|
||||
{ provide: ActiveRecallObservabilityService, useValue: {
|
||||
incrementUnifiedExecuteSuccess: jest.fn(),
|
||||
incrementUnifiedExecuteFailed: jest.fn(),
|
||||
|
||||
@ -20,8 +20,11 @@ import type { QuizGenerationSnapshot } from './quiz-generation-snapshot-builder'
|
||||
import type { FeynmanEvaluationResult } from '../ai/prompts/schemas/feynman-evaluation.schema';
|
||||
import type { ReviewCardGenerationResult } from '../ai/prompts/schemas/review-card-generation.schema';
|
||||
import type { QuizGenerationResult } from '../ai/prompts/schemas/quiz-generation.schema';
|
||||
import type { LearningAnalysisResult } from '../ai/prompts/schemas/learning-analysis.schema';
|
||||
import { QuizGenerationExecutor } from './quiz-generation-executor';
|
||||
import { QuizGenerationValidator } from './quiz-generation-validator';
|
||||
import { LearningAnalysisExecutor } from './learning-analysis-executor';
|
||||
import { LearningAnalysisExecutor } from './learning-analysis-executor';
|
||||
import {
|
||||
AiJobExecutionEngine,
|
||||
EngineJobContext,
|
||||
@ -100,6 +103,7 @@ export class AiJobExecutionEngineImpl implements AiJobExecutionEngine {
|
||||
private readonly reviewCardValidator: ReviewCardGenerationValidator,
|
||||
private readonly quizExecutor: QuizGenerationExecutor,
|
||||
private readonly quizValidator: QuizGenerationValidator,
|
||||
private readonly learningAnalysisExecutor: LearningAnalysisExecutor,
|
||||
private readonly observability: ActiveRecallObservabilityService,
|
||||
private readonly feynmanObs: FeynmanObservabilityService,
|
||||
) {}
|
||||
@ -263,11 +267,17 @@ export class AiJobExecutionEngineImpl implements AiJobExecutionEngine {
|
||||
try {
|
||||
this.quizValidator.validate(parsedOutput as QuizGenerationResult);
|
||||
} catch (validationErr: any) {
|
||||
this.logger.warn(
|
||||
`Quiz validation failed for job=${aiJobId}: ${validationErr.message}`,
|
||||
);
|
||||
this.logger.warn(`Quiz validation failed for job=${aiJobId}: ${validationErr.message}`);
|
||||
throw validationErr;
|
||||
}
|
||||
} else if (job.jobType === 'learning_analysis' && snapshot) {
|
||||
// M-AI-08-03: Learning Analysis Executor
|
||||
response = await this.learningAnalysisExecutor.execute(snapshot as any, timeoutMs);
|
||||
parsedOutput = response.parsed;
|
||||
this.logger.log(
|
||||
`Learning Analysis Executor completed: job=${aiJobId} ` +
|
||||
`confidence=${(parsedOutput as any)?.confidence}`,
|
||||
);
|
||||
} else {
|
||||
// 默认路径:直接调用 AiGateway(synthetic_job 等)
|
||||
response = await this.aiGateway.generate(
|
||||
|
||||
@ -41,6 +41,7 @@ import { QuizGenerationValidator } from './quiz-generation-validator';
|
||||
import { QuizGenerationProjector } from './quiz-generation-projector';
|
||||
import { LearningAnalysisRegistrationService } from './learning-analysis-registration.service';
|
||||
import { LearningAnalysisSnapshotBuilder } from './learning-analysis-snapshot-builder';
|
||||
import { LearningAnalysisExecutor } from './learning-analysis-executor';
|
||||
import {
|
||||
FeynmanBusinessValidator,
|
||||
FeynmanReferenceValidator,
|
||||
@ -90,6 +91,7 @@ import { AppConfigModule } from '../config/config.module';
|
||||
QuizGenerationProjector,
|
||||
LearningAnalysisRegistrationService,
|
||||
LearningAnalysisSnapshotBuilder,
|
||||
LearningAnalysisExecutor,
|
||||
{ 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,
|
||||
{ provide: AI_JOB_EXECUTION_ENGINE, useExisting: AiJobExecutionEngineImpl },
|
||||
],
|
||||
|
||||
60
src/modules/ai-job/learning-analysis-executor.ts
Normal file
60
src/modules/ai-job/learning-analysis-executor.ts
Normal file
@ -0,0 +1,60 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { AiGatewayService } from '../ai/gateway/ai-gateway.service';
|
||||
import { LearningAnalysisResultSchema } from '../ai/prompts/schemas/learning-analysis.schema';
|
||||
import type { LearningAnalysisSnapshot } from './learning-analysis-snapshot-builder';
|
||||
|
||||
@Injectable()
|
||||
export class LearningAnalysisExecutor {
|
||||
private readonly logger = new Logger(LearningAnalysisExecutor.name);
|
||||
constructor(private readonly aiGateway: AiGatewayService) {}
|
||||
|
||||
async execute(snapshot: LearningAnalysisSnapshot, timeoutMs: number) {
|
||||
const s = snapshot.snapshot;
|
||||
|
||||
const userMessage = [
|
||||
`【学习数据摘要】`,
|
||||
`学习时长:${s.studyMetrics.totalStudyDuration}秒,活跃${s.studyMetrics.activeDays}天`,
|
||||
`复习完成:${s.reviewMetrics.reviewCompletedCount}次,准确率:${Math.round(s.reviewMetrics.reviewAccuracy * 100)}%`,
|
||||
`测验完成:${s.quizMetrics.attemptCount}次,准确率:${Math.round(s.quizMetrics.accuracy * 100)}%`,
|
||||
`主动回忆:${s.activeRecallMetrics.count}次,平均分:${s.activeRecallMetrics.avgScore}`,
|
||||
`费曼练习:${s.feynmanMetrics.count}次,薄弱点:${s.feynmanMetrics.weaknessCount}个`,
|
||||
`知识点进度:${s.knowledgeProgress.totalItems}个知识点`,
|
||||
s.knowledgeProgress.weakItems.length > 0
|
||||
? `薄弱知识点:${s.knowledgeProgress.weakItems.map(i => i.title).join('、')}`
|
||||
: '',
|
||||
'',
|
||||
`数据质量:${s.dataQuality.availableSources.join(', ')} 可用`,
|
||||
s.dataQuality.insufficientDataReasons.length > 0
|
||||
? `注意:${s.dataQuality.insufficientDataReasons.join(';')}`
|
||||
: '',
|
||||
].filter(Boolean).join('\n');
|
||||
|
||||
this.logger.log(
|
||||
`Learning Analysis Executor calling AI: userId=${s.userId} ` +
|
||||
`modelTier=${s.modelTier} timeoutMs=${timeoutMs}`,
|
||||
);
|
||||
|
||||
const response = await this.aiGateway.generate(
|
||||
{
|
||||
feature: 'learning-analysis',
|
||||
userId: s.userId,
|
||||
tier: s.modelTier as any,
|
||||
promptKey: s.promptKey,
|
||||
promptVersion: s.promptVersion,
|
||||
messages: [{ role: 'user' as const, content: userMessage }],
|
||||
outputSchema: LearningAnalysisResultSchema,
|
||||
maxTokens: 4096,
|
||||
},
|
||||
timeoutMs,
|
||||
);
|
||||
|
||||
this.logger.log(
|
||||
`Learning Analysis Executor completed: userId=${s.userId} ` +
|
||||
`strengths=${(response.parsed as any)?.strengths?.length} ` +
|
||||
`recommendations=${(response.parsed as any)?.recommendations?.length} ` +
|
||||
`confidence=${(response.parsed as any)?.confidence}`,
|
||||
);
|
||||
|
||||
return response;
|
||||
}
|
||||
}
|
||||
90
src/modules/ai/prompts/learning-analysis.prompt.ts
Normal file
90
src/modules/ai/prompts/learning-analysis.prompt.ts
Normal file
@ -0,0 +1,90 @@
|
||||
export const LEARNING_ANALYSIS_SYSTEM_PROMPT = `你是一位学习分析专家,擅长根据学习数据提供客观、可执行的分析和建议。
|
||||
|
||||
你的任务:根据提供的学习行为数据,生成一份综合学习分析报告。
|
||||
|
||||
## 数据说明
|
||||
- 所有数据来源于服务端记录,是用户真实的学习行为
|
||||
- studyMetrics:学习时长、活跃天数、会话次数等
|
||||
- reviewMetrics:复习完成率、准确率、到期卡片数等
|
||||
- quizMetrics:测验数量、正确率等
|
||||
- activeRecallMetrics:主动回忆练习次数和表现
|
||||
- feynmanMetrics:费曼表达练习次数和薄弱点
|
||||
- knowledgeProgress:知识点学习进度和弱项列表
|
||||
- dataQuality:数据质量评估(哪些数据源可用、是否充足)
|
||||
|
||||
## 分析原则
|
||||
1. **只依据提供的数据**:不要编造不存在的信息
|
||||
2. **数据不足时明确说明**:如果某些维度没有数据,不要猜测或假设
|
||||
3. **客观中立**:不进行医疗诊断、心理健康评估或人格判断
|
||||
4. **建设性**:指出问题时提供具体、可执行的改进建议
|
||||
5. **可追溯**:重要结论必须引用具体的数据指标(metricKey)
|
||||
|
||||
## 输出要求
|
||||
- summary:综合概述(200字以内)
|
||||
- strengths:0-3个优势点,每个含 title/description/evidenceRefs
|
||||
- weaknesses:0-5个薄弱点,每个含 title/description/knowledgePointId/evidenceRefs
|
||||
- trends:0-3个趋势,每个含 metricKey/direction(improving/declining/stable)/description/evidenceRefs
|
||||
- risks:0-3个风险点,每个含 title/severity(low/medium/high)/description/evidenceRefs
|
||||
- recommendations:1-5条建议,每条含 actionType/title/reason/priority(0-10)/estimatedMinutes/targetType/targetId/evidenceRefs
|
||||
- confidence:0.0-1.0(对整体分析的置信度)
|
||||
- insufficientData:布尔值(数据是否严重不足)
|
||||
- dataQualityNotes:数据质量补充说明
|
||||
|
||||
## 禁止
|
||||
- 医疗诊断(如ADHD、抑郁症等)
|
||||
- 心理人格判断(如"你是一个懒惰的人")
|
||||
- 无数据依据的精确百分比或排名
|
||||
- 推荐用户无权限访问的内容
|
||||
- 自动执行任何操作(如创建测验、修改设置)
|
||||
- Markdown代码块或JSON格式包装
|
||||
- 对其他用户的比较或评价`;
|
||||
|
||||
export const LEARNING_ANALYSIS_OUTPUT_DESC = `{
|
||||
"summary": "过去7天你累计学习3.5小时,复习完成率较高但主动回忆练习偏少...",
|
||||
"strengths": [
|
||||
{
|
||||
"title": "复习完成率高",
|
||||
"description": "你的复习完成率达到85%,说明间隔重复策略执行良好",
|
||||
"evidenceRefs": [{"sourceType":"review_metric","metricKey":"reviewAccuracy","windowStart":"...","windowEnd":"..."}]
|
||||
}
|
||||
],
|
||||
"weaknesses": [
|
||||
{
|
||||
"title": "主动回忆练习不足",
|
||||
"description": "近30天仅有2次主动回忆,建议增加练习频率",
|
||||
"knowledgePointId": null,
|
||||
"evidenceRefs": [{"sourceType":"active_recall","metricKey":"count","windowStart":"...","windowEnd":"..."}]
|
||||
}
|
||||
],
|
||||
"trends": [
|
||||
{
|
||||
"metricKey": "totalStudyDuration",
|
||||
"direction": "declining",
|
||||
"description": "学习时长呈下降趋势,建议设定每日最低学习目标",
|
||||
"evidenceRefs": [{"sourceType":"study_metric","metricKey":"totalStudyDuration"}]
|
||||
}
|
||||
],
|
||||
"risks": [
|
||||
{
|
||||
"title": "知识点遗忘风险",
|
||||
"severity": "medium",
|
||||
"description": "有3个知识点的复习已过期,存在遗忘风险",
|
||||
"evidenceRefs": [{"sourceType":"review_metric","metricKey":"overdueCount"}]
|
||||
}
|
||||
],
|
||||
"recommendations": [
|
||||
{
|
||||
"actionType": "active_recall",
|
||||
"title": "增加主动回忆练习",
|
||||
"reason": "主动回忆是最有效的学习策略之一",
|
||||
"priority": 5,
|
||||
"estimatedMinutes": 15,
|
||||
"targetType": null,
|
||||
"targetId": null,
|
||||
"evidenceRefs": [{"sourceType":"active_recall","metricKey":"count"}]
|
||||
}
|
||||
],
|
||||
"confidence": 0.75,
|
||||
"insufficientData": false,
|
||||
"dataQualityNotes": "学习行为和复习数据充足,主动回忆数据偏少"
|
||||
}`;
|
||||
@ -13,6 +13,7 @@ import { RAG_CHAT_SYSTEM_PROMPT } from './rag-chat.prompt';
|
||||
import { RAG_CHAT_OUTPUT_SCHEMA_DESC } from './schemas/rag-chat.schema';
|
||||
import { QUIZ_GENERATION_SYSTEM_PROMPT } from './quiz-generation.prompt';
|
||||
import { QUIZ_OUTPUT_SCHEMA_DESC } from './schemas/quiz-generation.schema';
|
||||
import { LEARNING_ANALYSIS_SYSTEM_PROMPT, LEARNING_ANALYSIS_OUTPUT_DESC } from './learning-analysis.prompt';
|
||||
|
||||
export interface PromptTemplate {
|
||||
key: string;
|
||||
@ -68,6 +69,12 @@ export class PromptTemplateService {
|
||||
systemPrompt: QUIZ_GENERATION_SYSTEM_PROMPT,
|
||||
outputSchemaDesc: QUIZ_OUTPUT_SCHEMA_DESC,
|
||||
});
|
||||
this.register({
|
||||
key: 'learning-analysis',
|
||||
version: '1.0.0',
|
||||
systemPrompt: LEARNING_ANALYSIS_SYSTEM_PROMPT,
|
||||
outputSchemaDesc: LEARNING_ANALYSIS_OUTPUT_DESC,
|
||||
});
|
||||
}
|
||||
|
||||
get(key: string, version?: string): PromptTemplate {
|
||||
|
||||
61
src/modules/ai/prompts/schemas/learning-analysis.schema.ts
Normal file
61
src/modules/ai/prompts/schemas/learning-analysis.schema.ts
Normal file
@ -0,0 +1,61 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const EvidenceRefSchema = z.object({
|
||||
sourceType: z.enum(['study_metric', 'review_metric', 'quiz_metric', 'active_recall', 'feynman', 'knowledge_progress']),
|
||||
metricKey: z.string().min(1).max(100),
|
||||
entityId: z.string().max(255).optional(),
|
||||
windowStart: z.string().optional(),
|
||||
windowEnd: z.string().optional(),
|
||||
});
|
||||
|
||||
const StrengthSchema = z.object({
|
||||
title: z.string().min(1).max(255),
|
||||
description: z.string().min(1).max(1000),
|
||||
evidenceRefs: z.array(EvidenceRefSchema).min(1).max(5),
|
||||
});
|
||||
|
||||
const WeaknessSchema = z.object({
|
||||
title: z.string().min(1).max(255),
|
||||
description: z.string().min(1).max(1000),
|
||||
knowledgePointId: z.string().max(255).optional().nullable(),
|
||||
evidenceRefs: z.array(EvidenceRefSchema).min(1).max(5),
|
||||
});
|
||||
|
||||
const TrendSchema = z.object({
|
||||
metricKey: z.string().min(1).max(100),
|
||||
direction: z.enum(['improving', 'declining', 'stable']),
|
||||
description: z.string().min(1).max(500),
|
||||
evidenceRefs: z.array(EvidenceRefSchema).min(1).max(3),
|
||||
});
|
||||
|
||||
const RiskSchema = z.object({
|
||||
title: z.string().min(1).max(255),
|
||||
severity: z.enum(['low', 'medium', 'high']),
|
||||
description: z.string().min(1).max(1000),
|
||||
evidenceRefs: z.array(EvidenceRefSchema).min(1).max(3),
|
||||
});
|
||||
|
||||
const RecommendationSchema = z.object({
|
||||
actionType: z.string().min(1).max(100),
|
||||
title: z.string().min(1).max(255),
|
||||
reason: z.string().min(1).max(1000),
|
||||
priority: z.number().int().min(0).max(10),
|
||||
estimatedMinutes: z.number().int().min(1).max(480).optional(),
|
||||
targetType: z.string().max(32).optional().nullable(),
|
||||
targetId: z.string().max(255).optional().nullable(),
|
||||
evidenceRefs: z.array(EvidenceRefSchema).min(1).max(3),
|
||||
});
|
||||
|
||||
export const LearningAnalysisResultSchema = z.object({
|
||||
summary: z.string().min(1).max(500),
|
||||
strengths: z.array(StrengthSchema).min(0).max(3),
|
||||
weaknesses: z.array(WeaknessSchema).min(0).max(5),
|
||||
trends: z.array(TrendSchema).min(0).max(3),
|
||||
risks: z.array(RiskSchema).min(0).max(3),
|
||||
recommendations: z.array(RecommendationSchema).min(1).max(5),
|
||||
confidence: z.number().min(0).max(1),
|
||||
insufficientData: z.boolean(),
|
||||
dataQualityNotes: z.string().max(500).optional(),
|
||||
});
|
||||
|
||||
export type LearningAnalysisResult = z.infer<typeof LearningAnalysisResultSchema>;
|
||||
Loading…
x
Reference in New Issue
Block a user