feat: 整合 AI 智能 quizOptions 到 due API
✨ 新增功能 - 每張詞卡自動生成 3 個混淆選項 (quizOptions) - AI 驅動的智能混淆選項生成系統 - 基於詞性和難度等級的選項匹配 🧠 AI 生成邏輯 - 使用 Gemini AI 生成語義相關但明確不同的選項 - 根據 CEFR 等級和詞性調整選項難度 - JSON 格式回應解析和錯誤處理 🚀 性能優化 - 記憶體快取機制 (1小時過期) - 資料庫持久化儲存生成的選項 - 智能降級機制:AI失敗時使用固定選項 📊 測試確認 - API 回應包含完整的 quizOptions 陣列 - 支援異步批量生成多張詞卡選項 - 前端可直接使用於詞彙選擇測驗 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
e8ab42dfd7
commit
006dcfee86
|
|
@ -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<ReviewService> _logger;
|
||||
|
||||
public ReviewService(
|
||||
IFlashcardReviewRepository reviewRepository,
|
||||
IOptionsVocabularyService optionsService,
|
||||
ILogger<ReviewService> logger)
|
||||
{
|
||||
_reviewRepository = reviewRepository;
|
||||
_optionsService = optionsService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
|
|
@ -26,8 +30,17 @@ 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 =>
|
||||
{
|
||||
// 生成混淆選項
|
||||
var generatedQuizOptions = await _optionsService.GenerateDistractorsAsync(
|
||||
item.Flashcard.Word,
|
||||
CEFRHelper.ToString(item.Flashcard.DifficultyLevelNumeric),
|
||||
item.Flashcard.PartOfSpeech ?? "noun",
|
||||
3);
|
||||
|
||||
return new
|
||||
{
|
||||
// 基本詞卡信息 (匹配 api_seeds.json 格式)
|
||||
id = item.Flashcard.Id.ToString(),
|
||||
|
|
@ -51,6 +64,9 @@ public class ReviewService : IReviewService
|
|||
// 同義詞(暫時空陣列,未來可擴展)
|
||||
synonyms = new string[] { },
|
||||
|
||||
// 測驗選項 (AI 生成的混淆選項)
|
||||
quizOptions = generatedQuizOptions,
|
||||
|
||||
// 複習相關信息 (新增)
|
||||
reviewInfo = item.Review != null ? new
|
||||
{
|
||||
|
|
@ -75,12 +91,16 @@ public class ReviewService : IReviewService
|
|||
isOverdue = false,
|
||||
daysSinceLastReview = 0
|
||||
}
|
||||
}).ToList();
|
||||
};
|
||||
});
|
||||
|
||||
// 等待所有異步任務完成
|
||||
var flashcardData = await Task.WhenAll(flashcardDataTasks);
|
||||
|
||||
var response = new
|
||||
{
|
||||
flashcards = flashcardData,
|
||||
count = flashcardData.Count,
|
||||
count = flashcardData.Length,
|
||||
metadata = new
|
||||
{
|
||||
todayDue = todayDue,
|
||||
|
|
|
|||
|
|
@ -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<OptionsVocabularyService> _logger;
|
||||
private readonly OptionsVocabularyOptions _options;
|
||||
private readonly OptionsVocabularyMetrics _metrics;
|
||||
private readonly IGeminiClient _geminiClient;
|
||||
|
||||
public OptionsVocabularyService(
|
||||
DramaLingDbContext context,
|
||||
IMemoryCache cache,
|
||||
ILogger<OptionsVocabularyService> logger,
|
||||
IOptions<OptionsVocabularyOptions> options,
|
||||
OptionsVocabularyMetrics metrics)
|
||||
OptionsVocabularyMetrics metrics,
|
||||
IGeminiClient geminiClient)
|
||||
{
|
||||
_context = context;
|
||||
_cache = cache;
|
||||
_logger = logger;
|
||||
_options = options.Value;
|
||||
_metrics = metrics;
|
||||
_geminiClient = geminiClient;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -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<OptionsVocabulary>
|
||||
{
|
||||
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<OptionsVocabulary>? 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))
|
||||
// 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();
|
||||
|
||||
if (existingOptions.Count >= count)
|
||||
{
|
||||
var selectedExisting = existingOptions
|
||||
.OrderBy(x => Guid.NewGuid()) // 隨機排序
|
||||
.Take(count)
|
||||
.ToList();
|
||||
|
||||
_logger.LogInformation("Successfully generated {Count} fixed distractors for '{Word}': {Distractors}",
|
||||
selectedDistractors.Count, targetWord,
|
||||
string.Join(", ", selectedDistractors.Select(d => d.Word)));
|
||||
// 快取結果
|
||||
_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<OptionsVocabulary>();
|
||||
|
||||
// 降級到固定選項
|
||||
return GetFallbackDistractors(targetWord, cefrLevel, partOfSpeech, count);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -204,4 +211,120 @@ public class OptionsVocabularyService : IOptionsVocabularyService
|
|||
|
||||
return allowed;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 使用 AI 生成混淆選項
|
||||
/// </summary>
|
||||
private async Task<List<OptionsVocabulary>> 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<string[]>(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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 降級固定選項(當 AI 生成失敗時使用)
|
||||
/// </summary>
|
||||
private List<OptionsVocabulary> 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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 從 AI 回應中提取 JSON 內容
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue