284 lines
10 KiB
C#
284 lines
10 KiB
C#
using DramaLing.Api.Data;
|
|
using DramaLing.Api.Models.DTOs.SpacedRepetition;
|
|
using DramaLing.Api.Models.Entities;
|
|
using Microsoft.EntityFrameworkCore;
|
|
|
|
namespace DramaLing.Api.Services;
|
|
|
|
/// <summary>
|
|
/// 題目生成服務介面
|
|
/// </summary>
|
|
public interface IQuestionGeneratorService
|
|
{
|
|
Task<QuestionData> GenerateQuestionAsync(Guid flashcardId, string questionType);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 題目生成服務實現
|
|
/// </summary>
|
|
public class QuestionGeneratorService : IQuestionGeneratorService
|
|
{
|
|
private readonly DramaLingDbContext _context;
|
|
private readonly IOptionsVocabularyService _optionsVocabularyService;
|
|
private readonly ILogger<QuestionGeneratorService> _logger;
|
|
|
|
public QuestionGeneratorService(
|
|
DramaLingDbContext context,
|
|
IOptionsVocabularyService optionsVocabularyService,
|
|
ILogger<QuestionGeneratorService> logger)
|
|
{
|
|
_context = context;
|
|
_optionsVocabularyService = optionsVocabularyService;
|
|
_logger = logger;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 根據題型生成對應的題目數據
|
|
/// </summary>
|
|
public async Task<QuestionData> 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
|
|
}
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// 生成詞彙選擇題選項
|
|
/// </summary>
|
|
private async Task<QuestionData> GenerateVocabChoiceAsync(Flashcard flashcard)
|
|
{
|
|
var distractors = new List<string>();
|
|
|
|
// 🆕 優先嘗試使用智能詞彙庫生成選項
|
|
try
|
|
{
|
|
// 直接使用 Flashcard 的屬性
|
|
var cefrLevel = flashcard.DifficultyLevel ?? "B1"; // 預設為 B1
|
|
var partOfSpeech = flashcard.PartOfSpeech ?? "noun"; // 預設為 noun
|
|
|
|
_logger.LogDebug("Attempting to generate smart distractors for '{Word}' (CEFR: {CEFR}, PartOfSpeech: {PartOfSpeech})",
|
|
flashcard.Word, cefrLevel, partOfSpeech);
|
|
|
|
// 檢查詞彙庫是否有足夠詞彙
|
|
var hasSufficientVocab = await _optionsVocabularyService.HasSufficientVocabularyAsync(cefrLevel, partOfSpeech);
|
|
|
|
if (hasSufficientVocab)
|
|
{
|
|
var smartDistractors = await _optionsVocabularyService.GenerateDistractorsAsync(
|
|
flashcard.Word, cefrLevel, partOfSpeech, 3);
|
|
|
|
if (smartDistractors.Any())
|
|
{
|
|
distractors.AddRange(smartDistractors);
|
|
_logger.LogInformation("Successfully generated {Count} smart distractors for '{Word}'",
|
|
smartDistractors.Count, flashcard.Word);
|
|
}
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogWarning(ex, "Failed to generate smart distractors for '{Word}', falling back to user vocabulary",
|
|
flashcard.Word);
|
|
}
|
|
|
|
// 🔄 回退機制:如果智能詞彙庫無法提供足夠選項,使用原有邏輯
|
|
if (distractors.Count < 3)
|
|
{
|
|
_logger.LogInformation("Using fallback method for '{Word}' (current distractors: {Count})",
|
|
flashcard.Word, distractors.Count);
|
|
|
|
var userDistractors = await _context.Flashcards
|
|
.Where(f => f.UserId == flashcard.UserId &&
|
|
f.Id != flashcard.Id &&
|
|
!f.IsArchived &&
|
|
!distractors.Contains(f.Word)) // 避免重複
|
|
.OrderBy(x => Guid.NewGuid())
|
|
.Take(3 - distractors.Count)
|
|
.Select(f => f.Word)
|
|
.ToListAsync();
|
|
|
|
distractors.AddRange(userDistractors);
|
|
|
|
// 如果還是不夠,使用預設選項
|
|
while (distractors.Count < 3)
|
|
{
|
|
var defaultOptions = new[] { "example", "sample", "test", "word", "basic", "simple", "common", "usual" };
|
|
var availableDefaults = defaultOptions
|
|
.Where(opt => opt != flashcard.Word && !distractors.Contains(opt));
|
|
|
|
var neededCount = 3 - distractors.Count;
|
|
distractors.AddRange(availableDefaults.Take(neededCount));
|
|
|
|
// 防止無限循環
|
|
if (!availableDefaults.Any())
|
|
break;
|
|
}
|
|
}
|
|
|
|
var options = new List<string> { 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
|
|
};
|
|
}
|
|
|
|
|
|
/// <summary>
|
|
/// 生成填空題
|
|
/// </summary>
|
|
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
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// 生成例句重組題
|
|
/// </summary>
|
|
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
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// 生成例句聽力題選項
|
|
/// </summary>
|
|
private async Task<QuestionData> 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<string> { 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" // 未來音頻服務用
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// 判斷是否為A1學習者
|
|
/// </summary>
|
|
public bool IsA1Learner(int userLevel) => userLevel <= 20; // 固定A1門檻
|
|
|
|
/// <summary>
|
|
/// 獲取適配情境描述
|
|
/// </summary>
|
|
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 "困難詞彙";
|
|
}
|
|
|
|
} |