diff --git a/src/modules/ai-job/ai-job-execution-engine.spec.ts b/src/modules/ai-job/ai-job-execution-engine.spec.ts index c64ea5f..589558a 100644 --- a/src/modules/ai-job/ai-job-execution-engine.spec.ts +++ b/src/modules/ai-job/ai-job-execution-engine.spec.ts @@ -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(), diff --git a/src/modules/ai-job/ai-job-execution-engine.ts b/src/modules/ai-job/ai-job-execution-engine.ts index 205decb..b3aee1e 100644 --- a/src/modules/ai-job/ai-job-execution-engine.ts +++ b/src/modules/ai-job/ai-job-execution-engine.ts @@ -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( diff --git a/src/modules/ai-job/ai-job.module.ts b/src/modules/ai-job/ai-job.module.ts index b6deea6..b99c7f8 100644 --- a/src/modules/ai-job/ai-job.module.ts +++ b/src/modules/ai-job/ai-job.module.ts @@ -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 }, ], diff --git a/src/modules/ai-job/learning-analysis-executor.ts b/src/modules/ai-job/learning-analysis-executor.ts new file mode 100644 index 0000000..19be015 --- /dev/null +++ b/src/modules/ai-job/learning-analysis-executor.ts @@ -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; + } +} diff --git a/src/modules/ai/prompts/learning-analysis.prompt.ts b/src/modules/ai/prompts/learning-analysis.prompt.ts new file mode 100644 index 0000000..ef1d0e7 --- /dev/null +++ b/src/modules/ai/prompts/learning-analysis.prompt.ts @@ -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": "学习行为和复习数据充足,主动回忆数据偏少" +}`; diff --git a/src/modules/ai/prompts/prompt-template.service.ts b/src/modules/ai/prompts/prompt-template.service.ts index eed9348..086bef8 100644 --- a/src/modules/ai/prompts/prompt-template.service.ts +++ b/src/modules/ai/prompts/prompt-template.service.ts @@ -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 { diff --git a/src/modules/ai/prompts/schemas/learning-analysis.schema.ts b/src/modules/ai/prompts/schemas/learning-analysis.schema.ts new file mode 100644 index 0000000..f8860d0 --- /dev/null +++ b/src/modules/ai/prompts/schemas/learning-analysis.schema.ts @@ -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;