feat: implement P1 AI workflows (B7-B10)
All checks were successful
Deploy API Server / build-and-deploy (push) Successful in 59s
All checks were successful
Deploy API Server / build-and-deploy (push) Successful in 59s
B7 Feynman evaluation: POST /ai-analysis/feynman B8 Knowledge import: replaces DocumentImport setTimeout mock with AI B9 Review card generation: POST /reviews/generate-cards B10 Learning trend analysis: GET /activity/trend 4 workflows, 4 prompts, 4 schemas, all registered in AiModule. AiAnalysisRepository made generic to handle varied result shapes. DocumentImportService now calls KnowledgeImportWorkflow + saves to DB. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
bced62c8f6
commit
597c7b2310
@ -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) {
|
||||
|
||||
@ -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<string, any>) {
|
||||
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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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 {}
|
||||
|
||||
31
src/modules/ai/prompts/feynman-evaluation.prompt.ts
Normal file
31
src/modules/ai/prompts/feynman-evaluation.prompt.ts
Normal file
@ -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
|
||||
|
||||
重要原则:
|
||||
- 鼓励用自己的话表达,而不是复述原文
|
||||
- 重视解释的"可传播性"——能不能让别人听懂
|
||||
- 指出盲区时引用原文中的关键点
|
||||
- 建议应该具体可操作,帮助用户下次做得更好`;
|
||||
25
src/modules/ai/prompts/knowledge-import.prompt.ts
Normal file
25
src/modules/ai/prompts/knowledge-import.prompt.ts
Normal file
@ -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句话)
|
||||
|
||||
特别注意:
|
||||
- 如果文本内容很少,只提取确实存在的知识点,不要编造
|
||||
- 如果文本结构清晰(如标题、列表),优先按原文结构切分
|
||||
- 标题应该简洁且具有描述性`;
|
||||
32
src/modules/ai/prompts/learning-trend.prompt.ts
Normal file
32
src/modules/ai/prompts/learning-trend.prompt.ts
Normal file
@ -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个)
|
||||
|
||||
重要原则:
|
||||
- 如果数据不足,坦诚说明而不是编造趋势
|
||||
- 鼓励为主,批评为辅,保持积极语调
|
||||
- 建议应该具体可操作,而不是泛泛而谈
|
||||
- 注意保护用户隐私,不要输出任何原始数据`;
|
||||
@ -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 {
|
||||
|
||||
25
src/modules/ai/prompts/review-card-generation.prompt.ts
Normal file
25
src/modules/ai/prompts/review-card-generation.prompt.ts
Normal file
@ -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%
|
||||
- 每张卡片聚焦一个具体问题,不要包含多个独立问题`;
|
||||
29
src/modules/ai/prompts/schemas/feynman-evaluation.schema.ts
Normal file
29
src/modules/ai/prompts/schemas/feynman-evaluation.schema.ts
Normal file
@ -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<typeof FeynmanEvaluationResultSchema>;
|
||||
|
||||
export const FEYNMAN_OUTPUT_SCHEMA_DESC = `{
|
||||
"score": 75,
|
||||
"clarityLevel": "mostly_clear",
|
||||
"summary": "用户用自己的话解释了核心概念,但缺少具体类比帮助理解。",
|
||||
"strengths": ["用简单语言重述了概念", "抓住了核心要点"],
|
||||
"weaknesses": ["缺少生活化类比", "部分术语未解释"],
|
||||
"blindSpots": ["没有说明为什么这个知识点重要", "没有举例应用场景"],
|
||||
"suggestions": ["尝试用一个日常生活中类比来解释", "补充一个具体的使用场景"],
|
||||
"isBeginnerFriendly": true,
|
||||
"analogyQuality": "poor",
|
||||
"jargonUsage": "moderate"
|
||||
}`;
|
||||
33
src/modules/ai/prompts/schemas/knowledge-import.schema.ts
Normal file
33
src/modules/ai/prompts/schemas/knowledge-import.schema.ts
Normal file
@ -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<typeof KnowledgeImportResultSchema>;
|
||||
|
||||
export const KNOWLEDGE_IMPORT_OUTPUT_SCHEMA_DESC = `{
|
||||
"knowledgePoints": [
|
||||
{
|
||||
"title": "什么是主动回忆",
|
||||
"content": "主动回忆是一种学习策略,指在不看材料的情况下主动检索记忆中的信息...",
|
||||
"summary": "主动回忆是从记忆中主动提取信息的学习方法",
|
||||
"tags": ["学习方法", "记忆", "认知科学"],
|
||||
"difficulty": "beginner",
|
||||
"suggestedOrder": 1
|
||||
}
|
||||
],
|
||||
"totalCount": 5,
|
||||
"sourceSummary": "这段文本主要介绍了主动回忆学习法的原理和应用"
|
||||
}`;
|
||||
41
src/modules/ai/prompts/schemas/learning-trend.schema.ts
Normal file
41
src/modules/ai/prompts/schemas/learning-trend.schema.ts
Normal file
@ -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<typeof LearningTrendResultSchema>;
|
||||
|
||||
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": ["主动回忆量提升", "薄弱知识点巩固"]
|
||||
}`;
|
||||
@ -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<typeof ReviewCardGenerationResultSchema>;
|
||||
|
||||
export const REVIEW_CARD_OUTPUT_SCHEMA_DESC = `{
|
||||
"cards": [
|
||||
{
|
||||
"frontText": "什么是主动回忆?请用自己的话解释。",
|
||||
"backText": "主动回忆是一种学习策略,指在不看原始材料的情况下,主动从记忆中检索信息。它与被动重读不同,能更有效地强化记忆。",
|
||||
"difficulty": "easy",
|
||||
"tags": ["学习方法", "记忆"]
|
||||
}
|
||||
],
|
||||
"totalCount": 5
|
||||
}`;
|
||||
45
src/modules/ai/workflows/feynman-evaluation.workflow.ts
Normal file
45
src/modules/ai/workflows/feynman-evaluation.workflow.ts
Normal file
@ -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<FeynmanEvaluationResult> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
45
src/modules/ai/workflows/knowledge-import.workflow.ts
Normal file
45
src/modules/ai/workflows/knowledge-import.workflow.ts
Normal file
@ -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<KnowledgeImportResult> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
65
src/modules/ai/workflows/learning-trend.workflow.ts
Normal file
65
src/modules/ai/workflows/learning-trend.workflow.ts
Normal file
@ -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<LearningTrendResult> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
44
src/modules/ai/workflows/review-card-generation.workflow.ts
Normal file
44
src/modules/ai/workflows/review-card-generation.workflow.ts
Normal file
@ -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<ReviewCardGenerationResult> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@ -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],
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -6,6 +6,6 @@ import { KnowledgeItemsRepository } from './knowledge-items.repository';
|
||||
@Module({
|
||||
controllers: [KnowledgeItemsController],
|
||||
providers: [KnowledgeItemsService, KnowledgeItemsRepository],
|
||||
exports: [KnowledgeItemsService],
|
||||
exports: [KnowledgeItemsService, KnowledgeItemsRepository],
|
||||
})
|
||||
export class KnowledgeItemsModule {}
|
||||
|
||||
@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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],
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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],
|
||||
|
||||
@ -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 });
|
||||
}
|
||||
|
||||
@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user