diff --git a/backend/DramaLing.Api/Controllers/FlashcardsController.cs b/backend/DramaLing.Api/Controllers/FlashcardsController.cs index a05d5f0..67e41dd 100644 --- a/backend/DramaLing.Api/Controllers/FlashcardsController.cs +++ b/backend/DramaLing.Api/Controllers/FlashcardsController.cs @@ -2,96 +2,52 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using DramaLing.Api.Data; using DramaLing.Api.Models.Entities; -using DramaLing.Api.Models.DTOs; -using DramaLing.Api.Models.DTOs.SpacedRepetition; -using DramaLing.Api.Services; -using DramaLing.Api.Services.Storage; using Microsoft.AspNetCore.Authorization; -using System.Security.Claims; namespace DramaLing.Api.Controllers; [ApiController] [Route("api/flashcards")] -[AllowAnonymous] // 暫時移除認證要求,修復網路錯誤 +[AllowAnonymous] public class FlashcardsController : ControllerBase { private readonly DramaLingDbContext _context; private readonly ILogger _logger; - private readonly IImageStorageService _imageStorageService; - private readonly IAuthService _authService; - // 🆕 智能複習服務依賴 - private readonly ISpacedRepetitionService _spacedRepetitionService; - private readonly IReviewTypeSelectorService _reviewTypeSelectorService; - private readonly IQuestionGeneratorService _questionGeneratorService; - // 🆕 智能填空題服務依賴 - private readonly IBlankGenerationService _blankGenerationService; public FlashcardsController( DramaLingDbContext context, - ILogger logger, - IImageStorageService imageStorageService, - IAuthService authService, - ISpacedRepetitionService spacedRepetitionService, - IReviewTypeSelectorService reviewTypeSelectorService, - IQuestionGeneratorService questionGeneratorService, - IBlankGenerationService blankGenerationService) + ILogger logger) { _context = context; _logger = logger; - _imageStorageService = imageStorageService; - _authService = authService; - _spacedRepetitionService = spacedRepetitionService; - _reviewTypeSelectorService = reviewTypeSelectorService; - _questionGeneratorService = questionGeneratorService; - _blankGenerationService = blankGenerationService; } private Guid GetUserId() { - // 暫時使用固定測試用戶 ID,避免認證問題 - // TODO: 恢復真實認證後改回 JWT Token 解析 + // 暫時使用固定測試用戶 ID return Guid.Parse("00000000-0000-0000-0000-000000000001"); - - // var userIdString = User.FindFirst(ClaimTypes.NameIdentifier)?.Value ?? - // User.FindFirst("sub")?.Value; - // - // if (Guid.TryParse(userIdString, out var userId)) - // return userId; - // - // throw new UnauthorizedAccessException("Invalid user ID in token"); } [HttpGet] public async Task GetFlashcards( [FromQuery] string? search = null, - [FromQuery] bool favoritesOnly = false, - [FromQuery] string? cefrLevel = null, - [FromQuery] string? partOfSpeech = null, - [FromQuery] string? masteryLevel = null) + [FromQuery] bool favoritesOnly = false) { try { var userId = GetUserId(); - _logger.LogInformation("GetFlashcards called for user: {UserId}", userId); var query = _context.Flashcards - .Include(f => f.FlashcardExampleImages) - .ThenInclude(fei => fei.ExampleImage) .Where(f => f.UserId == userId && !f.IsArchived) .AsQueryable(); - _logger.LogInformation("Base query created successfully"); - - // 搜尋篩選 (擴展支援例句內容) + // 搜尋篩選 if (!string.IsNullOrEmpty(search)) { query = query.Where(f => f.Word.Contains(search) || f.Translation.Contains(search) || - (f.Definition != null && f.Definition.Contains(search)) || - (f.Example != null && f.Example.Contains(search)) || - (f.ExampleTranslation != null && f.ExampleTranslation.Contains(search))); + (f.Definition != null && f.Definition.Contains(search))); } // 收藏篩選 @@ -100,99 +56,40 @@ public class FlashcardsController : ControllerBase query = query.Where(f => f.IsFavorite); } - // CEFR 等級篩選 - if (!string.IsNullOrEmpty(cefrLevel)) - { - query = query.Where(f => f.DifficultyLevel == cefrLevel); - } - - // 詞性篩選 - if (!string.IsNullOrEmpty(partOfSpeech)) - { - query = query.Where(f => f.PartOfSpeech == partOfSpeech); - } - - // 掌握度篩選 - if (!string.IsNullOrEmpty(masteryLevel)) - { - switch (masteryLevel.ToLower()) - { - case "high": - query = query.Where(f => f.MasteryLevel >= 80); - break; - case "medium": - query = query.Where(f => f.MasteryLevel >= 60 && f.MasteryLevel < 80); - break; - case "low": - query = query.Where(f => f.MasteryLevel < 60); - break; - } - } - - _logger.LogInformation("Executing database query..."); var flashcards = await query - .AsNoTracking() // 效能優化:只讀查詢 + .AsNoTracking() .OrderByDescending(f => f.CreatedAt) - .ToListAsync(); - - _logger.LogInformation("Found {Count} flashcards", flashcards.Count); - - // 生成圖片資訊 - var flashcardDtos = new List(); - foreach (var flashcard in flashcards) - { - // 獲取例句圖片資料 (與 GetFlashcard 方法保持一致) - var exampleImages = flashcard.FlashcardExampleImages? - .Select(fei => new - { - Id = fei.ExampleImage.Id, - ImageUrl = $"http://localhost:5008/images/examples/{fei.ExampleImage.RelativePath}", - IsPrimary = fei.IsPrimary, - QualityScore = fei.ExampleImage.QualityScore, - FileSize = fei.ExampleImage.FileSize, - CreatedAt = fei.ExampleImage.CreatedAt - }) - .ToList(); - - flashcardDtos.Add(new + .Select(f => new { - flashcard.Id, - flashcard.Word, - flashcard.Translation, - flashcard.Definition, - flashcard.PartOfSpeech, - flashcard.Pronunciation, - flashcard.Example, - flashcard.ExampleTranslation, - flashcard.MasteryLevel, - flashcard.TimesReviewed, - flashcard.IsFavorite, - flashcard.NextReviewDate, - flashcard.DifficultyLevel, - flashcard.CreatedAt, - flashcard.UpdatedAt, - // 新增圖片相關欄位 - ExampleImages = exampleImages ?? (object)new List(), - HasExampleImage = exampleImages?.Any() ?? false, - PrimaryImageUrl = exampleImages?.FirstOrDefault(img => img.IsPrimary)?.ImageUrl - }); - } + f.Id, + f.Word, + f.Translation, + f.Definition, + f.PartOfSpeech, + f.Pronunciation, + f.Example, + f.ExampleTranslation, + f.IsFavorite, + f.DifficultyLevel, + f.CreatedAt, + f.UpdatedAt + }) + .ToListAsync(); return Ok(new { Success = true, Data = new { - Flashcards = flashcardDtos, - Count = flashcardDtos.Count + Flashcards = flashcards, + Count = flashcards.Count } }); } catch (Exception ex) { - _logger.LogError(ex, "Error getting flashcards for user: {Message}", ex.Message); - _logger.LogError("Stack trace: {StackTrace}", ex.StackTrace); - return StatusCode(500, new { Success = false, Error = "Failed to load flashcards", Details = ex.Message }); + _logger.LogError(ex, "Error getting flashcards"); + return StatusCode(500, new { Success = false, Error = "Failed to load flashcards" }); } } @@ -203,52 +100,6 @@ public class FlashcardsController : ControllerBase { var userId = GetUserId(); - // 確保測試用戶存在 - var testUser = await _context.Users.FirstOrDefaultAsync(u => u.Id == userId); - if (testUser == null) - { - testUser = new User - { - Id = userId, - Username = "testuser", - Email = "test@example.com", - PasswordHash = "test_hash", - DisplayName = "測試用戶", - SubscriptionType = "free", - Preferences = new Dictionary(), - EnglishLevel = "A2", - LevelUpdatedAt = DateTime.UtcNow, - IsLevelVerified = false, - CreatedAt = DateTime.UtcNow, - UpdatedAt = DateTime.UtcNow - }; - _context.Users.Add(testUser); - await _context.SaveChangesAsync(); - } - - // 檢測重複詞卡 - var existing = await _context.Flashcards - .FirstOrDefaultAsync(f => f.UserId == userId && - f.Word.ToLower() == request.Word.ToLower() && - !f.IsArchived); - - if (existing != null) - { - return Ok(new - { - Success = false, - Error = "詞卡已存在", - IsDuplicate = true, - ExistingCard = new - { - existing.Id, - existing.Word, - existing.Translation, - existing.CreatedAt - } - }); - } - var flashcard = new Flashcard { Id = Guid.NewGuid(), @@ -260,16 +111,7 @@ public class FlashcardsController : ControllerBase Pronunciation = request.Pronunciation, Example = request.Example, ExampleTranslation = request.ExampleTranslation, - Synonyms = request.Synonyms != null && request.Synonyms.Any() - ? System.Text.Json.JsonSerializer.Serialize(request.Synonyms) - : null, - MasteryLevel = 0, - TimesReviewed = 0, - IsFavorite = false, - NextReviewDate = DateTime.Today, DifficultyLevel = "A2", // 預設等級 - EasinessFactor = 2.5f, - IntervalDays = 1, CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow }; @@ -280,14 +122,7 @@ public class FlashcardsController : ControllerBase return Ok(new { Success = true, - Data = new - { - flashcard.Id, - flashcard.Word, - flashcard.Translation, - flashcard.Definition, - flashcard.CreatedAt - }, + Data = flashcard, Message = "詞卡創建成功" }); } @@ -306,8 +141,6 @@ public class FlashcardsController : ControllerBase var userId = GetUserId(); var flashcard = await _context.Flashcards - .Include(f => f.FlashcardExampleImages) - .ThenInclude(fei => fei.ExampleImage) .FirstOrDefaultAsync(f => f.Id == id && f.UserId == userId); if (flashcard == null) @@ -315,48 +148,7 @@ public class FlashcardsController : ControllerBase return NotFound(new { Success = false, Error = "Flashcard not found" }); } - // 獲取例句圖片資料 - var exampleImages = flashcard.FlashcardExampleImages - ?.Select(fei => new - { - Id = fei.ExampleImage.Id, - ImageUrl = $"http://localhost:5008/images/examples/{fei.ExampleImage.RelativePath}", - IsPrimary = fei.IsPrimary, - QualityScore = fei.ExampleImage.QualityScore, - FileSize = fei.ExampleImage.FileSize, - CreatedAt = fei.ExampleImage.CreatedAt - }) - .ToList(); - - return Ok(new - { - Success = true, - Data = new - { - flashcard.Id, - flashcard.Word, - flashcard.Translation, - flashcard.Definition, - flashcard.PartOfSpeech, - flashcard.Pronunciation, - flashcard.Example, - flashcard.ExampleTranslation, - flashcard.MasteryLevel, - flashcard.TimesReviewed, - flashcard.IsFavorite, - flashcard.NextReviewDate, - flashcard.DifficultyLevel, - flashcard.CreatedAt, - flashcard.UpdatedAt, - // 新增圖片相關欄位 - ExampleImages = exampleImages ?? (object)new List(), - HasExampleImage = exampleImages?.Any() ?? false, - PrimaryImageUrl = flashcard.FlashcardExampleImages? - .Where(fei => fei.IsPrimary) - .Select(fei => $"http://localhost:5008/images/examples/{fei.ExampleImage.RelativePath}") - .FirstOrDefault() - } - }); + return Ok(new { Success = true, Data = flashcard }); } catch (Exception ex) { @@ -395,15 +187,7 @@ public class FlashcardsController : ControllerBase return Ok(new { Success = true, - Data = new - { - flashcard.Id, - flashcard.Word, - flashcard.Translation, - flashcard.Definition, - flashcard.CreatedAt, - flashcard.UpdatedAt - }, + Data = flashcard, Message = "詞卡更新成功" }); } @@ -473,198 +257,9 @@ public class FlashcardsController : ControllerBase return StatusCode(500, new { Success = false, Error = "Failed to toggle favorite" }); } } - - // ================== 🆕 智能複習API端點 ================== - - /// - /// 取得到期詞卡列表 - /// - [HttpGet("due")] - public async Task GetDueFlashcards( - [FromQuery] string? date = null, - [FromQuery] int limit = 50) - { - try - { - var userId = GetUserId(); - var queryDate = DateTime.TryParse(date, out var parsed) ? parsed : DateTime.Now.Date; - - var dueCards = await _spacedRepetitionService.GetDueFlashcardsAsync(userId, queryDate, limit); - - // 🆕 智能挖空處理:檢查並生成缺失的填空題目 - var cardsToUpdate = new List(); - foreach(var flashcard in dueCards) - { - if(string.IsNullOrEmpty(flashcard.FilledQuestionText) && !string.IsNullOrEmpty(flashcard.Example)) - { - _logger.LogDebug("Generating blank question for flashcard {Id}, word: {Word}", - flashcard.Id, flashcard.Word); - - var blankQuestion = await _blankGenerationService.GenerateBlankQuestionAsync( - flashcard.Word, flashcard.Example); - - if(!string.IsNullOrEmpty(blankQuestion)) - { - flashcard.FilledQuestionText = blankQuestion; - flashcard.UpdatedAt = DateTime.UtcNow; - cardsToUpdate.Add(flashcard); - - _logger.LogInformation("Generated blank question for flashcard {Id}, word: {Word}", - flashcard.Id, flashcard.Word); - } - else - { - _logger.LogWarning("Failed to generate blank question for flashcard {Id}, word: {Word}", - flashcard.Id, flashcard.Word); - } - } - } - - // 批次更新資料庫 - if (cardsToUpdate.Count > 0) - { - _context.UpdateRange(cardsToUpdate); - await _context.SaveChangesAsync(); - _logger.LogInformation("Updated {Count} flashcards with blank questions", cardsToUpdate.Count); - } - - _logger.LogInformation("Retrieved {Count} due flashcards for user {UserId}", dueCards.Count, userId); - - return Ok(new { success = true, data = dueCards, count = dueCards.Count }); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting due flashcards"); - return StatusCode(500, new { success = false, error = "Failed to get due flashcards" }); - } - } - - /// - /// 取得下一張需要復習的詞卡 (最高優先級) - /// - [HttpGet("next-review")] - public async Task GetNextReviewCard() - { - try - { - var userId = GetUserId(); - var nextCard = await _spacedRepetitionService.GetNextReviewCardAsync(userId); - - if (nextCard == null) - { - return Ok(new { success = true, data = (object?)null, message = "沒有到期的詞卡" }); - } - - // 計算當前熟悉度 - var currentMasteryLevel = _spacedRepetitionService.CalculateCurrentMasteryLevel(nextCard); - - // UserLevel和WordLevel欄位已移除 - 改用即時CEFR轉換 - - var response = new - { - nextCard.Id, - nextCard.Word, - nextCard.Translation, - nextCard.Definition, - nextCard.Pronunciation, - nextCard.PartOfSpeech, - nextCard.Example, - nextCard.ExampleTranslation, - nextCard.MasteryLevel, - nextCard.TimesReviewed, - nextCard.IsFavorite, - nextCard.NextReviewDate, - nextCard.DifficultyLevel, - // 智能複習擴展欄位 (改用即時CEFR轉換) - BaseMasteryLevel = nextCard.MasteryLevel, - LastReviewDate = nextCard.LastReviewedAt, - CurrentInterval = nextCard.IntervalDays, - IsOverdue = DateTime.Now.Date > nextCard.NextReviewDate.Date, - OverdueDays = Math.Max(0, (DateTime.Now.Date - nextCard.NextReviewDate.Date).Days), - CurrentMasteryLevel = currentMasteryLevel - }; - - _logger.LogInformation("Retrieved next review card: {Word} for user {UserId}", nextCard.Word, userId); - - return Ok(new { success = true, data = response }); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting next review card"); - return StatusCode(500, new { success = false, error = "Failed to get next review card" }); - } - } - - /// - /// 系統自動選擇最適合的複習題型 (基於CEFR等級) - /// - [HttpPost("{id}/optimal-review-mode")] - public async Task GetOptimalReviewMode(Guid id, [FromBody] OptimalModeRequest request) - { - try - { - var result = await _reviewTypeSelectorService.SelectOptimalReviewModeAsync( - id, request.UserCEFRLevel, request.WordCEFRLevel); - - _logger.LogInformation("Selected optimal review mode {SelectedMode} for flashcard {FlashcardId}, userCEFR: {UserCEFR}, wordCEFR: {WordCEFR}", - result.SelectedMode, id, request.UserCEFRLevel, request.WordCEFRLevel); - - return Ok(new { success = true, data = result }); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error selecting optimal review mode for flashcard {FlashcardId}", id); - return StatusCode(500, new { success = false, error = "Failed to select optimal review mode" }); - } - } - - /// - /// 生成指定題型的題目選項 - /// - [HttpPost("{id}/question")] - public async Task GenerateQuestion(Guid id, [FromBody] QuestionRequest request) - { - try - { - var questionData = await _questionGeneratorService.GenerateQuestionAsync(id, request.QuestionType); - - _logger.LogInformation("Generated {QuestionType} question for flashcard {FlashcardId}", - request.QuestionType, id); - - return Ok(new { success = true, data = questionData }); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error generating {QuestionType} question for flashcard {FlashcardId}", - request.QuestionType, id); - return StatusCode(500, new { success = false, error = "Failed to generate question" }); - } - } - - /// - /// 提交復習結果並更新間隔重複算法 - /// - [HttpPost("{id}/review")] - public async Task SubmitReview(Guid id, [FromBody] ReviewRequest request) - { - try - { - var result = await _spacedRepetitionService.ProcessReviewAsync(id, request); - - _logger.LogInformation("Processed review for flashcard {FlashcardId}, questionType: {QuestionType}, isCorrect: {IsCorrect}", - id, request.QuestionType, request.IsCorrect); - - return Ok(new { success = true, data = result }); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error processing review for flashcard {FlashcardId}", id); - return StatusCode(500, new { success = false, error = "Failed to process review" }); - } - } } -// 請求 DTO +// DTO 類別 public class CreateFlashcardRequest { public string Word { get; set; } = string.Empty; @@ -674,5 +269,4 @@ public class CreateFlashcardRequest public string Pronunciation { get; set; } = string.Empty; public string Example { get; set; } = string.Empty; public string? ExampleTranslation { get; set; } - public List? Synonyms { get; set; } } \ No newline at end of file diff --git a/backend/DramaLing.Api/Controllers/StudyController.cs b/backend/DramaLing.Api/Controllers/StudyController.cs deleted file mode 100644 index da1ea21..0000000 --- a/backend/DramaLing.Api/Controllers/StudyController.cs +++ /dev/null @@ -1,755 +0,0 @@ -using Microsoft.AspNetCore.Mvc; -using Microsoft.EntityFrameworkCore; -using DramaLing.Api.Data; -using DramaLing.Api.Models.Entities; -using DramaLing.Api.Services; -using Microsoft.AspNetCore.Authorization; - -namespace DramaLing.Api.Controllers; - -[ApiController] -[Route("api/[controller]")] -[Authorize] -public class StudyController : ControllerBase -{ - private readonly DramaLingDbContext _context; - private readonly IAuthService _authService; - private readonly ILogger _logger; - - public StudyController( - DramaLingDbContext context, - IAuthService authService, - ILogger logger) - { - _context = context; - _authService = authService; - _logger = logger; - } - - /// - /// 獲取待複習的詞卡 (支援 /frontend/app/learn/page.tsx) - /// - [HttpGet("due-cards")] - public async Task GetDueCards( - [FromQuery] int limit = 50, - [FromQuery] string? mode = null, - [FromQuery] bool includeNew = true) - { - try - { - var userId = await _authService.GetUserIdFromTokenAsync(Request.Headers.Authorization); - if (userId == null) - return Unauthorized(new { Success = false, Error = "Invalid token" }); - - var today = DateTime.Today; - var query = _context.Flashcards - .Where(f => f.UserId == userId); - - // 篩選到期和新詞卡 - if (includeNew) - { - // 包含到期詞卡和新詞卡 - query = query.Where(f => f.NextReviewDate <= today || f.Repetitions == 0); - } - else - { - // 只包含到期詞卡 - query = query.Where(f => f.NextReviewDate <= today); - } - - var dueCards = await query.Take(limit * 2).ToListAsync(); // 取更多用於排序 - - // 計算優先級並排序 - var cardsWithPriority = dueCards.Select(card => new - { - Card = card, - Priority = ReviewPriorityCalculator.CalculatePriority( - card.NextReviewDate, - card.EasinessFactor, - card.Repetitions - ), - IsDue = ReviewPriorityCalculator.ShouldReview(card.NextReviewDate), - DaysOverdue = Math.Max(0, (today - card.NextReviewDate).Days) - }).OrderByDescending(x => x.Priority).Take(limit); - - var result = cardsWithPriority.Select(x => new - { - x.Card.Id, - x.Card.Word, - x.Card.Translation, - x.Card.Definition, - x.Card.PartOfSpeech, - x.Card.Pronunciation, - x.Card.Example, - x.Card.ExampleTranslation, - x.Card.MasteryLevel, - x.Card.NextReviewDate, - x.Card.DifficultyLevel, - CardSet = new - { - Name = "Default", - Color = "bg-blue-500" - }, - x.Priority, - x.IsDue, - x.DaysOverdue - }).ToList(); - - // 統計資訊 - var totalDue = await _context.Flashcards - .Where(f => f.UserId == userId && f.NextReviewDate <= today) - .CountAsync(); - - var totalCards = await _context.Flashcards - .Where(f => f.UserId == userId) - .CountAsync(); - - var newCards = await _context.Flashcards - .Where(f => f.UserId == userId && f.Repetitions == 0) - .CountAsync(); - - return Ok(new - { - Success = true, - Data = new - { - Cards = result, - TotalDue = totalDue, - TotalCards = totalCards, - NewCards = newCards - } - }); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error fetching due cards for user"); - return StatusCode(500, new - { - Success = false, - Error = "Failed to fetch due cards", - Timestamp = DateTime.UtcNow - }); - } - } - - /// - /// 開始學習會話 - /// - [HttpPost("sessions")] - public async Task CreateStudySession([FromBody] CreateStudySessionRequest request) - { - try - { - var userId = await _authService.GetUserIdFromTokenAsync(Request.Headers.Authorization); - if (userId == null) - return Unauthorized(new { Success = false, Error = "Invalid token" }); - - // 基本驗證 - if (string.IsNullOrEmpty(request.Mode) || - !new[] { "flip", "quiz", "fill", "listening", "speaking" }.Contains(request.Mode)) - { - return BadRequest(new { Success = false, Error = "Invalid study mode" }); - } - - if (request.CardIds == null || request.CardIds.Count == 0) - { - return BadRequest(new { Success = false, Error = "Card IDs are required" }); - } - - if (request.CardIds.Count > 50) - { - return BadRequest(new { Success = false, Error = "Cannot study more than 50 cards in one session" }); - } - - // 驗證詞卡是否屬於用戶 - var userCards = await _context.Flashcards - .Where(f => f.UserId == userId && request.CardIds.Contains(f.Id)) - .CountAsync(); - - if (userCards != request.CardIds.Count) - { - return BadRequest(new { Success = false, Error = "Some cards not found or not accessible" }); - } - - // 建立學習會話 - var session = new StudySession - { - Id = Guid.NewGuid(), - UserId = userId.Value, - SessionType = request.Mode, - TotalCards = request.CardIds.Count, - StartedAt = DateTime.UtcNow - }; - - _context.StudySessions.Add(session); - await _context.SaveChangesAsync(); - - // 獲取詞卡詳細資訊 - var cards = await _context.Flashcards - .Where(f => f.UserId == userId && request.CardIds.Contains(f.Id)) - .ToListAsync(); - - // 按照請求的順序排列 - var orderedCards = request.CardIds - .Select(id => cards.FirstOrDefault(c => c.Id == id)) - .Where(c => c != null) - .ToList(); - - return Ok(new - { - Success = true, - Data = new - { - SessionId = session.Id, - SessionType = request.Mode, - Cards = orderedCards.Select(c => new - { - c.Id, - c.Word, - c.Translation, - c.Definition, - c.PartOfSpeech, - c.Pronunciation, - c.Example, - c.ExampleTranslation, - c.MasteryLevel, - c.EasinessFactor, - c.Repetitions, - CardSet = new { Name = "Default", Color = "bg-blue-500" } - }), - TotalCards = orderedCards.Count, - StartedAt = session.StartedAt - }, - Message = $"Study session started with {orderedCards.Count} cards" - }); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error creating study session"); - return StatusCode(500, new - { - Success = false, - Error = "Failed to create study session", - Timestamp = DateTime.UtcNow - }); - } - } - - /// - /// 記錄學習結果 (支援 SM-2 算法) - /// - [HttpPost("sessions/{sessionId}/record")] - public async Task RecordStudyResult( - Guid sessionId, - [FromBody] RecordStudyResultRequest request) - { - try - { - var userId = await _authService.GetUserIdFromTokenAsync(Request.Headers.Authorization); - if (userId == null) - return Unauthorized(new { Success = false, Error = "Invalid token" }); - - // 基本驗證 - if (request.QualityRating < 1 || request.QualityRating > 5) - { - return BadRequest(new { Success = false, Error = "Quality rating must be between 1 and 5" }); - } - - // 驗證學習會話 - var session = await _context.StudySessions - .FirstOrDefaultAsync(s => s.Id == sessionId && s.UserId == userId); - - if (session == null) - { - return NotFound(new { Success = false, Error = "Study session not found" }); - } - - // 驗證詞卡 - var flashcard = await _context.Flashcards - .FirstOrDefaultAsync(f => f.Id == request.FlashcardId && f.UserId == userId); - - if (flashcard == null) - { - return NotFound(new { Success = false, Error = "Flashcard not found" }); - } - - // 計算新的 SM-2 參數 - var sm2Input = new SM2Input( - request.QualityRating, - flashcard.EasinessFactor, - flashcard.Repetitions, - flashcard.IntervalDays - ); - - var sm2Result = SM2Algorithm.Calculate(sm2Input); - - // 記錄學習結果 - var studyRecord = new StudyRecord - { - Id = Guid.NewGuid(), - UserId = userId.Value, - FlashcardId = request.FlashcardId, - SessionId = sessionId, - StudyMode = session.SessionType, - QualityRating = request.QualityRating, - ResponseTimeMs = request.ResponseTimeMs, - UserAnswer = request.UserAnswer, - IsCorrect = request.IsCorrect, - PreviousEasinessFactor = sm2Input.EasinessFactor, - NewEasinessFactor = sm2Result.EasinessFactor, - PreviousIntervalDays = sm2Input.IntervalDays, - NewIntervalDays = sm2Result.IntervalDays, - PreviousRepetitions = sm2Input.Repetitions, - NewRepetitions = sm2Result.Repetitions, - NextReviewDate = sm2Result.NextReviewDate, - StudiedAt = DateTime.UtcNow - }; - - _context.StudyRecords.Add(studyRecord); - - // 更新詞卡的 SM-2 參數 - 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 (request.IsCorrect) flashcard.TimesCorrect++; - flashcard.LastReviewedAt = DateTime.UtcNow; - - await _context.SaveChangesAsync(); - - return Ok(new - { - Success = true, - Data = new - { - RecordId = studyRecord.Id, - NextReviewDate = sm2Result.NextReviewDate.ToString("yyyy-MM-dd"), - NewIntervalDays = sm2Result.IntervalDays, - NewMasteryLevel = flashcard.MasteryLevel, - EasinessFactor = sm2Result.EasinessFactor, - Repetitions = sm2Result.Repetitions, - QualityDescription = SM2Algorithm.GetQualityDescription(request.QualityRating) - }, - Message = $"Study record saved. Next review in {sm2Result.IntervalDays} day(s)" - }); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error recording study result"); - return StatusCode(500, new - { - Success = false, - Error = "Failed to record study result", - Timestamp = DateTime.UtcNow - }); - } - } - - /// - /// 完成學習會話 - /// - [HttpPost("sessions/{sessionId}/complete")] - public async Task CompleteStudySession( - Guid sessionId, - [FromBody] CompleteStudySessionRequest request) - { - try - { - var userId = await _authService.GetUserIdFromTokenAsync(Request.Headers.Authorization); - if (userId == null) - return Unauthorized(new { Success = false, Error = "Invalid token" }); - - // 驗證會話 - var session = await _context.StudySessions - .FirstOrDefaultAsync(s => s.Id == sessionId && s.UserId == userId); - - if (session == null) - { - return NotFound(new { Success = false, Error = "Study session not found" }); - } - - // 計算會話統計 - var sessionRecords = await _context.StudyRecords - .Where(r => r.SessionId == sessionId && r.UserId == userId) - .ToListAsync(); - - var correctCount = sessionRecords.Count(r => r.IsCorrect); - var averageResponseTime = sessionRecords.Any(r => r.ResponseTimeMs.HasValue) - ? (int)sessionRecords.Where(r => r.ResponseTimeMs.HasValue).Average(r => r.ResponseTimeMs!.Value) - : 0; - - // 更新會話 - session.EndedAt = DateTime.UtcNow; - session.CorrectCount = correctCount; - session.DurationSeconds = request.DurationSeconds; - session.AverageResponseTimeMs = averageResponseTime; - - // 更新或建立每日統計 - var today = DateOnly.FromDateTime(DateTime.Today); - var dailyStats = await _context.DailyStats - .FirstOrDefaultAsync(ds => ds.UserId == userId && ds.Date == today); - - if (dailyStats == null) - { - dailyStats = new DailyStats - { - Id = Guid.NewGuid(), - UserId = userId.Value, - Date = today - }; - _context.DailyStats.Add(dailyStats); - } - - dailyStats.WordsStudied += sessionRecords.Count; - dailyStats.WordsCorrect += correctCount; - dailyStats.StudyTimeSeconds += request.DurationSeconds; - dailyStats.SessionCount++; - - await _context.SaveChangesAsync(); - - // 計算會話統計 - var accuracy = sessionRecords.Count > 0 - ? (int)Math.Round((double)correctCount / sessionRecords.Count * 100) - : 0; - - var averageTimePerCard = request.DurationSeconds > 0 && sessionRecords.Count > 0 - ? request.DurationSeconds / sessionRecords.Count - : 0; - - return Ok(new - { - Success = true, - Data = new - { - SessionId = sessionId, - TotalCards = session.TotalCards, - CardsStudied = sessionRecords.Count, - CorrectAnswers = correctCount, - AccuracyPercentage = accuracy, - DurationSeconds = request.DurationSeconds, - AverageTimePerCard = averageTimePerCard, - AverageResponseTimeMs = averageResponseTime, - StartedAt = session.StartedAt, - EndedAt = session.EndedAt - }, - Message = $"Study session completed! {correctCount}/{sessionRecords.Count} correct ({accuracy}%)" - }); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error completing study session"); - return StatusCode(500, new - { - Success = false, - Error = "Failed to complete study session", - Timestamp = DateTime.UtcNow - }); - } - } - - /// - /// 獲取智能複習排程 - /// - [HttpGet("schedule")] - public async Task GetReviewSchedule( - [FromQuery] bool includePlan = true, - [FromQuery] bool includeStats = true) - { - try - { - var userId = await _authService.GetUserIdFromTokenAsync(Request.Headers.Authorization); - if (userId == null) - return Unauthorized(new { Success = false, Error = "Invalid token" }); - - // 獲取用戶設定 - var settings = await _context.UserSettings - .FirstOrDefaultAsync(s => s.UserId == userId); - var dailyGoal = settings?.DailyGoal ?? 20; - - // 獲取所有詞卡 - var allCards = await _context.Flashcards - .Where(f => f.UserId == userId) - .ToListAsync(); - - var today = DateTime.Today; - - // 分類詞卡 - var dueToday = allCards.Where(c => c.NextReviewDate == today).ToList(); - var overdue = allCards.Where(c => c.NextReviewDate < today && c.Repetitions > 0).ToList(); - var upcoming = allCards.Where(c => c.NextReviewDate > today && c.NextReviewDate <= today.AddDays(7)).ToList(); - var newCards = allCards.Where(c => c.Repetitions == 0).ToList(); - - // 建立回應物件 - var responseData = new Dictionary - { - ["Schedule"] = new - { - DueToday = dueToday.Count, - Overdue = overdue.Count, - Upcoming = upcoming.Count, - NewCards = newCards.Count - } - }; - - // 生成學習計劃 - if (includePlan) - { - var recommendedCards = overdue.Take(dailyGoal / 2) - .Concat(dueToday.Take(dailyGoal / 3)) - .Concat(newCards.Take(Math.Min(5, dailyGoal / 4))) - .Take(dailyGoal) - .Select(c => new - { - c.Id, - c.Word, - c.Translation, - c.MasteryLevel, - c.NextReviewDate, - PriorityReason = c.Repetitions == 0 ? "new_card" : - c.NextReviewDate < today ? "overdue" : "due_today" - }); - - responseData["StudyPlan"] = new - { - RecommendedCards = recommendedCards, - Breakdown = new - { - Overdue = Math.Min(overdue.Count, dailyGoal / 2), - DueToday = Math.Min(dueToday.Count, dailyGoal / 3), - NewCards = Math.Min(newCards.Count, 5) - }, - EstimatedTimeMinutes = recommendedCards.Count() * 1, - DailyGoal = dailyGoal - }; - } - - // 計算統計 - if (includeStats) - { - responseData["Statistics"] = new - { - TotalCards = allCards.Count, - MasteredCards = allCards.Count(c => c.MasteryLevel >= 80), - LearningCards = allCards.Count(c => c.MasteryLevel >= 40 && c.MasteryLevel < 80), - NewCardsCount = newCards.Count, - AverageMastery = allCards.Count > 0 ? (int)allCards.Average(c => c.MasteryLevel) : 0, - RetentionRate = allCards.Count(c => c.Repetitions > 0) > 0 - ? (int)Math.Round((double)allCards.Count(c => c.MasteryLevel >= 60) / allCards.Count(c => c.Repetitions > 0) * 100) - : 0 - }; - } - - return Ok(new - { - Success = true, - Data = responseData - }); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error fetching review schedule"); - return StatusCode(500, new - { - Success = false, - Error = "Failed to fetch review schedule", - Timestamp = DateTime.UtcNow - }); - } - } - - /// - /// 獲取已完成的測驗記錄 (支援學習狀態恢復) - /// - [HttpGet("completed-tests")] - public async Task GetCompletedTests([FromQuery] string? cardIds = null) - { - try - { - var userId = await _authService.GetUserIdFromTokenAsync(Request.Headers.Authorization); - if (userId == null) - return Unauthorized(new { Success = false, Error = "Invalid token" }); - - var query = _context.StudyRecords.Where(r => r.UserId == userId); - - // 如果提供了詞卡ID列表,則篩選 - if (!string.IsNullOrEmpty(cardIds)) - { - var cardIdList = cardIds.Split(',') - .Where(id => Guid.TryParse(id, out _)) - .Select(Guid.Parse) - .ToList(); - - if (cardIdList.Any()) - { - query = query.Where(r => cardIdList.Contains(r.FlashcardId)); - } - } - - var completedTests = await query - .Select(r => new - { - FlashcardId = r.FlashcardId, - TestType = r.StudyMode, - IsCorrect = r.IsCorrect, - CompletedAt = r.StudiedAt, - UserAnswer = r.UserAnswer - }) - .ToListAsync(); - - _logger.LogInformation("Retrieved {Count} completed tests for user {UserId}", - completedTests.Count, userId); - - return Ok(new - { - Success = true, - Data = completedTests - }); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error retrieving completed tests for user"); - return StatusCode(500, new - { - Success = false, - Error = "Failed to retrieve completed tests", - Timestamp = DateTime.UtcNow - }); - } - } - - /// - /// 直接記錄測驗完成狀態 (不觸發SM2更新) - /// - [HttpPost("record-test")] - public async Task RecordTestCompletion([FromBody] RecordTestRequest request) - { - try - { - var userId = await _authService.GetUserIdFromTokenAsync(Request.Headers.Authorization); - if (userId == null) - { - _logger.LogWarning("RecordTest failed: Invalid or missing token"); - return Unauthorized(new { Success = false, Error = "Invalid token" }); - } - - _logger.LogInformation("Recording test for user {UserId}: FlashcardId={FlashcardId}, TestType={TestType}", - userId, request.FlashcardId, request.TestType); - - // 驗證測驗類型 - var validTestTypes = new[] { "flip-memory", "vocab-choice", "vocab-listening", - "sentence-listening", "sentence-fill", "sentence-reorder", "sentence-speaking" }; - if (!validTestTypes.Contains(request.TestType)) - { - _logger.LogWarning("Invalid test type: {TestType}", request.TestType); - return BadRequest(new { Success = false, Error = "Invalid test type" }); - } - - // 先檢查詞卡是否存在 - var flashcard = await _context.Flashcards - .FirstOrDefaultAsync(f => f.Id == request.FlashcardId); - - if (flashcard == null) - { - _logger.LogWarning("Flashcard {FlashcardId} does not exist", request.FlashcardId); - return NotFound(new { Success = false, Error = "Flashcard does not exist" }); - } - - // 再檢查詞卡是否屬於用戶 - if (flashcard.UserId != userId) - { - _logger.LogWarning("Flashcard {FlashcardId} does not belong to user {UserId}, actual owner: {ActualOwner}", - request.FlashcardId, userId, flashcard.UserId); - return Forbid(); - } - - // 檢查是否已經完成過這個測驗 - var existingRecord = await _context.StudyRecords - .FirstOrDefaultAsync(r => r.UserId == userId && - r.FlashcardId == request.FlashcardId && - r.StudyMode == request.TestType); - - if (existingRecord != null) - { - return Conflict(new { Success = false, Error = "Test already completed", - CompletedAt = existingRecord.StudiedAt }); - } - - // 記錄測驗完成狀態 - var studyRecord = new StudyRecord - { - Id = Guid.NewGuid(), - UserId = userId.Value, - FlashcardId = request.FlashcardId, - SessionId = Guid.NewGuid(), // 臨時會話ID - StudyMode = request.TestType, // 記錄具體測驗類型 - QualityRating = request.ConfidenceLevel ?? (request.IsCorrect ? 4 : 2), - ResponseTimeMs = request.ResponseTimeMs, - UserAnswer = request.UserAnswer, - IsCorrect = request.IsCorrect, - StudiedAt = DateTime.UtcNow - }; - - _context.StudyRecords.Add(studyRecord); - await _context.SaveChangesAsync(); - - _logger.LogInformation("Recorded test completion: {TestType} for {Word}, correct: {IsCorrect}", - request.TestType, flashcard.Word, request.IsCorrect); - - return Ok(new - { - Success = true, - Data = new - { - RecordId = studyRecord.Id, - TestType = request.TestType, - IsCorrect = request.IsCorrect, - CompletedAt = studyRecord.StudiedAt - }, - Message = $"Test {request.TestType} recorded successfully" - }); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error recording test completion"); - return StatusCode(500, new - { - Success = false, - Error = "Failed to record test completion", - Timestamp = DateTime.UtcNow - }); - } - } -} - -// Request DTOs -public class CreateStudySessionRequest -{ - public string Mode { get; set; } = string.Empty; // flip, quiz, fill, listening, speaking - public List CardIds { get; set; } = new(); -} - -public class RecordStudyResultRequest -{ - public Guid FlashcardId { get; set; } - public int QualityRating { get; set; } // 1-5 - public int? ResponseTimeMs { get; set; } - public string? UserAnswer { get; set; } - public bool IsCorrect { get; set; } -} - -public class CompleteStudySessionRequest -{ - public int DurationSeconds { get; set; } -} - -public class RecordTestRequest -{ - public Guid FlashcardId { get; set; } - public string TestType { get; set; } = string.Empty; // flip-memory, vocab-choice, etc. - public bool IsCorrect { get; set; } - public string? UserAnswer { get; set; } - public int? ConfidenceLevel { get; set; } // 1-5 - public int? ResponseTimeMs { get; set; } -} \ No newline at end of file diff --git a/backend/DramaLing.Api/Controllers/StudySessionController.cs b/backend/DramaLing.Api/Controllers/StudySessionController.cs deleted file mode 100644 index 6f75a4e..0000000 --- a/backend/DramaLing.Api/Controllers/StudySessionController.cs +++ /dev/null @@ -1,276 +0,0 @@ -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Authorization; -using DramaLing.Api.Services; - -namespace DramaLing.Api.Controllers; - -[ApiController] -[Route("api/study/sessions")] -[Authorize] -public class StudySessionController : ControllerBase -{ - private readonly IStudySessionService _studySessionService; - private readonly IAuthService _authService; - private readonly ILogger _logger; - - public StudySessionController( - IStudySessionService studySessionService, - IAuthService authService, - ILogger logger) - { - _studySessionService = studySessionService; - _authService = authService; - _logger = logger; - } - - /// - /// 開始新的學習會話 - /// - [HttpPost("start")] - public async Task StartSession() - { - try - { - var userId = await _authService.GetUserIdFromTokenAsync(Request.Headers.Authorization); - if (userId == null) - return Unauthorized(new { Success = false, Error = "Invalid token" }); - - var session = await _studySessionService.StartSessionAsync(userId.Value); - - return Ok(new - { - Success = true, - Data = new - { - SessionId = session.Id, - TotalCards = session.TotalCards, - TotalTests = session.TotalTests, - CurrentCardIndex = session.CurrentCardIndex, - CurrentTestType = session.CurrentTestType, - StartedAt = session.StartedAt - }, - Message = $"Study session started with {session.TotalCards} cards and {session.TotalTests} tests" - }); - } - catch (InvalidOperationException ex) - { - return BadRequest(new { Success = false, Error = ex.Message }); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error starting study session"); - return StatusCode(500, new - { - Success = false, - Error = "Failed to start study session", - Timestamp = DateTime.UtcNow - }); - } - } - - /// - /// 獲取當前測驗 - /// - [HttpGet("{sessionId}/current-test")] - public async Task GetCurrentTest(Guid sessionId) - { - try - { - var userId = await _authService.GetUserIdFromTokenAsync(Request.Headers.Authorization); - if (userId == null) - return Unauthorized(new { Success = false, Error = "Invalid token" }); - - var currentTest = await _studySessionService.GetCurrentTestAsync(sessionId); - - return Ok(new - { - Success = true, - Data = currentTest - }); - } - catch (InvalidOperationException ex) - { - return BadRequest(new { Success = false, Error = ex.Message }); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting current test for session {SessionId}", sessionId); - return StatusCode(500, new - { - Success = false, - Error = "Failed to get current test", - Timestamp = DateTime.UtcNow - }); - } - } - - /// - /// 提交測驗結果 - /// - [HttpPost("{sessionId}/submit-test")] - public async Task SubmitTest(Guid sessionId, [FromBody] SubmitTestRequestDto request) - { - try - { - var userId = await _authService.GetUserIdFromTokenAsync(Request.Headers.Authorization); - if (userId == null) - return Unauthorized(new { Success = false, Error = "Invalid token" }); - - // 基本驗證 - if (string.IsNullOrEmpty(request.TestType)) - { - return BadRequest(new { Success = false, Error = "Test type is required" }); - } - - if (request.ConfidenceLevel.HasValue && (request.ConfidenceLevel < 1 || request.ConfidenceLevel > 5)) - { - return BadRequest(new { Success = false, Error = "Confidence level must be between 1 and 5" }); - } - - var response = await _studySessionService.SubmitTestAsync(sessionId, request); - - return Ok(new - { - Success = response.Success, - Data = new - { - IsCardCompleted = response.IsCardCompleted, - Progress = response.Progress - }, - Message = response.IsCardCompleted ? "Card completed!" : "Test recorded successfully" - }); - } - catch (InvalidOperationException ex) - { - return BadRequest(new { Success = false, Error = ex.Message }); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error submitting test for session {SessionId}", sessionId); - return StatusCode(500, new - { - Success = false, - Error = "Failed to submit test result", - Timestamp = DateTime.UtcNow - }); - } - } - - /// - /// 獲取下一個測驗 - /// - [HttpGet("{sessionId}/next-test")] - public async Task GetNextTest(Guid sessionId) - { - try - { - var userId = await _authService.GetUserIdFromTokenAsync(Request.Headers.Authorization); - if (userId == null) - return Unauthorized(new { Success = false, Error = "Invalid token" }); - - var nextTest = await _studySessionService.GetNextTestAsync(sessionId); - - return Ok(new - { - Success = true, - Data = nextTest - }); - } - catch (InvalidOperationException ex) - { - return BadRequest(new { Success = false, Error = ex.Message }); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting next test for session {SessionId}", sessionId); - return StatusCode(500, new - { - Success = false, - Error = "Failed to get next test", - Timestamp = DateTime.UtcNow - }); - } - } - - /// - /// 獲取詳細進度 - /// - [HttpGet("{sessionId}/progress")] - public async Task GetProgress(Guid sessionId) - { - try - { - var userId = await _authService.GetUserIdFromTokenAsync(Request.Headers.Authorization); - if (userId == null) - return Unauthorized(new { Success = false, Error = "Invalid token" }); - - var progress = await _studySessionService.GetProgressAsync(sessionId); - - return Ok(new - { - Success = true, - Data = progress - }); - } - catch (InvalidOperationException ex) - { - return BadRequest(new { Success = false, Error = ex.Message }); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting progress for session {SessionId}", sessionId); - return StatusCode(500, new - { - Success = false, - Error = "Failed to get progress", - Timestamp = DateTime.UtcNow - }); - } - } - - /// - /// 完成學習會話 - /// - [HttpPut("{sessionId}/complete")] - public async Task CompleteSession(Guid sessionId) - { - try - { - var userId = await _authService.GetUserIdFromTokenAsync(Request.Headers.Authorization); - if (userId == null) - return Unauthorized(new { Success = false, Error = "Invalid token" }); - - var session = await _studySessionService.CompleteSessionAsync(sessionId); - - return Ok(new - { - Success = true, - Data = new - { - SessionId = session.Id, - CompletedAt = session.EndedAt, - TotalCards = session.TotalCards, - CompletedCards = session.CompletedCards, - TotalTests = session.TotalTests, - CompletedTests = session.CompletedTests, - DurationSeconds = session.DurationSeconds - }, - Message = "Study session completed successfully" - }); - } - catch (InvalidOperationException ex) - { - return BadRequest(new { Success = false, Error = ex.Message }); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error completing session {SessionId}", sessionId); - return StatusCode(500, new - { - Success = false, - Error = "Failed to complete session", - Timestamp = DateTime.UtcNow - }); - } - } -} \ No newline at end of file diff --git a/backend/DramaLing.Api/Data/DramaLingDbContext.cs b/backend/DramaLing.Api/Data/DramaLingDbContext.cs index 332ce8d..34b838c 100644 --- a/backend/DramaLing.Api/Data/DramaLingDbContext.cs +++ b/backend/DramaLing.Api/Data/DramaLingDbContext.cs @@ -16,10 +16,7 @@ public class DramaLingDbContext : DbContext public DbSet Flashcards { get; set; } public DbSet Tags { get; set; } public DbSet FlashcardTags { get; set; } - public DbSet StudySessions { get; set; } - public DbSet StudyRecords { get; set; } - public DbSet StudyCards { get; set; } - public DbSet TestResults { get; set; } + // StudyRecord removed - study system cleaned public DbSet ErrorReports { get; set; } public DbSet DailyStats { get; set; } public DbSet SentenceAnalysisCache { get; set; } @@ -42,10 +39,7 @@ public class DramaLingDbContext : DbContext modelBuilder.Entity().ToTable("flashcards"); modelBuilder.Entity().ToTable("tags"); modelBuilder.Entity().ToTable("flashcard_tags"); - modelBuilder.Entity().ToTable("study_sessions"); - modelBuilder.Entity().ToTable("study_records"); - modelBuilder.Entity().ToTable("study_cards"); - modelBuilder.Entity().ToTable("test_results"); + // StudyRecord table removed modelBuilder.Entity().ToTable("error_reports"); modelBuilder.Entity().ToTable("daily_stats"); modelBuilder.Entity().ToTable("audio_cache"); @@ -59,7 +53,7 @@ public class DramaLingDbContext : DbContext // 配置屬性名稱 (snake_case) ConfigureUserEntity(modelBuilder); ConfigureFlashcardEntity(modelBuilder); - ConfigureStudyEntities(modelBuilder); + // ConfigureStudyEntities removed ConfigureTagEntities(modelBuilder); ConfigureErrorReportEntity(modelBuilder); ConfigureDailyStatsEntity(modelBuilder); @@ -133,20 +127,10 @@ public class DramaLingDbContext : DbContext private void ConfigureStudyEntities(ModelBuilder modelBuilder) { - var sessionEntity = modelBuilder.Entity(); - sessionEntity.Property(s => s.UserId).HasColumnName("user_id"); - sessionEntity.Property(s => s.SessionType).HasColumnName("session_type"); - sessionEntity.Property(s => s.StartedAt).HasColumnName("started_at"); - sessionEntity.Property(s => s.EndedAt).HasColumnName("ended_at"); - sessionEntity.Property(s => s.TotalCards).HasColumnName("total_cards"); - sessionEntity.Property(s => s.CorrectCount).HasColumnName("correct_count"); - sessionEntity.Property(s => s.DurationSeconds).HasColumnName("duration_seconds"); - sessionEntity.Property(s => s.AverageResponseTimeMs).HasColumnName("average_response_time_ms"); - var recordEntity = modelBuilder.Entity(); recordEntity.Property(r => r.UserId).HasColumnName("user_id"); recordEntity.Property(r => r.FlashcardId).HasColumnName("flashcard_id"); - recordEntity.Property(r => r.SessionId).HasColumnName("session_id"); + // SessionId property removed recordEntity.Property(r => r.StudyMode).HasColumnName("study_mode"); recordEntity.Property(r => r.QualityRating).HasColumnName("quality_rating"); recordEntity.Property(r => r.ResponseTimeMs).HasColumnName("response_time_ms"); @@ -208,11 +192,6 @@ public class DramaLingDbContext : DbContext .OnDelete(DeleteBehavior.Cascade); // Study relationships - modelBuilder.Entity() - .HasOne(ss => ss.User) - .WithMany(u => u.StudySessions) - .HasForeignKey(ss => ss.UserId) - .OnDelete(DeleteBehavior.Cascade); modelBuilder.Entity() .HasOne(sr => sr.Flashcard) @@ -333,15 +312,14 @@ public class DramaLingDbContext : DbContext pronunciationEntity.Property(pa => pa.ProsodyScore).HasColumnName("prosody_score"); pronunciationEntity.Property(pa => pa.PhonemeScores).HasColumnName("phoneme_scores"); pronunciationEntity.Property(pa => pa.Suggestions).HasColumnName("suggestions"); - pronunciationEntity.Property(pa => pa.StudySessionId).HasColumnName("study_session_id"); + // StudySessionId removed pronunciationEntity.Property(pa => pa.PracticeMode).HasColumnName("practice_mode"); pronunciationEntity.Property(pa => pa.CreatedAt).HasColumnName("created_at"); pronunciationEntity.HasIndex(pa => new { pa.UserId, pa.FlashcardId }) .HasDatabaseName("IX_PronunciationAssessment_UserFlashcard"); - pronunciationEntity.HasIndex(pa => pa.StudySessionId) - .HasDatabaseName("IX_PronunciationAssessment_Session"); + // StudySessionId index removed // UserAudioPreferences configuration var audioPrefsEntity = modelBuilder.Entity(); @@ -371,11 +349,7 @@ public class DramaLingDbContext : DbContext .HasForeignKey(pa => pa.FlashcardId) .OnDelete(DeleteBehavior.SetNull); - modelBuilder.Entity() - .HasOne(pa => pa.StudySession) - .WithMany() - .HasForeignKey(pa => pa.StudySessionId) - .OnDelete(DeleteBehavior.SetNull); + // StudySession relationship removed // UserAudioPreferences relationship modelBuilder.Entity() diff --git a/backend/DramaLing.Api/Models/Configuration/SpacedRepetitionOptions.cs b/backend/DramaLing.Api/Models/Configuration/SpacedRepetitionOptions.cs deleted file mode 100644 index 87dd493..0000000 --- a/backend/DramaLing.Api/Models/Configuration/SpacedRepetitionOptions.cs +++ /dev/null @@ -1,124 +0,0 @@ -namespace DramaLing.Api.Models.Configuration; - -/// -/// 智能複習系統配置選項 -/// -public class SpacedRepetitionOptions -{ - public const string SectionName = "SpacedRepetition"; - - /// - /// 間隔增長係數 (基於演算法規格書) - /// - public GrowthFactors GrowthFactors { get; set; } = new(); - - /// - /// 逾期懲罰係數 - /// - public OverduePenalties OverduePenalties { get; set; } = new(); - - /// - /// 記憶衰減率 (每天百分比) - /// - public double MemoryDecayRate { get; set; } = 0.05; - - /// - /// 最大間隔天數 - /// - public int MaxInterval { get; set; } = 365; - - /// - /// A1學習者保護門檻 - /// - public int A1ProtectionLevel { get; set; } = 20; - - /// - /// 新用戶預設程度 - /// - public int DefaultUserLevel { get; set; } = 50; -} - -/// -/// 間隔增長係數配置 -/// -public class GrowthFactors -{ - /// - /// 短期間隔係數 (≤7天) - /// - public double ShortTerm { get; set; } = 1.8; - - /// - /// 中期間隔係數 (8-30天) - /// - public double MediumTerm { get; set; } = 1.4; - - /// - /// 長期間隔係數 (31-90天) - /// - public double LongTerm { get; set; } = 1.2; - - /// - /// 超長期間隔係數 (>90天) - /// - public double VeryLongTerm { get; set; } = 1.1; - - /// - /// 根據當前間隔獲取增長係數 - /// - /// 當前間隔天數 - /// 對應的增長係數 - public double GetGrowthFactor(int currentInterval) - { - return currentInterval switch - { - <= 7 => ShortTerm, - <= 30 => MediumTerm, - <= 90 => LongTerm, - _ => VeryLongTerm - }; - } -} - -/// -/// 逾期懲罰係數配置 -/// -public class OverduePenalties -{ - /// - /// 輕度逾期係數 (1-3天) - /// - public double Light { get; set; } = 0.9; - - /// - /// 中度逾期係數 (4-7天) - /// - public double Medium { get; set; } = 0.75; - - /// - /// 重度逾期係數 (8-30天) - /// - public double Heavy { get; set; } = 0.5; - - /// - /// 極度逾期係數 (>30天) - /// - public double Extreme { get; set; } = 0.3; - - /// - /// 根據逾期天數獲取懲罰係數 - /// - /// 逾期天數 - /// 對應的懲罰係數 - public double GetPenaltyFactor(int overdueDays) - { - return overdueDays switch - { - <= 0 => 1.0, // 準時,無懲罰 - <= 3 => Light, // 輕度逾期 - <= 7 => Medium, // 中度逾期 - <= 30 => Heavy, // 重度逾期 - _ => Extreme // 極度逾期 - }; - } -} \ No newline at end of file diff --git a/backend/DramaLing.Api/Models/DTOs/SpacedRepetition/OptimalModeRequest.cs b/backend/DramaLing.Api/Models/DTOs/SpacedRepetition/OptimalModeRequest.cs deleted file mode 100644 index 3da4433..0000000 --- a/backend/DramaLing.Api/Models/DTOs/SpacedRepetition/OptimalModeRequest.cs +++ /dev/null @@ -1,28 +0,0 @@ -using System.ComponentModel.DataAnnotations; - -namespace DramaLing.Api.Models.DTOs.SpacedRepetition; - -/// -/// 自動選擇最適合複習模式請求 (基於CEFR等級) -/// -public class OptimalModeRequest -{ - /// - /// 學習者CEFR等級 (A1, A2, B1, B2, C1, C2) - /// - [Required] - [MaxLength(10)] - public string UserCEFRLevel { get; set; } = "B1"; - - /// - /// 詞彙CEFR等級 (A1, A2, B1, B2, C1, C2) - /// - [Required] - [MaxLength(10)] - public string WordCEFRLevel { get; set; } = "B1"; - - /// - /// 是否包含歷史記錄進行智能避重 - /// - public bool IncludeHistory { get; set; } = true; -} \ No newline at end of file diff --git a/backend/DramaLing.Api/Models/DTOs/SpacedRepetition/QuestionData.cs b/backend/DramaLing.Api/Models/DTOs/SpacedRepetition/QuestionData.cs deleted file mode 100644 index bc1887b..0000000 --- a/backend/DramaLing.Api/Models/DTOs/SpacedRepetition/QuestionData.cs +++ /dev/null @@ -1,42 +0,0 @@ -namespace DramaLing.Api.Models.DTOs.SpacedRepetition; - -/// -/// 題目數據響應 -/// -public class QuestionData -{ - /// - /// 題型類型 - /// - public string QuestionType { get; set; } = string.Empty; - - /// - /// 選擇題選項 (用於vocab-choice, sentence-listening) - /// - public string[]? Options { get; set; } - - /// - /// 正確答案 - /// - public string CorrectAnswer { get; set; } = string.Empty; - - /// - /// 音頻URL (用於聽力題) - /// - public string? AudioUrl { get; set; } - - /// - /// 完整例句 (用於sentence-listening) - /// - public string? Sentence { get; set; } - - /// - /// 挖空例句 (用於sentence-fill) - /// - public string? BlankedSentence { get; set; } - - /// - /// 打亂的單字 (用於sentence-reorder) - /// - public string[]? ScrambledWords { get; set; } -} \ No newline at end of file diff --git a/backend/DramaLing.Api/Models/DTOs/SpacedRepetition/QuestionRequest.cs b/backend/DramaLing.Api/Models/DTOs/SpacedRepetition/QuestionRequest.cs deleted file mode 100644 index 11b2c7c..0000000 --- a/backend/DramaLing.Api/Models/DTOs/SpacedRepetition/QuestionRequest.cs +++ /dev/null @@ -1,16 +0,0 @@ -using System.ComponentModel.DataAnnotations; - -namespace DramaLing.Api.Models.DTOs.SpacedRepetition; - -/// -/// 題目生成請求 -/// -public class QuestionRequest -{ - /// - /// 題型類型 - /// - [Required] - [RegularExpression("^(vocab-choice|sentence-listening|sentence-fill|sentence-reorder)$")] - public string QuestionType { get; set; } = string.Empty; -} \ No newline at end of file diff --git a/backend/DramaLing.Api/Models/DTOs/SpacedRepetition/ReviewModeResult.cs b/backend/DramaLing.Api/Models/DTOs/SpacedRepetition/ReviewModeResult.cs deleted file mode 100644 index 6536799..0000000 --- a/backend/DramaLing.Api/Models/DTOs/SpacedRepetition/ReviewModeResult.cs +++ /dev/null @@ -1,27 +0,0 @@ -namespace DramaLing.Api.Models.DTOs.SpacedRepetition; - -/// -/// 智能複習模式選擇結果 -/// -public class ReviewModeResult -{ - /// - /// 系統選擇的複習模式 - /// - public string SelectedMode { get; set; } = string.Empty; - - /// - /// 選擇原因說明 - /// - public string Reason { get; set; } = string.Empty; - - /// - /// 可用的複習模式列表 - /// - public string[] AvailableModes { get; set; } = Array.Empty(); - - /// - /// 適配情境描述 - /// - public string AdaptationContext { get; set; } = string.Empty; -} \ No newline at end of file diff --git a/backend/DramaLing.Api/Models/DTOs/SpacedRepetition/ReviewRequest.cs b/backend/DramaLing.Api/Models/DTOs/SpacedRepetition/ReviewRequest.cs deleted file mode 100644 index 911aeb1..0000000 --- a/backend/DramaLing.Api/Models/DTOs/SpacedRepetition/ReviewRequest.cs +++ /dev/null @@ -1,43 +0,0 @@ -using System.ComponentModel.DataAnnotations; - -namespace DramaLing.Api.Models.DTOs.SpacedRepetition; - -/// -/// 復習結果提交請求 -/// -public class ReviewRequest -{ - /// - /// 答題是否正確 - /// - [Required] - public bool IsCorrect { get; set; } - - /// - /// 信心程度 (1-5,翻卡題必須) - /// - [Range(1, 5)] - public int? ConfidenceLevel { get; set; } - - /// - /// 題型類型 - /// - [Required] - [RegularExpression("^(flip-memory|vocab-choice|vocab-listening|sentence-listening|sentence-fill|sentence-reorder|sentence-speaking)$")] - public string QuestionType { get; set; } = string.Empty; - - /// - /// 用戶的答案 (可選) - /// - public string? UserAnswer { get; set; } - - /// - /// 答題時間 (毫秒) - /// - public long? TimeTaken { get; set; } - - /// - /// 時間戳記 - /// - public long Timestamp { get; set; } = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); -} \ No newline at end of file diff --git a/backend/DramaLing.Api/Models/DTOs/SpacedRepetition/ReviewResult.cs b/backend/DramaLing.Api/Models/DTOs/SpacedRepetition/ReviewResult.cs deleted file mode 100644 index 9837a5f..0000000 --- a/backend/DramaLing.Api/Models/DTOs/SpacedRepetition/ReviewResult.cs +++ /dev/null @@ -1,52 +0,0 @@ -namespace DramaLing.Api.Models.DTOs.SpacedRepetition; - -/// -/// 復習結果響應 -/// -public class ReviewResult -{ - /// - /// 新的間隔天數 - /// - public int NewInterval { get; set; } - - /// - /// 下次復習日期 - /// - public DateTime NextReviewDate { get; set; } - - /// - /// 更新後的熟悉度 - /// - public int MasteryLevel { get; set; } - - /// - /// 當前熟悉度 (考慮衰減) - /// - public int CurrentMasteryLevel { get; set; } - - /// - /// 是否逾期 - /// - public bool IsOverdue { get; set; } - - /// - /// 逾期天數 - /// - public int OverdueDays { get; set; } - - /// - /// 表現係數 (調試用) - /// - public double PerformanceFactor { get; set; } - - /// - /// 增長係數 (調試用) - /// - public double GrowthFactor { get; set; } - - /// - /// 逾期懲罰係數 (調試用) - /// - public double PenaltyFactor { get; set; } -} \ No newline at end of file diff --git a/backend/DramaLing.Api/Models/Entities/Flashcard.cs b/backend/DramaLing.Api/Models/Entities/Flashcard.cs index a350657..46430a0 100644 --- a/backend/DramaLing.Api/Models/Entities/Flashcard.cs +++ b/backend/DramaLing.Api/Models/Entities/Flashcard.cs @@ -2,6 +2,9 @@ using System.ComponentModel.DataAnnotations; namespace DramaLing.Api.Models.Entities; +/// +/// 簡化的詞卡實體 - 移除所有複習功能 +/// public class Flashcard { public Guid Id { get; set; } @@ -16,8 +19,7 @@ public class Flashcard [Required] public string Translation { get; set; } = string.Empty; - [Required] - public string Definition { get; set; } = string.Empty; + public string? Definition { get; set; } [MaxLength(50)] public string? PartOfSpeech { get; set; } @@ -29,32 +31,7 @@ public class Flashcard public string? ExampleTranslation { get; set; } - [MaxLength(1000)] - public string? FilledQuestionText { get; set; } - - [MaxLength(2000)] - public string? Synonyms { get; set; } // JSON 格式儲存同義詞列表 - - // SM-2 算法參數 - public float EasinessFactor { get; set; } = 2.5f; - - public int Repetitions { get; set; } = 0; - - public int IntervalDays { get; set; } = 1; - - public DateTime NextReviewDate { get; set; } = DateTime.Today; - - // 學習統計 - [Range(0, 100)] - public int MasteryLevel { get; set; } = 0; - - public int TimesReviewed { get; set; } = 0; - - public int TimesCorrect { get; set; } = 0; - - public DateTime? LastReviewedAt { get; set; } - - // 狀態 + // 基本狀態 public bool IsFavorite { get; set; } = false; public bool IsArchived { get; set; } = false; @@ -62,20 +39,11 @@ public class Flashcard [MaxLength(10)] public string? DifficultyLevel { get; set; } // A1, A2, B1, B2, C1, C2 - // 🆕 智能複習系統欄位 - // UserLevel和WordLevel已移除 - 改用即時CEFR轉換 - - public string? ReviewHistory { get; set; } // JSON格式的復習歷史 - - [MaxLength(50)] - public string? LastQuestionType { get; set; } // 最後使用的題型 - public DateTime CreatedAt { get; set; } = DateTime.UtcNow; public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; // Navigation Properties public virtual User User { get; set; } = null!; - public virtual ICollection StudyRecords { get; set; } = new List(); public virtual ICollection FlashcardTags { get; set; } = new List(); public virtual ICollection ErrorReports { get; set; } = new List(); public virtual ICollection FlashcardExampleImages { get; set; } = new List(); diff --git a/backend/DramaLing.Api/Models/Entities/PronunciationAssessment.cs b/backend/DramaLing.Api/Models/Entities/PronunciationAssessment.cs index ef715ec..212ec11 100644 --- a/backend/DramaLing.Api/Models/Entities/PronunciationAssessment.cs +++ b/backend/DramaLing.Api/Models/Entities/PronunciationAssessment.cs @@ -29,7 +29,7 @@ public class PronunciationAssessment public string[]? Suggestions { get; set; } // 學習情境 - public Guid? StudySessionId { get; set; } + // StudySessionId removed [MaxLength(20)] public string PracticeMode { get; set; } = "word"; // 'word', 'sentence', 'conversation' @@ -39,5 +39,5 @@ public class PronunciationAssessment // Navigation properties public User User { get; set; } = null!; public Flashcard? Flashcard { get; set; } - public StudySession? StudySession { get; set; } + // StudySession reference removed } \ No newline at end of file diff --git a/backend/DramaLing.Api/Models/Entities/StudyCard.cs b/backend/DramaLing.Api/Models/Entities/StudyCard.cs deleted file mode 100644 index 03d6044..0000000 --- a/backend/DramaLing.Api/Models/Entities/StudyCard.cs +++ /dev/null @@ -1,95 +0,0 @@ -using System.ComponentModel.DataAnnotations; - -namespace DramaLing.Api.Models.Entities; - -/// -/// 學習會話中的詞卡進度追蹤 -/// -public class StudyCard -{ - public Guid Id { get; set; } - - public Guid StudySessionId { get; set; } - - public Guid FlashcardId { get; set; } - - [Required] - [MaxLength(100)] - public string Word { get; set; } = string.Empty; - - /// - /// 該詞卡預定的測驗類型列表 (JSON序列化) - /// 例如: ["flip-memory", "vocab-choice", "sentence-fill"] - /// - [Required] - public string PlannedTestsJson { get; set; } = string.Empty; - - /// - /// 詞卡在會話中的順序 - /// - public int Order { get; set; } - - /// - /// 是否已完成所有測驗 - /// - public bool IsCompleted { get; set; } = false; - - /// - /// 詞卡學習開始時間 - /// - public DateTime StartedAt { get; set; } = DateTime.UtcNow; - - /// - /// 詞卡學習完成時間 - /// - public DateTime? CompletedAt { get; set; } - - // Navigation Properties - public virtual StudySession StudySession { get; set; } = null!; - public virtual Flashcard Flashcard { get; set; } = null!; - public virtual ICollection TestResults { get; set; } = new List(); - - // Helper Properties (不映射到資料庫) - public List PlannedTests - { - get => string.IsNullOrEmpty(PlannedTestsJson) - ? new List() - : System.Text.Json.JsonSerializer.Deserialize>(PlannedTestsJson) ?? new List(); - set => PlannedTestsJson = System.Text.Json.JsonSerializer.Serialize(value); - } - - public int CompletedTestsCount => TestResults?.Count ?? 0; - public int PlannedTestsCount => PlannedTests.Count; - public bool AllTestsCompleted => CompletedTestsCount >= PlannedTestsCount; -} - -/// -/// 詞卡內的測驗結果記錄 -/// -public class TestResult -{ - public Guid Id { get; set; } - - public Guid StudyCardId { get; set; } - - [Required] - [MaxLength(50)] - public string TestType { get; set; } = string.Empty; // flip-memory, vocab-choice, etc. - - public bool IsCorrect { get; set; } - - public string? UserAnswer { get; set; } - - /// - /// 信心等級 (1-5, 主要用於翻卡記憶測驗) - /// - [Range(1, 5)] - public int? ConfidenceLevel { get; set; } - - public int ResponseTimeMs { get; set; } - - public DateTime CompletedAt { get; set; } = DateTime.UtcNow; - - // Navigation Properties - public virtual StudyCard StudyCard { get; set; } = null!; -} \ No newline at end of file diff --git a/backend/DramaLing.Api/Models/Entities/StudySession.cs b/backend/DramaLing.Api/Models/Entities/StudySession.cs deleted file mode 100644 index ee4e547..0000000 --- a/backend/DramaLing.Api/Models/Entities/StudySession.cs +++ /dev/null @@ -1,116 +0,0 @@ -using System.ComponentModel.DataAnnotations; - -namespace DramaLing.Api.Models.Entities; - -/// -/// 會話狀態枚舉 -/// -public enum SessionStatus -{ - Active, // 進行中 - Completed, // 已完成 - Paused, // 暫停 - Abandoned // 放棄 -} - -/// -/// 學習會話實體 (擴展版本) -/// -public class StudySession -{ - public Guid Id { get; set; } - - public Guid UserId { get; set; } - - [Required] - [MaxLength(50)] - public string SessionType { get; set; } = string.Empty; // flip, quiz, fill, listening, speaking - - public DateTime StartedAt { get; set; } = DateTime.UtcNow; - - public DateTime? EndedAt { get; set; } - - public int TotalCards { get; set; } = 0; - - public int CorrectCount { get; set; } = 0; - - public int DurationSeconds { get; set; } = 0; - - public int AverageResponseTimeMs { get; set; } = 0; - - /// - /// 會話狀態 - /// - public SessionStatus Status { get; set; } = SessionStatus.Active; - - /// - /// 當前詞卡索引 (從0開始) - /// - public int CurrentCardIndex { get; set; } = 0; - - /// - /// 當前測驗類型 - /// - [MaxLength(50)] - public string? CurrentTestType { get; set; } - - /// - /// 總測驗數量 (所有詞卡的測驗總和) - /// - public int TotalTests { get; set; } = 0; - - /// - /// 已完成測驗數量 - /// - public int CompletedTests { get; set; } = 0; - - /// - /// 已完成詞卡數量 - /// - public int CompletedCards { get; set; } = 0; - - // Navigation Properties - public virtual User User { get; set; } = null!; - public virtual ICollection StudyRecords { get; set; } = new List(); - public virtual ICollection StudyCards { get; set; } = new List(); -} - -public class StudyRecord -{ - public Guid Id { get; set; } - - public Guid UserId { get; set; } - - public Guid FlashcardId { get; set; } - - public Guid SessionId { get; set; } - - [Required] - [MaxLength(50)] - public string StudyMode { get; set; } = string.Empty; - - [Range(1, 5)] - public int QualityRating { get; set; } - - public int? ResponseTimeMs { get; set; } - - public string? UserAnswer { get; set; } - - public bool IsCorrect { get; set; } - - // SM-2 算法記錄 - public float PreviousEasinessFactor { get; set; } - public float NewEasinessFactor { get; set; } - public int PreviousIntervalDays { get; set; } - public int NewIntervalDays { get; set; } - public int PreviousRepetitions { get; set; } - public int NewRepetitions { get; set; } - public DateTime NextReviewDate { get; set; } - - public DateTime StudiedAt { get; set; } = DateTime.UtcNow; - - // Navigation Properties - public virtual User User { get; set; } = null!; - public virtual Flashcard Flashcard { get; set; } = null!; - public virtual StudySession Session { get; set; } = null!; -} \ No newline at end of file diff --git a/backend/DramaLing.Api/Models/Entities/User.cs b/backend/DramaLing.Api/Models/Entities/User.cs index 35a5cc0..218fecf 100644 --- a/backend/DramaLing.Api/Models/Entities/User.cs +++ b/backend/DramaLing.Api/Models/Entities/User.cs @@ -45,7 +45,7 @@ public class User // Navigation Properties public virtual ICollection Flashcards { get; set; } = new List(); public virtual UserSettings? Settings { get; set; } - public virtual ICollection StudySessions { get; set; } = new List(); + // StudySession collection removed public virtual ICollection ErrorReports { get; set; } = new List(); public virtual ICollection DailyStats { get; set; } = new List(); } \ No newline at end of file diff --git a/backend/DramaLing.Api/Program.cs b/backend/DramaLing.Api/Program.cs index e1a108c..d23cda8 100644 --- a/backend/DramaLing.Api/Program.cs +++ b/backend/DramaLing.Api/Program.cs @@ -88,21 +88,12 @@ builder.Services.AddHttpClient(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); -// 智能填空題系統服務 -builder.Services.AddScoped(); -builder.Services.AddScoped(); +// 智能填空題系統服務已移除 builder.Services.AddScoped(); -// 🆕 智能複習服務註冊 -builder.Services.Configure( - builder.Configuration.GetSection(SpacedRepetitionOptions.SectionName)); -builder.Services.AddScoped(); -builder.Services.AddScoped(); -builder.Services.AddScoped(); +// 智能複習服務已移除,準備重新實施 -// 🆕 學習會話服務註冊 -builder.Services.AddScoped(); -builder.Services.AddScoped(); +// 學習會話服務已清理移除 // 🆕 選項詞彙庫服務註冊 builder.Services.Configure( diff --git a/backend/DramaLing.Api/Services/BlankGenerationService.cs b/backend/DramaLing.Api/Services/BlankGenerationService.cs deleted file mode 100644 index 0fd243d..0000000 --- a/backend/DramaLing.Api/Services/BlankGenerationService.cs +++ /dev/null @@ -1,138 +0,0 @@ -using System.Text.RegularExpressions; - -namespace DramaLing.Api.Services; - -public interface IBlankGenerationService -{ - Task GenerateBlankQuestionAsync(string word, string example); - string? TryProgrammaticBlank(string word, string example); - Task GenerateAIBlankAsync(string word, string example); - bool HasValidBlank(string blankQuestion); -} - -public class BlankGenerationService : IBlankGenerationService -{ - private readonly IWordVariationService _wordVariationService; - private readonly IGeminiService _geminiService; - private readonly ILogger _logger; - - public BlankGenerationService( - IWordVariationService wordVariationService, - IGeminiService geminiService, - ILogger logger) - { - _wordVariationService = wordVariationService; - _geminiService = geminiService; - _logger = logger; - } - - public async Task GenerateBlankQuestionAsync(string word, string example) - { - if (string.IsNullOrEmpty(word) || string.IsNullOrEmpty(example)) - { - _logger.LogWarning("Invalid input - word or example is null/empty"); - return null; - } - - _logger.LogInformation("Generating blank question for word: {Word}, example: {Example}", - word, example); - - // Step 1: 嘗試程式碼挖空 - var programmaticResult = TryProgrammaticBlank(word, example); - if (!string.IsNullOrEmpty(programmaticResult)) - { - _logger.LogInformation("Successfully generated programmatic blank for word: {Word}", word); - return programmaticResult; - } - - // Step 2: 程式碼挖空失敗,嘗試 AI 挖空 - _logger.LogInformation("Programmatic blank failed for word: {Word}, trying AI blank", word); - var aiResult = await GenerateAIBlankAsync(word, example); - - if (!string.IsNullOrEmpty(aiResult)) - { - _logger.LogInformation("Successfully generated AI blank for word: {Word}", word); - return aiResult; - } - - _logger.LogWarning("Both programmatic and AI blank generation failed for word: {Word}", word); - return null; - } - - public string? TryProgrammaticBlank(string word, string example) - { - try - { - _logger.LogDebug("Attempting programmatic blank for word: {Word}", word); - - // 1. 完全匹配 (不區分大小寫) - var exactMatch = Regex.Replace(example, $@"\b{Regex.Escape(word)}\b", "____", RegexOptions.IgnoreCase); - if (exactMatch != example) - { - _logger.LogDebug("Exact match blank successful for word: {Word}", word); - return exactMatch; - } - - // 2. 常見變形處理 - var variations = _wordVariationService.GetCommonVariations(word); - foreach(var variation in variations) - { - var variantMatch = Regex.Replace(example, $@"\b{Regex.Escape(variation)}\b", "____", RegexOptions.IgnoreCase); - if (variantMatch != example) - { - _logger.LogDebug("Variation match blank successful for word: {Word}, variation: {Variation}", - word, variation); - return variantMatch; - } - } - - _logger.LogDebug("Programmatic blank failed for word: {Word}", word); - return null; // 挖空失敗 - } - catch (Exception ex) - { - _logger.LogError(ex, "Error in programmatic blank for word: {Word}", word); - return null; - } - } - - public async Task GenerateAIBlankAsync(string word, string example) - { - try - { - var prompt = $@" -請將以下例句中與詞彙「{word}」相關的詞挖空,用____替代: - -詞彙: {word} -例句: {example} - -規則: -1. 只挖空與目標詞彙相關的詞(包含變形、時態、複數等) -2. 用____替代被挖空的詞 -3. 保持句子其他部分不變 -4. 直接返回挖空後的句子,不要額外說明 - -挖空後的句子:"; - - _logger.LogInformation("Generating AI blank for word: {Word}, example: {Example}", - word, example); - - // 暫時使用程式碼邏輯,AI 功能將在後續版本實現 - // TODO: 整合 Gemini API 進行智能挖空 - _logger.LogInformation("AI blank generation not yet implemented, returning null"); - return null; - } - catch (Exception ex) - { - _logger.LogError(ex, "Error generating AI blank for word: {Word}", word); - return null; - } - } - - public bool HasValidBlank(string blankQuestion) - { - var isValid = !string.IsNullOrEmpty(blankQuestion) && blankQuestion.Contains("____"); - _logger.LogDebug("Validating blank question: {IsValid}", isValid); - return isValid; - } -} \ No newline at end of file diff --git a/backend/DramaLing.Api/Services/Domain/Learning/ISpacedRepetitionService.cs b/backend/DramaLing.Api/Services/Domain/Learning/ISpacedRepetitionService.cs deleted file mode 100644 index cc3b0b1..0000000 --- a/backend/DramaLing.Api/Services/Domain/Learning/ISpacedRepetitionService.cs +++ /dev/null @@ -1,254 +0,0 @@ -using DramaLing.Api.Services; - -namespace DramaLing.Api.Services.Domain.Learning; - -/// -/// 間隔重複學習服務介面 -/// -public interface ISpacedRepetitionService -{ - /// - /// 計算下次複習時間 - /// - Task CalculateNextReviewAsync(ReviewInput input); - - /// - /// 更新學習進度 - /// - Task UpdateStudyProgressAsync(Guid flashcardId, int qualityRating, Guid userId); - - /// - /// 取得今日應複習的詞卡 - /// - Task> GetDueCardsAsync(Guid userId, int limit = 20); - - /// - /// 取得學習統計 - /// - Task GetLearningAnalyticsAsync(Guid userId); - - /// - /// 優化學習序列 - /// - Task GenerateStudyPlanAsync(Guid userId, int targetMinutes); -} - -/// -/// 複習輸入參數 -/// -public class ReviewInput -{ - public Guid FlashcardId { get; set; } - public Guid UserId { get; set; } - public int QualityRating { get; set; } // 1-5 (SM2 標準) - public int CurrentRepetitions { get; set; } - public float CurrentEasinessFactor { get; set; } - public int CurrentIntervalDays { get; set; } - public DateTime LastReviewDate { get; set; } -} - -/// -/// 複習排程結果 -/// -public class ReviewSchedule -{ - public DateTime NextReviewDate { get; set; } - public int NewIntervalDays { get; set; } - public float NewEasinessFactor { get; set; } - public int NewRepetitions { get; set; } - public int NewMasteryLevel { get; set; } - public string RecommendedAction { get; set; } = string.Empty; -} - -/// -/// 學習進度 -/// -public class StudyProgress -{ - public Guid FlashcardId { get; set; } - public bool IsImproved { get; set; } - public int PreviousMasteryLevel { get; set; } - public int NewMasteryLevel { get; set; } - public DateTime NextReviewDate { get; set; } - public string ProgressMessage { get; set; } = string.Empty; -} - -/// -/// 複習卡片 -/// -public class ReviewCard -{ - public Guid Id { get; set; } - public string Word { get; set; } = string.Empty; - public string Translation { get; set; } = string.Empty; - public string DifficultyLevel { get; set; } = string.Empty; - public int MasteryLevel { get; set; } - public DateTime NextReviewDate { get; set; } - public int DaysSinceLastReview { get; set; } - public int ReviewPriority { get; set; } // 1-5 (5 最高) -} - -/// -/// 學習分析 -/// -public class LearningAnalytics -{ - public int TotalCards { get; set; } - public int DueCards { get; set; } - public int OverdueCards { get; set; } - public int MasteredCards { get; set; } - public double RetentionRate { get; set; } - public TimeSpan AverageStudyInterval { get; set; } - public Dictionary DifficultyDistribution { get; set; } = new(); - public List RecentPerformance { get; set; } = new(); -} - -/// -/// 每日學習統計 -/// -public class DailyStudyStats -{ - public DateOnly Date { get; set; } - public int CardsReviewed { get; set; } - public int CorrectAnswers { get; set; } - public double AccuracyRate => CardsReviewed > 0 ? (double)CorrectAnswers / CardsReviewed : 0; - public TimeSpan StudyDuration { get; set; } -} - -/// -/// 優化學習計劃 -/// -public class OptimizedStudyPlan -{ - public IEnumerable RecommendedCards { get; set; } = new List(); - public int EstimatedMinutes { get; set; } - public string StudyFocus { get; set; } = string.Empty; // "複習", "新學習", "加強練習" - public Dictionary LevelBreakdown { get; set; } = new(); - public string RecommendationReason { get; set; } = string.Empty; -} - -/// -/// 間隔重複學習服務實作 -/// -public class SpacedRepetitionService : ISpacedRepetitionService -{ - private readonly ILogger _logger; - - public SpacedRepetitionService(ILogger logger) - { - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - } - - public Task CalculateNextReviewAsync(ReviewInput input) - { - try - { - // 使用現有的 SM2Algorithm - var sm2Input = new SM2Input( - input.QualityRating, - input.CurrentEasinessFactor, - input.CurrentRepetitions, - input.CurrentIntervalDays - ); - - var sm2Result = SM2Algorithm.Calculate(sm2Input); - - var schedule = new ReviewSchedule - { - NextReviewDate = sm2Result.NextReviewDate, - NewIntervalDays = sm2Result.IntervalDays, - NewEasinessFactor = sm2Result.EasinessFactor, - NewRepetitions = sm2Result.Repetitions, - NewMasteryLevel = CalculateMasteryLevel(sm2Result.EasinessFactor, sm2Result.Repetitions), - RecommendedAction = GetRecommendedAction(input.QualityRating) - }; - - return Task.FromResult(schedule); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error calculating next review for flashcard {FlashcardId}", input.FlashcardId); - throw; - } - } - - public Task UpdateStudyProgressAsync(Guid flashcardId, int qualityRating, Guid userId) - { - // 這裡應該整合 Repository 來獲取和更新詞卡數據 - // 暫時返回模擬結果 - var progress = new StudyProgress - { - FlashcardId = flashcardId, - IsImproved = qualityRating >= 3, - ProgressMessage = GetProgressMessage(qualityRating) - }; - - return Task.FromResult(progress); - } - - public Task> GetDueCardsAsync(Guid userId, int limit = 20) - { - // 需要整合 Repository 來實作 - var cards = new List(); - return Task.FromResult>(cards); - } - - public Task GetLearningAnalyticsAsync(Guid userId) - { - // 需要整合 Repository 來實作 - var analytics = new LearningAnalytics(); - return Task.FromResult(analytics); - } - - public Task GenerateStudyPlanAsync(Guid userId, int targetMinutes) - { - // 需要整合 Repository 和 AI 服務來實作 - var plan = new OptimizedStudyPlan - { - EstimatedMinutes = targetMinutes, - StudyFocus = "複習", - RecommendationReason = "基於間隔重複算法的個人化推薦" - }; - - return Task.FromResult(plan); - } - - #region 私有方法 - - private int CalculateMasteryLevel(float easinessFactor, int repetitions) - { - // 根據難度係數和重複次數計算掌握程度 - if (repetitions >= 5 && easinessFactor >= 2.3f) return 5; // 完全掌握 - if (repetitions >= 3 && easinessFactor >= 2.0f) return 4; // 熟練 - if (repetitions >= 2 && easinessFactor >= 1.8f) return 3; // 理解 - if (repetitions >= 1) return 2; // 認識 - return 1; // 新學習 - } - - private string GetRecommendedAction(int qualityRating) - { - return qualityRating switch - { - 1 => "建議重新學習此詞彙", - 2 => "需要額外練習", - 3 => "繼續複習", - 4 => "掌握良好", - 5 => "完全掌握", - _ => "繼續學習" - }; - } - - private string GetProgressMessage(int qualityRating) - { - return qualityRating switch - { - 1 or 2 => "需要加強練習,別氣餒!", - 3 => "不錯的進步!", - 4 => "很好!掌握得不錯", - 5 => "太棒了!完全掌握", - _ => "繼續努力學習!" - }; - } - - #endregion -} \ No newline at end of file diff --git a/backend/DramaLing.Api/Services/QuestionGeneratorService.cs b/backend/DramaLing.Api/Services/QuestionGeneratorService.cs deleted file mode 100644 index 7db6f10..0000000 --- a/backend/DramaLing.Api/Services/QuestionGeneratorService.cs +++ /dev/null @@ -1,284 +0,0 @@ -using DramaLing.Api.Data; -using DramaLing.Api.Models.DTOs.SpacedRepetition; -using DramaLing.Api.Models.Entities; -using Microsoft.EntityFrameworkCore; - -namespace DramaLing.Api.Services; - -/// -/// 題目生成服務介面 -/// -public interface IQuestionGeneratorService -{ - Task GenerateQuestionAsync(Guid flashcardId, string questionType); -} - -/// -/// 題目生成服務實現 -/// -public class QuestionGeneratorService : IQuestionGeneratorService -{ - private readonly DramaLingDbContext _context; - private readonly IOptionsVocabularyService _optionsVocabularyService; - private readonly ILogger _logger; - - public QuestionGeneratorService( - DramaLingDbContext context, - IOptionsVocabularyService optionsVocabularyService, - ILogger logger) - { - _context = context; - _optionsVocabularyService = optionsVocabularyService; - _logger = logger; - } - - /// - /// 根據題型生成對應的題目數據 - /// - public async Task GenerateQuestionAsync(Guid flashcardId, string questionType) - { - var flashcard = await _context.Flashcards.FindAsync(flashcardId); - if (flashcard == null) - throw new ArgumentException($"Flashcard {flashcardId} not found"); - - _logger.LogInformation("Generating {QuestionType} question for flashcard {FlashcardId}, word: {Word}", - questionType, flashcardId, flashcard.Word); - - return questionType switch - { - "vocab-choice" => await GenerateVocabChoiceAsync(flashcard), - "sentence-fill" => GenerateFillBlankQuestion(flashcard), - "sentence-reorder" => GenerateReorderQuestion(flashcard), - "sentence-listening" => await GenerateSentenceListeningAsync(flashcard), - _ => new QuestionData - { - QuestionType = questionType, - CorrectAnswer = flashcard.Word - } - }; - } - - /// - /// 生成詞彙選擇題選項 - /// - private async Task GenerateVocabChoiceAsync(Flashcard flashcard) - { - var distractors = new List(); - - // 🆕 優先嘗試使用智能詞彙庫生成選項 - try - { - // 直接使用 Flashcard 的屬性 - var cefrLevel = flashcard.DifficultyLevel ?? "B1"; // 預設為 B1 - var partOfSpeech = flashcard.PartOfSpeech ?? "noun"; // 預設為 noun - - _logger.LogDebug("Attempting to generate smart distractors for '{Word}' (CEFR: {CEFR}, PartOfSpeech: {PartOfSpeech})", - flashcard.Word, cefrLevel, partOfSpeech); - - // 檢查詞彙庫是否有足夠詞彙 - var hasSufficientVocab = await _optionsVocabularyService.HasSufficientVocabularyAsync(cefrLevel, partOfSpeech); - - if (hasSufficientVocab) - { - var smartDistractors = await _optionsVocabularyService.GenerateDistractorsAsync( - flashcard.Word, cefrLevel, partOfSpeech, 3); - - if (smartDistractors.Any()) - { - distractors.AddRange(smartDistractors); - _logger.LogInformation("Successfully generated {Count} smart distractors for '{Word}'", - smartDistractors.Count, flashcard.Word); - } - } - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Failed to generate smart distractors for '{Word}', falling back to user vocabulary", - flashcard.Word); - } - - // 🔄 回退機制:如果智能詞彙庫無法提供足夠選項,使用原有邏輯 - if (distractors.Count < 3) - { - _logger.LogInformation("Using fallback method for '{Word}' (current distractors: {Count})", - flashcard.Word, distractors.Count); - - var userDistractors = await _context.Flashcards - .Where(f => f.UserId == flashcard.UserId && - f.Id != flashcard.Id && - !f.IsArchived && - !distractors.Contains(f.Word)) // 避免重複 - .OrderBy(x => Guid.NewGuid()) - .Take(3 - distractors.Count) - .Select(f => f.Word) - .ToListAsync(); - - distractors.AddRange(userDistractors); - - // 如果還是不夠,使用預設選項 - while (distractors.Count < 3) - { - var defaultOptions = new[] { "example", "sample", "test", "word", "basic", "simple", "common", "usual" }; - var availableDefaults = defaultOptions - .Where(opt => opt != flashcard.Word && !distractors.Contains(opt)); - - var neededCount = 3 - distractors.Count; - distractors.AddRange(availableDefaults.Take(neededCount)); - - // 防止無限循環 - if (!availableDefaults.Any()) - break; - } - } - - var options = new List { flashcard.Word }; - options.AddRange(distractors.Take(3)); - - // 隨機打亂選項順序 - var shuffledOptions = options.OrderBy(x => Guid.NewGuid()).ToArray(); - - return new QuestionData - { - QuestionType = "vocab-choice", - Options = shuffledOptions, - CorrectAnswer = flashcard.Word - }; - } - - - /// - /// 生成填空題 - /// - private QuestionData GenerateFillBlankQuestion(Flashcard flashcard) - { - if (string.IsNullOrEmpty(flashcard.Example)) - { - throw new ArgumentException($"Flashcard {flashcard.Id} has no example sentence for fill-blank question"); - } - - // 在例句中將目標詞彙替換為空白 - var blankedSentence = flashcard.Example.Replace(flashcard.Word, "______", StringComparison.OrdinalIgnoreCase); - - // 如果沒有替換成功,嘗試其他變化形式 - if (blankedSentence == flashcard.Example) - { - // TODO: 未來可以實現更智能的詞形變化識別 - blankedSentence = flashcard.Example + " (請填入: " + flashcard.Word + ")"; - } - - return new QuestionData - { - QuestionType = "sentence-fill", - BlankedSentence = blankedSentence, - CorrectAnswer = flashcard.Word, - Sentence = flashcard.Example - }; - } - - /// - /// 生成例句重組題 - /// - private QuestionData GenerateReorderQuestion(Flashcard flashcard) - { - if (string.IsNullOrEmpty(flashcard.Example)) - { - throw new ArgumentException($"Flashcard {flashcard.Id} has no example sentence for reorder question"); - } - - // 將例句拆分為單字並打亂順序 - var words = flashcard.Example - .Split(new[] { ' ', '\t', '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries) - .Select(word => word.Trim('.',',','!','?',';',':')) // 移除標點符號 - .Where(word => !string.IsNullOrEmpty(word)) - .ToArray(); - - // 隨機打亂順序 - var scrambledWords = words.OrderBy(x => Guid.NewGuid()).ToArray(); - - return new QuestionData - { - QuestionType = "sentence-reorder", - ScrambledWords = scrambledWords, - CorrectAnswer = flashcard.Example, - Sentence = flashcard.Example - }; - } - - /// - /// 生成例句聽力題選項 - /// - private async Task GenerateSentenceListeningAsync(Flashcard flashcard) - { - if (string.IsNullOrEmpty(flashcard.Example)) - { - throw new ArgumentException($"Flashcard {flashcard.Id} has no example sentence for listening question"); - } - - // 從其他詞卡中選擇3個例句作為干擾選項 - var distractorSentences = await _context.Flashcards - .Where(f => f.UserId == flashcard.UserId && - f.Id != flashcard.Id && - !f.IsArchived && - !string.IsNullOrEmpty(f.Example)) - .OrderBy(x => Guid.NewGuid()) - .Take(3) - .Select(f => f.Example!) - .ToListAsync(); - - // 如果沒有足夠的例句,添加預設選項 - while (distractorSentences.Count < 3) - { - var defaultSentences = new[] - { - "This is a simple example sentence.", - "I think this is a good opportunity.", - "She decided to take a different approach.", - "They managed to solve the problem quickly." - }; - - var availableDefaults = defaultSentences - .Where(sent => sent != flashcard.Example && !distractorSentences.Contains(sent)); - distractorSentences.AddRange(availableDefaults.Take(3 - distractorSentences.Count)); - } - - var options = new List { flashcard.Example }; - options.AddRange(distractorSentences.Take(3)); - - // 隨機打亂選項順序 - var shuffledOptions = options.OrderBy(x => Guid.NewGuid()).ToArray(); - - return new QuestionData - { - QuestionType = "sentence-listening", - Options = shuffledOptions, - CorrectAnswer = flashcard.Example, - Sentence = flashcard.Example, - AudioUrl = $"/audio/sentences/{flashcard.Id}.mp3" // 未來音頻服務用 - }; - } - - /// - /// 判斷是否為A1學習者 - /// - public bool IsA1Learner(int userLevel) => userLevel <= 20; // 固定A1門檻 - - /// - /// 獲取適配情境描述 - /// - public string GetAdaptationContext(int userLevel, int wordLevel) - { - var difficulty = wordLevel - userLevel; - - if (userLevel <= 20) // 固定A1門檻 - return "A1學習者"; - - if (difficulty < -10) - return "簡單詞彙"; - - if (difficulty >= -10 && difficulty <= 10) - return "適中詞彙"; - - return "困難詞彙"; - } - -} \ No newline at end of file diff --git a/backend/DramaLing.Api/Services/ReviewModeSelector.cs b/backend/DramaLing.Api/Services/ReviewModeSelector.cs deleted file mode 100644 index d4a18f8..0000000 --- a/backend/DramaLing.Api/Services/ReviewModeSelector.cs +++ /dev/null @@ -1,83 +0,0 @@ -namespace DramaLing.Api.Services; - -/// -/// 測驗模式選擇服務介面 -/// -public interface IReviewModeSelector -{ - List GetPlannedTests(string userCEFRLevel, string wordCEFRLevel); - string GetNextTestType(List plannedTests, List completedTestTypes); -} - -/// -/// 測驗模式選擇服務實現 -/// -public class ReviewModeSelector : IReviewModeSelector -{ - private readonly ILogger _logger; - - public ReviewModeSelector(ILogger logger) - { - _logger = logger; - } - - /// - /// 根據CEFR等級獲取預定的測驗類型列表 - /// - public List GetPlannedTests(string userCEFRLevel, string wordCEFRLevel) - { - var userLevel = GetCEFRLevel(userCEFRLevel); - var wordLevel = GetCEFRLevel(wordCEFRLevel); - var difficulty = wordLevel - userLevel; - - _logger.LogDebug("Planning tests for user {UserCEFR} vs word {WordCEFR}, difficulty: {Difficulty}", - userCEFRLevel, wordCEFRLevel, difficulty); - - if (userCEFRLevel == "A1") - { - // A1學習者:基礎保護機制 - return new List { "flip-memory", "vocab-choice", "vocab-listening" }; - } - else if (difficulty < -10) - { - // 簡單詞彙:應用練習 - return new List { "sentence-fill", "sentence-reorder" }; - } - else if (difficulty >= -10 && difficulty <= 10) - { - // 適中詞彙:全方位練習 - return new List { "sentence-fill", "sentence-reorder", "sentence-speaking" }; - } - else - { - // 困難詞彙:基礎重建 - return new List { "flip-memory", "vocab-choice" }; - } - } - - /// - /// 獲取下一個測驗類型 - /// - public string GetNextTestType(List plannedTests, List completedTestTypes) - { - var nextTest = plannedTests.FirstOrDefault(test => !completedTestTypes.Contains(test)); - return nextTest ?? string.Empty; - } - - /// - /// CEFR等級轉換為數值 - /// - private int GetCEFRLevel(string cefrLevel) - { - return cefrLevel switch - { - "A1" => 20, - "A2" => 35, - "B1" => 50, - "B2" => 65, - "C1" => 80, - "C2" => 95, - _ => 50 // 預設B1 - }; - } -} diff --git a/backend/DramaLing.Api/Services/ReviewTypeSelectorService.cs b/backend/DramaLing.Api/Services/ReviewTypeSelectorService.cs deleted file mode 100644 index a2670a3..0000000 --- a/backend/DramaLing.Api/Services/ReviewTypeSelectorService.cs +++ /dev/null @@ -1,241 +0,0 @@ -using DramaLing.Api.Data; -using DramaLing.Api.Models.Configuration; -using DramaLing.Api.Models.DTOs.SpacedRepetition; -using DramaLing.Api.Models.Entities; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Options; -using System.Text.Json; - -namespace DramaLing.Api.Services; - -/// -/// 智能複習題型選擇服務介面 (基於CEFR等級) -/// -public interface IReviewTypeSelectorService -{ - Task SelectOptimalReviewModeAsync(Guid flashcardId, string userCEFRLevel, string wordCEFRLevel); - string[] GetAvailableReviewTypes(string userCEFRLevel, string wordCEFRLevel); - bool IsA1Learner(string userCEFRLevel); - string GetAdaptationContext(string userCEFRLevel, string wordCEFRLevel); -} - -/// -/// 智能複習題型選擇服務實現 -/// -public class ReviewTypeSelectorService : IReviewTypeSelectorService -{ - private readonly DramaLingDbContext _context; - private readonly ILogger _logger; - private readonly SpacedRepetitionOptions _options; - - public ReviewTypeSelectorService( - DramaLingDbContext context, - ILogger logger, - IOptions options) - { - _context = context; - _logger = logger; - _options = options.Value; - } - - /// - /// 智能選擇最適合的複習模式 (基於CEFR等級) - /// - public async Task SelectOptimalReviewModeAsync(Guid flashcardId, string userCEFRLevel, string wordCEFRLevel) - { - _logger.LogInformation("Selecting optimal review mode for flashcard {FlashcardId}, userCEFR: {UserCEFR}, wordCEFR: {WordCEFR}", - flashcardId, userCEFRLevel, wordCEFRLevel); - - // 即時轉換CEFR等級為數值進行計算 - var userLevel = CEFRMappingService.GetWordLevel(userCEFRLevel); - var wordLevel = CEFRMappingService.GetWordLevel(wordCEFRLevel); - - _logger.LogInformation("CEFR converted to levels: {UserCEFR}→{UserLevel}, {WordCEFR}→{WordLevel}", - userCEFRLevel, userLevel, wordCEFRLevel, wordLevel); - - // 1. 四情境判斷,獲取可用題型 - var availableModes = GetAvailableReviewTypes(userLevel, wordLevel); - - // 2. 檢查復習歷史,實現智能避重 - var filteredModes = await ApplyAntiRepetitionLogicAsync(flashcardId, availableModes); - - // 3. 智能選擇 (A1學習者權重選擇,其他隨機) - var selectedMode = SelectModeWithWeights(filteredModes, userLevel); - - var adaptationContext = GetAdaptationContext(userLevel, wordLevel); - var reason = GetSelectionReason(selectedMode, userLevel, wordLevel); - - _logger.LogInformation("Selected mode: {SelectedMode}, context: {Context}, reason: {Reason}", - selectedMode, adaptationContext, reason); - - return new ReviewModeResult - { - SelectedMode = selectedMode, - AvailableModes = availableModes, - AdaptationContext = adaptationContext, - Reason = reason - }; - } - - /// - /// 四情境自動適配:根據學習者程度和詞彙難度決定可用題型 - /// - public string[] GetAvailableReviewTypes(int userLevel, int wordLevel) - { - var difficulty = wordLevel - userLevel; - - if (userLevel <= _options.A1ProtectionLevel) - { - // A1學習者 - 自動保護,只使用基礎題型 - return new[] { "flip-memory", "vocab-choice", "vocab-listening" }; - } - - if (difficulty < -10) - { - // 簡單詞彙 (學習者程度 > 詞彙程度) - 應用練習 - return new[] { "sentence-reorder", "sentence-fill" }; - } - - if (difficulty >= -10 && difficulty <= 10) - { - // 適中詞彙 (學習者程度 ≈ 詞彙程度) - 全方位練習 - return new[] { "sentence-fill", "sentence-reorder", "sentence-speaking" }; - } - - // 困難詞彙 (學習者程度 < 詞彙程度) - 基礎重建 - return new[] { "flip-memory", "vocab-choice" }; - } - - /// - /// 智能避重邏輯:避免連續使用相同題型 - /// - private async Task ApplyAntiRepetitionLogicAsync(Guid flashcardId, string[] availableModes) - { - try - { - var flashcard = await _context.Flashcards.FindAsync(flashcardId); - if (flashcard?.ReviewHistory == null) - return availableModes; - - var history = JsonSerializer.Deserialize>(flashcard.ReviewHistory) ?? new List(); - var recentModes = history.TakeLast(3).Select(r => r.QuestionType).ToList(); - - if (recentModes.Count >= 2 && recentModes.TakeLast(2).All(m => m == recentModes.Last())) - { - // 最近2次都是相同題型,避免使用 - var filteredModes = availableModes.Where(m => m != recentModes.Last()).ToArray(); - return filteredModes.Length > 0 ? filteredModes : availableModes; - } - - return availableModes; - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Failed to apply anti-repetition logic for flashcard {FlashcardId}", flashcardId); - return availableModes; - } - } - - /// - /// 權重選擇模式 (A1學習者有權重,其他隨機) - /// - private string SelectModeWithWeights(string[] modes, int userLevel) - { - if (userLevel <= _options.A1ProtectionLevel) - { - // A1學習者權重分配 - var weights = new Dictionary - { - { "flip-memory", 0.4 }, // 40% - 主要熟悉方式 - { "vocab-choice", 0.4 }, // 40% - 概念強化 - { "vocab-listening", 0.2 } // 20% - 發音練習 - }; - - return WeightedRandomSelect(modes, weights); - } - - // 其他情況隨機選擇 - var random = new Random(); - return modes[random.Next(modes.Length)]; - } - - /// - /// 權重隨機選擇 - /// - private string WeightedRandomSelect(string[] items, Dictionary weights) - { - var totalWeight = items.Sum(item => weights.GetValueOrDefault(item, 1.0 / items.Length)); - var random = new Random().NextDouble() * totalWeight; - - foreach (var item in items) - { - var weight = weights.GetValueOrDefault(item, 1.0 / items.Length); - random -= weight; - if (random <= 0) - return item; - } - - return items[0]; // 備用返回 - } - - /// - /// 新增CEFR字符串版本的方法 - /// - public string[] GetAvailableReviewTypes(string userCEFRLevel, string wordCEFRLevel) - { - var userLevel = CEFRMappingService.GetWordLevel(userCEFRLevel); - var wordLevel = CEFRMappingService.GetWordLevel(wordCEFRLevel); - return GetAvailableReviewTypes(userLevel, wordLevel); - } - - public bool IsA1Learner(string userCEFRLevel) => userCEFRLevel == "A1"; - public bool IsA1Learner(int userLevel) => userLevel <= _options.A1ProtectionLevel; - - public string GetAdaptationContext(string userCEFRLevel, string wordCEFRLevel) - { - var userLevel = CEFRMappingService.GetWordLevel(userCEFRLevel); - var wordLevel = CEFRMappingService.GetWordLevel(wordCEFRLevel); - return GetAdaptationContext(userLevel, wordLevel); - } - - /// - /// 獲取適配情境描述 (數值版本,內部使用) - /// - public string GetAdaptationContext(int userLevel, int wordLevel) - { - var difficulty = wordLevel - userLevel; - - if (userLevel <= _options.A1ProtectionLevel) - return "A1學習者"; - - if (difficulty < -10) - return "簡單詞彙"; - - if (difficulty >= -10 && difficulty <= 10) - return "適中詞彙"; - - return "困難詞彙"; - } - - /// - /// 獲取選擇原因說明 - /// - private string GetSelectionReason(string selectedMode, int userLevel, int wordLevel) - { - var context = GetAdaptationContext(userLevel, wordLevel); - - return context switch - { - "A1學習者" => "A1學習者使用基礎題型建立信心", - "簡單詞彙" => "簡單詞彙重點練習應用和拼寫", - "適中詞彙" => "適中詞彙進行全方位練習", - "困難詞彙" => "困難詞彙回歸基礎重建記憶", - _ => "系統智能選擇" - }; - } -} - -/// -/// 復習記錄 (用於ReviewHistory JSON序列化) -/// -public record ReviewRecord(string QuestionType, bool IsCorrect, DateTime Date); \ No newline at end of file diff --git a/backend/DramaLing.Api/Services/SM2Algorithm.cs b/backend/DramaLing.Api/Services/SM2Algorithm.cs deleted file mode 100644 index 05e8c6e..0000000 --- a/backend/DramaLing.Api/Services/SM2Algorithm.cs +++ /dev/null @@ -1,162 +0,0 @@ -namespace DramaLing.Api.Services; - -public record SM2Input( - int Quality, // 1-5 評分 - float EasinessFactor, // 難度係數 - int Repetitions, // 重複次數 - int IntervalDays // 當前間隔天數 -); - -public record SM2Result( - float EasinessFactor, // 新的難度係數 - int Repetitions, // 新的重複次數 - int IntervalDays, // 新的間隔天數 - DateTime NextReviewDate // 下次複習日期 -); - -public static class SM2Algorithm -{ - // SM-2 算法常數 - private const float MIN_EASINESS_FACTOR = 1.3f; - private const float MAX_EASINESS_FACTOR = 2.5f; - private const float INITIAL_EASINESS_FACTOR = 2.5f; - private const int MIN_INTERVAL = 1; - private const int MAX_INTERVAL = 365; - - /// - /// 計算下次複習的間隔和參數 - /// - public static SM2Result Calculate(SM2Input input) - { - var (quality, easinessFactor, repetitions, intervalDays) = input; - - // 驗證輸入參數 - if (quality < 1 || quality > 5) - throw new ArgumentException("Quality must be between 1 and 5", nameof(input)); - - // 更新難度係數 - var newEasinessFactor = UpdateEasinessFactor(easinessFactor, quality); - - int newRepetitions; - int newIntervalDays; - - // 如果回答錯誤 (quality < 3),重置進度 - if (quality < 3) - { - newRepetitions = 0; - newIntervalDays = 1; - } - else - { - // 如果回答正確,增加重複次數並計算新間隔 - newRepetitions = repetitions + 1; - - newIntervalDays = newRepetitions switch - { - 1 => 1, - 2 => 6, - _ => (int)Math.Round(intervalDays * newEasinessFactor) - }; - } - - // 限制間隔範圍 - newIntervalDays = Math.Clamp(newIntervalDays, MIN_INTERVAL, MAX_INTERVAL); - - // 計算下次複習日期 - var nextReviewDate = DateTime.Today.AddDays(newIntervalDays); - - return new SM2Result( - newEasinessFactor, - newRepetitions, - newIntervalDays, - nextReviewDate - ); - } - - /// - /// 更新難度係數 - /// - private static float UpdateEasinessFactor(float currentEF, int quality) - { - // SM-2 公式:EF' = EF + (0.1 - (5-q) * (0.08 + (5-q) * 0.02)) - var newEF = currentEF + (0.1f - (5 - quality) * (0.08f + (5 - quality) * 0.02f)); - - // 限制在有效範圍內 - return Math.Clamp(newEF, MIN_EASINESS_FACTOR, MAX_EASINESS_FACTOR); - } - - /// - /// 獲取初始參數(新詞卡) - /// - public static SM2Input GetInitialParameters() - { - return new SM2Input( - Quality: 3, - EasinessFactor: INITIAL_EASINESS_FACTOR, - Repetitions: 0, - IntervalDays: 1 - ); - } - - /// - /// 根據評分獲取描述 - /// - public static string GetQualityDescription(int quality) - { - return quality switch - { - 1 => "完全不記得", - 2 => "有印象但錯誤", - 3 => "困難但正確", - 4 => "猶豫後正確", - 5 => "輕鬆正確", - _ => "無效評分" - }; - } - - /// - /// 計算掌握度百分比 - /// - public static int CalculateMastery(int repetitions, float easinessFactor) - { - // 基於重複次數和難度係數計算掌握度 (0-100) - var baseScore = Math.Min(repetitions * 20, 80); // 重複次數最多貢獻80分 - var efficiencyBonus = Math.Min((easinessFactor - 1.3f) * 16.67f, 20f); // 難度係數最多貢獻20分 - - return Math.Min((int)Math.Round(baseScore + efficiencyBonus), 100); - } -} - -/// -/// 複習優先級計算器 -/// -public static class ReviewPriorityCalculator -{ - /// - /// 計算複習優先級 (數字越大優先級越高) - /// - public static double CalculatePriority(DateTime nextReviewDate, float easinessFactor, int repetitions) - { - var now = DateTime.Today; - var daysDiff = (now - nextReviewDate).Days; - - // 過期天數的權重 (越過期優先級越高) - var overdueWeight = Math.Max(0, daysDiff) * 10; - - // 難度權重 (越難的優先級越高) - var difficultyWeight = (3.8f - easinessFactor) * 5; - - // 新詞權重 (新詞優先級較高) - var newWordWeight = repetitions == 0 ? 20 : 0; - - return overdueWeight + difficultyWeight + newWordWeight; - } - - /// - /// 獲取應該複習的詞卡 - /// - public static bool ShouldReview(DateTime nextReviewDate) - { - return DateTime.Today >= nextReviewDate; - } -} \ No newline at end of file diff --git a/backend/DramaLing.Api/Services/SpacedRepetitionService.cs b/backend/DramaLing.Api/Services/SpacedRepetitionService.cs deleted file mode 100644 index 97ffec9..0000000 --- a/backend/DramaLing.Api/Services/SpacedRepetitionService.cs +++ /dev/null @@ -1,227 +0,0 @@ -using DramaLing.Api.Data; -using DramaLing.Api.Models.Configuration; -using DramaLing.Api.Models.DTOs.SpacedRepetition; -using DramaLing.Api.Models.Entities; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Options; - -namespace DramaLing.Api.Services; - -/// -/// 智能複習間隔重複服務介面 -/// -public interface ISpacedRepetitionService -{ - Task ProcessReviewAsync(Guid flashcardId, ReviewRequest request); - int CalculateCurrentMasteryLevel(Flashcard flashcard); - Task> GetDueFlashcardsAsync(Guid userId, DateTime? date = null, int limit = 50); - Task GetNextReviewCardAsync(Guid userId); -} - -/// -/// 智能複習間隔重複服務實現 (基於現有SM2Algorithm擴展) -/// -public class SpacedRepetitionService : ISpacedRepetitionService -{ - private readonly DramaLingDbContext _context; - private readonly ILogger _logger; - private readonly SpacedRepetitionOptions _options; - - public SpacedRepetitionService( - DramaLingDbContext context, - ILogger logger, - IOptions options) - { - _context = context; - _logger = logger; - _options = options.Value; - } - - /// - /// 處理復習結果並更新間隔重複算法 - /// - public async Task ProcessReviewAsync(Guid flashcardId, ReviewRequest request) - { - var flashcard = await _context.Flashcards.FindAsync(flashcardId); - if (flashcard == null) - throw new ArgumentException($"Flashcard {flashcardId} not found"); - - var actualReviewDate = DateTime.Now.Date; - var overdueDays = Math.Max(0, (actualReviewDate - flashcard.NextReviewDate.Date).Days); - - _logger.LogInformation("Processing review for flashcard {FlashcardId}, word: {Word}, overdue: {OverdueDays} days", - flashcardId, flashcard.Word, overdueDays); - - // 1. 基於現有SM2Algorithm計算基礎間隔 - var quality = GetQualityFromRequest(request); - var sm2Input = new SM2Input(quality, flashcard.EasinessFactor, flashcard.Repetitions, flashcard.IntervalDays); - var sm2Result = SM2Algorithm.Calculate(sm2Input); - - // 2. 應用智能複習系統的增強邏輯 - var enhancedInterval = ApplyEnhancedSpacedRepetitionLogic(sm2Result.IntervalDays, request, overdueDays); - - // 3. 計算表現係數和增長係數 - var performanceFactor = GetPerformanceFactor(request); - var growthFactor = _options.GrowthFactors.GetGrowthFactor(flashcard.IntervalDays); - var penaltyFactor = _options.OverduePenalties.GetPenaltyFactor(overdueDays); - - // 4. 更新熟悉度 - var newMasteryLevel = CalculateMasteryLevel( - flashcard.TimesCorrect + (request.IsCorrect ? 1 : 0), - flashcard.TimesReviewed + 1, - enhancedInterval - ); - - // 5. 更新資料庫 - flashcard.EasinessFactor = sm2Result.EasinessFactor; - flashcard.Repetitions = sm2Result.Repetitions; - flashcard.IntervalDays = enhancedInterval; - flashcard.NextReviewDate = actualReviewDate.AddDays(enhancedInterval); - flashcard.MasteryLevel = newMasteryLevel; - flashcard.TimesReviewed++; - if (request.IsCorrect) flashcard.TimesCorrect++; - flashcard.LastReviewedAt = DateTime.Now; - flashcard.LastQuestionType = request.QuestionType; - flashcard.UpdatedAt = DateTime.UtcNow; - - await _context.SaveChangesAsync(); - - return new ReviewResult - { - NewInterval = enhancedInterval, - NextReviewDate = flashcard.NextReviewDate, - MasteryLevel = newMasteryLevel, - CurrentMasteryLevel = CalculateCurrentMasteryLevel(flashcard), - IsOverdue = overdueDays > 0, - OverdueDays = overdueDays, - PerformanceFactor = performanceFactor, - GrowthFactor = growthFactor, - PenaltyFactor = penaltyFactor - }; - } - - /// - /// 計算當前熟悉度 (考慮記憶衰減) - /// - public int CalculateCurrentMasteryLevel(Flashcard flashcard) - { - if (flashcard.LastReviewedAt == null) - return flashcard.MasteryLevel; - - var daysSinceReview = (DateTime.Now.Date - flashcard.LastReviewedAt.Value.Date).Days; - - if (daysSinceReview <= 0) - return flashcard.MasteryLevel; - - // 應用記憶衰減 - var decayRate = _options.MemoryDecayRate; - var maxDecayDays = 30; - var effectiveDays = Math.Min(daysSinceReview, maxDecayDays); - var decayFactor = Math.Pow(1 - decayRate, effectiveDays); - - return Math.Max(0, (int)(flashcard.MasteryLevel * decayFactor)); - } - - /// - /// 取得到期詞卡列表 - /// - public async Task> GetDueFlashcardsAsync(Guid userId, DateTime? date = null, int limit = 50) - { - var queryDate = date ?? DateTime.Now.Date; - - var dueCards = await _context.Flashcards - .Where(f => f.UserId == userId && - !f.IsArchived && - f.NextReviewDate.Date <= queryDate) - .OrderBy(f => f.NextReviewDate) // 最逾期的優先 - .ThenByDescending(f => f.MasteryLevel) // 熟悉度低的優先 - .Take(limit) - .ToListAsync(); - - // UserLevel和WordLevel欄位已移除 - 改用即時CEFR轉換 - // 不需要初始化數值欄位 - - return dueCards; - } - - /// - /// 取得下一張需要復習的詞卡 (最高優先級) - /// - public async Task GetNextReviewCardAsync(Guid userId) - { - var dueCards = await GetDueFlashcardsAsync(userId, limit: 1); - return dueCards.FirstOrDefault(); - } - - /// - /// 應用增強的間隔重複邏輯 (基於演算法規格書) - /// - private int ApplyEnhancedSpacedRepetitionLogic(int baseInterval, ReviewRequest request, int overdueDays) - { - var performanceFactor = GetPerformanceFactor(request); - var growthFactor = _options.GrowthFactors.GetGrowthFactor(baseInterval); - var penaltyFactor = _options.OverduePenalties.GetPenaltyFactor(overdueDays); - - var enhancedInterval = (int)(baseInterval * growthFactor * performanceFactor * penaltyFactor); - - return Math.Clamp(enhancedInterval, 1, _options.MaxInterval); - } - - /// - /// 根據題型和表現計算表現係數 - /// - private double GetPerformanceFactor(ReviewRequest request) - { - return request.QuestionType switch - { - "flip-memory" => GetFlipCardPerformanceFactor(request.ConfidenceLevel ?? 3), - "vocab-choice" or "sentence-fill" or "sentence-reorder" => request.IsCorrect ? 1.1 : 0.6, - "vocab-listening" or "sentence-listening" => request.IsCorrect ? 1.2 : 0.5, // 聽力題難度加權 - "sentence-speaking" => 1.0, // 口說題重在參與 - _ => 0.9 - }; - } - - /// - /// 翻卡題信心等級映射 - /// - private double GetFlipCardPerformanceFactor(int confidenceLevel) - { - return confidenceLevel switch - { - 1 => 0.5, // 很不確定 - 2 => 0.7, // 不確定 - 3 => 0.9, // 一般 - 4 => 1.1, // 確定 - 5 => 1.4, // 很確定 - _ => 0.9 - }; - } - - /// - /// 從請求轉換為SM2Algorithm需要的品質分數 - /// - private int GetQualityFromRequest(ReviewRequest request) - { - if (request.QuestionType == "flip-memory") - { - return request.ConfidenceLevel ?? 3; - } - - return request.IsCorrect ? 4 : 2; // 客觀題簡化映射 - } - - /// - /// 計算基礎熟悉度 (基於現有算法調整) - /// - private int CalculateMasteryLevel(int timesCorrect, int totalReviews, int currentInterval) - { - var successRate = totalReviews > 0 ? (double)timesCorrect / totalReviews : 0; - - var baseScore = Math.Min(timesCorrect * 8, 60); // 答對次數貢獻 (最多60%) - var intervalBonus = Math.Min(currentInterval / 365.0 * 25, 25); // 間隔貢獻 (最多25%) - var accuracyBonus = successRate * 15; // 正確率貢獻 (最多15%) - - return Math.Min(100, (int)Math.Round(baseScore + intervalBonus + accuracyBonus)); - } -} \ No newline at end of file diff --git a/backend/DramaLing.Api/Services/StudySessionService.cs b/backend/DramaLing.Api/Services/StudySessionService.cs deleted file mode 100644 index f6eb96b..0000000 --- a/backend/DramaLing.Api/Services/StudySessionService.cs +++ /dev/null @@ -1,498 +0,0 @@ -using DramaLing.Api.Data; -using DramaLing.Api.Models.Entities; -using DramaLing.Api.Services; -using Microsoft.EntityFrameworkCore; - -namespace DramaLing.Api.Services; - -/// -/// 學習會話服務介面 -/// -public interface IStudySessionService -{ - Task StartSessionAsync(Guid userId); - Task GetCurrentTestAsync(Guid sessionId); - Task SubmitTestAsync(Guid sessionId, SubmitTestRequestDto request); - Task GetNextTestAsync(Guid sessionId); - Task GetProgressAsync(Guid sessionId); - Task CompleteSessionAsync(Guid sessionId); -} - -/// -/// 學習會話服務實現 -/// -public class StudySessionService : IStudySessionService -{ - private readonly DramaLingDbContext _context; - private readonly ILogger _logger; - private readonly IReviewModeSelector _reviewModeSelector; - - public StudySessionService( - DramaLingDbContext context, - ILogger logger, - IReviewModeSelector reviewModeSelector) - { - _context = context; - _logger = logger; - _reviewModeSelector = reviewModeSelector; - } - - /// - /// 開始新的學習會話 - /// - public async Task 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; - } - - /// - /// 獲取當前測驗 - /// - public async Task 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 - .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 - } - }; - } - - /// - /// 提交測驗結果 - /// - public async Task 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 - } - }; - } - - /// - /// 獲取下一個測驗 - /// - public async Task 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" - }; - } - } - } - - /// - /// 獲取詳細進度 - /// - public async Task 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 - }; - } - - /// - /// 完成學習會話 - /// - public async Task 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 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> 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 Cards { get; set; } = new(); -} - -public class CardProgressDto -{ - public Guid CardId { get; set; } - public string Word { get; set; } = string.Empty; - public List PlannedTests { get; set; } = new(); - public int CompletedTestsCount { get; set; } - public bool IsCompleted { get; set; } - public List 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; -} \ No newline at end of file diff --git a/backend/DramaLing.Api/Services/WordVariationService.cs b/backend/DramaLing.Api/Services/WordVariationService.cs deleted file mode 100644 index e407138..0000000 --- a/backend/DramaLing.Api/Services/WordVariationService.cs +++ /dev/null @@ -1,127 +0,0 @@ -namespace DramaLing.Api.Services; - -public interface IWordVariationService -{ - string[] GetCommonVariations(string word); - bool IsVariationOf(string baseWord, string variation); -} - -public class WordVariationService : IWordVariationService -{ - private readonly ILogger _logger; - - public WordVariationService(ILogger logger) - { - _logger = logger; - } - - private readonly Dictionary CommonVariations = new() - { - ["eat"] = ["eats", "ate", "eaten", "eating"], - ["go"] = ["goes", "went", "gone", "going"], - ["have"] = ["has", "had", "having"], - ["be"] = ["am", "is", "are", "was", "were", "been", "being"], - ["do"] = ["does", "did", "done", "doing"], - ["take"] = ["takes", "took", "taken", "taking"], - ["make"] = ["makes", "made", "making"], - ["come"] = ["comes", "came", "coming"], - ["see"] = ["sees", "saw", "seen", "seeing"], - ["get"] = ["gets", "got", "gotten", "getting"], - ["give"] = ["gives", "gave", "given", "giving"], - ["know"] = ["knows", "knew", "known", "knowing"], - ["think"] = ["thinks", "thought", "thinking"], - ["say"] = ["says", "said", "saying"], - ["tell"] = ["tells", "told", "telling"], - ["find"] = ["finds", "found", "finding"], - ["work"] = ["works", "worked", "working"], - ["feel"] = ["feels", "felt", "feeling"], - ["try"] = ["tries", "tried", "trying"], - ["ask"] = ["asks", "asked", "asking"], - ["need"] = ["needs", "needed", "needing"], - ["seem"] = ["seems", "seemed", "seeming"], - ["turn"] = ["turns", "turned", "turning"], - ["start"] = ["starts", "started", "starting"], - ["show"] = ["shows", "showed", "shown", "showing"], - ["hear"] = ["hears", "heard", "hearing"], - ["play"] = ["plays", "played", "playing"], - ["run"] = ["runs", "ran", "running"], - ["move"] = ["moves", "moved", "moving"], - ["live"] = ["lives", "lived", "living"], - ["believe"] = ["believes", "believed", "believing"], - ["hold"] = ["holds", "held", "holding"], - ["bring"] = ["brings", "brought", "bringing"], - ["happen"] = ["happens", "happened", "happening"], - ["write"] = ["writes", "wrote", "written", "writing"], - ["sit"] = ["sits", "sat", "sitting"], - ["stand"] = ["stands", "stood", "standing"], - ["lose"] = ["loses", "lost", "losing"], - ["pay"] = ["pays", "paid", "paying"], - ["meet"] = ["meets", "met", "meeting"], - ["include"] = ["includes", "included", "including"], - ["continue"] = ["continues", "continued", "continuing"], - ["set"] = ["sets", "setting"], - ["learn"] = ["learns", "learned", "learnt", "learning"], - ["change"] = ["changes", "changed", "changing"], - ["lead"] = ["leads", "led", "leading"], - ["understand"] = ["understands", "understood", "understanding"], - ["watch"] = ["watches", "watched", "watching"], - ["follow"] = ["follows", "followed", "following"], - ["stop"] = ["stops", "stopped", "stopping"], - ["create"] = ["creates", "created", "creating"], - ["speak"] = ["speaks", "spoke", "spoken", "speaking"], - ["read"] = ["reads", "reading"], - ["spend"] = ["spends", "spent", "spending"], - ["grow"] = ["grows", "grew", "grown", "growing"], - ["open"] = ["opens", "opened", "opening"], - ["walk"] = ["walks", "walked", "walking"], - ["win"] = ["wins", "won", "winning"], - ["offer"] = ["offers", "offered", "offering"], - ["remember"] = ["remembers", "remembered", "remembering"], - ["love"] = ["loves", "loved", "loving"], - ["consider"] = ["considers", "considered", "considering"], - ["appear"] = ["appears", "appeared", "appearing"], - ["buy"] = ["buys", "bought", "buying"], - ["wait"] = ["waits", "waited", "waiting"], - ["serve"] = ["serves", "served", "serving"], - ["die"] = ["dies", "died", "dying"], - ["send"] = ["sends", "sent", "sending"], - ["expect"] = ["expects", "expected", "expecting"], - ["build"] = ["builds", "built", "building"], - ["stay"] = ["stays", "stayed", "staying"], - ["fall"] = ["falls", "fell", "fallen", "falling"], - ["cut"] = ["cuts", "cutting"], - ["reach"] = ["reaches", "reached", "reaching"], - ["kill"] = ["kills", "killed", "killing"], - ["remain"] = ["remains", "remained", "remaining"] - }; - - public string[] GetCommonVariations(string word) - { - if (string.IsNullOrEmpty(word)) - return Array.Empty(); - - var lowercaseWord = word.ToLower(); - if (CommonVariations.TryGetValue(lowercaseWord, out var variations)) - { - _logger.LogDebug("Found {Count} variations for word: {Word}", variations.Length, word); - return variations; - } - - _logger.LogDebug("No variations found for word: {Word}", word); - return Array.Empty(); - } - - public bool IsVariationOf(string baseWord, string variation) - { - if (string.IsNullOrEmpty(baseWord) || string.IsNullOrEmpty(variation)) - return false; - - var variations = GetCommonVariations(baseWord); - var result = variations.Contains(variation.ToLower()); - - _logger.LogDebug("Checking if {Variation} is variation of {BaseWord}: {Result}", - variation, baseWord, result); - - return result; - } -} \ No newline at end of file diff --git a/backend/DramaLing.Api/appsettings.json b/backend/DramaLing.Api/appsettings.json index 24c8583..e33ea80 100644 --- a/backend/DramaLing.Api/appsettings.json +++ b/backend/DramaLing.Api/appsettings.json @@ -59,23 +59,5 @@ "MaxFileSize": 10485760, "AllowedFormats": ["png", "jpg", "jpeg", "webp"] } - }, - "SpacedRepetition": { - "GrowthFactors": { - "ShortTerm": 1.8, - "MediumTerm": 1.4, - "LongTerm": 1.2, - "VeryLongTerm": 1.1 - }, - "OverduePenalties": { - "Light": 0.9, - "Medium": 0.75, - "Heavy": 0.5, - "Extreme": 0.3 - }, - "MemoryDecayRate": 0.05, - "MaxInterval": 365, - "A1ProtectionLevel": 20, - "DefaultUserLevel": 50 } } \ No newline at end of file diff --git a/note/智能複習/智能複習系統-後端功能規格書.md b/note/done/智能複習系統-後端功能規格書.md similarity index 100% rename from note/智能複習/智能複習系統-後端功能規格書.md rename to note/done/智能複習系統-後端功能規格書.md diff --git a/note/智能複習/智能複習系統-技術實作架構規格書.md b/note/智能複習/智能複習系統-技術實作架構規格書.md new file mode 100644 index 0000000..31b24e7 --- /dev/null +++ b/note/智能複習/智能複習系統-技術實作架構規格書.md @@ -0,0 +1,1248 @@ +# 智能複習系統 - 技術實作架構規格書 (TAS) + +**目標讀者**: 全端開發工程師、系統架構師、技術主管 +**版本**: 1.0 +**日期**: 2025-09-29 +**實施狀態**: 🎯 **架構設計階段** + +--- + +## 🏗️ **整體系統架構設計** + +### **三層架構模式** +``` +┌─────────────────────────────────────────────────────────────────┐ +│ 🌐 前端層 (React) │ +├─────────────────┬─────────────────┬─────────────────┬──────────────┤ +│ 學習流程組件 │ 測驗類型組件 │ 狀態管理 │ API整合層 │ +│ │ │ │ │ +│ SmartReview │ FlipMemoryTest │ ReviewContext │ ReviewAPI │ +│ Container │ VocabChoice │ QueueManager │ FlashcardAPI│ +│ TestQueue │ SentenceFill │ StateRecovery │ StudyAPI │ +│ Progress │ SentenceReorder│ Navigation │ │ +│ Tracker │ ListeningTest │ Controller │ │ +│ │ SpeakingTest │ │ │ +└─────────────────┴─────────────────┴─────────────────┴──────────────┘ + │ +┌─────────────────────────────────────────────────────────────────┐ +│ 🔗 API 契約層 │ +├─────────────────────────────────────────────────────────────────┤ +│ 統一API規範 + 錯誤處理 + 認證授權 + 快取策略 │ +└─────────────────────────────────────────────────────────────────┘ + │ +┌─────────────────────────────────────────────────────────────────┐ +│ 🏛️ 後端層 (.NET Core) │ +├─────────────────┬─────────────────┬─────────────────┬──────────────┤ +│ 控制器層 │ 服務層 │ 資料層 │ 基礎設施 │ +│ │ │ │ │ +│ StudyController │ SpacedRepetition│ StudyRecord │ JWTAuth │ +│ FlashcardCtrl │ ReviewSelector │ Flashcard │ Cache │ +│ StatsController │ QuestionGen │ DailyStats │ Logging │ +│ │ BlankGeneration │ OptionsVocab │ Monitoring │ +│ │ CEFRMapping │ │ │ +└─────────────────┴─────────────────┴─────────────────┴──────────────┘ + │ +┌─────────────────────────────────────────────────────────────────┐ +│ 💾 資料庫層 (SQLite/PostgreSQL) │ +│ 智能索引 + 關聯關係 + 效能優化 │ +└─────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 🎯 **前端組件架構設計** + +### **核心組件層次結構** +```typescript +// 最外層容器 +SmartReviewContainer +├── ReviewContextProvider // 全域狀態管理 +├── TestQueueManager // 測驗隊列管理 +├── ProgressTracker // 進度追蹤和可視化 +├── NavigationController // 導航邏輯控制 +└── TestRenderer // 動態測驗渲染器 + ├── FlipMemoryTest // 翻卡記憶測驗 + ├── VocabChoiceTest // 詞彙選擇測驗 + ├── SentenceFillTest // 例句填空測驗 + ├── SentenceReorderTest // 例句重組測驗 + ├── VocabListeningTest // 詞彙聽力測驗 + ├── SentenceListeningTest // 例句聽力測驗 + └── SentenceSpeakingTest // 例句口說測驗 +``` + +### **狀態管理架構** +```typescript +// 🧠 全域學習狀態 Context +interface ReviewContextState { + // 基礎數據 + dueCards: Flashcard[] + completedTests: CompletedTest[] + + // 隊列管理 + testQueue: TestItem[] + currentTestIndex: number + skippedTests: TestItem[] + + // 用戶狀態 + userCEFRLevel: string + learningSession: LearningSession + + // UI 狀態 + isAnswered: boolean + showResult: boolean + navigationState: 'skip' | 'continue' +} + +// 🎯 隊列管理邏輯 +class TestQueueManager { + // 智能排序邏輯 + prioritizeTests(tests: TestItem[]): TestItem[] { + return tests.sort((a, b) => { + // 1. 新測驗 (最高優先級) + if (a.status === 'new' && b.status !== 'new') return -1 + if (b.status === 'new' && a.status !== 'new') return 1 + + // 2. 答錯測驗 (中等優先級) + if (a.status === 'incorrect' && b.status === 'skipped') return -1 + if (b.status === 'incorrect' && a.status === 'skipped') return 1 + + // 3. 跳過測驗 (最低優先級) + return 0 + }) + } + + // 處理測驗結果 + handleTestResult(testId: string, result: 'correct' | 'incorrect' | 'skipped') { + switch (result) { + case 'correct': + // 從隊列完全移除 + this.removeFromQueue(testId) + break + case 'incorrect': + // 移到隊列最後 + this.moveToEnd(testId, 'incorrect') + break + case 'skipped': + // 移到隊列最後 + this.moveToEnd(testId, 'skipped') + break + } + } +} +``` + +### **測驗組件標準化介面** +```typescript +// 🧩 測驗組件統一介面 +interface TestComponentProps { + flashcard: Flashcard + testType: TestType + onSubmit: (result: TestResult) => void + onSkip: () => void + isDisabled: boolean +} + +// 📝 測驗結果標準格式 +interface TestResult { + flashcardId: string + testType: TestType + isCorrect: boolean + userAnswer: string + confidenceLevel?: number + responseTimeMs: number +} + +// 🎮 導航狀態管理 +type NavigationState = 'pre-answer' | 'post-answer' +type ButtonAction = 'skip' | 'continue' +``` + +--- + +## 🏛️ **後端服務架構設計** + +### **控制器職責重新劃分** +```csharp +// 📚 FlashcardsController - 純粹詞卡資料管理 +[Route("api/flashcards")] +public class FlashcardsController +{ + // 純粹的 CRUD 操作 + GET / // 詞卡列表查詢 + POST / // 創建新詞卡 + GET /{id} // 詞卡詳情 + PUT /{id} // 更新詞卡 + DELETE /{id} // 刪除詞卡 + POST /{id}/favorite // 收藏切換 +} + +// 🎯 StudyController - 完整學習管理 +[Route("api/study")] +public class StudyController +{ + // 學習狀態管理 + GET /today // 今日學習總覽 + GET /next // 下一個測驗 + GET /progress // 學習進度 + + // 測驗執行 + POST /{id}/question // 生成題目選項 + POST /{id}/submit // 提交測驗結果 + POST /{id}/skip // 跳過測驗 + + // 智能適配 + POST /{id}/optimal-mode // 智能模式選擇 + + // 狀態持久化 + GET /completed-tests // 已完成測驗 + POST /record-test // 記錄測驗 + GET /stats // 學習統計 +} +``` + +### **智能複習服務群架構** +```csharp +// 🧠 核心算法服務 +public interface ISpacedRepetitionService +{ + Task ProcessReviewAsync(Guid flashcardId, ReviewRequest request); + Task> GetTodaysDueCardsAsync(Guid userId); + Task GetNextTestAsync(Guid userId); + int CalculateCurrentMasteryLevel(Flashcard flashcard); +} + +// 🎯 智能適配服務 +public interface IReviewAdaptationService +{ + Task SelectOptimalModeAsync(Flashcard flashcard, User user); + string[] GetAvailableTestTypes(string userCEFR, string wordCEFR); + AdaptationContext GetAdaptationContext(string userCEFR, string wordCEFR); +} + +// 🔄 隊列管理服務 +public interface ITestQueueService +{ + Task BuildTodaysQueueAsync(Guid userId); + Task GetNextTestItemAsync(Guid userId); + Task HandleTestResultAsync(Guid userId, TestResult result); + Task GetQueueStatusAsync(Guid userId); +} + +// 📝 題目生成服務 +public interface IQuestionGeneratorService +{ + Task GenerateQuestionAsync(Guid flashcardId, TestType testType); +} + +// 💾 狀態持久化服務 +public interface IStudyStateService +{ + Task GetCompletedTestsAsync(Guid userId, DateTime? date = null); + Task RecordTestCompletionAsync(Guid userId, TestResult result); + Task GetProgressAsync(Guid userId); +} +``` + +--- + +## 💾 **資料模型設計規範** + +### **核心實體關係** +```csharp +// 👤 用戶實體 (擴展 CEFR 支援) +public class User +{ + public Guid Id { get; set; } + public string Username { get; set; } + public string Email { get; set; } + + // 🆕 CEFR 智能適配 + public string EnglishLevel { get; set; } = "A2"; // A1-C2 + public DateTime LevelUpdatedAt { get; set; } + public bool IsLevelVerified { get; set; } + + // Navigation Properties + public ICollection Flashcards { get; set; } + public ICollection StudyRecords { get; set; } + public ICollection DailyStats { get; set; } +} + +// 📚 詞卡實體 (智能複習增強) +public class Flashcard +{ + public Guid Id { get; set; } + public Guid UserId { get; set; } + + // 詞卡內容 + public string Word { get; set; } + public string Translation { get; set; } + public string Definition { get; set; } + public string? PartOfSpeech { get; set; } + public string? Pronunciation { get; set; } + public string? Example { get; set; } + public string? ExampleTranslation { get; set; } + + // 🆕 智能複習參數 + public string? DifficultyLevel { get; set; } // A1-C2 + public float EasinessFactor { get; set; } = 2.5f; // SM-2 算法 + public int Repetitions { get; set; } = 0; + public int IntervalDays { get; set; } = 1; + public DateTime NextReviewDate { get; set; } + public int MasteryLevel { get; set; } = 0; // 0-100 + public int TimesReviewed { get; set; } = 0; + public int TimesCorrect { get; set; } = 0; + public DateTime? LastReviewedAt { get; set; } + + // 🆕 測驗歷史追蹤 + public string? ReviewHistory { get; set; } // JSON + public string? LastQuestionType { get; set; } + + // Navigation Properties + public User User { get; set; } + public ICollection StudyRecords { get; set; } +} + +// 📊 學習記錄實體 (簡化無 Session) +public class StudyRecord +{ + public Guid Id { get; set; } + public Guid UserId { get; set; } + public Guid FlashcardId { get; set; } + + // 測驗資訊 + public string StudyMode { get; set; } // 測驗類型 + public int QualityRating { get; set; } // 1-5 (SM-2) + public bool IsCorrect { get; set; } + public string? UserAnswer { get; set; } + public int? ResponseTimeMs { get; set; } + public DateTime StudiedAt { get; set; } + + // SM-2 追蹤參數 + public double? PreviousEasinessFactor { get; set; } + public double? NewEasinessFactor { get; set; } + public int? PreviousIntervalDays { get; set; } + public int? NewIntervalDays { get; set; } + public DateTime? NextReviewDate { get; set; } + + // Navigation Properties + public User User { get; set; } + public Flashcard Flashcard { get; set; } +} + +// 📈 每日統計實體 +public class DailyStats +{ + public Guid Id { get; set; } + public Guid UserId { get; set; } + public DateOnly Date { get; set; } + + // 學習統計 + public int WordsStudied { get; set; } = 0; + public int WordsCorrect { get; set; } = 0; + public int StudyTimeSeconds { get; set; } = 0; + public int SessionCount { get; set; } = 0; + + // Navigation Properties + public User User { get; set; } +} +``` + +### **資料庫索引策略** +```sql +-- 🎯 智能複習核心索引 +CREATE INDEX IX_Flashcards_UserDue +ON flashcards(user_id, next_review_date) +WHERE is_archived = 0; + +-- 📊 學習記錄查詢優化 +CREATE UNIQUE INDEX IX_StudyRecord_UserCardTest +ON study_records(user_id, flashcard_id, study_mode); + +-- 📈 統計查詢優化 +CREATE UNIQUE INDEX IX_DailyStats_UserDate +ON daily_stats(user_id, date); + +-- 🔍 詞卡搜尋優化 +CREATE INDEX IX_Flashcards_Search +ON flashcards(user_id, word, translation) +WHERE is_archived = 0; + +-- ⚡ CEFR 等級查詢優化 +CREATE INDEX IX_Flashcards_CEFR +ON flashcards(user_id, difficulty_level, mastery_level); +``` + +--- + +## 🔗 **API 設計規範** + +### **統一 API 契約** +```typescript +// 🌐 統一響應格式 +interface ApiResponse { + success: boolean + data?: T + error?: string + message?: string + timestamp: string + requestId?: string +} + +// ⚠️ 統一錯誤格式 +interface ApiError { + code: string + message: string + details?: any + suggestions?: string[] +} +``` + +### **智能複習 API 設計** +```typescript +// 📋 今日學習總覽 API +GET /api/study/today +Response: { + success: true, + data: { + dueCards: Flashcard[], + totalTests: number, + completedTests: number, + estimatedTimeMinutes: number, + adaptationSummary: { + a1Protection: boolean, + primaryAdaptation: "簡單詞彙" | "適中詞彙" | "困難詞彙", + recommendedTestTypes: string[] + } + } +} + +// 🎯 下一個測驗 API +GET /api/study/next +Response: { + success: true, + data: { + testItem: { + flashcardId: string, + testType: string, + flashcard: Flashcard, + questionData: QuestionData + }, + queueStatus: { + remaining: number, + completed: number, + skipped: number + }, + navigationState: "skip" | "continue" + } +} + +// 📝 提交測驗 API +POST /api/study/{flashcardId}/submit +Request: { + testType: string, + isCorrect: boolean, + userAnswer: string, + confidenceLevel?: number, + responseTimeMs: number +} +Response: { + success: true, + data: { + reviewResult: ReviewResult, + nextAction: "continue" | "session_complete", + masteryUpdate: { + previousLevel: number, + newLevel: number, + nextReviewDate: string + } + } +} + +// ⏭️ 跳過測驗 API +POST /api/study/{flashcardId}/skip +Request: { + testType: string, + reason?: string +} +Response: { + success: true, + data: { + skippedTestId: string, + nextAction: "continue", + queueStatus: QueueStatus + } +} +``` + +--- + +## 🧠 **智能適配算法設計** + +### **CEFR 四情境適配邏輯** +```csharp +// 🎯 CEFRMappingService - 等級轉換服務 +public static class CEFRMappingService +{ + private static readonly Dictionary CEFRToLevel = new() + { + { "A1", 20 }, { "A2", 35 }, { "B1", 50 }, + { "B2", 65 }, { "C1", 80 }, { "C2", 95 } + }; + + public static int GetNumericLevel(string cefrLevel) + => CEFRToLevel.GetValueOrDefault(cefrLevel, 50); + + public static string GetCEFRLevel(int numericLevel) + => CEFRToLevel.FirstOrDefault(kvp => kvp.Value == numericLevel).Key ?? "B1"; +} + +// 🛡️ 智能適配服務實現 +public class ReviewAdaptationService : IReviewAdaptationService +{ + public async Task SelectOptimalModeAsync(Flashcard flashcard, User user) + { + var userLevel = CEFRMappingService.GetNumericLevel(user.EnglishLevel); + var wordLevel = CEFRMappingService.GetNumericLevel(flashcard.DifficultyLevel ?? "B1"); + + // 四情境判斷邏輯 + var adaptationContext = GetAdaptationContext(userLevel, wordLevel); + var availableTestTypes = GetAvailableTestTypes(adaptationContext); + var selectedMode = await SelectModeWithHistory(flashcard.Id, availableTestTypes); + + return new ReviewModeResult + { + SelectedMode = selectedMode, + AdaptationContext = adaptationContext, + AvailableTestTypes = availableTestTypes, + Reason = GetSelectionReason(adaptationContext, selectedMode) + }; + } + + private AdaptationContext GetAdaptationContext(int userLevel, int wordLevel) + { + var difficulty = wordLevel - userLevel; + + if (userLevel <= 20) // A1 保護 + return AdaptationContext.A1Protection; + + if (difficulty < -10) // 簡單詞彙 + return AdaptationContext.EasyVocabulary; + + if (difficulty >= -10 && difficulty <= 10) // 適中詞彙 + return AdaptationContext.ModerateVocabulary; + + return AdaptationContext.DifficultVocabulary; // 困難詞彙 + } + + private string[] GetAvailableTestTypes(AdaptationContext context) + { + return context switch + { + AdaptationContext.A1Protection => new[] { "flip-memory", "vocab-choice", "vocab-listening" }, + AdaptationContext.EasyVocabulary => new[] { "sentence-reorder", "sentence-fill" }, + AdaptationContext.ModerateVocabulary => new[] { "sentence-fill", "sentence-reorder", "sentence-speaking" }, + AdaptationContext.DifficultVocabulary => new[] { "flip-memory", "vocab-choice" }, + _ => new[] { "flip-memory", "vocab-choice" } + }; + } +} +``` + +### **隊列管理算法** +```csharp +// 🔄 測驗隊列服務 +public class TestQueueService : ITestQueueService +{ + public async Task BuildTodaysQueueAsync(Guid userId) + { + // 1. 獲取今日到期詞卡 + var dueCards = await GetTodaysDueCardsAsync(userId); + + // 2. 獲取已完成測驗 + var completedTests = await GetCompletedTestsAsync(userId, DateTime.Today); + + // 3. 計算剩餘測驗 + var allTests = GenerateAllPossibleTests(dueCards); + var remainingTests = FilterCompletedTests(allTests, completedTests); + + // 4. 智能排序 + var prioritizedTests = PrioritizeTests(remainingTests); + + return new TestQueue + { + Tests = prioritizedTests, + TotalCount = allTests.Count, + CompletedCount = completedTests.Count, + RemainingCount = remainingTests.Count + }; + } + + private List PrioritizeTests(List tests) + { + return tests + .OrderBy(t => t.Status switch { + TestStatus.New => 0, // 優先處理新測驗 + TestStatus.Incorrect => 1, // 然後是答錯的 + TestStatus.Skipped => 2, // 最後是跳過的 + _ => 3 + }) + .ThenBy(t => t.LastAttemptAt ?? DateTime.MinValue) // 按最後嘗試時間排序 + .ToList(); + } +} +``` + +--- + +## 🎮 **前端狀態管理架構** + +### **Context + Hooks 架構** +```typescript +// 🧠 Review Context Provider +export const ReviewContextProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const [state, dispatch] = useReducer(reviewReducer, initialState) + + // 核心方法 + const contextValue = { + // 狀態 + ...state, + + // 操作方法 + loadTodaysReview: () => dispatch({ type: 'LOAD_TODAYS_REVIEW' }), + submitTest: (result: TestResult) => dispatch({ type: 'SUBMIT_TEST', payload: result }), + skipTest: (testId: string) => dispatch({ type: 'SKIP_TEST', payload: testId }), + proceedToNext: () => dispatch({ type: 'PROCEED_TO_NEXT' }), + + // 狀態查詢 + getCurrentTest: () => state.testQueue[state.currentTestIndex], + getNavigationState: () => state.isAnswered ? 'continue' : 'skip', + isSessionComplete: () => state.testQueue.every(t => t.status === 'completed') + } + + return ( + + {children} + + ) +} + +// 🎯 自定義 Hooks +export const useReviewFlow = () => { + const context = useContext(ReviewContext) + + return { + // 當前測驗 + currentTest: context.getCurrentTest(), + + // 導航控制 + navigationState: context.getNavigationState(), + canSkip: !context.isAnswered, + canContinue: context.isAnswered, + + // 操作方法 + submitAnswer: context.submitTest, + skipCurrent: context.skipTest, + proceedNext: context.proceedToNext, + + // 進度資訊 + progress: { + completed: context.testQueue.filter(t => t.status === 'completed').length, + total: context.testQueue.length, + isComplete: context.isSessionComplete() + } + } +} +``` + +### **組件化架構實現** +```typescript +// 🎮 智能複習主容器 +export const SmartReviewContainer: React.FC = () => { + return ( + +
+ + + +
+
+ ) +} + +// 📊 進度追蹤組件 +export const ProgressTracker: React.FC = () => { + const { progress } = useReviewFlow() + + return ( +
+ {/* 雙層進度條實現 */} +
+
+
+
+ {progress.completed}/{progress.total} 已完成 +
+
+ ) +} + +// 🎯 動態測驗渲染器 +export const TestRenderer: React.FC = () => { + const { currentTest } = useReviewFlow() + + if (!currentTest) { + return + } + + // 動態載入對應的測驗組件 + const TestComponent = getTestComponent(currentTest.testType) + + return ( +
+ +
+ ) +} + +// 🧭 導航控制器 +export const NavigationController: React.FC = () => { + const { navigationState, canSkip, canContinue, skipCurrent, proceedNext } = useReviewFlow() + + return ( +
+ {navigationState === 'skip' && canSkip && ( + + )} + + {navigationState === 'continue' && canContinue && ( + + )} +
+ ) +} +``` + +--- + +## 🔧 **服務層實現規範** + +### **依賴注入配置** +```csharp +// 📦 服務註冊 (Program.cs) +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddSmartReviewServices(this IServiceCollection services) + { + // 核心智能複習服務 + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + // 輔助服務 + services.AddSingleton(); + services.AddScoped(); + services.AddScoped(); + + // 配置選項 + services.Configure(config.GetSection("SpacedRepetition")); + + return services; + } +} +``` + +### **錯誤處理統一標準** +```csharp +// 🛡️ 全域錯誤處理中間件 +public class SmartReviewErrorHandlingMiddleware +{ + public async Task InvokeAsync(HttpContext context, RequestDelegate next) + { + try + { + await next(context); + } + catch (Exception ex) + { + await HandleExceptionAsync(context, ex); + } + } + + private async Task HandleExceptionAsync(HttpContext context, Exception ex) + { + var response = ex switch + { + ArgumentException => CreateErrorResponse("INVALID_INPUT", ex.Message, 400), + UnauthorizedAccessException => CreateErrorResponse("UNAUTHORIZED", "認證失敗", 401), + InvalidOperationException => CreateErrorResponse("INVALID_OPERATION", ex.Message, 400), + _ => CreateErrorResponse("INTERNAL_ERROR", "系統內部錯誤", 500) + }; + + context.Response.StatusCode = response.StatusCode; + context.Response.ContentType = "application/json"; + await context.Response.WriteAsync(JsonSerializer.Serialize(response.Content)); + } +} +``` + +--- + +## 📊 **效能優化架構** + +### **快取策略設計** +```csharp +// 🚀 多層快取架構 +public class SmartReviewCacheService +{ + // L1: Memory Cache (最快) + private readonly IMemoryCache _memoryCache; + + // L2: Distributed Cache (Redis/SQL) + private readonly IDistributedCache _distributedCache; + + // L3: Database (最持久) + private readonly DramaLingDbContext _context; + + public async Task GetAsync(string key) where T : class + { + // 1. 嘗試記憶體快取 + if (_memoryCache.TryGetValue(key, out T? cachedValue)) + return cachedValue; + + // 2. 嘗試分散式快取 + var distributedValue = await _distributedCache.GetStringAsync(key); + if (distributedValue != null) + { + var deserializedValue = JsonSerializer.Deserialize(distributedValue); + _memoryCache.Set(key, deserializedValue, TimeSpan.FromMinutes(5)); + return deserializedValue; + } + + // 3. 資料庫查詢 (最後選擇) + return null; + } +} + +// 📈 關鍵快取策略 +var cacheStrategies = new Dictionary +{ + ["today_due_cards"] = new(TimeSpan.FromMinutes(15), CacheLevel.Memory), + ["completed_tests"] = new(TimeSpan.FromMinutes(30), CacheLevel.Distributed), + ["user_cefr_mapping"] = new(TimeSpan.FromHours(1), CacheLevel.Memory), + ["question_options"] = new(TimeSpan.FromMinutes(10), CacheLevel.Memory) +}; +``` + +### **API 效能優化** +```csharp +// ⚡ 查詢優化策略 +public class OptimizedStudyService +{ + // 批量預載入相關資料 + public async Task> GetDueCardsWithOptimizationAsync(Guid userId) + { + return await _context.Flashcards + .Where(f => f.UserId == userId && f.NextReviewDate <= DateTime.Today) + .Include(f => f.StudyRecords.Where(sr => sr.StudiedAt.Date == DateTime.Today)) + .AsNoTracking() // 只讀查詢優化 + .AsSplitQuery() // 分割查詢避免笛卡爾積 + .ToListAsync(); + } + + // 智能預測下一個測驗 + public async Task PredictNextTestAsync(Guid userId) + { + // 使用演算法預測,減少即時計算 + var cachedPrediction = await _cache.GetAsync($"next_test:{userId}"); + if (cachedPrediction != null) return cachedPrediction; + + // 重新計算並快取 + var nextTest = await ComputeNextTestAsync(userId); + await _cache.SetAsync($"next_test:{userId}", nextTest, TimeSpan.FromMinutes(5)); + + return nextTest; + } +} +``` + +--- + +## 🔐 **安全與認證架構** + +### **JWT 認證策略** +```csharp +// 🔑 JWT 配置 (Program.cs) +builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddJwtBearer(options => + { + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidateAudience = true, + ValidateLifetime = true, + ValidateIssuerSigningKey = true, + ValidIssuer = supabaseUrl, + ValidAudience = "authenticated", + IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtSecret)) + }; + + // 智能複習特殊處理:允許過期 token 緩衝期 + options.Events = new JwtBearerEvents + { + OnTokenValidated = context => + { + // 檢查學習狀態,允許正在學習的用戶有 5 分鐘緩衝 + return Task.CompletedTask; + } + }; + }); +``` + +### **權限控制策略** +```csharp +// 🛡️ 智能複習權限檢查 +[AttributeUsage(AttributeTargets.Method)] +public class RequireFlashcardOwnershipAttribute : Attribute, IAuthorizationFilter +{ + public void OnAuthorization(AuthorizationFilterContext context) + { + // 檢查詞卡所有權 + var flashcardId = GetFlashcardIdFromRoute(context); + var userId = GetUserIdFromToken(context); + + if (!ValidateOwnership(flashcardId, userId)) + { + context.Result = new UnauthorizedResult(); + } + } +} + +// 使用範例 +[HttpPost("{id}/submit")] +[RequireFlashcardOwnership] +public async Task SubmitTest(Guid id, [FromBody] TestResult result) +{ + // 已通過權限檢查,直接處理業務邏輯 +} +``` + +--- + +## 📈 **監控與可觀測性** + +### **關鍵指標監控** +```csharp +// 📊 智能複習指標收集 +public class SmartReviewMetrics +{ + // 業務指標 + [Counter("smart_review_tests_completed_total")] + public static readonly Counter TestsCompleted; + + [Counter("smart_review_tests_skipped_total")] + public static readonly Counter TestsSkipped; + + [Histogram("smart_review_response_time_ms")] + public static readonly Histogram ResponseTime; + + [Gauge("smart_review_active_sessions")] + public static readonly Gauge ActiveSessions; + + // CEFR 適配指標 + [Counter("cefr_adaptation_selections_total")] + public static readonly Counter CEFRAdaptations; + + [Histogram("cefr_adaptation_accuracy")] + public static readonly Histogram AdaptationAccuracy; +} +``` + +### **健康檢查端點** +```csharp +// 🩺 智能複習系統健康檢查 +[HttpGet("/health/smart-review")] +public async Task GetSmartReviewHealth() +{ + var healthChecks = new Dictionary + { + ["database"] = await CheckDatabaseConnectionAsync(), + ["cefr_mapping"] = CheckCEFRMappingService(), + ["spaced_repetition"] = CheckSpacedRepetitionAlgorithm(), + ["test_queue"] = await CheckTestQueueServiceAsync(), + ["api_response_time"] = await MeasureAPIResponseTimeAsync() + }; + + var isHealthy = healthChecks.Values.All(v => v.ToString() == "Healthy"); + + return Ok(new + { + Status = isHealthy ? "Healthy" : "Degraded", + Checks = healthChecks, + Timestamp = DateTime.UtcNow, + SystemInfo = new + { + ActiveUsers = await GetActiveUserCountAsync(), + TestsCompletedToday = await GetTodaysTestCountAsync(), + AverageResponseTimeMs = GetAverageResponseTime() + } + }); +} +``` + +--- + +## 🚀 **部署架構規範** + +### **環境配置標準** +```json +// 🔧 appsettings.json (生產環境) +{ + "SmartReview": { + "EnableAdaptiveAlgorithm": true, + "A1ProtectionLevel": 20, + "CEFRMappingCache": { + "ExpirationMinutes": 60, + "MaxEntries": 1000 + }, + "TestQueue": { + "MaxQueueSize": 100, + "PriorityRecalculationMinutes": 15, + "SkipLimitPerSession": 50 + }, + "Performance": { + "MaxConcurrentSessions": 1000, + "ResponseTimeThresholdMs": 100, + "CacheHitRateThreshold": 0.8 + } + }, + "SpacedRepetition": { + "Algorithm": "SM2Enhanced", + "GrowthFactors": { + "ShortTerm": 1.8, + "MediumTerm": 1.4, + "LongTerm": 1.2, + "VeryLongTerm": 1.1 + }, + "OverduePenalties": { + "Light": 0.9, + "Medium": 0.75, + "Heavy": 0.5, + "Extreme": 0.3 + } + } +} +``` + +### **容器化部署配置** +```dockerfile +# 🐳 Dockerfile (生產環境) +FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base +WORKDIR /app +EXPOSE 8080 + +FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build +WORKDIR /src +COPY ["DramaLing.Api.csproj", "."] +RUN dotnet restore + +COPY . . +RUN dotnet build -c Release -o /app/build + +FROM build AS publish +RUN dotnet publish -c Release -o /app/publish + +FROM base AS final +WORKDIR /app +COPY --from=publish /app/publish . + +# 智能複習系統特殊配置 +ENV ASPNETCORE_ENVIRONMENT=Production +ENV SMART_REVIEW_ENABLED=true +ENV CEFR_CACHE_SIZE=1000 + +ENTRYPOINT ["dotnet", "DramaLing.Api.dll"] +``` + +--- + +## 🧪 **測試架構設計** + +### **測試策略規範** +```csharp +// 🧪 智能複習服務測試 +[TestFixture] +public class SmartReviewServiceTests +{ + private ITestQueueService _queueService; + private ISpacedRepetitionService _spacedRepService; + private Mock _mockContext; + + [Test] + public async Task BuildTodaysQueue_ShouldPrioritizeNewTests() + { + // Arrange + var userId = Guid.NewGuid(); + var dueCards = CreateTestDueCards(); + var completedTests = CreateTestCompletedTests(); + + // Act + var queue = await _queueService.BuildTodaysQueueAsync(userId); + + // Assert + Assert.That(queue.Tests.First().Status, Is.EqualTo(TestStatus.New)); + Assert.That(queue.Tests.Where(t => t.Status == TestStatus.New).Count(), Is.GreaterThan(0)); + } + + [Test] + public async Task CEFRAdaptation_A1User_ShouldLimitToBasicTests() + { + // Arrange + var userLevel = 20; // A1 + var wordLevel = 50; // B1 + + // Act + var availableTests = _adaptationService.GetAvailableTestTypes(userLevel, wordLevel); + + // Assert + Assert.That(availableTests, Is.EquivalentTo(new[] { "flip-memory", "vocab-choice", "vocab-listening" })); + } +} +``` + +### **前端組件測試** +```typescript +// 🧪 React 組件測試 +describe('SmartReviewContainer', () => { + test('should load todays review on mount', async () => { + render( + + + + ) + + await waitFor(() => { + expect(screen.getByText(/今日複習/)).toBeInTheDocument() + }) + }) + + test('should show skip button before answering', () => { + const { getByText } = renderWithContext(, { + isAnswered: false, + navigationState: 'skip' + }) + + expect(getByText('跳過這題')).toBeInTheDocument() + }) + + test('should show continue button after answering', () => { + const { getByText } = renderWithContext(, { + isAnswered: true, + navigationState: 'continue' + }) + + expect(getByText('繼續下一題')).toBeInTheDocument() + }) +}) +``` + +--- + +## 🎯 **實施路線圖** + +### **階段一:核心架構 (第1-2週)** +1. **後端服務層重構** + - 實現 TestQueueService + - 重構 StudyController API + - 移除 Session 複雜性 + +2. **前端組件基礎** + - 建立 ReviewContext + - 實現核心 Hooks + - 基礎組件框架 + +### **階段二:智能適配 (第3-4週)** +1. **CEFR 適配系統** + - ReviewAdaptationService 實現 + - 四情境邏輯完整實現 + - A1 保護機制 + +2. **測驗類型組件** + - 7 種測驗組件實現 + - 統一介面標準化 + - 答案驗證邏輯 + +### **階段三:隊列管理 (第5-6週)** +1. **智能隊列系統** + - 優先級排序算法 + - 跳過邏輯實現 + - 狀態持久化 + +2. **導航控制系統** + - 狀態驅動導航 + - 流暢的用戶體驗 + - 錯誤恢復機制 + +### **階段四:優化與監控 (第7-8週)** +1. **效能優化** + - 快取策略實施 + - 查詢優化 + - 響應時間優化 + +2. **監控與測試** + - 監控指標實施 + - 自動化測試 + - 效能基準測試 + +--- + +## 📋 **開發檢查清單** + +### **前端開發檢查項目** +- [ ] ReviewContext 實現完成 +- [ ] 7 個測驗組件實現完成 +- [ ] 導航控制邏輯實現 +- [ ] 隊列管理邏輯實現 +- [ ] 狀態持久化機制 +- [ ] 錯誤處理和重試機制 +- [ ] 響應式設計適配 +- [ ] 無障礙設計支援 + +### **後端開發檢查項目** +- [ ] StudyController 重構完成 +- [ ] 智能複習服務實現 +- [ ] CEFR 適配邏輯實現 +- [ ] 隊列管理服務實現 +- [ ] API 錯誤處理統一 +- [ ] 認證授權機制 +- [ ] 效能優化實施 +- [ ] 監控指標收集 + +### **整合測試檢查項目** +- [ ] 前後端 API 契約測試 +- [ ] 四情境適配邏輯測試 +- [ ] 隊列管理端到端測試 +- [ ] 狀態持久化測試 +- [ ] 效能基準測試 +- [ ] 用戶體驗流程測試 + +--- + +**文檔版本**: 1.0 +**創建日期**: 2025-09-29 +**技術負責**: 開發團隊 +**審核狀態**: 待審核 +**實施優先級**: P0 (最高優先級) \ No newline at end of file diff --git a/note/智能複習/智能複習系統-演算法規格書.md b/note/智能複習/智能複習系統-演算法規格書.md index 2c3f46d..40ef6f8 100644 --- a/note/智能複習/智能複習系統-演算法規格書.md +++ b/note/智能複習/智能複習系統-演算法規格書.md @@ -229,7 +229,7 @@ def weighted_select(types, weights): 計算難度差異 (wordLevel - userLevel) ↓ 判斷學習情境 - ├── A1學習者 (≤20) → 基礎3題型池 + ├── A1學習者 (user的cefr=A1) → 基礎3題型池 ├── 簡單詞彙 (<-10) → 應用2題型池 ├── 適中詞彙 (-10~10) → 全方位3題型池 └── 困難詞彙 (>10) → 基礎2題型池 diff --git a/後端複習系統清空執行計劃.md b/後端複習系統清空執行計劃.md new file mode 100644 index 0000000..ef935b0 --- /dev/null +++ b/後端複習系統清空執行計劃.md @@ -0,0 +1,514 @@ +# 後端複習系統清空執行計劃 + +**目標**: 完全移除當前後端複雜的複習系統程式碼,準備重新實施簡潔版本 +**日期**: 2025-09-29 +**執行範圍**: DramaLing.Api 後端專案 +**風險等級**: 🟡 中等 (需要仔細執行以避免破壞核心功能) + +--- + +## 🎯 **清空目標與範圍** + +### **清空目標** +1. **移除複雜的智能複習邏輯**: 包含 Session 概念、複雜隊列管理 +2. **保留核心詞卡功能**: 基本 CRUD 和簡單統計 +3. **為重新實施做準備**: 清潔的代碼基礎 + +### **保留功能** +- ✅ 詞卡基本 CRUD (FlashcardsController) +- ✅ 用戶認證 (AuthController) +- ✅ AI 分析服務 (AIController) +- ✅ 音訊服務 (AudioController) +- ✅ 圖片生成 (ImageGenerationController) +- ✅ 基礎統計 (StatsController) + +--- + +## 🗄️ **資料庫結構盤點** + +### **複習系統相關資料表** +```sql +📊 當前資料庫表結構分析: + +✅ 需要保留的核心表: +├── user_profiles 用戶基本資料 +├── flashcards 詞卡核心資料 (需簡化) +├── study_records 學習記錄 (需簡化) +├── daily_stats 每日統計 +├── audio_cache 音訊快取 +├── example_images 例句圖片 +├── flashcard_example_images 詞卡圖片關聯 +├── image_generation_requests 圖片生成請求 +├── options_vocabularies 選項詞彙庫 +├── error_reports 錯誤報告 +└── sentence_analysis_cache 句子分析快取 + +❌ 需要處理的複習相關表: +├── study_sessions 學習會話表 🗑️ 刪除 +├── study_cards 會話詞卡表 🗑️ 刪除 +├── test_results 測驗結果表 🗑️ 刪除 +└── pronunciation_assessments 發音評估 ⚠️ 檢查關聯 +``` + +### **資料庫遷移文件** +``` +📁 Migrations/ +├── 20250926053105_AddStudyCardAndTestResult.cs 🗑️ 需要回滾 +├── 20250926053105_AddStudyCardAndTestResult.Designer.cs 🗑️ 需要回滾 +├── 20250926061341_AddStudyRecordUniqueIndex.cs ⚠️ 可能保留 +├── 20250926061341_AddStudyRecordUniqueIndex.Designer.cs ⚠️ 可能保留 +└── 其他遷移文件 ✅ 保留 +``` + +### **資料庫清理策略** +1. **StudyRecord 表處理**: + - ✅ **保留基本結構**: 作為簡化的學習記錄 + - 🔧 **移除複雜欄位**: SM-2 相關的追蹤欄位 + - ⚠️ **保留核心欄位**: user_id, flashcard_id, study_mode, is_correct, studied_at + +2. **複雜表結構移除**: + - 🗑️ **study_sessions**: 完全移除 (Session 概念) + - 🗑️ **study_cards**: 完全移除 (Session 相關) + - 🗑️ **test_results**: 完全移除 (與 StudyRecord 重複) + +3. **遷移文件處理**: + - 📝 **創建回滾遷移**: 移除 study_sessions, study_cards, test_results 表 + - ✅ **保留核心遷移**: StudyRecord 基本結構保留 + +--- + +## 📋 **完整清空文件清單** + +### 🗑️ **需要完全刪除的文件** + +#### **服務層文件** +``` +📁 Services/ +├── SpacedRepetitionService.cs 🗑️ 完全刪除 (8,574 bytes) +├── ReviewTypeSelectorService.cs 🗑️ 完全刪除 (8,887 bytes) +├── ReviewModeSelector.cs 🗑️ 完全刪除 (2,598 bytes) +├── QuestionGeneratorService.cs 🗑️ 檢查後可能刪除 +└── BlankGenerationService.cs 🗑️ 檢查後可能刪除 +``` + +#### **DTO 相關文件** +``` +📁 Models/DTOs/SpacedRepetition/ +├── ReviewModeResult.cs 🗑️ 完全刪除 +├── ReviewRequest.cs 🗑️ 完全刪除 +├── ReviewResult.cs 🗑️ 完全刪除 +└── 整個 SpacedRepetition 資料夾 🗑️ 完全刪除 +``` + +#### **配置文件** +``` +📁 Models/Configuration/ +└── SpacedRepetitionOptions.cs 🗑️ 完全刪除 +``` + +#### **實體文件** +``` +📁 Models/Entities/ +├── StudySession.cs 🗑️ 完全刪除 (如存在) +├── StudyCard.cs 🗑️ 完全刪除 (如存在) +└── TestResult.cs 🗑️ 完全刪除 (如存在) +``` + +### ⚠️ **需要大幅簡化的文件** + +#### **控制器文件** +``` +📁 Controllers/ +└── StudyController.cs 🔧 大幅簡化 + ├── 移除所有智能複習 API (due, next-review, optimal-mode, question, review) + ├── 保留基礎統計 (stats) + ├── 保留測驗記錄 (record-test, completed-tests) + └── 移除複雜的 Session 邏輯 +``` + +#### **核心配置文件** +``` +📁 根目錄文件 +├── Program.cs 🔧 移除複習服務註冊 +├── DramaLingDbContext.cs 🔧 移除複習相關配置 +└── appsettings.json 🔧 移除 SpacedRepetition 配置 +``` + +#### **實體文件** +``` +📁 Models/Entities/ +├── Flashcard.cs 🔧 簡化複習相關屬性 +├── User.cs ✅ 基本保持不變 +└── StudyRecord.cs 🔧 簡化為基礎記錄 +``` + +--- + +## 🔍 **詳細清空步驟** + +### **第一階段:停止服務並備份** +1. **停止當前運行的服務** + ```bash + # 停止所有後端服務 + pkill -f "dotnet run" + ``` + +2. **創建備份分支** (可選) + ```bash + git checkout -b backup/before-review-cleanup + git add . + git commit -m "backup: 清空前的複習系統狀態備份" + ``` + +### **第二階段:刪除服務層文件** +```bash +# 刪除智能複習服務 +rm Services/SpacedRepetitionService.cs +rm Services/ReviewTypeSelectorService.cs +rm Services/ReviewModeSelector.cs + +# 檢查並決定是否刪除 +# rm Services/QuestionGeneratorService.cs # 可能被選項詞彙庫使用 +# rm Services/BlankGenerationService.cs # 可能被其他功能使用 +``` + +### **第三階段:刪除 DTO 和配置文件** +```bash +# 刪除整個 SpacedRepetition DTO 資料夾 +rm -rf Models/DTOs/SpacedRepetition/ + +# 刪除配置文件 +rm Models/Configuration/SpacedRepetitionOptions.cs +``` + +### **第四階段:簡化 StudyController** +需要手動編輯 `Controllers/StudyController.cs`: +```csharp +// 移除的內容: +- 智能複習服務依賴注入 (ISpacedRepetitionService, IReviewTypeSelectorService 等) +- 智能複習 API 方法 (GetDueCards, GetNextReview, GetOptimalReviewMode, GenerateQuestion, SubmitReview) +- 複雜的 Session 相關邏輯 + +// 保留的內容: +- 基礎統計 (GetStudyStats) +- 測驗記錄 (RecordTestCompletion, GetCompletedTests) +- 基礎認證和日誌功能 +``` + +### **第五階段:清理 Program.cs 服務註冊** +```csharp +// 移除的服務註冊: +// builder.Services.AddScoped(); +// builder.Services.AddScoped(); +// builder.Services.AddScoped(); +// builder.Services.Configure(...); +``` + +### **第六階段:簡化資料模型** +1. **簡化 Flashcard.cs** + ```csharp + // 移除的屬性: + - ReviewHistory + - LastQuestionType + - 複雜的 SM-2 算法屬性 (可選保留基礎的) + + // 保留的屬性: + - 基本詞卡內容 (Word, Translation, Definition 等) + - 基礎學習狀態 (MasteryLevel, TimesReviewed) + - 基礎複習間隔 (NextReviewDate, IntervalDays) + ``` + +2. **簡化 StudyRecord.cs** + ```csharp + // 保留簡化版本: + - 基本測驗記錄 (FlashcardId, TestType, IsCorrect) + - 移除複雜的 SM-2 追蹤參數 + ``` + +### **第七階段:資料庫結構清理** + +#### **7.1 清理 DramaLingDbContext.cs** +```csharp +// 移除的 DbSet: +- DbSet StudySessions 🗑️ 刪除 +- DbSet StudyCards 🗑️ 刪除 +- DbSet TestResults 🗑️ 刪除 + +// 移除的 ToTable 配置: +- .ToTable("study_sessions") 🗑️ 刪除 +- .ToTable("study_cards") 🗑️ 刪除 +- .ToTable("test_results") 🗑️ 刪除 + +// 移除的關聯配置: +- StudySession 與 User 的關聯 +- StudyCard 與 StudySession 的關聯 +- TestResult 與 StudyCard 的關聯 +- PronunciationAssessment 與 StudySession 的關聯 + +// 簡化 StudyRecord 配置: +- 移除複雜的 SM-2 追蹤欄位配置 +- 保留基本的 user_id, flashcard_id, study_mode 索引 +``` + +#### **7.2 創建資料庫清理遷移** +```bash +# 創建新的遷移來移除複雜表結構 +dotnet ef migrations add RemoveComplexStudyTables + +# 在遷移中執行: +migrationBuilder.DropTable("study_sessions"); +migrationBuilder.DropTable("study_cards"); +migrationBuilder.DropTable("test_results"); + +# 移除 PronunciationAssessment 中的 StudySessionId 欄位 +migrationBuilder.DropColumn("study_session_id", "pronunciation_assessments"); +``` + +#### **7.3 清理配置文件** +```json +// appsettings.json - 移除的配置段落: +- "SpacedRepetition": { ... } 🗑️ 完全移除 +``` + +#### **7.4 簡化 Flashcard 實體** +```csharp +// Flashcard.cs - 移除的複習相關屬性: +- ReviewHistory (JSON 複習歷史) 🗑️ 移除 +- LastQuestionType 🗑️ 移除 +- 複雜的 SM-2 追蹤欄位 (可選保留基礎的) ⚠️ 檢查 + +// 保留的基本屬性: +- EasinessFactor, Repetitions, IntervalDays ✅ 保留 (基礎複習間隔) +- NextReviewDate, MasteryLevel ✅ 保留 (基本狀態) +- TimesReviewed, TimesCorrect ✅ 保留 (統計) +``` + +--- + +## 🧹 **清空後的目標架構** + +### **簡化後的 API 端點** +``` +📍 保留的端點: +├── /api/flashcards/* 詞卡 CRUD (6個端點) +├── /api/auth/* 用戶認證 (4個端點) +├── /api/ai/* AI 分析 (3個端點) +├── /api/audio/* 音訊服務 (4個端點) +├── /api/ImageGeneration/* 圖片生成 (4個端點) +├── /api/stats/* 統計分析 (3個端點) +└── /api/study/* 簡化學習 (2個端點) + ├── GET /stats 學習統計 + └── POST /record-test 測驗記錄 + +❌ 移除的端點: +├── /api/study/due (複雜的到期詞卡邏輯) +├── /api/study/next-review (複雜的下一張邏輯) +├── /api/study/{id}/optimal-mode (智能模式選擇) +├── /api/study/{id}/question (題目生成) +├── /api/study/{id}/review (複習結果提交) +└── 所有 Session 相關端點 +``` + +### **簡化後的服務層** +``` +📦 保留的服務: +├── AuthService 認證服務 +├── GeminiService AI 分析 +├── AnalysisService 句子分析 +├── AzureSpeechService 語音服務 +├── AudioCacheService 音訊快取 +├── ImageGenerationOrchestrator 圖片生成 +├── ImageStorageService 圖片儲存 +├── UsageTrackingService 使用追蹤 +└── OptionsVocabularyService 選項詞彙庫 + +❌ 移除的服務: +├── SpacedRepetitionService 間隔重複算法 +├── ReviewTypeSelectorService 複習題型選擇 +├── ReviewModeSelector 複習模式選擇 +├── StudySessionService 學習會話管理 +└── 相關的介面檔案 +``` + +### **簡化後的資料模型** +``` +📊 核心實體 (簡化版): +├── User 基本用戶資料 +├── Flashcard 基本詞卡 (移除複雜複習屬性) +├── StudyRecord 簡化學習記錄 +├── DailyStats 基礎統計 +├── AudioCache 音訊快取 +├── ExampleImage 例句圖片 +├── OptionsVocabulary 選項詞彙庫 +└── ErrorReport 錯誤報告 + +❌ 移除的實體: +├── StudySession 學習會話 +├── StudyCard 會話詞卡 +├── TestResult 測驗結果 +└── 複雜的複習相關實體 +``` + +--- + +## ⚡ **預期清空效果** + +### **代碼量減少** +- **服務層**: 減少 ~20,000 行複雜邏輯 +- **DTO 層**: 減少 ~1,000 行傳輸物件 +- **控制器**: StudyController 從 583行 → ~100行 +- **總計**: 預計減少 ~25,000 行複雜代碼 + +### **API 端點簡化** +- **移除端點**: 5-8 個複雜的智能複習端點 +- **保留端點**: ~25 個核心功能端點 +- **複雜度**: 從複雜多層依賴 → 簡單直接邏輯 + +### **系統複雜度** +- **服務依賴**: 從 8個複習服務 → 0個 +- **資料實體**: 從 18個 → ~12個 核心實體 +- **配置項目**: 從複雜參數配置 → 基本配置 + +--- + +## 🛡️ **風險控制措施** + +### **清空前檢查** +1. **確認無前端依賴**: 檢查前端是否調用即將刪除的 API +2. **資料備份**: 確保重要資料已備份 +3. **服務停止**: 確保所有相關服務已停止 + +### **分階段執行** +1. **先註解服務註冊**: 在 Program.cs 中註解掉服務,確保編譯通過 +2. **逐步刪除文件**: 按依賴關係順序刪除 +3. **驗證編譯**: 每階段後驗證系統可編譯 +4. **功能測試**: 確保保留功能正常運作 + +### **回滾準備** +1. **Git 分支備份**: 清空前創建備份分支 +2. **關鍵文件備份**: 手動備份重要配置文件 +3. **快速恢復腳本**: 準備快速恢復命令 + +--- + +## 📝 **執行步驟檢查清單** + +### **準備階段** +- [ ] 停止所有後端服務 +- [ ] 創建 Git 備份分支 +- [ ] 確認前端無依賴調用 +- [ ] 備份關鍵配置文件 + +### **刪除階段** +- [ ] 註解 Program.cs 服務註冊 +- [ ] 刪除 SpacedRepetition DTO 資料夾 +- [ ] 刪除複習相關服務文件 +- [ ] 刪除配置文件 +- [ ] 簡化 StudyController + +### **清理階段** +- [ ] 清理 DramaLingDbContext 配置 +- [ ] 簡化 Flashcard 實體 +- [ ] 移除 appsettings 複習配置 +- [ ] 清理 using 語句 + +### **資料庫清理階段** +- [ ] 創建清理遷移檔案 +- [ ] 執行資料庫清理遷移 +- [ ] 驗證資料表結構正確 +- [ ] 檢查資料完整性 +- [ ] 清理過時的遷移文件 + +### **驗證階段** +- [ ] 編譯測試通過 +- [ ] 基礎 API 功能正常 +- [ ] 詞卡 CRUD 正常 +- [ ] 認證功能正常 +- [ ] 統計功能正常 +- [ ] 資料庫查詢正常 + +### **完成階段** +- [ ] 提交清空變更 +- [ ] 更新架構文檔 +- [ ] 通知團隊清空完成 +- [ ] 準備重新實施 + +--- + +## 🚀 **清空後的架構優勢** + +### **簡潔性** +- **代碼可讀性**: 移除複雜邏輯後代碼更易理解 +- **維護性**: 減少相互依賴,更易維護 +- **除錯性**: 簡化的邏輯更容易除錯 + +### **可擴展性** +- **重新設計**: 為新的簡潔設計提供清潔基礎 +- **模組化**: 功能模組更加獨立 +- **測試友善**: 簡化的邏輯更容易測試 + +### **效能提升** +- **響應速度**: 移除複雜計算邏輯 +- **記憶體使用**: 減少複雜物件實例 +- **啟動速度**: 減少服務註冊和初始化 + +--- + +## ⚠️ **風險評估與緩解** + +### **高風險項目** +1. **資料完整性**: + - **風險**: 刪除實體可能影響資料庫 + - **緩解**: 先移除代碼引用,保留資料庫結構 + +2. **API 相容性**: + - **風險**: 前端可能調用被刪除的 API + - **緩解**: 清空前確認前端依賴關係 + +### **中風險項目** +1. **編譯錯誤**: + - **風險**: 刪除文件後可能有編譯錯誤 + - **緩解**: 分階段執行,每步驗證編譯 + +2. **功能缺失**: + - **風險**: 意外刪除必要功能 + - **緩解**: 仔細檢查文件依賴關係 + +--- + +## 📊 **清空進度追蹤** + +### **進度指標** +- **文件刪除進度**: X / Y 個文件已刪除 +- **代碼行數減少**: 當前 / 目標 行數 +- **編譯狀態**: ✅ 通過 / ❌ 失敗 +- **功能測試**: X / Y 個核心功能正常 + +### **完成標準** +- ✅ 所有複習相關文件已刪除 +- ✅ 系統可正常編譯運行 +- ✅ 核心功能 (詞卡 CRUD, 認證) 正常 +- ✅ API 端點從 ~30個 減少到 ~20個 +- ✅ 代碼複雜度大幅降低 + +--- + +## 🎉 **清空完成後的下一步** + +### **立即後續工作** +1. **更新技術文檔**: 反映清空後的架構 +2. **重新規劃**: 基於簡潔架構重新設計複習系統 +3. **前端調整**: 調整前端 API 調用 (如有必要) + +### **重新實施準備** +1. **需求重審**: 基於產品需求規格書重新設計 +2. **技術選型**: 選擇更簡潔的實施方案 +3. **組件化設計**: 按技術實作架構規格書實施 + +--- + +**執行負責人**: 開發團隊 +**預計執行時間**: 2-4 小時 +**風險等級**: 🟡 中等 +**回滾準備**: ✅ 已準備 +**執行狀態**: 📋 **待執行** \ No newline at end of file