From 006dcfee861ea5e1d696af640cec5e68f4618218 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=84=AD=E6=B2=9B=E8=BB=92?= Date: Mon, 6 Oct 2025 21:15:12 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=95=B4=E5=90=88=20AI=20=E6=99=BA?= =?UTF-8?q?=E8=83=BD=20quizOptions=20=E5=88=B0=20due=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ✨ 新增功能 - 每張詞卡自動生成 3 個混淆選項 (quizOptions) - AI 驅動的智能混淆選項生成系統 - 基於詞性和難度等級的選項匹配 🧠 AI 生成邏輯 - 使用 Gemini AI 生成語義相關但明確不同的選項 - 根據 CEFR 等級和詞性調整選項難度 - JSON 格式回應解析和錯誤處理 🚀 性能優化 - 記憶體快取機制 (1小時過期) - 資料庫持久化儲存生成的選項 - 智能降級機制:AI失敗時使用固定選項 📊 測試確認 - API 回應包含完整的 quizOptions 陣列 - 支援異步批量生成多張詞卡選項 - 前端可直接使用於詞彙選擇測驗 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- .../Services/Review/ReviewService.cs | 116 +++++---- .../Options/OptionsVocabularyService.cs | 223 ++++++++++++++---- 2 files changed, 241 insertions(+), 98 deletions(-) diff --git a/backend/DramaLing.Api/Services/Review/ReviewService.cs b/backend/DramaLing.Api/Services/Review/ReviewService.cs index 6c37143..d8f74fa 100644 --- a/backend/DramaLing.Api/Services/Review/ReviewService.cs +++ b/backend/DramaLing.Api/Services/Review/ReviewService.cs @@ -3,19 +3,23 @@ using DramaLing.Api.Models.Entities; using DramaLing.Api.Repositories; using DramaLing.Api.Controllers; using DramaLing.Api.Utils; +using DramaLing.Api.Services; namespace DramaLing.Api.Services.Review; public class ReviewService : IReviewService { private readonly IFlashcardReviewRepository _reviewRepository; + private readonly IOptionsVocabularyService _optionsService; private readonly ILogger _logger; public ReviewService( IFlashcardReviewRepository reviewRepository, + IOptionsVocabularyService optionsService, ILogger logger) { _reviewRepository = reviewRepository; + _optionsService = optionsService; _logger = logger; } @@ -26,61 +30,77 @@ public class ReviewService : IReviewService var dueFlashcards = await _reviewRepository.GetDueFlashcardsAsync(userId, query); var (todayDue, overdue, totalReviews) = await _reviewRepository.GetReviewStatsAsync(userId); - // 轉換為符合前端期望的格式 - var flashcardData = dueFlashcards.Select(item => new + // 為每張詞卡生成 quizOptions + var flashcardDataTasks = dueFlashcards.Select(async item => { - // 基本詞卡信息 (匹配 api_seeds.json 格式) - id = item.Flashcard.Id.ToString(), - word = item.Flashcard.Word, - translation = item.Flashcard.Translation, - definition = item.Flashcard.Definition ?? "", - partOfSpeech = item.Flashcard.PartOfSpeech ?? "", - pronunciation = item.Flashcard.Pronunciation ?? "", - example = item.Flashcard.Example ?? "", - exampleTranslation = item.Flashcard.ExampleTranslation ?? "", - isFavorite = item.Flashcard.IsFavorite, - difficultyLevelNumeric = item.Flashcard.DifficultyLevelNumeric, - cefr = CEFRHelper.ToString(item.Flashcard.DifficultyLevelNumeric), - createdAt = item.Flashcard.CreatedAt.ToString("yyyy-MM-ddTHH:mm:ssZ"), - updatedAt = item.Flashcard.UpdatedAt.ToString("yyyy-MM-ddTHH:mm:ssZ"), + // 生成混淆選項 + var generatedQuizOptions = await _optionsService.GenerateDistractorsAsync( + item.Flashcard.Word, + CEFRHelper.ToString(item.Flashcard.DifficultyLevelNumeric), + item.Flashcard.PartOfSpeech ?? "noun", + 3); - // 圖片相關 (暫時設為預設值,因為需要額外查詢) - hasExampleImage = false, - primaryImageUrl = (string?)null, - - // 同義詞(暫時空陣列,未來可擴展) - synonyms = new string[] { }, - - // 複習相關信息 (新增) - reviewInfo = item.Review != null ? new + return new { - successCount = item.Review.SuccessCount, - nextReviewDate = item.Review.NextReviewDate.ToString("yyyy-MM-ddTHH:mm:ssZ"), - lastReviewDate = item.Review.LastReviewDate?.ToString("yyyy-MM-ddTHH:mm:ssZ"), - totalCorrectCount = item.Review.TotalCorrectCount, - totalWrongCount = item.Review.TotalWrongCount, - totalSkipCount = item.Review.TotalSkipCount, - isOverdue = item.Review.NextReviewDate < DateTime.UtcNow.Date, - daysSinceLastReview = item.Review.LastReviewDate.HasValue - ? (int)(DateTime.UtcNow - item.Review.LastReviewDate.Value).TotalDays - : 0 - } : new - { - successCount = 0, - nextReviewDate = DateTime.UtcNow.AddDays(1).ToString("yyyy-MM-ddTHH:mm:ssZ"), - lastReviewDate = (string?)null, - totalCorrectCount = 0, - totalWrongCount = 0, - totalSkipCount = 0, - isOverdue = false, - daysSinceLastReview = 0 - } - }).ToList(); + // 基本詞卡信息 (匹配 api_seeds.json 格式) + id = item.Flashcard.Id.ToString(), + word = item.Flashcard.Word, + translation = item.Flashcard.Translation, + definition = item.Flashcard.Definition ?? "", + partOfSpeech = item.Flashcard.PartOfSpeech ?? "", + pronunciation = item.Flashcard.Pronunciation ?? "", + example = item.Flashcard.Example ?? "", + exampleTranslation = item.Flashcard.ExampleTranslation ?? "", + isFavorite = item.Flashcard.IsFavorite, + difficultyLevelNumeric = item.Flashcard.DifficultyLevelNumeric, + cefr = CEFRHelper.ToString(item.Flashcard.DifficultyLevelNumeric), + createdAt = item.Flashcard.CreatedAt.ToString("yyyy-MM-ddTHH:mm:ssZ"), + updatedAt = item.Flashcard.UpdatedAt.ToString("yyyy-MM-ddTHH:mm:ssZ"), + + // 圖片相關 (暫時設為預設值,因為需要額外查詢) + hasExampleImage = false, + primaryImageUrl = (string?)null, + + // 同義詞(暫時空陣列,未來可擴展) + synonyms = new string[] { }, + + // 測驗選項 (AI 生成的混淆選項) + quizOptions = generatedQuizOptions, + + // 複習相關信息 (新增) + reviewInfo = item.Review != null ? new + { + successCount = item.Review.SuccessCount, + nextReviewDate = item.Review.NextReviewDate.ToString("yyyy-MM-ddTHH:mm:ssZ"), + lastReviewDate = item.Review.LastReviewDate?.ToString("yyyy-MM-ddTHH:mm:ssZ"), + totalCorrectCount = item.Review.TotalCorrectCount, + totalWrongCount = item.Review.TotalWrongCount, + totalSkipCount = item.Review.TotalSkipCount, + isOverdue = item.Review.NextReviewDate < DateTime.UtcNow.Date, + daysSinceLastReview = item.Review.LastReviewDate.HasValue + ? (int)(DateTime.UtcNow - item.Review.LastReviewDate.Value).TotalDays + : 0 + } : new + { + successCount = 0, + nextReviewDate = DateTime.UtcNow.AddDays(1).ToString("yyyy-MM-ddTHH:mm:ssZ"), + lastReviewDate = (string?)null, + totalCorrectCount = 0, + totalWrongCount = 0, + totalSkipCount = 0, + isOverdue = false, + daysSinceLastReview = 0 + } + }; + }); + + // 等待所有異步任務完成 + var flashcardData = await Task.WhenAll(flashcardDataTasks); var response = new { flashcards = flashcardData, - count = flashcardData.Count, + count = flashcardData.Length, metadata = new { todayDue = todayDue, diff --git a/backend/DramaLing.Api/Services/Vocabulary/Options/OptionsVocabularyService.cs b/backend/DramaLing.Api/Services/Vocabulary/Options/OptionsVocabularyService.cs index 917318b..79a6ab0 100644 --- a/backend/DramaLing.Api/Services/Vocabulary/Options/OptionsVocabularyService.cs +++ b/backend/DramaLing.Api/Services/Vocabulary/Options/OptionsVocabularyService.cs @@ -2,10 +2,12 @@ using DramaLing.Api.Data; using DramaLing.Api.Models.Configuration; using DramaLing.Api.Models.Entities; using DramaLing.Api.Services.Monitoring; +using DramaLing.Api.Services.AI.Gemini; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Options; using System.Diagnostics; +using System.Text.Json; namespace DramaLing.Api.Services; @@ -20,19 +22,22 @@ public class OptionsVocabularyService : IOptionsVocabularyService private readonly ILogger _logger; private readonly OptionsVocabularyOptions _options; private readonly OptionsVocabularyMetrics _metrics; + private readonly IGeminiClient _geminiClient; public OptionsVocabularyService( DramaLingDbContext context, IMemoryCache cache, ILogger logger, IOptions options, - OptionsVocabularyMetrics metrics) + OptionsVocabularyMetrics metrics, + IGeminiClient geminiClient) { _context = context; _cache = cache; _logger = logger; _options = options.Value; _metrics = metrics; + _geminiClient = geminiClient; } /// @@ -66,71 +71,73 @@ public class OptionsVocabularyService : IOptionsVocabularyService // 記錄請求指標 _metrics.RecordGenerationRequest(cefrLevel, partOfSpeech, count); - _logger.LogInformation("Generating {Count} distractors for word '{Word}' (CEFR: {CEFR}, PartOfSpeech: {PartOfSpeech}) - Using fixed options", + _logger.LogInformation("Generating {Count} distractors for word '{Word}' (CEFR: {CEFR}, PartOfSpeech: {PartOfSpeech})", count, targetWord, cefrLevel, partOfSpeech); - // 暫時使用固定選項,跳過複雜的詞彙篩選機制 - var fixedDistractors = new List - { - new OptionsVocabulary - { - Id = Guid.NewGuid(), - Word = "apple", - CEFRLevel = cefrLevel, - PartOfSpeech = partOfSpeech, - IsActive = true, - CreatedAt = DateTime.UtcNow, - UpdatedAt = DateTime.UtcNow - }, - new OptionsVocabulary - { - Id = Guid.NewGuid(), - Word = "orange", - CEFRLevel = cefrLevel, - PartOfSpeech = partOfSpeech, - IsActive = true, - CreatedAt = DateTime.UtcNow, - UpdatedAt = DateTime.UtcNow - }, - new OptionsVocabulary - { - Id = Guid.NewGuid(), - Word = "banana", - CEFRLevel = cefrLevel, - PartOfSpeech = partOfSpeech, - IsActive = true, - CreatedAt = DateTime.UtcNow, - UpdatedAt = DateTime.UtcNow - } - }; + // 1. 首先檢查資料庫中是否已有該詞彙的混淆選項 + var cacheKey = $"quiz_options_{targetWord.ToLowerInvariant()}_{cefrLevel}_{partOfSpeech}"; - // 計算字數長度 - foreach (var distractor in fixedDistractors) + if (_cache.TryGetValue(cacheKey, out List? cachedOptions) && cachedOptions != null) { - distractor.CalculateWordLength(); + _logger.LogInformation("Using cached distractors for '{Word}': {Distractors}", + targetWord, string.Join(", ", cachedOptions.Select(d => d.Word))); + return cachedOptions.Take(count).ToList(); } - // 排除目標詞彙本身(如果匹配) - var selectedDistractors = fixedDistractors - .Where(v => !string.Equals(v.Word, targetWord, StringComparison.OrdinalIgnoreCase)) - .Take(count) - .ToList(); + // 2. 檢查資料庫中是否有現有選項 + var existingOptions = await _context.OptionsVocabularies + .Where(ov => ov.CEFRLevel == cefrLevel && + ov.PartOfSpeech == partOfSpeech && + ov.IsActive && + !string.Equals(ov.Word, targetWord, StringComparison.OrdinalIgnoreCase)) + .Take(count * 2) // 取更多選項以供隨機選擇 + .ToListAsync(); - _logger.LogInformation("Successfully generated {Count} fixed distractors for '{Word}': {Distractors}", - selectedDistractors.Count, targetWord, - string.Join(", ", selectedDistractors.Select(d => d.Word))); + if (existingOptions.Count >= count) + { + var selectedExisting = existingOptions + .OrderBy(x => Guid.NewGuid()) // 隨機排序 + .Take(count) + .ToList(); + + // 快取結果 + _cache.Set(cacheKey, selectedExisting, TimeSpan.FromHours(1)); + + _logger.LogInformation("Using existing distractors for '{Word}': {Distractors}", + targetWord, string.Join(", ", selectedExisting.Select(d => d.Word))); + return selectedExisting; + } + + // 3. 使用 AI 生成新的混淆選項 + var aiGeneratedOptions = await GenerateOptionsWithAI(targetWord, cefrLevel, partOfSpeech, count); + + // 4. 保存新生成的選項到資料庫 + if (aiGeneratedOptions.Any()) + { + await _context.OptionsVocabularies.AddRangeAsync(aiGeneratedOptions); + await _context.SaveChangesAsync(); + + // 快取結果 + _cache.Set(cacheKey, aiGeneratedOptions, TimeSpan.FromHours(1)); + } + + _logger.LogInformation("Successfully generated {Count} AI distractors for '{Word}': {Distractors}", + aiGeneratedOptions.Count, targetWord, + string.Join(", ", aiGeneratedOptions.Select(d => d.Word))); // 記錄生成完成指標 stopwatch.Stop(); - _metrics.RecordGenerationDuration(stopwatch.Elapsed, selectedDistractors.Count); + _metrics.RecordGenerationDuration(stopwatch.Elapsed, aiGeneratedOptions.Count); - return selectedDistractors; + return aiGeneratedOptions; } catch (Exception ex) { _logger.LogError(ex, "Error generating distractors for word '{Word}'", targetWord); _metrics.RecordError("generation_failed", "GenerateDistractorsWithDetailsAsync"); - return new List(); + + // 降級到固定選項 + return GetFallbackDistractors(targetWord, cefrLevel, partOfSpeech, count); } } @@ -204,4 +211,120 @@ public class OptionsVocabularyService : IOptionsVocabularyService return allowed; } + + /// + /// 使用 AI 生成混淆選項 + /// + private async Task> GenerateOptionsWithAI( + string targetWord, + string cefrLevel, + string partOfSpeech, + int count) + { + try + { + var prompt = $@"Generate {count} vocabulary words that would be good wrong answers for a multiple choice quiz about the word ""{targetWord}"". + +Requirements: +- CEFR level: {cefrLevel} +- Part of speech: {partOfSpeech} +- The words should be plausible but clearly different from ""{targetWord}"" +- Avoid obviously wrong answers +- Make the words challenging but fair for language learners + +Please respond with ONLY a JSON array of strings, like: [""word1"", ""word2"", ""word3""]"; + + var response = await _geminiClient.CallGeminiAPIAsync(prompt); + + // 解析 AI 回應 + var cleanedResponse = ExtractJsonFromResponse(response); + var generatedWords = JsonSerializer.Deserialize(cleanedResponse); + + if (generatedWords == null || generatedWords.Length == 0) + { + _logger.LogWarning("AI returned empty or invalid response for word '{Word}'", targetWord); + return GetFallbackDistractors(targetWord, cefrLevel, partOfSpeech, count); + } + + // 轉換為 OptionsVocabulary 實體 + var options = generatedWords.Take(count).Select(word => new OptionsVocabulary + { + Id = Guid.NewGuid(), + Word = word.Trim(), + CEFRLevel = cefrLevel, + PartOfSpeech = partOfSpeech, + IsActive = true, + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow + }).ToList(); + + // 計算字數長度 + foreach (var option in options) + { + option.CalculateWordLength(); + } + + return options; + } + catch (Exception ex) + { + _logger.LogError(ex, "AI generation failed for word '{Word}', falling back to fixed options", targetWord); + return GetFallbackDistractors(targetWord, cefrLevel, partOfSpeech, count); + } + } + + /// + /// 降級固定選項(當 AI 生成失敗時使用) + /// + private List GetFallbackDistractors( + string targetWord, + string cefrLevel, + string partOfSpeech, + int count) + { + // 根據詞性提供不同的固定選項 + var fallbackWords = partOfSpeech.ToLower() switch + { + "verb" => new[] { "create", "destroy", "change", "develop", "improve", "reduce" }, + "noun" => new[] { "example", "result", "problem", "method", "system", "process" }, + "adjective" => new[] { "important", "different", "special", "general", "common", "simple" }, + "adverb" => new[] { "quickly", "carefully", "easily", "slowly", "clearly", "directly" }, + _ => new[] { "option", "choice", "answer", "question", "test", "study" } + }; + + return fallbackWords + .Where(word => !string.Equals(word, targetWord, StringComparison.OrdinalIgnoreCase)) + .Take(count) + .Select(word => new OptionsVocabulary + { + Id = Guid.NewGuid(), + Word = word, + CEFRLevel = cefrLevel, + PartOfSpeech = partOfSpeech, + IsActive = true, + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow + }) + .ToList(); + } + + /// + /// 從 AI 回應中提取 JSON 內容 + /// + private string ExtractJsonFromResponse(string response) + { + // 移除可能的 markdown 代碼塊標記 + var cleaned = response.Replace("```json", "").Replace("```", "").Trim(); + + // 找到第一個 [ 和最後一個 ] + var startIndex = cleaned.IndexOf('['); + var endIndex = cleaned.LastIndexOf(']'); + + if (startIndex >= 0 && endIndex >= 0 && endIndex > startIndex) + { + return cleaned.Substring(startIndex, endIndex - startIndex + 1); + } + + return cleaned; + } } \ No newline at end of file