dramaling-vocab-learning/backend/DramaLing.Api/Services/StudySessionService.cs

499 lines
17 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using DramaLing.Api.Data;
using DramaLing.Api.Models.Entities;
using DramaLing.Api.Services;
using Microsoft.EntityFrameworkCore;
namespace DramaLing.Api.Services;
/// <summary>
/// 學習會話服務介面
/// </summary>
public interface IStudySessionService
{
Task<StudySession> StartSessionAsync(Guid userId);
Task<CurrentTestDto> GetCurrentTestAsync(Guid sessionId);
Task<SubmitTestResponseDto> SubmitTestAsync(Guid sessionId, SubmitTestRequestDto request);
Task<NextTestDto> GetNextTestAsync(Guid sessionId);
Task<ProgressDto> GetProgressAsync(Guid sessionId);
Task<StudySession> CompleteSessionAsync(Guid sessionId);
}
/// <summary>
/// 學習會話服務實現
/// </summary>
public class StudySessionService : IStudySessionService
{
private readonly DramaLingDbContext _context;
private readonly ILogger<StudySessionService> _logger;
private readonly IReviewModeSelector _reviewModeSelector;
public StudySessionService(
DramaLingDbContext context,
ILogger<StudySessionService> logger,
IReviewModeSelector reviewModeSelector)
{
_context = context;
_logger = logger;
_reviewModeSelector = reviewModeSelector;
}
/// <summary>
/// 開始新的學習會話
/// </summary>
public async Task<StudySession> StartSessionAsync(Guid userId)
{
_logger.LogInformation("Starting new study session for user {UserId}", userId);
// 獲取到期詞卡
var dueCards = await GetDueCardsAsync(userId);
if (!dueCards.Any())
{
throw new InvalidOperationException("No due cards available for study");
}
// 獲取用戶CEFR等級
var user = await _context.Users.FindAsync(userId);
var userCEFRLevel = user?.EnglishLevel ?? "A2";
// 創建學習會話
var session = new StudySession
{
Id = Guid.NewGuid(),
UserId = userId,
SessionType = "mixed", // 混合模式
StartedAt = DateTime.UtcNow,
Status = SessionStatus.Active,
TotalCards = dueCards.Count,
CurrentCardIndex = 0
};
_context.StudySessions.Add(session);
// 為每張詞卡創建學習進度記錄
int totalTests = 0;
for (int i = 0; i < dueCards.Count; i++)
{
var card = dueCards[i];
var wordCEFRLevel = card.DifficultyLevel ?? "A2";
var plannedTests = _reviewModeSelector.GetPlannedTests(userCEFRLevel, wordCEFRLevel);
var studyCard = new StudyCard
{
Id = Guid.NewGuid(),
StudySessionId = session.Id,
FlashcardId = card.Id,
Word = card.Word,
PlannedTests = plannedTests,
Order = i,
StartedAt = DateTime.UtcNow
};
_context.StudyCards.Add(studyCard);
totalTests += plannedTests.Count;
}
session.TotalTests = totalTests;
// 設置第一個測驗
if (session.StudyCards.Any())
{
var firstCard = session.StudyCards.OrderBy(c => c.Order).First();
session.CurrentTestType = firstCard.PlannedTests.First();
}
await _context.SaveChangesAsync();
_logger.LogInformation("Study session created: {SessionId}, Cards: {CardCount}, Tests: {TestCount}",
session.Id, session.TotalCards, session.TotalTests);
return session;
}
/// <summary>
/// 獲取當前測驗
/// </summary>
public async Task<CurrentTestDto> GetCurrentTestAsync(Guid sessionId)
{
var session = await GetSessionWithDetailsAsync(sessionId);
if (session == null || session.Status != SessionStatus.Active)
{
throw new InvalidOperationException("Session not found or not active");
}
var currentCard = session.StudyCards.OrderBy(c => c.Order).Skip(session.CurrentCardIndex).FirstOrDefault();
if (currentCard == null)
{
throw new InvalidOperationException("No current card found");
}
var flashcard = await _context.Flashcards
.Include(f => f.CardSet)
.FirstOrDefaultAsync(f => f.Id == currentCard.FlashcardId);
return new CurrentTestDto
{
SessionId = sessionId,
TestType = session.CurrentTestType ?? "flip-memory",
Card = new CardDto
{
Id = flashcard!.Id,
Word = flashcard.Word,
Translation = flashcard.Translation,
Definition = flashcard.Definition,
Example = flashcard.Example,
ExampleTranslation = flashcard.ExampleTranslation,
Pronunciation = flashcard.Pronunciation,
DifficultyLevel = flashcard.DifficultyLevel
},
Progress = new ProgressSummaryDto
{
CurrentCardIndex = session.CurrentCardIndex,
TotalCards = session.TotalCards,
CompletedTests = session.CompletedTests,
TotalTests = session.TotalTests,
CompletedCards = session.CompletedCards
}
};
}
/// <summary>
/// 提交測驗結果
/// </summary>
public async Task<SubmitTestResponseDto> SubmitTestAsync(Guid sessionId, SubmitTestRequestDto request)
{
var session = await GetSessionWithDetailsAsync(sessionId);
if (session == null || session.Status != SessionStatus.Active)
{
throw new InvalidOperationException("Session not found or not active");
}
var currentCard = session.StudyCards.OrderBy(c => c.Order).Skip(session.CurrentCardIndex).FirstOrDefault();
if (currentCard == null)
{
throw new InvalidOperationException("No current card found");
}
// 記錄測驗結果
var testResult = new TestResult
{
Id = Guid.NewGuid(),
StudyCardId = currentCard.Id,
TestType = request.TestType,
IsCorrect = request.IsCorrect,
UserAnswer = request.UserAnswer,
ConfidenceLevel = request.ConfidenceLevel,
ResponseTimeMs = request.ResponseTimeMs,
CompletedAt = DateTime.UtcNow
};
_context.TestResults.Add(testResult);
// 更新會話進度
session.CompletedTests++;
// 檢查當前詞卡是否完成所有測驗
var completedTestsForCard = await _context.TestResults
.Where(tr => tr.StudyCardId == currentCard.Id)
.CountAsync() + 1; // +1 因為當前測驗還未保存
if (completedTestsForCard >= currentCard.PlannedTestsCount)
{
// 詞卡完成觸發SM2算法更新
currentCard.IsCompleted = true;
currentCard.CompletedAt = DateTime.UtcNow;
session.CompletedCards++;
await UpdateFlashcardWithSM2Async(currentCard, testResult);
}
await _context.SaveChangesAsync();
return new SubmitTestResponseDto
{
Success = true,
IsCardCompleted = currentCard.IsCompleted,
Progress = new ProgressSummaryDto
{
CurrentCardIndex = session.CurrentCardIndex,
TotalCards = session.TotalCards,
CompletedTests = session.CompletedTests,
TotalTests = session.TotalTests,
CompletedCards = session.CompletedCards
}
};
}
/// <summary>
/// 獲取下一個測驗
/// </summary>
public async Task<NextTestDto> GetNextTestAsync(Guid sessionId)
{
var session = await GetSessionWithDetailsAsync(sessionId);
if (session == null || session.Status != SessionStatus.Active)
{
throw new InvalidOperationException("Session not found or not active");
}
var currentCard = session.StudyCards.OrderBy(c => c.Order).Skip(session.CurrentCardIndex).FirstOrDefault();
if (currentCard == null)
{
return new NextTestDto { HasNextTest = false, Message = "All cards completed" };
}
// 檢查當前詞卡是否還有未完成的測驗
var completedTestTypes = await _context.TestResults
.Where(tr => tr.StudyCardId == currentCard.Id)
.Select(tr => tr.TestType)
.ToListAsync();
var nextTestType = currentCard.PlannedTests.FirstOrDefault(t => !completedTestTypes.Contains(t));
if (nextTestType != null)
{
// 當前詞卡還有測驗
session.CurrentTestType = nextTestType;
await _context.SaveChangesAsync();
return new NextTestDto
{
HasNextTest = true,
TestType = nextTestType,
SameCard = true,
Message = $"Next test: {nextTestType}"
};
}
else
{
// 當前詞卡完成,移到下一張詞卡
session.CurrentCardIndex++;
if (session.CurrentCardIndex < session.TotalCards)
{
var nextCard = session.StudyCards.OrderBy(c => c.Order).Skip(session.CurrentCardIndex).FirstOrDefault();
session.CurrentTestType = nextCard?.PlannedTests.FirstOrDefault();
await _context.SaveChangesAsync();
return new NextTestDto
{
HasNextTest = true,
TestType = session.CurrentTestType!,
SameCard = false,
Message = "Moving to next card"
};
}
else
{
// 所有詞卡完成
session.Status = SessionStatus.Completed;
session.EndedAt = DateTime.UtcNow;
await _context.SaveChangesAsync();
return new NextTestDto
{
HasNextTest = false,
Message = "Session completed"
};
}
}
}
/// <summary>
/// 獲取詳細進度
/// </summary>
public async Task<ProgressDto> GetProgressAsync(Guid sessionId)
{
var session = await GetSessionWithDetailsAsync(sessionId);
if (session == null)
{
throw new InvalidOperationException("Session not found");
}
var cardProgress = session.StudyCards.Select(card => new CardProgressDto
{
CardId = card.FlashcardId,
Word = card.Word,
PlannedTests = card.PlannedTests,
CompletedTestsCount = card.TestResults.Count,
IsCompleted = card.IsCompleted,
Tests = card.TestResults.Select(tr => new TestProgressDto
{
TestType = tr.TestType,
IsCorrect = tr.IsCorrect,
CompletedAt = tr.CompletedAt
}).ToList()
}).ToList();
return new ProgressDto
{
SessionId = sessionId,
Status = session.Status.ToString(),
CurrentCardIndex = session.CurrentCardIndex,
TotalCards = session.TotalCards,
CompletedTests = session.CompletedTests,
TotalTests = session.TotalTests,
CompletedCards = session.CompletedCards,
Cards = cardProgress
};
}
/// <summary>
/// 完成學習會話
/// </summary>
public async Task<StudySession> CompleteSessionAsync(Guid sessionId)
{
var session = await GetSessionWithDetailsAsync(sessionId);
if (session == null)
{
throw new InvalidOperationException("Session not found");
}
session.Status = SessionStatus.Completed;
session.EndedAt = DateTime.UtcNow;
await _context.SaveChangesAsync();
_logger.LogInformation("Study session completed: {SessionId}", sessionId);
return session;
}
// Helper Methods
private async Task<StudySession?> GetSessionWithDetailsAsync(Guid sessionId)
{
return await _context.StudySessions
.Include(s => s.StudyCards)
.ThenInclude(sc => sc.TestResults)
.Include(s => s.StudyCards)
.ThenInclude(sc => sc.Flashcard)
.FirstOrDefaultAsync(s => s.Id == sessionId);
}
private async Task<List<Flashcard>> GetDueCardsAsync(Guid userId, int limit = 50)
{
var today = DateTime.Today;
return await _context.Flashcards
.Where(f => f.UserId == userId &&
(f.NextReviewDate <= today || f.Repetitions == 0))
.OrderBy(f => f.NextReviewDate)
.Take(limit)
.ToListAsync();
}
private async Task UpdateFlashcardWithSM2Async(StudyCard studyCard, TestResult latestResult)
{
var flashcard = await _context.Flashcards.FindAsync(studyCard.FlashcardId);
if (flashcard == null) return;
// 計算詞卡的綜合表現
var allResults = await _context.TestResults
.Where(tr => tr.StudyCardId == studyCard.Id)
.ToListAsync();
var correctCount = allResults.Count(r => r.IsCorrect);
var totalTests = allResults.Count;
var accuracy = totalTests > 0 ? (double)correctCount / totalTests : 0;
// 使用現有的SM2Algorithm
var quality = accuracy >= 0.8 ? 5 : accuracy >= 0.6 ? 4 : accuracy >= 0.4 ? 3 : 2;
var sm2Input = new SM2Input(quality, flashcard.EasinessFactor, flashcard.Repetitions, flashcard.IntervalDays);
var sm2Result = SM2Algorithm.Calculate(sm2Input);
// 更新詞卡
flashcard.EasinessFactor = sm2Result.EasinessFactor;
flashcard.Repetitions = sm2Result.Repetitions;
flashcard.IntervalDays = sm2Result.IntervalDays;
flashcard.NextReviewDate = sm2Result.NextReviewDate;
flashcard.MasteryLevel = SM2Algorithm.CalculateMastery(sm2Result.Repetitions, sm2Result.EasinessFactor);
flashcard.TimesReviewed++;
if (accuracy >= 0.7) flashcard.TimesCorrect++;
flashcard.LastReviewedAt = DateTime.UtcNow;
_logger.LogInformation("Updated flashcard {Word} with SM2: Mastery={Mastery}, NextReview={NextReview}",
flashcard.Word, flashcard.MasteryLevel, sm2Result.NextReviewDate);
}
}
// DTOs
public class CurrentTestDto
{
public Guid SessionId { get; set; }
public string TestType { get; set; } = string.Empty;
public CardDto Card { get; set; } = new();
public ProgressSummaryDto Progress { get; set; } = new();
}
public class SubmitTestRequestDto
{
public string TestType { get; set; } = string.Empty;
public bool IsCorrect { get; set; }
public string? UserAnswer { get; set; }
public int? ConfidenceLevel { get; set; }
public int ResponseTimeMs { get; set; }
}
public class SubmitTestResponseDto
{
public bool Success { get; set; }
public bool IsCardCompleted { get; set; }
public ProgressSummaryDto Progress { get; set; } = new();
public string Message { get; set; } = string.Empty;
}
public class NextTestDto
{
public bool HasNextTest { get; set; }
public string? TestType { get; set; }
public bool SameCard { get; set; }
public string Message { get; set; } = string.Empty;
}
public class ProgressDto
{
public Guid SessionId { get; set; }
public string Status { get; set; } = string.Empty;
public int CurrentCardIndex { get; set; }
public int TotalCards { get; set; }
public int CompletedTests { get; set; }
public int TotalTests { get; set; }
public int CompletedCards { get; set; }
public List<CardProgressDto> Cards { get; set; } = new();
}
public class CardProgressDto
{
public Guid CardId { get; set; }
public string Word { get; set; } = string.Empty;
public List<string> PlannedTests { get; set; } = new();
public int CompletedTestsCount { get; set; }
public bool IsCompleted { get; set; }
public List<TestProgressDto> Tests { get; set; } = new();
}
public class TestProgressDto
{
public string TestType { get; set; } = string.Empty;
public bool IsCorrect { get; set; }
public DateTime CompletedAt { get; set; }
}
public class ProgressSummaryDto
{
public int CurrentCardIndex { get; set; }
public int TotalCards { get; set; }
public int CompletedTests { get; set; }
public int TotalTests { get; set; }
public int CompletedCards { get; set; }
}
public class CardDto
{
public Guid Id { get; set; }
public string Word { get; set; } = string.Empty;
public string Translation { get; set; } = string.Empty;
public string Definition { get; set; } = string.Empty;
public string Example { get; set; } = string.Empty;
public string ExampleTranslation { get; set; } = string.Empty;
public string Pronunciation { get; set; } = string.Empty;
public string DifficultyLevel { get; set; } = string.Empty;
}