diff --git a/src/modules/ai-analysis/ai-analysis.controller.ts b/src/modules/ai-analysis/ai-analysis.controller.ts index 6a78f25..d288592 100644 --- a/src/modules/ai-analysis/ai-analysis.controller.ts +++ b/src/modules/ai-analysis/ai-analysis.controller.ts @@ -18,6 +18,15 @@ export class AiAnalysisController { return this.service.analyze(String(user?.id || 'anonymous'), body); } + @Post('feynman') + @ApiOperation({ summary: '提交费曼解释评估' }) + async evaluateFeynman( + @CurrentUser() user: UserPayload, + @Body() body: { knowledgeItemTitle: string; knowledgeItemContent: string; userExplanation: string }, + ) { + return this.service.evaluateFeynman(String(user?.id || 'anonymous'), body); + } + @Get(':id') @ApiOperation({ summary: '获取分析结果' }) async findOne(@Param('id') id: string) { diff --git a/src/modules/ai-analysis/ai-analysis.repository.ts b/src/modules/ai-analysis/ai-analysis.repository.ts index 22a9606..0556d7b 100644 --- a/src/modules/ai-analysis/ai-analysis.repository.ts +++ b/src/modules/ai-analysis/ai-analysis.repository.ts @@ -5,28 +5,18 @@ import { PrismaService } from '../../infrastructure/database/prisma.service'; export class AiAnalysisRepository { constructor(private readonly prisma: PrismaService) {} - async createResult(userId: string, aiResult: { - score: number; - masteryLevel: string; - summary: string; - strengths: string[]; - weaknesses: string[]; - missingKeyPoints: string[]; - misconceptions: string[]; - focusItems: Array<{ title: string; reason: string; suggestion?: string; priority: string }>; - reviewSuggestion: { shouldReview: boolean; intervalDays: number; cardFront?: string; cardBack?: string }; - }) { + async createResult(userId: string, result: Record) { return this.prisma.aiAnalysisResult.create({ data: { userId, - jobId: '', // no job for sync analysis - summary: aiResult.summary, - masteryScore: aiResult.score, - strengths: aiResult.strengths as any, - weaknesses: aiResult.weaknesses as any, - suggestions: aiResult.focusItems as any, - nextActions: aiResult.reviewSuggestion as any, - rawResult: aiResult as any, + jobId: '', + summary: result.summary ?? '', + masteryScore: result.score ?? null, + strengths: (result.strengths ?? []) as any, + weaknesses: (result.weaknesses ?? []) as any, + suggestions: (result.focusItems ?? result.suggestions ?? []) as any, + nextActions: (result.reviewSuggestion ?? result.recommendations ?? null) as any, + rawResult: result as any, }, }); } diff --git a/src/modules/ai-analysis/ai-analysis.service.ts b/src/modules/ai-analysis/ai-analysis.service.ts index e8bff28..fea115b 100644 --- a/src/modules/ai-analysis/ai-analysis.service.ts +++ b/src/modules/ai-analysis/ai-analysis.service.ts @@ -1,5 +1,6 @@ import { Injectable, Logger } from '@nestjs/common'; import { ActiveRecallAnalysisWorkflow } from '../ai/workflows/active-recall-analysis.workflow'; +import { FeynmanEvaluationWorkflow } from '../ai/workflows/feynman-evaluation.workflow'; import { AiAnalysisRepository } from './ai-analysis.repository'; @Injectable() @@ -8,6 +9,7 @@ export class AiAnalysisService { constructor( private readonly workflow: ActiveRecallAnalysisWorkflow, + private readonly feynmanWorkflow: FeynmanEvaluationWorkflow, private readonly repository: AiAnalysisRepository, ) {} @@ -27,6 +29,22 @@ export class AiAnalysisService { return { resultId: saved.id, ...result }; } + async evaluateFeynman(userId: string, input: { + knowledgeItemTitle: string; + knowledgeItemContent: string; + userExplanation: string; + }) { + const result = await this.feynmanWorkflow.execute({ + userId, + knowledgeItemTitle: input.knowledgeItemTitle, + knowledgeItemContent: input.knowledgeItemContent, + userExplanation: input.userExplanation, + }); + + const saved = await this.repository.createResult(userId, result); + return { resultId: saved.id, ...result }; + } + async getResult(id: string) { return this.repository.findResultById(id); } diff --git a/src/modules/ai/ai.module.ts b/src/modules/ai/ai.module.ts index 370ef1e..0e07349 100644 --- a/src/modules/ai/ai.module.ts +++ b/src/modules/ai/ai.module.ts @@ -6,6 +6,10 @@ import { AiCostCalculatorService } from './usage/ai-cost-calculator.service'; import { AiUsageLogService } from './usage/ai-usage-log.service'; import { AiGatewayService } from './gateway/ai-gateway.service'; import { ActiveRecallAnalysisWorkflow } from './workflows/active-recall-analysis.workflow'; +import { FeynmanEvaluationWorkflow } from './workflows/feynman-evaluation.workflow'; +import { KnowledgeImportWorkflow } from './workflows/knowledge-import.workflow'; +import { ReviewCardGenerationWorkflow } from './workflows/review-card-generation.workflow'; +import { LearningTrendWorkflow } from './workflows/learning-trend.workflow'; import { AiController } from './ai.controller'; import { MockAiProvider } from './providers/mock-ai.provider'; import { DeepSeekProvider } from './providers/deepseek.provider'; @@ -64,7 +68,11 @@ import type { AiProvider } from './providers/ai-provider.interface'; ], }, ActiveRecallAnalysisWorkflow, + FeynmanEvaluationWorkflow, + KnowledgeImportWorkflow, + ReviewCardGenerationWorkflow, + LearningTrendWorkflow, ], - exports: [AiGatewayService, ActiveRecallAnalysisWorkflow], + exports: [AiGatewayService, ActiveRecallAnalysisWorkflow, FeynmanEvaluationWorkflow, KnowledgeImportWorkflow, ReviewCardGenerationWorkflow, LearningTrendWorkflow], }) export class AiModule {} diff --git a/src/modules/ai/prompts/feynman-evaluation.prompt.ts b/src/modules/ai/prompts/feynman-evaluation.prompt.ts new file mode 100644 index 0000000..86df6e2 --- /dev/null +++ b/src/modules/ai/prompts/feynman-evaluation.prompt.ts @@ -0,0 +1,31 @@ +export const FEYNMAN_EVALUATION_SYSTEM_PROMPT = `你是一位费曼学习法评估专家,擅长判断学习者是否真正理解了一个概念。 + +费曼学习法的核心原则:如果你不能用简单的语言向一个新手解释清楚一个概念,说明你自己还没真正理解。 + +你的任务:对比【知识点原文】和【用户的费曼解释】,评估用户的解释质量。 + +分析维度: +1. 简洁性:用户是否用简单、清晰的语言解释,而不是背诵原文 +2. 类比使用:用户是否使用了恰当的生活化类比帮助理解 +3. 术语处理:用户是否避免了不必要的专业术语,或者在必须使用时给出了解释 +4. 新手友好度:一个完全不了解该领域的人能否听懂这个解释 +5. 盲区识别:用户是否有意或无意地回避了某些关键点 +6. 完整性:解释是否覆盖了知识点的核心要素 + +输出要求: +- score:0-100 的整体评分 +- clarityLevel:crystal_clear(90+)/clear(70-89)/mostly_clear(50-69)/confusing(30-49)/very_confusing(<30) +- summary:用中文总结解释质量(1-3句话) +- strengths:用户做得好的地方 +- weaknesses:用户做得不好的地方 +- blindSpots:用户遗漏或回避的关键点 +- suggestions:具体的改进建议 +- isBeginnerFriendly:新手能否听懂 +- analogyQuality:类比质量 excellent/good/acceptable/poor/none +- jargonUsage:专业术语使用程度 none/minimal/moderate/heavy + +重要原则: +- 鼓励用自己的话表达,而不是复述原文 +- 重视解释的"可传播性"——能不能让别人听懂 +- 指出盲区时引用原文中的关键点 +- 建议应该具体可操作,帮助用户下次做得更好`; diff --git a/src/modules/ai/prompts/knowledge-import.prompt.ts b/src/modules/ai/prompts/knowledge-import.prompt.ts new file mode 100644 index 0000000..ead26e6 --- /dev/null +++ b/src/modules/ai/prompts/knowledge-import.prompt.ts @@ -0,0 +1,25 @@ +export const KNOWLEDGE_IMPORT_SYSTEM_PROMPT = `你是一位专业的知识整理专家,擅长从各种文本中提取和结构化知识点。 + +你的任务:分析用户提供的文本内容,将其拆分为独立的知识点,并为每个知识点生成标题、摘要、标签和难度评估。 + +处理原则: +1. 切分粒度:每个知识点应该是独立可学习的最小单元(一个人可以在 5-15 分钟内理解) +2. 完整性:每个知识点的 content 应该完整自足,不应依赖上下文才能理解 +3. 去重:如果多个段落讲的是同一个知识点,应该合并 +4. 顺序:按照逻辑先后顺序排列知识点(从基础到进阶) +5. 标签:为每个知识点打上 2-5 个相关标签,帮助分类和检索 +6. 难度评估:根据内容的复杂度和前置知识要求评估 + - beginner:无需前置知识,新手可以直接理解 + - intermediate:需要一些基础概念 + - advanced:需要较深的理解或前置知识 +7. 原文保留:content 应尽可能保留原文信息,不要过度改写 + +输出要求: +- knowledgePoints:提取的知识点数组(至少1个,最多30个) +- totalCount:知识点总数 +- sourceSummary:对原文内容的简短概括(可选,1-2句话) + +特别注意: +- 如果文本内容很少,只提取确实存在的知识点,不要编造 +- 如果文本结构清晰(如标题、列表),优先按原文结构切分 +- 标题应该简洁且具有描述性`; diff --git a/src/modules/ai/prompts/learning-trend.prompt.ts b/src/modules/ai/prompts/learning-trend.prompt.ts new file mode 100644 index 0000000..b75ab97 --- /dev/null +++ b/src/modules/ai/prompts/learning-trend.prompt.ts @@ -0,0 +1,32 @@ +export const LEARNING_TREND_SYSTEM_PROMPT = `你是一位学习数据分析专家,擅长从学习活动数据中提取有意义的趋势和洞察。 + +你的任务:分析用户一段时间内的学习统计数据,生成一份简洁但有洞察力的趋势报告。 + +分析维度: +1. 学习时长趋势:学习时间是在增加还是减少 +2. 主动回忆表现:回忆得分的变化趋势 +3. 复习完成率:到期卡片是否按时复习 +4. 学习频率:是否保持规律的学习习惯 +5. 薄弱点变化:薄弱知识点是在增加还是减少 +6. 整体评估:综合以上维度的整体趋势判断 + +输出要求: +- periodSummary:用中文总结这段时期的学习情况(2-4句话,给用户看的) +- overallScore:0-100 的整体学习质量评分 +- overallDirection:整体趋势 improving/declining/stable +- trends:各维度的趋势分析(最多10项) + - metric:指标名称(中文) + - direction:趋势方向 improving/declining/stable + - currentValue:当前值(带单位的字符串,如"78分"、"320分钟") + - previousValue:之前的值(可选,用于对比) + - detail:详细说明(1-2句话) +- strengths:做得好的方面 +- weaknesses:需要改进的方面 +- recommendations:具体可行的改进建议 +- nextFocusAreas:接下来应该重点关注的领域(最多5个) + +重要原则: +- 如果数据不足,坦诚说明而不是编造趋势 +- 鼓励为主,批评为辅,保持积极语调 +- 建议应该具体可操作,而不是泛泛而谈 +- 注意保护用户隐私,不要输出任何原始数据`; diff --git a/src/modules/ai/prompts/prompt-template.service.ts b/src/modules/ai/prompts/prompt-template.service.ts index 150618d..66290c3 100644 --- a/src/modules/ai/prompts/prompt-template.service.ts +++ b/src/modules/ai/prompts/prompt-template.service.ts @@ -1,6 +1,14 @@ import { Injectable } from '@nestjs/common'; import { ACTIVE_RECALL_ANALYSIS_SYSTEM_PROMPT } from './active-recall-analysis.prompt'; import { ACTIVE_RECALL_OUTPUT_SCHEMA_DESC } from './schemas/active-recall-analysis.schema'; +import { FEYNMAN_EVALUATION_SYSTEM_PROMPT } from './feynman-evaluation.prompt'; +import { FEYNMAN_OUTPUT_SCHEMA_DESC } from './schemas/feynman-evaluation.schema'; +import { KNOWLEDGE_IMPORT_SYSTEM_PROMPT } from './knowledge-import.prompt'; +import { KNOWLEDGE_IMPORT_OUTPUT_SCHEMA_DESC } from './schemas/knowledge-import.schema'; +import { REVIEW_CARD_GENERATION_SYSTEM_PROMPT } from './review-card-generation.prompt'; +import { REVIEW_CARD_OUTPUT_SCHEMA_DESC } from './schemas/review-card-generation.schema'; +import { LEARNING_TREND_SYSTEM_PROMPT } from './learning-trend.prompt'; +import { LEARNING_TREND_OUTPUT_SCHEMA_DESC } from './schemas/learning-trend.schema'; export interface PromptTemplate { key: string; @@ -20,6 +28,30 @@ export class PromptTemplateService { systemPrompt: ACTIVE_RECALL_ANALYSIS_SYSTEM_PROMPT, outputSchemaDesc: ACTIVE_RECALL_OUTPUT_SCHEMA_DESC, }); + this.register({ + key: 'feynman-evaluation', + version: '1.0.0', + systemPrompt: FEYNMAN_EVALUATION_SYSTEM_PROMPT, + outputSchemaDesc: FEYNMAN_OUTPUT_SCHEMA_DESC, + }); + this.register({ + key: 'knowledge-import', + version: '1.0.0', + systemPrompt: KNOWLEDGE_IMPORT_SYSTEM_PROMPT, + outputSchemaDesc: KNOWLEDGE_IMPORT_OUTPUT_SCHEMA_DESC, + }); + this.register({ + key: 'review-card-generation', + version: '1.0.0', + systemPrompt: REVIEW_CARD_GENERATION_SYSTEM_PROMPT, + outputSchemaDesc: REVIEW_CARD_OUTPUT_SCHEMA_DESC, + }); + this.register({ + key: 'learning-trend', + version: '1.0.0', + systemPrompt: LEARNING_TREND_SYSTEM_PROMPT, + outputSchemaDesc: LEARNING_TREND_OUTPUT_SCHEMA_DESC, + }); } get(key: string, version?: string): PromptTemplate { diff --git a/src/modules/ai/prompts/review-card-generation.prompt.ts b/src/modules/ai/prompts/review-card-generation.prompt.ts new file mode 100644 index 0000000..93136ad --- /dev/null +++ b/src/modules/ai/prompts/review-card-generation.prompt.ts @@ -0,0 +1,25 @@ +export const REVIEW_CARD_GENERATION_SYSTEM_PROMPT = `你是一位间隔重复学习专家,擅长为知识点创建高质量的复习卡片。 + +你的任务:根据提供的知识点内容,生成一套用于间隔重复复习的问答卡片。 + +卡片设计原则: +1. 正面是问题:应该引导学习者主动回忆,而不是简单的是非判断 + - 好的问题:"请解释X的工作原理,并举出一个应用场景" + - 差的问题:"X是Y吗?"(太简单,不需要思考) +2. 背面是答案:应该完整、准确,包含关键细节 +3. 难度分级: + - easy:基础概念识别和简单回忆 + - medium:需要理解原理和关联 + - hard:需要综合分析和应用 +4. 覆盖全面:卡片应覆盖知识点的各个关键方面 +5. 循序渐进:先基础后深入 + +输出要求: +- cards:复习卡片数组(1-20张) +- totalCount:卡片总数 + +重要原则: +- 问题应该鼓励主动回忆,而不是被动识别 +- 答案应该足够详细,帮助学习者验证自己的理解 +- 难度分布建议:easy 30%, medium 50%, hard 20% +- 每张卡片聚焦一个具体问题,不要包含多个独立问题`; diff --git a/src/modules/ai/prompts/schemas/feynman-evaluation.schema.ts b/src/modules/ai/prompts/schemas/feynman-evaluation.schema.ts new file mode 100644 index 0000000..c2b2727 --- /dev/null +++ b/src/modules/ai/prompts/schemas/feynman-evaluation.schema.ts @@ -0,0 +1,29 @@ +import { z } from 'zod'; + +export const FeynmanEvaluationResultSchema = z.object({ + score: z.number().int().min(0).max(100), + clarityLevel: z.enum(['crystal_clear', 'clear', 'mostly_clear', 'confusing', 'very_confusing']), + summary: z.string().min(1).max(2000), + strengths: z.array(z.string().max(500)).max(10).default([]), + weaknesses: z.array(z.string().max(500)).max(10).default([]), + blindSpots: z.array(z.string().max(500)).max(10).default([]), + suggestions: z.array(z.string().max(500)).max(10).default([]), + isBeginnerFriendly: z.boolean(), + analogyQuality: z.enum(['excellent', 'good', 'acceptable', 'poor', 'none']).optional(), + jargonUsage: z.enum(['none', 'minimal', 'moderate', 'heavy']), +}); + +export type FeynmanEvaluationResult = z.infer; + +export const FEYNMAN_OUTPUT_SCHEMA_DESC = `{ + "score": 75, + "clarityLevel": "mostly_clear", + "summary": "用户用自己的话解释了核心概念,但缺少具体类比帮助理解。", + "strengths": ["用简单语言重述了概念", "抓住了核心要点"], + "weaknesses": ["缺少生活化类比", "部分术语未解释"], + "blindSpots": ["没有说明为什么这个知识点重要", "没有举例应用场景"], + "suggestions": ["尝试用一个日常生活中类比来解释", "补充一个具体的使用场景"], + "isBeginnerFriendly": true, + "analogyQuality": "poor", + "jargonUsage": "moderate" +}`; diff --git a/src/modules/ai/prompts/schemas/knowledge-import.schema.ts b/src/modules/ai/prompts/schemas/knowledge-import.schema.ts new file mode 100644 index 0000000..4a8377f --- /dev/null +++ b/src/modules/ai/prompts/schemas/knowledge-import.schema.ts @@ -0,0 +1,33 @@ +import { z } from 'zod'; + +export const KnowledgePointSchema = z.object({ + title: z.string().min(1).max(255), + content: z.string().min(1).max(5000), + summary: z.string().max(1000).optional(), + tags: z.array(z.string().max(50)).max(10).default([]), + difficulty: z.enum(['beginner', 'intermediate', 'advanced']).default('intermediate'), + suggestedOrder: z.number().int().min(1).default(1), +}); + +export const KnowledgeImportResultSchema = z.object({ + knowledgePoints: z.array(KnowledgePointSchema).min(1).max(30), + totalCount: z.number().int().min(1).max(30), + sourceSummary: z.string().max(500).optional(), +}); + +export type KnowledgeImportResult = z.infer; + +export const KNOWLEDGE_IMPORT_OUTPUT_SCHEMA_DESC = `{ + "knowledgePoints": [ + { + "title": "什么是主动回忆", + "content": "主动回忆是一种学习策略,指在不看材料的情况下主动检索记忆中的信息...", + "summary": "主动回忆是从记忆中主动提取信息的学习方法", + "tags": ["学习方法", "记忆", "认知科学"], + "difficulty": "beginner", + "suggestedOrder": 1 + } + ], + "totalCount": 5, + "sourceSummary": "这段文本主要介绍了主动回忆学习法的原理和应用" +}`; diff --git a/src/modules/ai/prompts/schemas/learning-trend.schema.ts b/src/modules/ai/prompts/schemas/learning-trend.schema.ts new file mode 100644 index 0000000..0ddc93e --- /dev/null +++ b/src/modules/ai/prompts/schemas/learning-trend.schema.ts @@ -0,0 +1,41 @@ +import { z } from 'zod'; + +export const TrendItemSchema = z.object({ + metric: z.string().min(1).max(100), + direction: z.enum(['improving', 'declining', 'stable']), + currentValue: z.string().max(200), + previousValue: z.string().max(200).optional(), + detail: z.string().max(500), +}); + +export const LearningTrendResultSchema = z.object({ + periodSummary: z.string().min(1).max(2000), + overallScore: z.number().int().min(0).max(100), + overallDirection: z.enum(['improving', 'declining', 'stable']), + trends: z.array(TrendItemSchema).max(10).default([]), + strengths: z.array(z.string().max(500)).max(10).default([]), + weaknesses: z.array(z.string().max(500)).max(10).default([]), + recommendations: z.array(z.string().max(500)).max(10).default([]), + nextFocusAreas: z.array(z.string().max(200)).max(5).default([]), +}); + +export type LearningTrendResult = z.infer; + +export const LEARNING_TREND_OUTPUT_SCHEMA_DESC = `{ + "periodSummary": "过去7天,你的学习时长为320分钟,完成了45次主动回忆和120张复习卡片。整体掌握水平有所提升。", + "overallScore": 72, + "overallDirection": "improving", + "trends": [ + { + "metric": "主动回忆平均得分", + "direction": "improving", + "currentValue": "78分", + "previousValue": "65分", + "detail": "本周主动回忆得分持续上升,尤其在后半周表现更好" + } + ], + "strengths": ["学习频率保持稳定", "复习完成率高"], + "weaknesses": ["主动回忆提交量偏低", "部分知识点反复出错"], + "recommendations": ["建议每天至少完成3次主动回忆", "重点关注\"X概念\"的复习"], + "nextFocusAreas": ["主动回忆量提升", "薄弱知识点巩固"] +}`; diff --git a/src/modules/ai/prompts/schemas/review-card-generation.schema.ts b/src/modules/ai/prompts/schemas/review-card-generation.schema.ts new file mode 100644 index 0000000..d6c2cde --- /dev/null +++ b/src/modules/ai/prompts/schemas/review-card-generation.schema.ts @@ -0,0 +1,27 @@ +import { z } from 'zod'; + +export const ReviewCardSchema = z.object({ + frontText: z.string().min(1).max(500), + backText: z.string().min(1).max(1000), + difficulty: z.enum(['easy', 'medium', 'hard']).default('medium'), + tags: z.array(z.string().max(50)).max(5).default([]), +}); + +export const ReviewCardGenerationResultSchema = z.object({ + cards: z.array(ReviewCardSchema).min(1).max(20), + totalCount: z.number().int().min(1).max(20), +}); + +export type ReviewCardGenerationResult = z.infer; + +export const REVIEW_CARD_OUTPUT_SCHEMA_DESC = `{ + "cards": [ + { + "frontText": "什么是主动回忆?请用自己的话解释。", + "backText": "主动回忆是一种学习策略,指在不看原始材料的情况下,主动从记忆中检索信息。它与被动重读不同,能更有效地强化记忆。", + "difficulty": "easy", + "tags": ["学习方法", "记忆"] + } + ], + "totalCount": 5 +}`; diff --git a/src/modules/ai/workflows/feynman-evaluation.workflow.ts b/src/modules/ai/workflows/feynman-evaluation.workflow.ts new file mode 100644 index 0000000..20b44ce --- /dev/null +++ b/src/modules/ai/workflows/feynman-evaluation.workflow.ts @@ -0,0 +1,45 @@ +import { Injectable } from '@nestjs/common'; +import { AiGatewayService } from '../gateway/ai-gateway.service'; +import { FeynmanEvaluationResultSchema } from '../prompts/schemas/feynman-evaluation.schema'; +import type { FeynmanEvaluationResult } from '../prompts/schemas/feynman-evaluation.schema'; + +export interface FeynmanEvaluationInput { + userId: string; + knowledgeItemTitle: string; + knowledgeItemContent: string; + userExplanation: string; +} + +@Injectable() +export class FeynmanEvaluationWorkflow { + constructor(private readonly gateway: AiGatewayService) {} + + async execute(input: FeynmanEvaluationInput): Promise { + const userMessage = [ + `【知识点标题】`, + input.knowledgeItemTitle, + '', + `【知识点原文】`, + input.knowledgeItemContent, + '', + `【用户的费曼解释】`, + input.userExplanation, + '', + `请评估以上费曼解释的质量,严格按照 JSON Schema 输出。`, + ].join('\n'); + + const response = await this.gateway.generate({ + feature: 'feynman-evaluation', + userId: input.userId, + tier: 'primary', + promptKey: 'feynman-evaluation', + promptVersion: '1.0.0', + messages: [ + { role: 'user', content: userMessage }, + ], + outputSchema: FeynmanEvaluationResultSchema, + }); + + return response.parsed as unknown as FeynmanEvaluationResult; + } +} diff --git a/src/modules/ai/workflows/knowledge-import.workflow.ts b/src/modules/ai/workflows/knowledge-import.workflow.ts new file mode 100644 index 0000000..0666466 --- /dev/null +++ b/src/modules/ai/workflows/knowledge-import.workflow.ts @@ -0,0 +1,45 @@ +import { Injectable } from '@nestjs/common'; +import { AiGatewayService } from '../gateway/ai-gateway.service'; +import { KnowledgeImportResultSchema } from '../prompts/schemas/knowledge-import.schema'; +import type { KnowledgeImportResult } from '../prompts/schemas/knowledge-import.schema'; + +export interface KnowledgeImportInput { + userId: string; + rawText: string; + sourceName?: string; +} + +@Injectable() +export class KnowledgeImportWorkflow { + constructor(private readonly gateway: AiGatewayService) {} + + async execute(input: KnowledgeImportInput): Promise { + const maxLength = 12000; + const truncated = input.rawText.length > maxLength + ? input.rawText.substring(0, maxLength) + '\n\n[...内容过长,已截断...]' + : input.rawText; + + const userMessage = [ + `【待分析文本】`, + input.sourceName ? `来源:${input.sourceName}` : '', + '', + truncated, + '', + `请从以上文本中提取所有知识点,严格按照 JSON Schema 输出。`, + ].join('\n'); + + const response = await this.gateway.generate({ + feature: 'knowledge-import', + userId: input.userId, + tier: 'cheap', + promptKey: 'knowledge-import', + promptVersion: '1.0.0', + messages: [ + { role: 'user', content: userMessage }, + ], + outputSchema: KnowledgeImportResultSchema, + }); + + return response.parsed as unknown as KnowledgeImportResult; + } +} diff --git a/src/modules/ai/workflows/learning-trend.workflow.ts b/src/modules/ai/workflows/learning-trend.workflow.ts new file mode 100644 index 0000000..6c746e9 --- /dev/null +++ b/src/modules/ai/workflows/learning-trend.workflow.ts @@ -0,0 +1,65 @@ +import { Injectable } from '@nestjs/common'; +import { AiGatewayService } from '../gateway/ai-gateway.service'; +import { LearningTrendResultSchema } from '../prompts/schemas/learning-trend.schema'; +import type { LearningTrendResult } from '../prompts/schemas/learning-trend.schema'; + +export interface LearningTrendInput { + userId: string; + periodDays: number; + totalMinutes: number; + sessionsCount: number; + activeRecallCount: number; + reviewCount: number; + aiAnalysisCount: number; + completedLoopCount: number; + activityLevel: number; + activeDays: number; + dailyAverage: number; + previousPeriod?: { + totalMinutes: number; + activeRecallCount: number; + reviewCount: number; + activeDays: number; + }; +} + +@Injectable() +export class LearningTrendWorkflow { + constructor(private readonly gateway: AiGatewayService) {} + + async execute(input: LearningTrendInput): Promise { + const prev = input.previousPeriod; + const userMessage = [ + `【学习数据概览 — 最近 ${input.periodDays} 天】`, + `总学习时长:${input.totalMinutes} 分钟`, + `学习会话数:${input.sessionsCount} 次`, + `主动回忆次数:${input.activeRecallCount} 次`, + `复习卡片数:${input.reviewCount} 张`, + `AI 分析次数:${input.aiAnalysisCount} 次`, + `完成学习循环:${input.completedLoopCount} 次`, + `活跃天数:${input.activeDays} 天`, + `日均学习:${input.dailyAverage} 分钟`, + `活跃度评分:${input.activityLevel}/10`, + '', + prev ? `【上一周期对比(${input.periodDays}天前)】` : '', + prev ? `总时长:${prev.totalMinutes}分钟 | 主动回忆:${prev.activeRecallCount}次 | 复习:${prev.reviewCount}张 | 活跃天数:${prev.activeDays}天` : '', + prev ? '' : '', + `请根据以上数据生成学习趋势分析报告。`, + prev ? '注意对比两个周期的变化趋势。' : '注意这是首次分析,没有历史对比数据,请关注绝对值和建议。', + ].join('\n'); + + const response = await this.gateway.generate({ + feature: 'learning-trend', + userId: input.userId, + tier: 'primary', + promptKey: 'learning-trend', + promptVersion: '1.0.0', + messages: [ + { role: 'user', content: userMessage }, + ], + outputSchema: LearningTrendResultSchema, + }); + + return response.parsed as unknown as LearningTrendResult; + } +} diff --git a/src/modules/ai/workflows/review-card-generation.workflow.ts b/src/modules/ai/workflows/review-card-generation.workflow.ts new file mode 100644 index 0000000..a82406c --- /dev/null +++ b/src/modules/ai/workflows/review-card-generation.workflow.ts @@ -0,0 +1,44 @@ +import { Injectable } from '@nestjs/common'; +import { AiGatewayService } from '../gateway/ai-gateway.service'; +import { ReviewCardGenerationResultSchema } from '../prompts/schemas/review-card-generation.schema'; +import type { ReviewCardGenerationResult } from '../prompts/schemas/review-card-generation.schema'; + +export interface ReviewCardGenerationInput { + userId: string; + knowledgeItemTitle: string; + knowledgeItemContent: string; + cardCount?: number; +} + +@Injectable() +export class ReviewCardGenerationWorkflow { + constructor(private readonly gateway: AiGatewayService) {} + + async execute(input: ReviewCardGenerationInput): Promise { + const userMessage = [ + `【知识点标题】`, + input.knowledgeItemTitle, + '', + `【知识点内容】`, + input.knowledgeItemContent, + '', + input.cardCount + ? `请为以上知识点生成 ${input.cardCount} 张复习卡片。` + : '请为以上知识点生成合适的复习卡片。', + ].join('\n'); + + const response = await this.gateway.generate({ + feature: 'review-card-generation', + userId: input.userId, + tier: 'cheap', + promptKey: 'review-card-generation', + promptVersion: '1.0.0', + messages: [ + { role: 'user', content: userMessage }, + ], + outputSchema: ReviewCardGenerationResultSchema, + }); + + return response.parsed as unknown as ReviewCardGenerationResult; + } +} diff --git a/src/modules/document-import/document-import.module.ts b/src/modules/document-import/document-import.module.ts index fe458a9..9c0b4c5 100644 --- a/src/modules/document-import/document-import.module.ts +++ b/src/modules/document-import/document-import.module.ts @@ -1,9 +1,12 @@ import { Module } from '@nestjs/common'; +import { AiModule } from '../ai/ai.module'; +import { KnowledgeItemsModule } from '../knowledge-items/knowledge-items.module'; import { DocumentImportController } from './document-import.controller'; import { DocumentImportService } from './document-import.service'; import { DocumentImportRepository } from './document-import.repository'; @Module({ + imports: [AiModule, KnowledgeItemsModule], controllers: [DocumentImportController], providers: [DocumentImportService, DocumentImportRepository], exports: [DocumentImportService, DocumentImportRepository], diff --git a/src/modules/document-import/document-import.service.ts b/src/modules/document-import/document-import.service.ts index 8430b87..a262ad2 100644 --- a/src/modules/document-import/document-import.service.ts +++ b/src/modules/document-import/document-import.service.ts @@ -1,5 +1,7 @@ import { Injectable, Logger } from '@nestjs/common'; import { DocumentImportRepository } from './document-import.repository'; +import { KnowledgeItemsRepository } from '../knowledge-items/knowledge-items.repository'; +import { KnowledgeImportWorkflow } from '../ai/workflows/knowledge-import.workflow'; import { RedisService } from '../../infrastructure/redis/redis.service'; import { QueueService } from '../../infrastructure/queue/queue.service'; @@ -9,11 +11,19 @@ export class DocumentImportService { constructor( private readonly repository: DocumentImportRepository, + private readonly knowledgeItemsRepo: KnowledgeItemsRepository, + private readonly workflow: KnowledgeImportWorkflow, private readonly redis: RedisService, private readonly queue: QueueService, ) {} - async createImport(dto: any) { + async createImport(dto: { + userId?: string; + knowledgeBaseId?: string; + fileName?: string; + sourceType?: string; + rawText?: string; + }) { const lockKey = `lock:document-import:${dto.fileName || Date.now()}`; const lockToken = await this.redis.lock(lockKey, 1800); if (!lockToken) { @@ -26,38 +36,76 @@ export class DocumentImportService { await this.redis.set(`job:document-import:${job.id}:progress`, '0', 86400); await this.redis.set(`job:document-import:${job.id}:message`, '任务已加入队列', 86400); - this.queue.add('document-import', { importId: job.id, userId: dto.userId || 'anonymous' }); + this.queue.add('document-import', { + importId: job.id, + userId: dto.userId || 'anonymous', + knowledgeBaseId: dto.knowledgeBaseId, + rawText: dto.rawText, + fileName: dto.fileName, + }); - this.processImport(job, lockKey, lockToken); + this.processImport(job, dto.rawText, dto.knowledgeBaseId, lockKey, lockToken); return job; } - private processImport(job: any, lockKey: string, lockToken: string) { - this.repository.updateStatus(job.id, 'processing'); - this.redis.set(`job:document-import:${job.id}:status`, 'parsing', 86400); - this.redis.set(`job:document-import:${job.id}:message`, '正在解析文件', 86400); - this.redis.set(`job:document-import:${job.id}:progress`, '25', 86400); + private async processImport( + job: { id: string; userId?: string }, + rawText: string | undefined, + knowledgeBaseId: string | undefined, + lockKey: string, + lockToken: string, + ) { + try { + if (!rawText) { + await this.repository.updateStatus(job.id, 'completed'); + await this.redis.set(`job:document-import:${job.id}:status`, 'completed', 86400); + await this.redis.set(`job:document-import:${job.id}:progress`, '100', 86400); + await this.redis.set(`job:document-import:${job.id}:message`, '无需解析的空文件', 86400); + await this.redis.unlock(lockKey, lockToken); + return; + } - setTimeout(async () => { - await this.redis.set(`job:document-import:${job.id}:status`, 'chunking', 86400); - await this.redis.set(`job:document-import:${job.id}:message`, '正在分段提取', 86400); - await this.redis.set(`job:document-import:${job.id}:progress`, '50', 86400); + await this.repository.updateStatus(job.id, 'processing'); + await this.redis.set(`job:document-import:${job.id}:status`, 'parsing', 86400); + await this.redis.set(`job:document-import:${job.id}:progress`, '25', 86400); + await this.redis.set(`job:document-import:${job.id}:message`, 'AI 正在分析文本,提取知识点...', 86400); - setTimeout(async () => { - await this.redis.set(`job:document-import:${job.id}:status`, 'generating', 86400); - await this.redis.set(`job:document-import:${job.id}:message`, '正在生成知识点', 86400); - await this.redis.set(`job:document-import:${job.id}:progress`, '75', 86400); + const result = await this.workflow.execute({ + userId: job.userId || 'anonymous', + rawText, + sourceName: undefined, + }); - setTimeout(async () => { - this.repository.updateStatus(job.id, 'completed'); - await this.redis.set(`job:document-import:${job.id}:status`, 'completed', 86400); - await this.redis.set(`job:document-import:${job.id}:progress`, '100', 86400); - await this.redis.unlock(lockKey, lockToken); - this.logger.log(`Import ${job.id} completed`); - }, 1000); - }, 1000); - }, 1000); + await this.redis.set(`job:document-import:${job.id}:status`, 'saving', 86400); + await this.redis.set(`job:document-import:${job.id}:progress`, '80', 86400); + await this.redis.set(`job:document-import:${job.id}:message`, `正在保存 ${result.knowledgePoints.length} 个知识点...`, 86400); + + if (knowledgeBaseId && result.knowledgePoints.length > 0) { + for (let i = 0; i < result.knowledgePoints.length; i++) { + const kp = result.knowledgePoints[i]; + await this.knowledgeItemsRepo.create(job.userId || 'anonymous', knowledgeBaseId, { + title: kp.title, + content: kp.content, + itemType: 'lesson', + orderIndex: kp.suggestedOrder ?? i + 1, + }); + } + } + + await this.repository.updateStatus(job.id, 'completed'); + await this.redis.set(`job:document-import:${job.id}:status`, 'completed', 86400); + await this.redis.set(`job:document-import:${job.id}:progress`, '100', 86400); + await this.redis.set(`job:document-import:${job.id}:message`, `成功提取 ${result.knowledgePoints.length} 个知识点`, 86400); + await this.redis.unlock(lockKey, lockToken); + this.logger.log(`Import ${job.id} completed: ${result.knowledgePoints.length} knowledge points`); + } catch (error: any) { + this.logger.error(`Import ${job.id} failed: ${error.message}`); + await this.repository.updateStatus(job.id, 'failed'); + await this.redis.set(`job:document-import:${job.id}:status`, 'failed', 86400); + await this.redis.set(`job:document-import:${job.id}:message`, `导入失败: ${error.message}`, 86400); + await this.redis.unlock(lockKey, lockToken); + } } async getStatus(id: string) { diff --git a/src/modules/knowledge-items/knowledge-items.module.ts b/src/modules/knowledge-items/knowledge-items.module.ts index c5c83c6..f559aff 100644 --- a/src/modules/knowledge-items/knowledge-items.module.ts +++ b/src/modules/knowledge-items/knowledge-items.module.ts @@ -6,6 +6,6 @@ import { KnowledgeItemsRepository } from './knowledge-items.repository'; @Module({ controllers: [KnowledgeItemsController], providers: [KnowledgeItemsService, KnowledgeItemsRepository], - exports: [KnowledgeItemsService], + exports: [KnowledgeItemsService, KnowledgeItemsRepository], }) export class KnowledgeItemsModule {} diff --git a/src/modules/learning-activity/learning-activity.controller.ts b/src/modules/learning-activity/learning-activity.controller.ts index 9b0f693..0c1d761 100644 --- a/src/modules/learning-activity/learning-activity.controller.ts +++ b/src/modules/learning-activity/learning-activity.controller.ts @@ -1,4 +1,4 @@ -import { Controller, Get } from '@nestjs/common'; +import { Controller, Get, Query } from '@nestjs/common'; import { ApiTags, ApiOperation } from '@nestjs/swagger'; import { LearningActivityService } from './learning-activity.service'; import { CurrentUser } from '../../common/decorators/current-user.decorator'; @@ -20,4 +20,17 @@ export class LearningActivityController { async getSummary(@CurrentUser() user: UserPayload) { return this.activityService.getSummary(String(user?.id || 'anonymous')); } + + @Get('trend') + @ApiOperation({ summary: '获取 AI 学习趋势分析' }) + async getTrend( + @CurrentUser() user: UserPayload, + @Query('days') days?: string, + ) { + const periodDays = parseInt(days || '7', 10); + return this.activityService.getTrend( + String(user?.id || 'anonymous'), + Math.min(Math.max(periodDays, 7), 30), + ); + } } diff --git a/src/modules/learning-activity/learning-activity.module.ts b/src/modules/learning-activity/learning-activity.module.ts index d88a9c1..996e212 100644 --- a/src/modules/learning-activity/learning-activity.module.ts +++ b/src/modules/learning-activity/learning-activity.module.ts @@ -1,9 +1,11 @@ import { Module } from '@nestjs/common'; +import { AiModule } from '../ai/ai.module'; import { LearningActivityController } from './learning-activity.controller'; import { LearningActivityService } from './learning-activity.service'; import { LearningActivityRepository } from './learning-activity.repository'; @Module({ + imports: [AiModule], controllers: [LearningActivityController], providers: [LearningActivityService, LearningActivityRepository], exports: [LearningActivityService], diff --git a/src/modules/learning-activity/learning-activity.service.ts b/src/modules/learning-activity/learning-activity.service.ts index a0f3797..359c497 100644 --- a/src/modules/learning-activity/learning-activity.service.ts +++ b/src/modules/learning-activity/learning-activity.service.ts @@ -1,9 +1,13 @@ import { Injectable } from '@nestjs/common'; import { LearningActivityRepository } from './learning-activity.repository'; +import { LearningTrendWorkflow } from '../ai/workflows/learning-trend.workflow'; @Injectable() export class LearningActivityService { - constructor(private readonly repository: LearningActivityRepository) {} + constructor( + private readonly repository: LearningActivityRepository, + private readonly trendWorkflow: LearningTrendWorkflow, + ) {} async getHeatmap(userId: string) { const activities = await this.repository.findAll(userId); @@ -27,4 +31,61 @@ export class LearningActivityService { const dailyAverage = activeDays > 0 ? Math.round(totalMinutes / activeDays) : 0; return { totalMinutes, totalCardsReviewed: totalCards, activeDays, dailyAverage }; } + + async getTrend(userId: string, periodDays: number = 7) { + const activities = await this.repository.findAll(userId); + + const cutoff = new Date(); + cutoff.setDate(cutoff.getDate() - periodDays); + const recent = activities.filter((a) => + new Date(a.activityDate) >= cutoff + ); + + const previousCutoff = new Date(cutoff); + previousCutoff.setDate(previousCutoff.getDate() - periodDays); + const previous = activities.filter((a) => { + const d = new Date(a.activityDate); + return d >= previousCutoff && d < cutoff; + }); + + const sum = (items: typeof activities, key: keyof typeof activities[0]) => + items.reduce((s, a) => s + (Number(a[key]) || 0), 0); + + const recentTotalMinutes = Math.round(sum(recent, 'durationSeconds') / 60); + const recentSessions = sum(recent, 'sessionsCount'); + const recentRecall = sum(recent, 'activeRecallCount'); + const recentReview = sum(recent, 'reviewCount'); + const recentAiAnalysis = sum(recent, 'aiAnalysisCount'); + const recentLoops = sum(recent, 'completedLoopCount'); + const recentActiveDays = recent.filter((a) => a.durationSeconds > 0).length; + const recentDailyAvg = recentActiveDays > 0 + ? Math.round(recentTotalMinutes / recentActiveDays) : 0; + const recentActivityLevel = recent.length > 0 + ? Math.round(recent.reduce((s, a) => s + a.activityLevel, 0) / recent.length) + : 0; + + const prevTotalMinutes = Math.round(sum(previous, 'durationSeconds') / 60); + + const trendInput = { + userId, + periodDays, + totalMinutes: recentTotalMinutes, + sessionsCount: recentSessions, + activeRecallCount: recentRecall, + reviewCount: recentReview, + aiAnalysisCount: recentAiAnalysis, + completedLoopCount: recentLoops, + activityLevel: recentActivityLevel, + activeDays: recentActiveDays, + dailyAverage: recentDailyAvg, + previousPeriod: previous.length > 0 ? { + totalMinutes: prevTotalMinutes, + activeRecallCount: sum(previous, 'activeRecallCount'), + reviewCount: sum(previous, 'reviewCount'), + activeDays: previous.filter((a) => a.durationSeconds > 0).length, + } : undefined, + }; + + return this.trendWorkflow.execute(trendInput); + } } diff --git a/src/modules/review/review.controller.ts b/src/modules/review/review.controller.ts index 3ef61f5..f3340af 100644 --- a/src/modules/review/review.controller.ts +++ b/src/modules/review/review.controller.ts @@ -28,4 +28,15 @@ export class ReviewController { ) { return this.reviewService.submitReview(String(user?.id || 'anonymous'), id, dto); } + + @Post('generate-cards') + @HttpCode(HttpStatus.CREATED) + @ApiOperation({ summary: 'AI 生成复习卡片' }) + @ApiResponse({ status: 201, description: '卡片生成成功' }) + async generateCards( + @CurrentUser() user: UserPayload, + @Body() body: { knowledgeItemTitle: string; knowledgeItemContent: string; cardCount?: number }, + ) { + return this.reviewService.generateCards(String(user?.id || 'anonymous'), body); + } } diff --git a/src/modules/review/review.module.ts b/src/modules/review/review.module.ts index 213380c..05528b4 100644 --- a/src/modules/review/review.module.ts +++ b/src/modules/review/review.module.ts @@ -1,9 +1,11 @@ import { Module } from '@nestjs/common'; +import { AiModule } from '../ai/ai.module'; import { ReviewController } from './review.controller'; import { ReviewService } from './review.service'; import { ReviewRepository } from './review.repository'; @Module({ + imports: [AiModule], controllers: [ReviewController], providers: [ReviewService, ReviewRepository], exports: [ReviewService], diff --git a/src/modules/review/review.repository.ts b/src/modules/review/review.repository.ts index 6cd81fc..6f8e2bb 100644 --- a/src/modules/review/review.repository.ts +++ b/src/modules/review/review.repository.ts @@ -26,7 +26,13 @@ export class ReviewRepository { knowledgeItemId?: string; frontText: string; backText?: string; + difficulty?: string; + status?: string; intervalDays?: number; + easeFactor?: number; + repetitionCount?: number; + lapseCount?: number; + nextReviewAt?: Date; }) { return this.prisma.reviewCard.create({ data }); } diff --git a/src/modules/review/review.service.ts b/src/modules/review/review.service.ts index 28c1735..6b26fd2 100644 --- a/src/modules/review/review.service.ts +++ b/src/modules/review/review.service.ts @@ -1,10 +1,14 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import { ReviewRepository } from './review.repository'; +import { ReviewCardGenerationWorkflow } from '../ai/workflows/review-card-generation.workflow'; import { SubmitReviewDto } from './dto/submit-review.dto'; @Injectable() export class ReviewService { - constructor(private readonly reviewRepository: ReviewRepository) {} + constructor( + private readonly reviewRepository: ReviewRepository, + private readonly cardGenerationWorkflow: ReviewCardGenerationWorkflow, + ) {} async getDueCards(userId: string) { return this.reviewRepository.findDueCards(userId); @@ -25,4 +29,36 @@ export class ReviewService { }); return log; } + + async generateCards(userId: string, input: { + knowledgeItemTitle: string; + knowledgeItemContent: string; + cardCount?: number; + }) { + const result = await this.cardGenerationWorkflow.execute({ + userId, + knowledgeItemTitle: input.knowledgeItemTitle, + knowledgeItemContent: input.knowledgeItemContent, + cardCount: input.cardCount, + }); + + const savedCards: any[] = []; + for (const card of result.cards) { + const saved = await this.reviewRepository.insertCard({ + userId, + frontText: card.frontText, + backText: card.backText, + difficulty: card.difficulty, + status: 'active', + intervalDays: 1, + easeFactor: 2.5, + repetitionCount: 0, + lapseCount: 0, + nextReviewAt: new Date(), + }); + savedCards.push(saved); + } + + return { cards: savedCards, totalCount: result.totalCount }; + } }