diff --git a/backend/DramaLing.Api/Controllers/FlashcardsController.cs b/backend/DramaLing.Api/Controllers/FlashcardsController.cs index 490ab81..8d67123 100644 --- a/backend/DramaLing.Api/Controllers/FlashcardsController.cs +++ b/backend/DramaLing.Api/Controllers/FlashcardsController.cs @@ -3,6 +3,8 @@ 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; @@ -17,15 +19,25 @@ public class FlashcardsController : ControllerBase private readonly DramaLingDbContext _context; private readonly ILogger _logger; private readonly IImageStorageService _imageStorageService; + // 🆕 智能複習服務依賴 + private readonly ISpacedRepetitionService _spacedRepetitionService; + private readonly IReviewTypeSelectorService _reviewTypeSelectorService; + private readonly IQuestionGeneratorService _questionGeneratorService; public FlashcardsController( DramaLingDbContext context, ILogger logger, - IImageStorageService imageStorageService) + IImageStorageService imageStorageService, + ISpacedRepetitionService spacedRepetitionService, + IReviewTypeSelectorService reviewTypeSelectorService, + IQuestionGeneratorService questionGeneratorService) { _context = context; _logger = logger; _imageStorageService = imageStorageService; + _spacedRepetitionService = spacedRepetitionService; + _reviewTypeSelectorService = reviewTypeSelectorService; + _questionGeneratorService = questionGeneratorService; } private Guid GetUserId() @@ -445,6 +457,164 @@ 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); + + _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 (如果是舊資料) + if (nextCard.UserLevel == 0) + nextCard.UserLevel = CEFRMappingService.GetDefaultUserLevel(); + if (nextCard.WordLevel == 0) + nextCard.WordLevel = CEFRMappingService.GetWordLevel(nextCard.DifficultyLevel); + + 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, + // 智能複習擴展欄位 + nextCard.UserLevel, + nextCard.WordLevel, + 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" }); + } + } + + /// + /// 系統自動選擇最適合的複習題型 + /// + [HttpPost("{id}/optimal-review-mode")] + public async Task GetOptimalReviewMode(Guid id, [FromBody] OptimalModeRequest request) + { + try + { + var result = await _reviewTypeSelectorService.SelectOptimalReviewModeAsync( + id, request.UserLevel, request.WordLevel); + + _logger.LogInformation("Selected optimal review mode {SelectedMode} for flashcard {FlashcardId}", + result.SelectedMode, id); + + 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 diff --git a/backend/DramaLing.Api/Migrations/20250925104256_AddSpacedRepetitionFields.Designer.cs b/backend/DramaLing.Api/Migrations/20250925104256_AddSpacedRepetitionFields.Designer.cs new file mode 100644 index 0000000..27f7b4b --- /dev/null +++ b/backend/DramaLing.Api/Migrations/20250925104256_AddSpacedRepetitionFields.Designer.cs @@ -0,0 +1,1433 @@ +// +using System; +using DramaLing.Api.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace DramaLing.Api.Migrations +{ + [DbContext(typeof(DramaLingDbContext))] + [Migration("20250925104256_AddSpacedRepetitionFields")] + partial class AddSpacedRepetitionFields + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "8.0.10"); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.AudioCache", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Accent") + .IsRequired() + .HasMaxLength(2) + .HasColumnType("TEXT"); + + b.Property("AccessCount") + .HasColumnType("INTEGER") + .HasColumnName("access_count"); + + b.Property("AudioUrl") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("audio_url"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("DurationMs") + .HasColumnType("INTEGER") + .HasColumnName("duration_ms"); + + b.Property("FileSize") + .HasColumnType("INTEGER") + .HasColumnName("file_size"); + + b.Property("LastAccessed") + .HasColumnType("TEXT") + .HasColumnName("last_accessed"); + + b.Property("TextContent") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("text_content"); + + b.Property("TextHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT") + .HasColumnName("text_hash"); + + b.Property("VoiceId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT") + .HasColumnName("voice_id"); + + b.HasKey("Id"); + + b.HasIndex("LastAccessed") + .HasDatabaseName("IX_AudioCache_LastAccessed"); + + b.HasIndex("TextHash") + .IsUnique() + .HasDatabaseName("IX_AudioCache_TextHash"); + + b.ToTable("audio_cache", (string)null); + }); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.CardSet", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CardCount") + .HasColumnType("INTEGER"); + + b.Property("Color") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("IsDefault") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("card_sets", (string)null); + }); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.DailyStats", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AiApiCalls") + .HasColumnType("INTEGER") + .HasColumnName("ai_api_calls"); + + b.Property("CardsGenerated") + .HasColumnType("INTEGER") + .HasColumnName("cards_generated"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("Date") + .HasColumnType("TEXT"); + + b.Property("SessionCount") + .HasColumnType("INTEGER") + .HasColumnName("session_count"); + + b.Property("StudyTimeSeconds") + .HasColumnType("INTEGER") + .HasColumnName("study_time_seconds"); + + b.Property("UserId") + .HasColumnType("TEXT") + .HasColumnName("user_id"); + + b.Property("WordsCorrect") + .HasColumnType("INTEGER") + .HasColumnName("words_correct"); + + b.Property("WordsStudied") + .HasColumnType("INTEGER") + .HasColumnName("words_studied"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Date") + .IsUnique(); + + b.ToTable("daily_stats", (string)null); + }); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.ErrorReport", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AdminNotes") + .HasColumnType("TEXT") + .HasColumnName("admin_notes"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("FlashcardId") + .HasColumnType("TEXT") + .HasColumnName("flashcard_id"); + + b.Property("ReportType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT") + .HasColumnName("report_type"); + + b.Property("ResolvedAt") + .HasColumnType("TEXT") + .HasColumnName("resolved_at"); + + b.Property("ResolvedBy") + .HasColumnType("TEXT") + .HasColumnName("resolved_by"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("StudyMode") + .HasMaxLength(50) + .HasColumnType("TEXT") + .HasColumnName("study_mode"); + + b.Property("UserId") + .HasColumnType("TEXT") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("FlashcardId"); + + b.HasIndex("ResolvedBy"); + + b.HasIndex("UserId"); + + b.ToTable("error_reports", (string)null); + }); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.ExampleImage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AccessCount") + .HasColumnType("INTEGER") + .HasColumnName("access_count"); + + b.Property("AltText") + .HasMaxLength(200) + .HasColumnType("TEXT") + .HasColumnName("alt_text"); + + b.Property("ContentHash") + .HasMaxLength(64) + .HasColumnType("TEXT") + .HasColumnName("content_hash"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("FileSize") + .HasColumnType("INTEGER") + .HasColumnName("file_size"); + + b.Property("GeminiCost") + .HasColumnType("TEXT") + .HasColumnName("gemini_cost"); + + b.Property("GeminiDescription") + .HasColumnType("TEXT") + .HasColumnName("gemini_description"); + + b.Property("GeminiPrompt") + .HasColumnType("TEXT") + .HasColumnName("gemini_prompt"); + + b.Property("ImageHeight") + .HasColumnType("INTEGER") + .HasColumnName("image_height"); + + b.Property("ImageWidth") + .HasColumnType("INTEGER") + .HasColumnName("image_width"); + + b.Property("ModerationNotes") + .HasColumnType("TEXT") + .HasColumnName("moderation_notes"); + + b.Property("ModerationStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT") + .HasColumnName("moderation_status"); + + b.Property("QualityScore") + .HasColumnType("TEXT") + .HasColumnName("quality_score"); + + b.Property("RelativePath") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT") + .HasColumnName("relative_path"); + + b.Property("ReplicateCost") + .HasColumnType("TEXT") + .HasColumnName("replicate_cost"); + + b.Property("ReplicateModel") + .HasMaxLength(100) + .HasColumnType("TEXT") + .HasColumnName("replicate_model"); + + b.Property("ReplicatePrompt") + .HasColumnType("TEXT") + .HasColumnName("replicate_prompt"); + + b.Property("ReplicateVersion") + .HasMaxLength(100) + .HasColumnType("TEXT") + .HasColumnName("replicate_version"); + + b.Property("TotalGenerationCost") + .HasColumnType("TEXT") + .HasColumnName("total_generation_cost"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT") + .HasColumnName("updated_at"); + + b.HasKey("Id"); + + b.HasIndex("AccessCount"); + + b.HasIndex("ContentHash") + .IsUnique(); + + b.ToTable("example_images", (string)null); + }); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.Flashcard", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CardSetId") + .HasColumnType("TEXT") + .HasColumnName("card_set_id"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("Definition") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DifficultyLevel") + .HasMaxLength(10) + .HasColumnType("TEXT") + .HasColumnName("difficulty_level"); + + b.Property("EasinessFactor") + .HasColumnType("REAL") + .HasColumnName("easiness_factor"); + + b.Property("Example") + .HasColumnType("TEXT"); + + b.Property("ExampleTranslation") + .HasColumnType("TEXT") + .HasColumnName("example_translation"); + + b.Property("IntervalDays") + .HasColumnType("INTEGER") + .HasColumnName("interval_days"); + + b.Property("IsArchived") + .HasColumnType("INTEGER") + .HasColumnName("is_archived"); + + b.Property("IsFavorite") + .HasColumnType("INTEGER") + .HasColumnName("is_favorite"); + + b.Property("LastQuestionType") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("LastReviewedAt") + .HasColumnType("TEXT") + .HasColumnName("last_reviewed_at"); + + b.Property("MasteryLevel") + .HasColumnType("INTEGER") + .HasColumnName("mastery_level"); + + b.Property("NextReviewDate") + .HasColumnType("TEXT") + .HasColumnName("next_review_date"); + + b.Property("PartOfSpeech") + .HasMaxLength(50) + .HasColumnType("TEXT") + .HasColumnName("part_of_speech"); + + b.Property("Pronunciation") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("Repetitions") + .HasColumnType("INTEGER"); + + b.Property("ReviewHistory") + .HasColumnType("TEXT"); + + b.Property("TimesCorrect") + .HasColumnType("INTEGER") + .HasColumnName("times_correct"); + + b.Property("TimesReviewed") + .HasColumnType("INTEGER") + .HasColumnName("times_reviewed"); + + b.Property("Translation") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT") + .HasColumnName("updated_at"); + + b.Property("UserId") + .HasColumnType("TEXT") + .HasColumnName("user_id"); + + b.Property("UserLevel") + .HasColumnType("INTEGER"); + + b.Property("Word") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("WordLevel") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CardSetId"); + + b.HasIndex("UserId"); + + b.ToTable("flashcards", (string)null); + }); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.FlashcardExampleImage", b => + { + b.Property("FlashcardId") + .HasColumnType("TEXT") + .HasColumnName("flashcard_id"); + + b.Property("ExampleImageId") + .HasColumnType("TEXT") + .HasColumnName("example_image_id"); + + b.Property("ContextRelevance") + .HasColumnType("TEXT") + .HasColumnName("context_relevance"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("DisplayOrder") + .HasColumnType("INTEGER") + .HasColumnName("display_order"); + + b.Property("IsPrimary") + .HasColumnType("INTEGER") + .HasColumnName("is_primary"); + + b.HasKey("FlashcardId", "ExampleImageId"); + + b.HasIndex("ExampleImageId"); + + b.ToTable("flashcard_example_images", (string)null); + }); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.FlashcardTag", b => + { + b.Property("FlashcardId") + .HasColumnType("TEXT") + .HasColumnName("flashcard_id"); + + b.Property("TagId") + .HasColumnType("TEXT") + .HasColumnName("tag_id"); + + b.HasKey("FlashcardId", "TagId"); + + b.HasIndex("TagId"); + + b.ToTable("flashcard_tags", (string)null); + }); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.ImageGenerationRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CompletedAt") + .HasColumnType("TEXT") + .HasColumnName("completed_at"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("FinalReplicatePrompt") + .HasColumnType("TEXT") + .HasColumnName("final_replicate_prompt"); + + b.Property("FlashcardId") + .HasColumnType("TEXT") + .HasColumnName("flashcard_id"); + + b.Property("GeminiCompletedAt") + .HasColumnType("TEXT") + .HasColumnName("gemini_completed_at"); + + b.Property("GeminiCost") + .HasColumnType("TEXT") + .HasColumnName("gemini_cost"); + + b.Property("GeminiErrorMessage") + .HasColumnType("TEXT") + .HasColumnName("gemini_error_message"); + + b.Property("GeminiProcessingTimeMs") + .HasColumnType("INTEGER") + .HasColumnName("gemini_processing_time_ms"); + + b.Property("GeminiPrompt") + .HasColumnType("TEXT") + .HasColumnName("gemini_prompt"); + + b.Property("GeminiStartedAt") + .HasColumnType("TEXT") + .HasColumnName("gemini_started_at"); + + b.Property("GeminiStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT") + .HasColumnName("gemini_status"); + + b.Property("GeneratedDescription") + .HasColumnType("TEXT") + .HasColumnName("generated_description"); + + b.Property("GeneratedImageId") + .HasColumnType("TEXT") + .HasColumnName("generated_image_id"); + + b.Property("OriginalRequest") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("original_request"); + + b.Property("OverallStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT") + .HasColumnName("overall_status"); + + b.Property("ReplicateCompletedAt") + .HasColumnType("TEXT") + .HasColumnName("replicate_completed_at"); + + b.Property("ReplicateCost") + .HasColumnType("TEXT") + .HasColumnName("replicate_cost"); + + b.Property("ReplicateErrorMessage") + .HasColumnType("TEXT") + .HasColumnName("replicate_error_message"); + + b.Property("ReplicateProcessingTimeMs") + .HasColumnType("INTEGER") + .HasColumnName("replicate_processing_time_ms"); + + b.Property("ReplicateStartedAt") + .HasColumnType("TEXT") + .HasColumnName("replicate_started_at"); + + b.Property("ReplicateStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT") + .HasColumnName("replicate_status"); + + b.Property("TotalCost") + .HasColumnType("TEXT") + .HasColumnName("total_cost"); + + b.Property("TotalProcessingTimeMs") + .HasColumnType("INTEGER") + .HasColumnName("total_processing_time_ms"); + + b.Property("UserId") + .HasColumnType("TEXT") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("FlashcardId"); + + b.HasIndex("GeneratedImageId"); + + b.HasIndex("UserId"); + + b.ToTable("image_generation_requests", (string)null); + }); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.PronunciationAssessment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AccuracyScore") + .HasColumnType("TEXT") + .HasColumnName("accuracy_score"); + + b.Property("AudioUrl") + .HasColumnType("TEXT") + .HasColumnName("audio_url"); + + b.Property("CompletenessScore") + .HasColumnType("TEXT") + .HasColumnName("completeness_score"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("FlashcardId") + .HasColumnType("TEXT") + .HasColumnName("flashcard_id"); + + b.Property("FluencyScore") + .HasColumnType("TEXT") + .HasColumnName("fluency_score"); + + b.Property("OverallScore") + .HasColumnType("INTEGER") + .HasColumnName("overall_score"); + + b.Property("PhonemeScores") + .HasColumnType("TEXT") + .HasColumnName("phoneme_scores"); + + b.Property("PracticeMode") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT") + .HasColumnName("practice_mode"); + + b.Property("ProsodyScore") + .HasColumnType("TEXT") + .HasColumnName("prosody_score"); + + b.Property("StudySessionId") + .HasColumnType("TEXT") + .HasColumnName("study_session_id"); + + b.Property("Suggestions") + .HasColumnType("TEXT") + .HasColumnName("suggestions"); + + b.Property("TargetText") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("target_text"); + + b.Property("UserId") + .HasColumnType("TEXT") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("FlashcardId"); + + b.HasIndex("StudySessionId") + .HasDatabaseName("IX_PronunciationAssessment_Session"); + + b.HasIndex("UserId", "FlashcardId") + .HasDatabaseName("IX_PronunciationAssessment_UserFlashcard"); + + b.ToTable("pronunciation_assessments", (string)null); + }); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.SentenceAnalysisCache", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AccessCount") + .HasColumnType("INTEGER"); + + b.Property("AnalysisResult") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CorrectedText") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.Property("GrammarCorrections") + .HasColumnType("TEXT"); + + b.Property("HasGrammarErrors") + .HasColumnType("INTEGER"); + + b.Property("HighValueWords") + .HasColumnType("TEXT"); + + b.Property("IdiomsDetected") + .HasColumnType("TEXT"); + + b.Property("InputText") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("InputTextHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("LastAccessedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ExpiresAt") + .HasDatabaseName("IX_SentenceAnalysisCache_Expires"); + + b.HasIndex("InputTextHash") + .HasDatabaseName("IX_SentenceAnalysisCache_Hash"); + + b.HasIndex("InputTextHash", "ExpiresAt") + .HasDatabaseName("IX_SentenceAnalysisCache_Hash_Expires"); + + b.ToTable("SentenceAnalysisCache"); + }); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.StudyRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("FlashcardId") + .HasColumnType("TEXT") + .HasColumnName("flashcard_id"); + + b.Property("IsCorrect") + .HasColumnType("INTEGER") + .HasColumnName("is_correct"); + + b.Property("NewEasinessFactor") + .HasColumnType("REAL"); + + b.Property("NewIntervalDays") + .HasColumnType("INTEGER"); + + b.Property("NewRepetitions") + .HasColumnType("INTEGER"); + + b.Property("NextReviewDate") + .HasColumnType("TEXT"); + + b.Property("PreviousEasinessFactor") + .HasColumnType("REAL"); + + b.Property("PreviousIntervalDays") + .HasColumnType("INTEGER"); + + b.Property("PreviousRepetitions") + .HasColumnType("INTEGER"); + + b.Property("QualityRating") + .HasColumnType("INTEGER") + .HasColumnName("quality_rating"); + + b.Property("ResponseTimeMs") + .HasColumnType("INTEGER") + .HasColumnName("response_time_ms"); + + b.Property("SessionId") + .HasColumnType("TEXT") + .HasColumnName("session_id"); + + b.Property("StudiedAt") + .HasColumnType("TEXT") + .HasColumnName("studied_at"); + + b.Property("StudyMode") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT") + .HasColumnName("study_mode"); + + b.Property("UserAnswer") + .HasColumnType("TEXT") + .HasColumnName("user_answer"); + + b.Property("UserId") + .HasColumnType("TEXT") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("FlashcardId"); + + b.HasIndex("SessionId"); + + b.HasIndex("UserId"); + + b.ToTable("study_records", (string)null); + }); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.StudySession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AverageResponseTimeMs") + .HasColumnType("INTEGER") + .HasColumnName("average_response_time_ms"); + + b.Property("CorrectCount") + .HasColumnType("INTEGER") + .HasColumnName("correct_count"); + + b.Property("DurationSeconds") + .HasColumnType("INTEGER") + .HasColumnName("duration_seconds"); + + b.Property("EndedAt") + .HasColumnType("TEXT") + .HasColumnName("ended_at"); + + b.Property("SessionType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT") + .HasColumnName("session_type"); + + b.Property("StartedAt") + .HasColumnType("TEXT") + .HasColumnName("started_at"); + + b.Property("TotalCards") + .HasColumnType("INTEGER") + .HasColumnName("total_cards"); + + b.Property("UserId") + .HasColumnType("TEXT") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("study_sessions", (string)null); + }); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.Tag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Color") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("UsageCount") + .HasColumnType("INTEGER") + .HasColumnName("usage_count"); + + b.Property("UserId") + .HasColumnType("TEXT") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("tags", (string)null); + }); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AvatarUrl") + .HasColumnType("TEXT") + .HasColumnName("avatar_url"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("DisplayName") + .HasMaxLength(100) + .HasColumnType("TEXT") + .HasColumnName("display_name"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT") + .HasColumnName("email"); + + b.Property("EnglishLevel") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("TEXT") + .HasColumnName("english_level"); + + b.Property("IsLevelVerified") + .HasColumnType("INTEGER") + .HasColumnName("is_level_verified"); + + b.Property("LevelNotes") + .HasMaxLength(500) + .HasColumnType("TEXT") + .HasColumnName("level_notes"); + + b.Property("LevelUpdatedAt") + .HasColumnType("TEXT") + .HasColumnName("level_updated_at"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT") + .HasColumnName("password_hash"); + + b.Property("Preferences") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("preferences"); + + b.Property("SubscriptionType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT") + .HasColumnName("subscription_type"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT") + .HasColumnName("updated_at"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT") + .HasColumnName("username"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("user_profiles", (string)null); + }); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.UserAudioPreferences", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("AutoPlayEnabled") + .HasColumnType("INTEGER") + .HasColumnName("auto_play_enabled"); + + b.Property("DefaultSpeed") + .HasColumnType("TEXT") + .HasColumnName("default_speed"); + + b.Property("EnableDetailedFeedback") + .HasColumnType("INTEGER") + .HasColumnName("enable_detailed_feedback"); + + b.Property("PreferredAccent") + .IsRequired() + .HasMaxLength(2) + .HasColumnType("TEXT") + .HasColumnName("preferred_accent"); + + b.Property("PreferredVoiceFemale") + .HasMaxLength(50) + .HasColumnType("TEXT") + .HasColumnName("preferred_voice_female"); + + b.Property("PreferredVoiceMale") + .HasMaxLength(50) + .HasColumnType("TEXT") + .HasColumnName("preferred_voice_male"); + + b.Property("PronunciationDifficulty") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT") + .HasColumnName("pronunciation_difficulty"); + + b.Property("TargetScoreThreshold") + .HasColumnType("INTEGER") + .HasColumnName("target_score_threshold"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT") + .HasColumnName("updated_at"); + + b.HasKey("UserId"); + + b.ToTable("user_audio_preferences", (string)null); + }); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.UserSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AutoPlayAudio") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DailyGoal") + .HasColumnType("INTEGER"); + + b.Property("DifficultyPreference") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("ReminderEnabled") + .HasColumnType("INTEGER"); + + b.Property("ReminderTime") + .HasColumnType("TEXT"); + + b.Property("ShowPronunciation") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("user_settings", (string)null); + }); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.WordQueryUsageStats", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Date") + .HasColumnType("TEXT"); + + b.Property("HighValueWordClicks") + .HasColumnType("INTEGER"); + + b.Property("LowValueWordClicks") + .HasColumnType("INTEGER"); + + b.Property("SentenceAnalysisCount") + .HasColumnType("INTEGER"); + + b.Property("TotalApiCalls") + .HasColumnType("INTEGER"); + + b.Property("UniqueWordsQueried") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt") + .HasDatabaseName("IX_WordQueryUsageStats_CreatedAt"); + + b.HasIndex("UserId", "Date") + .IsUnique() + .HasDatabaseName("IX_WordQueryUsageStats_UserDate"); + + b.ToTable("WordQueryUsageStats"); + }); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.CardSet", b => + { + b.HasOne("DramaLing.Api.Models.Entities.User", "User") + .WithMany("CardSets") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.DailyStats", b => + { + b.HasOne("DramaLing.Api.Models.Entities.User", "User") + .WithMany("DailyStats") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.ErrorReport", b => + { + b.HasOne("DramaLing.Api.Models.Entities.Flashcard", "Flashcard") + .WithMany("ErrorReports") + .HasForeignKey("FlashcardId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("DramaLing.Api.Models.Entities.User", "ResolvedByUser") + .WithMany() + .HasForeignKey("ResolvedBy") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("DramaLing.Api.Models.Entities.User", "User") + .WithMany("ErrorReports") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Flashcard"); + + b.Navigation("ResolvedByUser"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.Flashcard", b => + { + b.HasOne("DramaLing.Api.Models.Entities.CardSet", "CardSet") + .WithMany("Flashcards") + .HasForeignKey("CardSetId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("DramaLing.Api.Models.Entities.User", "User") + .WithMany("Flashcards") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CardSet"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.FlashcardExampleImage", b => + { + b.HasOne("DramaLing.Api.Models.Entities.ExampleImage", "ExampleImage") + .WithMany("FlashcardExampleImages") + .HasForeignKey("ExampleImageId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("DramaLing.Api.Models.Entities.Flashcard", "Flashcard") + .WithMany("FlashcardExampleImages") + .HasForeignKey("FlashcardId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ExampleImage"); + + b.Navigation("Flashcard"); + }); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.FlashcardTag", b => + { + b.HasOne("DramaLing.Api.Models.Entities.Flashcard", "Flashcard") + .WithMany("FlashcardTags") + .HasForeignKey("FlashcardId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("DramaLing.Api.Models.Entities.Tag", "Tag") + .WithMany("FlashcardTags") + .HasForeignKey("TagId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Flashcard"); + + b.Navigation("Tag"); + }); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.ImageGenerationRequest", b => + { + b.HasOne("DramaLing.Api.Models.Entities.Flashcard", "Flashcard") + .WithMany() + .HasForeignKey("FlashcardId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("DramaLing.Api.Models.Entities.ExampleImage", "GeneratedImage") + .WithMany() + .HasForeignKey("GeneratedImageId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("DramaLing.Api.Models.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Flashcard"); + + b.Navigation("GeneratedImage"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.PronunciationAssessment", b => + { + b.HasOne("DramaLing.Api.Models.Entities.Flashcard", "Flashcard") + .WithMany() + .HasForeignKey("FlashcardId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("DramaLing.Api.Models.Entities.StudySession", "StudySession") + .WithMany() + .HasForeignKey("StudySessionId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("DramaLing.Api.Models.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Flashcard"); + + b.Navigation("StudySession"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.StudyRecord", b => + { + b.HasOne("DramaLing.Api.Models.Entities.Flashcard", "Flashcard") + .WithMany("StudyRecords") + .HasForeignKey("FlashcardId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("DramaLing.Api.Models.Entities.StudySession", "Session") + .WithMany("StudyRecords") + .HasForeignKey("SessionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("DramaLing.Api.Models.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Flashcard"); + + b.Navigation("Session"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.StudySession", b => + { + b.HasOne("DramaLing.Api.Models.Entities.User", "User") + .WithMany("StudySessions") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.Tag", b => + { + b.HasOne("DramaLing.Api.Models.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.UserAudioPreferences", b => + { + b.HasOne("DramaLing.Api.Models.Entities.User", "User") + .WithOne() + .HasForeignKey("DramaLing.Api.Models.Entities.UserAudioPreferences", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.UserSettings", b => + { + b.HasOne("DramaLing.Api.Models.Entities.User", "User") + .WithOne("Settings") + .HasForeignKey("DramaLing.Api.Models.Entities.UserSettings", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.WordQueryUsageStats", b => + { + b.HasOne("DramaLing.Api.Models.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.CardSet", b => + { + b.Navigation("Flashcards"); + }); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.ExampleImage", b => + { + b.Navigation("FlashcardExampleImages"); + }); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.Flashcard", b => + { + b.Navigation("ErrorReports"); + + b.Navigation("FlashcardExampleImages"); + + b.Navigation("FlashcardTags"); + + b.Navigation("StudyRecords"); + }); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.StudySession", b => + { + b.Navigation("StudyRecords"); + }); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.Tag", b => + { + b.Navigation("FlashcardTags"); + }); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.User", b => + { + b.Navigation("CardSets"); + + b.Navigation("DailyStats"); + + b.Navigation("ErrorReports"); + + b.Navigation("Flashcards"); + + b.Navigation("Settings"); + + b.Navigation("StudySessions"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/DramaLing.Api/Migrations/20250925104256_AddSpacedRepetitionFields.cs b/backend/DramaLing.Api/Migrations/20250925104256_AddSpacedRepetitionFields.cs new file mode 100644 index 0000000..4a67058 --- /dev/null +++ b/backend/DramaLing.Api/Migrations/20250925104256_AddSpacedRepetitionFields.cs @@ -0,0 +1,61 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace DramaLing.Api.Migrations +{ + /// + public partial class AddSpacedRepetitionFields : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "LastQuestionType", + table: "flashcards", + type: "TEXT", + maxLength: 50, + nullable: true); + + migrationBuilder.AddColumn( + name: "ReviewHistory", + table: "flashcards", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "UserLevel", + table: "flashcards", + type: "INTEGER", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "WordLevel", + table: "flashcards", + type: "INTEGER", + nullable: false, + defaultValue: 0); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "LastQuestionType", + table: "flashcards"); + + migrationBuilder.DropColumn( + name: "ReviewHistory", + table: "flashcards"); + + migrationBuilder.DropColumn( + name: "UserLevel", + table: "flashcards"); + + migrationBuilder.DropColumn( + name: "WordLevel", + table: "flashcards"); + } + } +} diff --git a/backend/DramaLing.Api/Migrations/DramaLingDbContextModelSnapshot.cs b/backend/DramaLing.Api/Migrations/DramaLingDbContextModelSnapshot.cs index f8d9540..bc5bb57 100644 --- a/backend/DramaLing.Api/Migrations/DramaLingDbContextModelSnapshot.cs +++ b/backend/DramaLing.Api/Migrations/DramaLingDbContextModelSnapshot.cs @@ -381,6 +381,10 @@ namespace DramaLing.Api.Migrations .HasColumnType("INTEGER") .HasColumnName("is_favorite"); + b.Property("LastQuestionType") + .HasMaxLength(50) + .HasColumnType("TEXT"); + b.Property("LastReviewedAt") .HasColumnType("TEXT") .HasColumnName("last_reviewed_at"); @@ -405,6 +409,9 @@ namespace DramaLing.Api.Migrations b.Property("Repetitions") .HasColumnType("INTEGER"); + b.Property("ReviewHistory") + .HasColumnType("TEXT"); + b.Property("TimesCorrect") .HasColumnType("INTEGER") .HasColumnName("times_correct"); @@ -425,11 +432,17 @@ namespace DramaLing.Api.Migrations .HasColumnType("TEXT") .HasColumnName("user_id"); + b.Property("UserLevel") + .HasColumnType("INTEGER"); + b.Property("Word") .IsRequired() .HasMaxLength(255) .HasColumnType("TEXT"); + b.Property("WordLevel") + .HasColumnType("INTEGER"); + b.HasKey("Id"); b.HasIndex("CardSetId"); @@ -1204,7 +1217,7 @@ namespace DramaLing.Api.Migrations .IsRequired(); b.HasOne("DramaLing.Api.Models.Entities.Flashcard", "Flashcard") - .WithMany() + .WithMany("FlashcardExampleImages") .HasForeignKey("FlashcardId") .OnDelete(DeleteBehavior.Cascade) .IsRequired(); @@ -1380,6 +1393,8 @@ namespace DramaLing.Api.Migrations { b.Navigation("ErrorReports"); + b.Navigation("FlashcardExampleImages"); + b.Navigation("FlashcardTags"); b.Navigation("StudyRecords"); diff --git a/backend/DramaLing.Api/Models/Configuration/SpacedRepetitionOptions.cs b/backend/DramaLing.Api/Models/Configuration/SpacedRepetitionOptions.cs new file mode 100644 index 0000000..87dd493 --- /dev/null +++ b/backend/DramaLing.Api/Models/Configuration/SpacedRepetitionOptions.cs @@ -0,0 +1,124 @@ +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 new file mode 100644 index 0000000..34523b2 --- /dev/null +++ b/backend/DramaLing.Api/Models/DTOs/SpacedRepetition/OptimalModeRequest.cs @@ -0,0 +1,28 @@ +using System.ComponentModel.DataAnnotations; + +namespace DramaLing.Api.Models.DTOs.SpacedRepetition; + +/// +/// 自動選擇最適合複習模式請求 +/// +public class OptimalModeRequest +{ + /// + /// 學習者程度 (1-100) + /// + [Required] + [Range(1, 100)] + public int UserLevel { get; set; } + + /// + /// 詞彙難度 (1-100) + /// + [Required] + [Range(1, 100)] + public int WordLevel { get; set; } + + /// + /// 是否包含歷史記錄進行智能避重 + /// + 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 new file mode 100644 index 0000000..bc1887b --- /dev/null +++ b/backend/DramaLing.Api/Models/DTOs/SpacedRepetition/QuestionData.cs @@ -0,0 +1,42 @@ +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 new file mode 100644 index 0000000..11b2c7c --- /dev/null +++ b/backend/DramaLing.Api/Models/DTOs/SpacedRepetition/QuestionRequest.cs @@ -0,0 +1,16 @@ +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 new file mode 100644 index 0000000..6536799 --- /dev/null +++ b/backend/DramaLing.Api/Models/DTOs/SpacedRepetition/ReviewModeResult.cs @@ -0,0 +1,27 @@ +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 new file mode 100644 index 0000000..911aeb1 --- /dev/null +++ b/backend/DramaLing.Api/Models/DTOs/SpacedRepetition/ReviewRequest.cs @@ -0,0 +1,43 @@ +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 new file mode 100644 index 0000000..9837a5f --- /dev/null +++ b/backend/DramaLing.Api/Models/DTOs/SpacedRepetition/ReviewResult.cs @@ -0,0 +1,52 @@ +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 a7779d3..a0750c4 100644 --- a/backend/DramaLing.Api/Models/Entities/Flashcard.cs +++ b/backend/DramaLing.Api/Models/Entities/Flashcard.cs @@ -58,6 +58,18 @@ public class Flashcard [MaxLength(10)] public string? DifficultyLevel { get; set; } // A1, A2, B1, B2, C1, C2 + // 🆕 智能複習系統欄位 + [Range(1, 100)] + public int UserLevel { get; set; } = 50; // 學習者程度 (1-100) + + [Range(1, 100)] + public int WordLevel { get; set; } = 50; // 詞彙難度 (1-100) + + 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; diff --git a/backend/DramaLing.Api/Program.cs b/backend/DramaLing.Api/Program.cs index b223d51..34b8839 100644 --- a/backend/DramaLing.Api/Program.cs +++ b/backend/DramaLing.Api/Program.cs @@ -89,6 +89,13 @@ 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(); + // Image Generation Services builder.Services.AddHttpClient(); builder.Services.AddScoped(); diff --git a/backend/DramaLing.Api/Services/CEFRMappingService.cs b/backend/DramaLing.Api/Services/CEFRMappingService.cs new file mode 100644 index 0000000..cc0903a --- /dev/null +++ b/backend/DramaLing.Api/Services/CEFRMappingService.cs @@ -0,0 +1,119 @@ +namespace DramaLing.Api.Services; + +/// +/// CEFR等級映射服務 - 將CEFR等級轉換為詞彙難度數值 +/// +public static class CEFRMappingService +{ + private static readonly Dictionary CEFRToWordLevel = new() + { + { "A1", 20 }, // 基礎詞彙 (1-1000常用詞) + { "A2", 35 }, // 常用詞彙 (1001-3000詞) + { "B1", 50 }, // 中級詞彙 (3001-6000詞) + { "B2", 65 }, // 中高級詞彙 (6001-12000詞) + { "C1", 80 }, // 高級詞彙 (12001-20000詞) + { "C2", 95 } // 精通詞彙 (20000+詞) + }; + + private static readonly Dictionary WordLevelToCEFR = new() + { + { 20, "A1" }, { 35, "A2" }, { 50, "B1" }, + { 65, "B2" }, { 80, "C1" }, { 95, "C2" } + }; + + /// + /// 根據CEFR等級獲取詞彙難度數值 + /// + /// CEFR等級 (A1-C2) + /// 詞彙難度 (1-100) + public static int GetWordLevel(string? cefrLevel) + { + if (string.IsNullOrEmpty(cefrLevel)) + return 50; // 預設B1級別 + + return CEFRToWordLevel.GetValueOrDefault(cefrLevel.ToUpperInvariant(), 50); + } + + /// + /// 根據詞彙難度數值獲取CEFR等級 + /// + /// 詞彙難度 (1-100) + /// 對應的CEFR等級 + public static string GetCEFRLevel(int wordLevel) + { + // 找到最接近的CEFR等級 + var closestLevel = WordLevelToCEFR.Keys + .OrderBy(level => Math.Abs(level - wordLevel)) + .First(); + + return WordLevelToCEFR[closestLevel]; + } + + /// + /// 獲取新用戶的預設程度 + /// + /// 預設用戶程度 (50 = B1級別) + public static int GetDefaultUserLevel() => 50; + + /// + /// 判斷是否為A1學習者 + /// + /// 學習者程度 + /// 是否為A1學習者 + public static bool IsA1Learner(int userLevel) => userLevel <= 20; + + /// + /// 獲取學習者程度描述 + /// + /// 學習者程度 (1-100) + /// 程度描述 + public static string GetUserLevelDescription(int userLevel) + { + return userLevel switch + { + <= 20 => "A1 - 初學者", + <= 35 => "A2 - 基礎", + <= 50 => "B1 - 中級", + <= 65 => "B2 - 中高級", + <= 80 => "C1 - 高級", + _ => "C2 - 精通" + }; + } + + /// + /// 根據詞彙使用頻率估算難度 (未來擴展用) + /// + /// 詞彙頻率排名 + /// 估算的詞彙難度 + public static int EstimateWordLevelByFrequency(int frequency) + { + return frequency switch + { + <= 1000 => 20, // 最常用1000詞 → A1 + <= 3000 => 35, // 常用3000詞 → A2 + <= 6000 => 50, // 中級6000詞 → B1 + <= 12000 => 65, // 中高級12000詞 → B2 + <= 20000 => 80, // 高級20000詞 → C1 + _ => 95 // 超過20000詞 → C2 + }; + } + + /// + /// 獲取所有CEFR等級列表 + /// + /// CEFR等級數組 + public static string[] GetAllCEFRLevels() => new[] { "A1", "A2", "B1", "B2", "C1", "C2" }; + + /// + /// 驗證CEFR等級是否有效 + /// + /// 要驗證的CEFR等級 + /// 是否有效 + public static bool IsValidCEFRLevel(string? cefrLevel) + { + if (string.IsNullOrEmpty(cefrLevel)) + return false; + + return CEFRToWordLevel.ContainsKey(cefrLevel.ToUpperInvariant()); + } +} \ No newline at end of file diff --git a/backend/DramaLing.Api/Services/QuestionGeneratorService.cs b/backend/DramaLing.Api/Services/QuestionGeneratorService.cs new file mode 100644 index 0000000..2b6260a --- /dev/null +++ b/backend/DramaLing.Api/Services/QuestionGeneratorService.cs @@ -0,0 +1,246 @@ +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 ILogger _logger; + + public QuestionGeneratorService( + DramaLingDbContext context, + ILogger logger) + { + _context = context; + _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) + { + // 從相同用戶的其他詞卡中選擇3個干擾選項 + var distractors = await _context.Flashcards + .Where(f => f.UserId == flashcard.UserId && + f.Id != flashcard.Id && + !f.IsArchived) + .OrderBy(x => Guid.NewGuid()) // 隨機排序 + .Take(3) + .Select(f => f.Word) + .ToListAsync(); + + // 如果沒有足夠的詞卡,添加一些預設選項 + 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)); + distractors.AddRange(availableDefaults.Take(3 - distractors.Count)); + } + + 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 "困難詞彙"; + } + + /// + /// 獲取選擇原因說明 + /// + private string GetSelectionReason(string selectedMode, int userLevel, int wordLevel) + { + var context = GetAdaptationContext(userLevel, wordLevel); + + return context switch + { + "A1學習者" => "A1學習者使用基礎題型建立信心", + "簡單詞彙" => "簡單詞彙重點練習應用和拼寫", + "適中詞彙" => "適中詞彙進行全方位練習,包括口說", + "困難詞彙" => "困難詞彙回歸基礎重建記憶", + _ => "系統智能選擇最適合的複習方式" + }; + } +} \ No newline at end of file diff --git a/backend/DramaLing.Api/Services/ReviewTypeSelectorService.cs b/backend/DramaLing.Api/Services/ReviewTypeSelectorService.cs new file mode 100644 index 0000000..abe69f7 --- /dev/null +++ b/backend/DramaLing.Api/Services/ReviewTypeSelectorService.cs @@ -0,0 +1,219 @@ +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; + +/// +/// 智能複習題型選擇服務介面 +/// +public interface IReviewTypeSelectorService +{ + Task SelectOptimalReviewModeAsync(Guid flashcardId, int userLevel, int wordLevel); + string[] GetAvailableReviewTypes(int userLevel, int wordLevel); + bool IsA1Learner(int userLevel); + string GetAdaptationContext(int userLevel, int wordLevel); +} + +/// +/// 智能複習題型選擇服務實現 +/// +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; + } + + /// + /// 智能選擇最適合的複習模式 + /// + public async Task SelectOptimalReviewModeAsync(Guid flashcardId, int userLevel, int wordLevel) + { + _logger.LogInformation("Selecting optimal review mode for flashcard {FlashcardId}, userLevel: {UserLevel}, wordLevel: {WordLevel}", + flashcardId, userLevel, 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]; // 備用返回 + } + + /// + /// 判斷是否為A1學習者 + /// + public bool IsA1Learner(int userLevel) => userLevel <= _options.A1ProtectionLevel; + + /// + /// 獲取適配情境描述 + /// + 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/SpacedRepetitionService.cs b/backend/DramaLing.Api/Services/SpacedRepetitionService.cs new file mode 100644 index 0000000..d6a04c2 --- /dev/null +++ b/backend/DramaLing.Api/Services/SpacedRepetitionService.cs @@ -0,0 +1,237 @@ +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(); + + // 初始化WordLevel (如果是舊資料) + foreach (var card in dueCards.Where(c => c.WordLevel == 0)) + { + card.WordLevel = CEFRMappingService.GetWordLevel(card.DifficultyLevel); + if (card.UserLevel == 0) + card.UserLevel = _options.DefaultUserLevel; + } + + if (dueCards.Any(c => c.WordLevel != 0 || c.UserLevel != 0)) + { + await _context.SaveChangesAsync(); + } + + 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/appsettings.json b/backend/DramaLing.Api/appsettings.json index e33ea80..24c8583 100644 --- a/backend/DramaLing.Api/appsettings.json +++ b/backend/DramaLing.Api/appsettings.json @@ -59,5 +59,23 @@ "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/智能複習/智能複習系統-後端開發計劃.md new file mode 100644 index 0000000..9156daf --- /dev/null +++ b/note/智能複習/智能複習系統-後端開發計劃.md @@ -0,0 +1,500 @@ +# 智能複習系統 - 後端開發計劃 + +**項目基礎**: ASP.NET Core 8.0 + Entity Framework + SQLite +**開發週期**: 3-4天 (基於現有架構擴展) +**目標**: 實現智能複習系統的5個核心API端點 + +--- + +## 📋 **現況分析** + +### **✅ 現有後端優勢** +- **成熟架構**: ASP.NET Core 8.0 + Entity Framework Core +- **完整基礎設施**: DramaLingDbContext + FlashcardsController 已完善 +- **現有間隔重複**: SM2Algorithm.cs 已實現基礎算法 +- **服務層架構**: DI容器、配置管理、錯誤處理已完整 +- **Flashcard模型**: 已包含MasteryLevel、TimesReviewed、IntervalDays等關鍵欄位 +- **認證系統**: JWT + 固定測試用戶ID已就緒 +- **API格式標準**: 統一的success/error響應格式 + +### **❌ 需要新增的智能複習功能** +- **智能複習API**: 缺少前端需要的5個關鍵端點 +- **四情境適配邏輯**: 需要新增A1/簡單/適中/困難自動判斷 +- **題型選擇服務**: 需要實現智能自動選擇邏輯 +- **題目生成服務**: 需要動態生成選項和挖空邏輯 +- **數據模型擴展**: 需要新增少量智能複習相關欄位 + +--- + +## 🎯 **開發計劃 (4天完成)** + +### **📅 第一天: 數據模型擴展和遷移** + +#### **1.1 擴展Flashcard模型** +```csharp +// 在現有 Models/Entities/Flashcard.cs 中新增欄位 +public class Flashcard +{ + // ... 現有欄位保持不變 ... + + // 🆕 新增智能複習欄位 + public int UserLevel { get; set; } = 50; // 學習者程度 (1-100) + public int WordLevel { get; set; } = 50; // 詞彙難度 (1-100) + public string? ReviewHistory { get; set; } // JSON格式復習歷史 + public string? LastQuestionType { get; set; } // 最後使用的題型 + + // 重用現有欄位,語義調整 + // MasteryLevel -> 基礎熟悉度 ✅ + // TimesReviewed -> 總復習次數 ✅ + // TimesCorrect -> 答對次數 ✅ + // IntervalDays -> 當前間隔 ✅ + // LastReviewedAt -> 最後復習時間 ✅ +} +``` + +#### **1.2 資料庫遷移** +```bash +# 新增遷移 +cd backend/DramaLing.Api +dotnet ef migrations add AddSpacedRepetitionFields + +# 預覽SQL +dotnet ef migrations script + +# 執行遷移 +dotnet ef database update +``` + +#### **1.3 CEFR映射服務** +```csharp +// 新增 Services/CEFRMappingService.cs +public class CEFRMappingService +{ + public static int GetWordLevel(string? cefrLevel) { ... } + public static int GetDefaultUserLevel() => 50; +} +``` + +### **📅 第二天: 核心服務層實現** + +#### **2.1 SpacedRepetitionService** +```csharp +// 新增 Services/SpacedRepetitionService.cs +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); +} + +public class SpacedRepetitionService : ISpacedRepetitionService +{ + // 基於現有SM2Algorithm.cs擴展 + // 整合演算法規格書的增長係數和逾期懲罰 + // 實現記憶衰減和熟悉度實時計算 +} +``` + +#### **2.2 ReviewTypeSelectorService** +```csharp +// 新增 Services/ReviewTypeSelectorService.cs +public class ReviewTypeSelectorService : IReviewTypeSelectorService +{ + // 實現四情境自動適配邏輯 + // A1學習者保護機制 + // 智能避重算法 + // 權重隨機選擇 +} +``` + +#### **2.3 QuestionGeneratorService** +```csharp +// 新增 Services/QuestionGeneratorService.cs +public class QuestionGeneratorService : IQuestionGeneratorService +{ + // 選擇題選項生成 + // 填空題挖空邏輯 + // 重組題單字打亂 + // 聽力題選項生成 +} +``` + +### **📅 第三天: API端點實現** + +#### **3.1 擴展FlashcardsController** +```csharp +// 在現有 Controllers/FlashcardsController.cs 中新增端點 +public class FlashcardsController : ControllerBase +{ + // ... 現有CRUD端點保持不變 ... + + // 🆕 新增智能複習端點 + [HttpGet("due")] + public async Task GetDueFlashcards(...) { ... } + + [HttpGet("next-review")] + public async Task GetNextReviewCard() { ... } + + [HttpPost("{id}/optimal-review-mode")] + public async Task GetOptimalReviewMode(...) { ... } + + [HttpPost("{id}/question")] + public async Task GenerateQuestion(...) { ... } + + [HttpPost("{id}/review")] + public async Task SubmitReview(...) { ... } +} +``` + +#### **3.2 DTOs和請求模型** +```csharp +// 新增 Models/DTOs/SpacedRepetition/ +├── ReviewRequest.cs +├── ReviewResult.cs +├── OptimalModeRequest.cs +├── ReviewModeResult.cs +├── QuestionRequest.cs +└── QuestionData.cs +``` + +#### **3.3 輸入驗證和錯誤處理** +```csharp +// 新增驗證規則 +public class ReviewRequestValidator : AbstractValidator { ... } +public class OptimalModeRequestValidator : AbstractValidator { ... } +``` + +### **📅 第四天: 整合測試和優化** + +#### **4.1 單元測試** +```csharp +// 新增 Tests/Services/ +├── SpacedRepetitionServiceTests.cs +├── ReviewTypeSelectorServiceTests.cs +└── QuestionGeneratorServiceTests.cs +``` + +#### **4.2 API整合測試** +```csharp +// 新增 Tests/Controllers/ +└── FlashcardsControllerSpacedRepetitionTests.cs +``` + +#### **4.3 前後端整合驗證** +- 與前端flashcardsService API對接測試 +- 四情境自動適配邏輯驗證 +- A1學習者保護機制測試 + +--- + +## 📊 **現有架構整合分析** + +### **✅ 可直接復用的組件** +- **DramaLingDbContext** - 無需修改,直接擴展 +- **FlashcardsController** - 現有CRUD端點保持不變 +- **SM2Algorithm.cs** - 基礎算法可重用和擴展 +- **服務註冊架構** - DI容器和配置系統成熟 +- **錯誤處理機制** - 統一的響應格式已完善 + +### **🔄 需要適配的部分** +- **Flashcard模型** - 新增4個智能複習欄位 +- **服務註冊** - 新增3個智能複習服務 +- **配置文件** - 新增SpacedRepetition配置段 + +### **🆕 需要新建的組件** +- **3個核心服務** - SpacedRepetition, ReviewTypeSelector, QuestionGenerator +- **DTOs和驗證** - 智能複習相關的數據傳輸對象 +- **5個API端點** - 在現有控制器中新增 + +--- + +## 🔧 **技術實現重點** + +### **整合到現有服務註冊** +```csharp +// 在 Program.cs 中新增 (第40行左右) +// 🆕 智能複習服務註冊 +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); + +// 🆕 智能複習配置 +builder.Services.Configure( + builder.Configuration.GetSection("SpacedRepetition")); +``` + +### **擴展現有FlashcardsController構造函數** +```csharp +public FlashcardsController( + DramaLingDbContext context, + ILogger logger, + IImageStorageService imageStorageService, + // 🆕 新增智能複習服務依賴 + ISpacedRepetitionService spacedRepetitionService, + IReviewTypeSelectorService reviewTypeSelectorService, + IQuestionGeneratorService questionGeneratorService) +``` + +### **重用現有算法邏輯** +```csharp +// 基於現有SM2Algorithm擴展 +public class SpacedRepetitionService : ISpacedRepetitionService +{ + public async Task ProcessReviewAsync(Guid flashcardId, ReviewRequest request) + { + // 1. 重用現有SM2Algorithm.Calculate() + var sm2Input = new SM2Input( + request.ConfidenceLevel ?? (request.IsCorrect ? 4 : 2), + flashcard.EasinessFactor, + flashcard.Repetitions, + flashcard.IntervalDays + ); + + var sm2Result = SM2Algorithm.Calculate(sm2Input); + + // 2. 應用新的逾期懲罰和增長係數調整 + var adjustedInterval = ApplyEnhancedLogic(sm2Result, request); + + // 3. 更新資料庫 + return await UpdateFlashcardAsync(flashcard, adjustedInterval); + } +} +``` + +--- + +## 🚀 **開發里程碑** + +### **Day 1 里程碑** +- [ ] Flashcard模型擴展完成 +- [ ] 資料庫遷移執行成功 +- [ ] CEFR映射服務實現 +- [ ] 初始配置設定完成 + +### **Day 2 里程碑** +- [ ] SpacedRepetitionService完成 (基於現有SM2Algorithm) +- [ ] ReviewTypeSelectorService完成 (四情境邏輯) +- [ ] QuestionGeneratorService完成 (選項生成) +- [ ] 服務註冊和依賴注入配置 + +### **Day 3 里程碑** +- [ ] 5個API端點在FlashcardsController中實現 +- [ ] DTOs和驗證規則完成 +- [ ] 錯誤處理整合到現有機制 +- [ ] Swagger文檔更新 + +### **Day 4 里程碑** +- [ ] 單元測試和整合測試完成 +- [ ] 前後端API對接測試 +- [ ] 四情境適配邏輯驗證 +- [ ] 性能測試和優化 + +--- + +## 📁 **文件結構規劃** + +### **新增文件 (基於現有結構)** +``` +backend/DramaLing.Api/ +├── Controllers/ +│ └── FlashcardsController.cs # 🔄 擴展現有控制器 +├── Services/ +│ ├── SpacedRepetitionService.cs # 🆕 核心間隔重複服務 +│ ├── ReviewTypeSelectorService.cs # 🆕 智能題型選擇服務 +│ ├── QuestionGeneratorService.cs # 🆕 題目生成服務 +│ └── CEFRMappingService.cs # 🆕 CEFR等級映射 +├── Models/ +│ ├── Entities/ +│ │ └── Flashcard.cs # 🔄 擴展現有模型 +│ └── DTOs/SpacedRepetition/ # 🆕 智能複習DTOs +│ ├── ReviewRequest.cs +│ ├── ReviewResult.cs +│ ├── OptimalModeRequest.cs +│ ├── ReviewModeResult.cs +│ ├── QuestionRequest.cs +│ └── QuestionData.cs +├── Configuration/ +│ └── SpacedRepetitionOptions.cs # 🆕 配置選項 +└── Migrations/ + └── AddSpacedRepetitionFields.cs # 🆕 資料庫遷移 +``` + +### **修改現有文件** +``` +🔄 Program.cs # 新增服務註冊 +🔄 appsettings.json # 新增SpacedRepetition配置段 +🔄 Controllers/FlashcardsController.cs # 新增5個智能複習端點 +🔄 Models/Entities/Flashcard.cs # 新增4個欄位 +``` + +--- + +## 🔌 **API實現優先級** + +### **P0 (最高優先級) - 核心復習流程** +1. **GET /api/flashcards/next-review** - 前端載入下一張詞卡 +2. **POST /api/flashcards/{id}/review** - 提交復習結果 +3. **POST /api/flashcards/{id}/optimal-review-mode** - 系統自動選擇題型 + +### **P1 (高優先級) - 完整體驗** +4. **GET /api/flashcards/due** - 到期詞卡列表 +5. **POST /api/flashcards/{id}/question** - 題目選項生成 + +### **P2 (中優先級) - 優化功能** +- 智能避重邏輯完善 +- 性能優化和快取 +- 詳細的錯誤處理 + +--- + +## 💾 **資料庫遷移規劃** + +### **新增欄位到現有Flashcards表** +```sql +-- 基於現有表結構,只新增必要欄位 +ALTER TABLE Flashcards ADD COLUMN UserLevel INTEGER DEFAULT 50; +ALTER TABLE Flashcards ADD COLUMN WordLevel INTEGER DEFAULT 50; +ALTER TABLE Flashcards ADD COLUMN ReviewHistory TEXT; +ALTER TABLE Flashcards ADD COLUMN LastQuestionType VARCHAR(50); + +-- 初始化現有詞卡的WordLevel (基於DifficultyLevel) +UPDATE Flashcards SET WordLevel = + CASE DifficultyLevel + WHEN 'A1' THEN 20 + WHEN 'A2' THEN 35 + WHEN 'B1' THEN 50 + WHEN 'B2' THEN 65 + WHEN 'C1' THEN 80 + WHEN 'C2' THEN 95 + ELSE 50 + END +WHERE WordLevel = 50; + +-- 新增索引提升查詢性能 +CREATE INDEX IX_Flashcards_DueReview ON Flashcards(UserId, NextReviewDate) WHERE IsArchived = 0; +CREATE INDEX IX_Flashcards_UserLevel ON Flashcards(UserId, UserLevel, WordLevel); +``` + +--- + +## 🧪 **測試策略** + +### **單元測試重點** +```csharp +// SpacedRepetitionServiceTests.cs +[Test] ProcessReview_ShouldCalculateCorrectInterval_ForA1Learner() +[Test] GetNextReviewCard_ShouldReturnHighestPriorityCard() +[Test] CalculateCurrentMastery_ShouldApplyDecay_WhenOverdue() + +// ReviewTypeSelectorServiceTests.cs +[Test] SelectOptimalMode_ShouldReturnBasicTypes_ForA1Learner() +[Test] SelectOptimalMode_ShouldAvoidRecentlyUsedTypes() +[Test] GetAvailableReviewTypes_ShouldMapFourSituationsCorrectly() + +// QuestionGeneratorServiceTests.cs +[Test] GenerateVocabChoice_ShouldReturnFourOptions_WithCorrectAnswer() +[Test] GenerateFillBlank_ShouldCreateBlankInSentence() +``` + +### **API整合測試** +```bash +# 使用現有的 DramaLing.Api.http 或 Postman +GET http://localhost:5008/api/flashcards/due +GET http://localhost:5008/api/flashcards/next-review +POST http://localhost:5008/api/flashcards/{id}/optimal-review-mode +POST http://localhost:5008/api/flashcards/{id}/question +POST http://localhost:5008/api/flashcards/{id}/review +``` + +--- + +## ⚡ **性能考量** + +### **查詢優化** +- 復用現有的AsNoTracking查詢模式 +- 新增索引避免全表掃描 +- 分頁和限制避免大量數據傳輸 + +### **快取策略** +- 復用現有的ICacheService架構 +- 到期詞卡列表快取5分鐘 +- 用戶程度資料快取30分鐘 + +--- + +## 🔗 **與現有系統整合** + +### **保持向後相容** +- ✅ 現有詞卡CRUD API完全不變 +- ✅ 現有前端功能不受影響 +- ✅ 資料庫結構僅擴展,不破壞 + +### **復用現有基礎設施** +- ✅ DramaLingDbContext 和 Entity Framework +- ✅ JWT認證和授權機制 +- ✅ 統一的錯誤處理和日誌 +- ✅ CORS和API響應格式標準 + +### **服務層整合** +- ✅ 使用現有依賴注入架構 +- ✅ 整合到現有配置管理 +- ✅ 復用現有的健康檢查和監控 + +--- + +## 🎯 **預期成果** + +### **技術目標** +- 5個智能複習API穩定運行 +- 四情境自動適配準確率 > 95% +- API響應時間 < 100ms +- 零破壞性變更,現有功能正常 + +### **功能目標** +- 前端零選擇負擔體驗完全實現 +- A1學習者自動保護機制生效 +- 間隔重複算法科學精準 +- 7種題型後端支援完整 + +### **品質目標** +- 單元測試覆蓋率 > 90% +- API文檔完整更新 +- 代碼品質符合現有標準 +- 部署零停機時間 + +--- + +## 📋 **開發檢查清單** + +### **數據層** +- [ ] Flashcard模型擴展 (4個新欄位) +- [ ] 資料庫遷移腳本 +- [ ] 初始化現有數據的WordLevel +- [ ] 索引優化 + +### **服務層** +- [ ] SpacedRepetitionService (基於SM2Algorithm) +- [ ] ReviewTypeSelectorService (四情境邏輯) +- [ ] QuestionGeneratorService (題目生成) +- [ ] CEFRMappingService (等級映射) + +### **API層** +- [ ] 5個智能複習端點 +- [ ] DTOs和驗證規則 +- [ ] 錯誤處理整合 +- [ ] Swagger文檔更新 + +### **測試** +- [ ] 單元測試 > 90%覆蓋率 +- [ ] API整合測試 +- [ ] 前後端對接驗證 +- [ ] 性能測試 + +--- + +**開發負責人**: [待指派] +**開始時間**: [確認前端對接需求後開始] +**預計完成**: 3-4個工作日 +**技術風險**: 極低 (基於成熟架構擴展) +**部署影響**: 零停機時間 (純擴展功能) \ No newline at end of file