feat: implement P1 AI workflows (B7-B10)
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:
WangDL 2026-05-18 10:07:57 +08:00
parent bced62c8f6
commit 597c7b2310
27 changed files with 730 additions and 49 deletions

View File

@ -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) {

View File

@ -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,
},
});
}

View File

@ -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);
}

View File

@ -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 {}

View File

@ -0,0 +1,31 @@
export const FEYNMAN_EVALUATION_SYSTEM_PROMPT = `你是一位费曼学习法评估专家,擅长判断学习者是否真正理解了一个概念。
1.
2. 使使
3. 使
4.
5.
6.
- score0-100
- clarityLevelcrystal_clear(90+)/clear(70-89)/mostly_clear(50-69)/confusing(30-49)/very_confusing(<30)
- summary1-3
- strengths
- weaknesses
- blindSpots
- suggestions
- isBeginnerFriendly
- analogyQuality excellent/good/acceptable/poor/none
- jargonUsage使 none/minimal/moderate/heavy
-
- "可传播性"
-
- `;

View 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
- knowledgePoints130
- totalCount
- sourceSummary1-2
-
-
- `;

View File

@ -0,0 +1,32 @@
export const LEARNING_TREND_SYSTEM_PROMPT = `你是一位学习数据分析专家,擅长从学习活动数据中提取有意义的趋势和洞察。
1.
2.
3.
4.
5.
6.
- periodSummary2-4
- overallScore0-100
- overallDirection improving/declining/stable
- trends10
- metric
- direction improving/declining/stable
- currentValue"78分""320分钟"
- previousValue
- detail1-2
- strengths
- weaknesses
- recommendations
- nextFocusAreas5
-
-
-
- `;

View File

@ -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 {

View File

@ -0,0 +1,25 @@
export const REVIEW_CARD_GENERATION_SYSTEM_PROMPT = `你是一位间隔重复学习专家,擅长为知识点创建高质量的复习卡片。
1.
- "请解释X的工作原理并举出一个应用场景"
- "X是Y吗"
2.
3.
- easy
- medium
- hard
4.
5.
- cards1-20
- totalCount
-
-
- easy 30%, medium 50%, hard 20%
- `;

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

View 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": "这段文本主要介绍了主动回忆学习法的原理和应用"
}`;

View 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": ["主动回忆量提升", "薄弱知识点巩固"]
}`;

View File

@ -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
}`;

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

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

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

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

View File

@ -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],

View File

@ -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) {

View File

@ -6,6 +6,6 @@ import { KnowledgeItemsRepository } from './knowledge-items.repository';
@Module({
controllers: [KnowledgeItemsController],
providers: [KnowledgeItemsService, KnowledgeItemsRepository],
exports: [KnowledgeItemsService],
exports: [KnowledgeItemsService, KnowledgeItemsRepository],
})
export class KnowledgeItemsModule {}

View File

@ -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),
);
}
}

View File

@ -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],

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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],

View File

@ -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 });
}

View File

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