330 lines
12 KiB
C#
330 lines
12 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// 選項詞彙庫服務實作
|
|
/// 提供基於 CEFR 等級、詞性和字數的智能選項生成
|
|
/// </summary>
|
|
public class OptionsVocabularyService : IOptionsVocabularyService
|
|
{
|
|
private readonly DramaLingDbContext _context;
|
|
private readonly IMemoryCache _cache;
|
|
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,
|
|
IGeminiClient geminiClient)
|
|
{
|
|
_context = context;
|
|
_cache = cache;
|
|
_logger = logger;
|
|
_options = options.Value;
|
|
_metrics = metrics;
|
|
_geminiClient = geminiClient;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 生成智能干擾選項
|
|
/// </summary>
|
|
public async Task<List<string>> GenerateDistractorsAsync(
|
|
string targetWord,
|
|
string cefrLevel,
|
|
string partOfSpeech,
|
|
int count = 3)
|
|
{
|
|
var distractorsWithDetails = await GenerateDistractorsWithDetailsAsync(
|
|
targetWord, cefrLevel, partOfSpeech, count);
|
|
|
|
return distractorsWithDetails.Select(v => v.Word).ToList();
|
|
}
|
|
|
|
/// <summary>
|
|
/// 生成智能干擾選項(含詳細資訊)
|
|
/// </summary>
|
|
public async Task<List<OptionsVocabulary>> GenerateDistractorsWithDetailsAsync(
|
|
string targetWord,
|
|
string cefrLevel,
|
|
string partOfSpeech,
|
|
int count = 3)
|
|
{
|
|
var stopwatch = Stopwatch.StartNew();
|
|
|
|
try
|
|
{
|
|
// 記錄請求指標
|
|
_metrics.RecordGenerationRequest(cefrLevel, partOfSpeech, count);
|
|
|
|
_logger.LogInformation("Generating {Count} distractors for word '{Word}' (CEFR: {CEFR}, PartOfSpeech: {PartOfSpeech})",
|
|
count, targetWord, cefrLevel, partOfSpeech);
|
|
|
|
// 1. 首先檢查資料庫中是否已有該詞彙的混淆選項
|
|
var cacheKey = $"quiz_options_{targetWord.ToLowerInvariant()}_{cefrLevel}_{partOfSpeech}";
|
|
|
|
if (_cache.TryGetValue(cacheKey, out List<OptionsVocabulary>? cachedOptions) && cachedOptions != null)
|
|
{
|
|
_logger.LogInformation("Using cached distractors for '{Word}': {Distractors}",
|
|
targetWord, string.Join(", ", cachedOptions.Select(d => d.Word)));
|
|
return cachedOptions.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();
|
|
|
|
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, aiGeneratedOptions.Count);
|
|
|
|
return aiGeneratedOptions;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error generating distractors for word '{Word}'", targetWord);
|
|
_metrics.RecordError("generation_failed", "GenerateDistractorsWithDetailsAsync");
|
|
|
|
// 降級到固定選項
|
|
return GetFallbackDistractors(targetWord, cefrLevel, partOfSpeech, count);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 檢查詞彙庫是否有足夠的詞彙支援選項生成
|
|
/// </summary>
|
|
public async Task<bool> HasSufficientVocabularyAsync(string cefrLevel, string partOfSpeech)
|
|
{
|
|
try
|
|
{
|
|
var allowedLevels = GetAllowedCEFRLevels(cefrLevel);
|
|
|
|
var count = await _context.OptionsVocabularies
|
|
.Where(v => v.IsActive &&
|
|
allowedLevels.Contains(v.CEFRLevel) &&
|
|
v.PartOfSpeech == partOfSpeech)
|
|
.CountAsync();
|
|
|
|
var hasSufficient = count >= _options.MinimumVocabularyThreshold;
|
|
|
|
_logger.LogDebug("Vocabulary count for CEFR: {CEFR}, PartOfSpeech: {PartOfSpeech}: {Count} (sufficient: {HasSufficient})",
|
|
cefrLevel, partOfSpeech, count, hasSufficient);
|
|
|
|
return hasSufficient;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error checking vocabulary sufficiency for CEFR: {CEFR}, PartOfSpeech: {PartOfSpeech}",
|
|
cefrLevel, partOfSpeech);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 獲取允許的 CEFR 等級(包含相鄰等級)
|
|
/// </summary>
|
|
private static List<string> GetAllowedCEFRLevels(string targetLevel)
|
|
{
|
|
var levels = new[] { "A1", "A2", "B1", "B2", "C1", "C2" };
|
|
var targetIndex = Array.IndexOf(levels, targetLevel);
|
|
|
|
if (targetIndex == -1)
|
|
{
|
|
// 如果不是標準 CEFR 等級,只返回原等級
|
|
return new List<string> { targetLevel };
|
|
}
|
|
|
|
var allowed = new List<string> { targetLevel };
|
|
|
|
// 加入相鄰等級(允許難度稍有差異)
|
|
if (targetIndex > 0)
|
|
allowed.Add(levels[targetIndex - 1]);
|
|
if (targetIndex < levels.Length - 1)
|
|
allowed.Add(levels[targetIndex + 1]);
|
|
|
|
return allowed;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 獲取允許的 CEFR 數字等級(包含相鄰等級)
|
|
/// </summary>
|
|
private static List<int> GetAllowedCEFRLevelsNumeric(int targetLevelNumeric)
|
|
{
|
|
var allowed = new List<int> { targetLevelNumeric };
|
|
|
|
// 加入相鄰等級(允許難度稍有差異)
|
|
if (targetLevelNumeric > 1)
|
|
allowed.Add(targetLevelNumeric - 1);
|
|
if (targetLevelNumeric < 6)
|
|
allowed.Add(targetLevelNumeric + 1);
|
|
|
|
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;
|
|
}
|
|
} |