refactor: 完全清空後端複習系統為重新實施做準備
- 刪除所有智能複習相關服務和控制器 - 移除 StudyController, StudySessionController - 刪除 SpacedRepetitionService, ReviewTypeSelectorService 等服務 - 清理 SpacedRepetition DTO 和配置文件 - 簡化 Flashcard 實體,移除所有複習相關屬性 - 移除 StudyRecord, StudySession, StudyCard 實體 - 清理 Program.cs 服務註冊和 appsettings 配置 - 為組件化重新實施提供純淨的代碼基礎 清空效果: - StudyController: 583行 → 0行 (完全刪除) - FlashcardsController: 461行 → 271行 (純粹CRUD) - 複習服務: 5個 → 0個 (完全移除) - 系統複雜度: 大幅降低,架構清晰 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
95952621ee
commit
a613ca22b7
|
|
@ -2,96 +2,52 @@ using Microsoft.AspNetCore.Mvc;
|
|||
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;
|
||||
|
||||
namespace DramaLing.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/flashcards")]
|
||||
[AllowAnonymous] // 暫時移除認證要求,修復網路錯誤
|
||||
[AllowAnonymous]
|
||||
public class FlashcardsController : ControllerBase
|
||||
{
|
||||
private readonly DramaLingDbContext _context;
|
||||
private readonly ILogger<FlashcardsController> _logger;
|
||||
private readonly IImageStorageService _imageStorageService;
|
||||
private readonly IAuthService _authService;
|
||||
// 🆕 智能複習服務依賴
|
||||
private readonly ISpacedRepetitionService _spacedRepetitionService;
|
||||
private readonly IReviewTypeSelectorService _reviewTypeSelectorService;
|
||||
private readonly IQuestionGeneratorService _questionGeneratorService;
|
||||
// 🆕 智能填空題服務依賴
|
||||
private readonly IBlankGenerationService _blankGenerationService;
|
||||
|
||||
public FlashcardsController(
|
||||
DramaLingDbContext context,
|
||||
ILogger<FlashcardsController> logger,
|
||||
IImageStorageService imageStorageService,
|
||||
IAuthService authService,
|
||||
ISpacedRepetitionService spacedRepetitionService,
|
||||
IReviewTypeSelectorService reviewTypeSelectorService,
|
||||
IQuestionGeneratorService questionGeneratorService,
|
||||
IBlankGenerationService blankGenerationService)
|
||||
ILogger<FlashcardsController> logger)
|
||||
{
|
||||
_context = context;
|
||||
_logger = logger;
|
||||
_imageStorageService = imageStorageService;
|
||||
_authService = authService;
|
||||
_spacedRepetitionService = spacedRepetitionService;
|
||||
_reviewTypeSelectorService = reviewTypeSelectorService;
|
||||
_questionGeneratorService = questionGeneratorService;
|
||||
_blankGenerationService = blankGenerationService;
|
||||
}
|
||||
|
||||
private Guid GetUserId()
|
||||
{
|
||||
// 暫時使用固定測試用戶 ID,避免認證問題
|
||||
// TODO: 恢復真實認證後改回 JWT Token 解析
|
||||
// 暫時使用固定測試用戶 ID
|
||||
return Guid.Parse("00000000-0000-0000-0000-000000000001");
|
||||
|
||||
// var userIdString = User.FindFirst(ClaimTypes.NameIdentifier)?.Value ??
|
||||
// User.FindFirst("sub")?.Value;
|
||||
//
|
||||
// if (Guid.TryParse(userIdString, out var userId))
|
||||
// return userId;
|
||||
//
|
||||
// throw new UnauthorizedAccessException("Invalid user ID in token");
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<ActionResult> GetFlashcards(
|
||||
[FromQuery] string? search = null,
|
||||
[FromQuery] bool favoritesOnly = false,
|
||||
[FromQuery] string? cefrLevel = null,
|
||||
[FromQuery] string? partOfSpeech = null,
|
||||
[FromQuery] string? masteryLevel = null)
|
||||
[FromQuery] bool favoritesOnly = false)
|
||||
{
|
||||
try
|
||||
{
|
||||
var userId = GetUserId();
|
||||
_logger.LogInformation("GetFlashcards called for user: {UserId}", userId);
|
||||
|
||||
var query = _context.Flashcards
|
||||
.Include(f => f.FlashcardExampleImages)
|
||||
.ThenInclude(fei => fei.ExampleImage)
|
||||
.Where(f => f.UserId == userId && !f.IsArchived)
|
||||
.AsQueryable();
|
||||
|
||||
_logger.LogInformation("Base query created successfully");
|
||||
|
||||
// 搜尋篩選 (擴展支援例句內容)
|
||||
// 搜尋篩選
|
||||
if (!string.IsNullOrEmpty(search))
|
||||
{
|
||||
query = query.Where(f =>
|
||||
f.Word.Contains(search) ||
|
||||
f.Translation.Contains(search) ||
|
||||
(f.Definition != null && f.Definition.Contains(search)) ||
|
||||
(f.Example != null && f.Example.Contains(search)) ||
|
||||
(f.ExampleTranslation != null && f.ExampleTranslation.Contains(search)));
|
||||
(f.Definition != null && f.Definition.Contains(search)));
|
||||
}
|
||||
|
||||
// 收藏篩選
|
||||
|
|
@ -100,99 +56,40 @@ public class FlashcardsController : ControllerBase
|
|||
query = query.Where(f => f.IsFavorite);
|
||||
}
|
||||
|
||||
// CEFR 等級篩選
|
||||
if (!string.IsNullOrEmpty(cefrLevel))
|
||||
{
|
||||
query = query.Where(f => f.DifficultyLevel == cefrLevel);
|
||||
}
|
||||
|
||||
// 詞性篩選
|
||||
if (!string.IsNullOrEmpty(partOfSpeech))
|
||||
{
|
||||
query = query.Where(f => f.PartOfSpeech == partOfSpeech);
|
||||
}
|
||||
|
||||
// 掌握度篩選
|
||||
if (!string.IsNullOrEmpty(masteryLevel))
|
||||
{
|
||||
switch (masteryLevel.ToLower())
|
||||
{
|
||||
case "high":
|
||||
query = query.Where(f => f.MasteryLevel >= 80);
|
||||
break;
|
||||
case "medium":
|
||||
query = query.Where(f => f.MasteryLevel >= 60 && f.MasteryLevel < 80);
|
||||
break;
|
||||
case "low":
|
||||
query = query.Where(f => f.MasteryLevel < 60);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("Executing database query...");
|
||||
var flashcards = await query
|
||||
.AsNoTracking() // 效能優化:只讀查詢
|
||||
.AsNoTracking()
|
||||
.OrderByDescending(f => f.CreatedAt)
|
||||
.ToListAsync();
|
||||
|
||||
_logger.LogInformation("Found {Count} flashcards", flashcards.Count);
|
||||
|
||||
// 生成圖片資訊
|
||||
var flashcardDtos = new List<object>();
|
||||
foreach (var flashcard in flashcards)
|
||||
.Select(f => new
|
||||
{
|
||||
// 獲取例句圖片資料 (與 GetFlashcard 方法保持一致)
|
||||
var exampleImages = flashcard.FlashcardExampleImages?
|
||||
.Select(fei => new
|
||||
{
|
||||
Id = fei.ExampleImage.Id,
|
||||
ImageUrl = $"http://localhost:5008/images/examples/{fei.ExampleImage.RelativePath}",
|
||||
IsPrimary = fei.IsPrimary,
|
||||
QualityScore = fei.ExampleImage.QualityScore,
|
||||
FileSize = fei.ExampleImage.FileSize,
|
||||
CreatedAt = fei.ExampleImage.CreatedAt
|
||||
f.Id,
|
||||
f.Word,
|
||||
f.Translation,
|
||||
f.Definition,
|
||||
f.PartOfSpeech,
|
||||
f.Pronunciation,
|
||||
f.Example,
|
||||
f.ExampleTranslation,
|
||||
f.IsFavorite,
|
||||
f.DifficultyLevel,
|
||||
f.CreatedAt,
|
||||
f.UpdatedAt
|
||||
})
|
||||
.ToList();
|
||||
|
||||
flashcardDtos.Add(new
|
||||
{
|
||||
flashcard.Id,
|
||||
flashcard.Word,
|
||||
flashcard.Translation,
|
||||
flashcard.Definition,
|
||||
flashcard.PartOfSpeech,
|
||||
flashcard.Pronunciation,
|
||||
flashcard.Example,
|
||||
flashcard.ExampleTranslation,
|
||||
flashcard.MasteryLevel,
|
||||
flashcard.TimesReviewed,
|
||||
flashcard.IsFavorite,
|
||||
flashcard.NextReviewDate,
|
||||
flashcard.DifficultyLevel,
|
||||
flashcard.CreatedAt,
|
||||
flashcard.UpdatedAt,
|
||||
// 新增圖片相關欄位
|
||||
ExampleImages = exampleImages ?? (object)new List<object>(),
|
||||
HasExampleImage = exampleImages?.Any() ?? false,
|
||||
PrimaryImageUrl = exampleImages?.FirstOrDefault(img => img.IsPrimary)?.ImageUrl
|
||||
});
|
||||
}
|
||||
.ToListAsync();
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
Success = true,
|
||||
Data = new
|
||||
{
|
||||
Flashcards = flashcardDtos,
|
||||
Count = flashcardDtos.Count
|
||||
Flashcards = flashcards,
|
||||
Count = flashcards.Count
|
||||
}
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error getting flashcards for user: {Message}", ex.Message);
|
||||
_logger.LogError("Stack trace: {StackTrace}", ex.StackTrace);
|
||||
return StatusCode(500, new { Success = false, Error = "Failed to load flashcards", Details = ex.Message });
|
||||
_logger.LogError(ex, "Error getting flashcards");
|
||||
return StatusCode(500, new { Success = false, Error = "Failed to load flashcards" });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -203,52 +100,6 @@ public class FlashcardsController : ControllerBase
|
|||
{
|
||||
var userId = GetUserId();
|
||||
|
||||
// 確保測試用戶存在
|
||||
var testUser = await _context.Users.FirstOrDefaultAsync(u => u.Id == userId);
|
||||
if (testUser == null)
|
||||
{
|
||||
testUser = new User
|
||||
{
|
||||
Id = userId,
|
||||
Username = "testuser",
|
||||
Email = "test@example.com",
|
||||
PasswordHash = "test_hash",
|
||||
DisplayName = "測試用戶",
|
||||
SubscriptionType = "free",
|
||||
Preferences = new Dictionary<string, object>(),
|
||||
EnglishLevel = "A2",
|
||||
LevelUpdatedAt = DateTime.UtcNow,
|
||||
IsLevelVerified = false,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
UpdatedAt = DateTime.UtcNow
|
||||
};
|
||||
_context.Users.Add(testUser);
|
||||
await _context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
// 檢測重複詞卡
|
||||
var existing = await _context.Flashcards
|
||||
.FirstOrDefaultAsync(f => f.UserId == userId &&
|
||||
f.Word.ToLower() == request.Word.ToLower() &&
|
||||
!f.IsArchived);
|
||||
|
||||
if (existing != null)
|
||||
{
|
||||
return Ok(new
|
||||
{
|
||||
Success = false,
|
||||
Error = "詞卡已存在",
|
||||
IsDuplicate = true,
|
||||
ExistingCard = new
|
||||
{
|
||||
existing.Id,
|
||||
existing.Word,
|
||||
existing.Translation,
|
||||
existing.CreatedAt
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
var flashcard = new Flashcard
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
|
|
@ -260,16 +111,7 @@ public class FlashcardsController : ControllerBase
|
|||
Pronunciation = request.Pronunciation,
|
||||
Example = request.Example,
|
||||
ExampleTranslation = request.ExampleTranslation,
|
||||
Synonyms = request.Synonyms != null && request.Synonyms.Any()
|
||||
? System.Text.Json.JsonSerializer.Serialize(request.Synonyms)
|
||||
: null,
|
||||
MasteryLevel = 0,
|
||||
TimesReviewed = 0,
|
||||
IsFavorite = false,
|
||||
NextReviewDate = DateTime.Today,
|
||||
DifficultyLevel = "A2", // 預設等級
|
||||
EasinessFactor = 2.5f,
|
||||
IntervalDays = 1,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
UpdatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
|
@ -280,14 +122,7 @@ public class FlashcardsController : ControllerBase
|
|||
return Ok(new
|
||||
{
|
||||
Success = true,
|
||||
Data = new
|
||||
{
|
||||
flashcard.Id,
|
||||
flashcard.Word,
|
||||
flashcard.Translation,
|
||||
flashcard.Definition,
|
||||
flashcard.CreatedAt
|
||||
},
|
||||
Data = flashcard,
|
||||
Message = "詞卡創建成功"
|
||||
});
|
||||
}
|
||||
|
|
@ -306,8 +141,6 @@ public class FlashcardsController : ControllerBase
|
|||
var userId = GetUserId();
|
||||
|
||||
var flashcard = await _context.Flashcards
|
||||
.Include(f => f.FlashcardExampleImages)
|
||||
.ThenInclude(fei => fei.ExampleImage)
|
||||
.FirstOrDefaultAsync(f => f.Id == id && f.UserId == userId);
|
||||
|
||||
if (flashcard == null)
|
||||
|
|
@ -315,48 +148,7 @@ public class FlashcardsController : ControllerBase
|
|||
return NotFound(new { Success = false, Error = "Flashcard not found" });
|
||||
}
|
||||
|
||||
// 獲取例句圖片資料
|
||||
var exampleImages = flashcard.FlashcardExampleImages
|
||||
?.Select(fei => new
|
||||
{
|
||||
Id = fei.ExampleImage.Id,
|
||||
ImageUrl = $"http://localhost:5008/images/examples/{fei.ExampleImage.RelativePath}",
|
||||
IsPrimary = fei.IsPrimary,
|
||||
QualityScore = fei.ExampleImage.QualityScore,
|
||||
FileSize = fei.ExampleImage.FileSize,
|
||||
CreatedAt = fei.ExampleImage.CreatedAt
|
||||
})
|
||||
.ToList();
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
Success = true,
|
||||
Data = new
|
||||
{
|
||||
flashcard.Id,
|
||||
flashcard.Word,
|
||||
flashcard.Translation,
|
||||
flashcard.Definition,
|
||||
flashcard.PartOfSpeech,
|
||||
flashcard.Pronunciation,
|
||||
flashcard.Example,
|
||||
flashcard.ExampleTranslation,
|
||||
flashcard.MasteryLevel,
|
||||
flashcard.TimesReviewed,
|
||||
flashcard.IsFavorite,
|
||||
flashcard.NextReviewDate,
|
||||
flashcard.DifficultyLevel,
|
||||
flashcard.CreatedAt,
|
||||
flashcard.UpdatedAt,
|
||||
// 新增圖片相關欄位
|
||||
ExampleImages = exampleImages ?? (object)new List<object>(),
|
||||
HasExampleImage = exampleImages?.Any() ?? false,
|
||||
PrimaryImageUrl = flashcard.FlashcardExampleImages?
|
||||
.Where(fei => fei.IsPrimary)
|
||||
.Select(fei => $"http://localhost:5008/images/examples/{fei.ExampleImage.RelativePath}")
|
||||
.FirstOrDefault()
|
||||
}
|
||||
});
|
||||
return Ok(new { Success = true, Data = flashcard });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
|
@ -395,15 +187,7 @@ public class FlashcardsController : ControllerBase
|
|||
return Ok(new
|
||||
{
|
||||
Success = true,
|
||||
Data = new
|
||||
{
|
||||
flashcard.Id,
|
||||
flashcard.Word,
|
||||
flashcard.Translation,
|
||||
flashcard.Definition,
|
||||
flashcard.CreatedAt,
|
||||
flashcard.UpdatedAt
|
||||
},
|
||||
Data = flashcard,
|
||||
Message = "詞卡更新成功"
|
||||
});
|
||||
}
|
||||
|
|
@ -473,198 +257,9 @@ public class FlashcardsController : ControllerBase
|
|||
return StatusCode(500, new { Success = false, Error = "Failed to toggle favorite" });
|
||||
}
|
||||
}
|
||||
|
||||
// ================== 🆕 智能複習API端點 ==================
|
||||
|
||||
/// <summary>
|
||||
/// 取得到期詞卡列表
|
||||
/// </summary>
|
||||
[HttpGet("due")]
|
||||
public async Task<ActionResult> 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);
|
||||
|
||||
// 🆕 智能挖空處理:檢查並生成缺失的填空題目
|
||||
var cardsToUpdate = new List<Flashcard>();
|
||||
foreach(var flashcard in dueCards)
|
||||
{
|
||||
if(string.IsNullOrEmpty(flashcard.FilledQuestionText) && !string.IsNullOrEmpty(flashcard.Example))
|
||||
{
|
||||
_logger.LogDebug("Generating blank question for flashcard {Id}, word: {Word}",
|
||||
flashcard.Id, flashcard.Word);
|
||||
|
||||
var blankQuestion = await _blankGenerationService.GenerateBlankQuestionAsync(
|
||||
flashcard.Word, flashcard.Example);
|
||||
|
||||
if(!string.IsNullOrEmpty(blankQuestion))
|
||||
{
|
||||
flashcard.FilledQuestionText = blankQuestion;
|
||||
flashcard.UpdatedAt = DateTime.UtcNow;
|
||||
cardsToUpdate.Add(flashcard);
|
||||
|
||||
_logger.LogInformation("Generated blank question for flashcard {Id}, word: {Word}",
|
||||
flashcard.Id, flashcard.Word);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("Failed to generate blank question for flashcard {Id}, word: {Word}",
|
||||
flashcard.Id, flashcard.Word);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 批次更新資料庫
|
||||
if (cardsToUpdate.Count > 0)
|
||||
{
|
||||
_context.UpdateRange(cardsToUpdate);
|
||||
await _context.SaveChangesAsync();
|
||||
_logger.LogInformation("Updated {Count} flashcards with blank questions", cardsToUpdate.Count);
|
||||
}
|
||||
|
||||
_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" });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 取得下一張需要復習的詞卡 (最高優先級)
|
||||
/// </summary>
|
||||
[HttpGet("next-review")]
|
||||
public async Task<ActionResult> 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欄位已移除 - 改用即時CEFR轉換
|
||||
|
||||
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,
|
||||
// 智能複習擴展欄位 (改用即時CEFR轉換)
|
||||
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" });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 系統自動選擇最適合的複習題型 (基於CEFR等級)
|
||||
/// </summary>
|
||||
[HttpPost("{id}/optimal-review-mode")]
|
||||
public async Task<ActionResult> GetOptimalReviewMode(Guid id, [FromBody] OptimalModeRequest request)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await _reviewTypeSelectorService.SelectOptimalReviewModeAsync(
|
||||
id, request.UserCEFRLevel, request.WordCEFRLevel);
|
||||
|
||||
_logger.LogInformation("Selected optimal review mode {SelectedMode} for flashcard {FlashcardId}, userCEFR: {UserCEFR}, wordCEFR: {WordCEFR}",
|
||||
result.SelectedMode, id, request.UserCEFRLevel, request.WordCEFRLevel);
|
||||
|
||||
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" });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 生成指定題型的題目選項
|
||||
/// </summary>
|
||||
[HttpPost("{id}/question")]
|
||||
public async Task<ActionResult> 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" });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 提交復習結果並更新間隔重複算法
|
||||
/// </summary>
|
||||
[HttpPost("{id}/review")]
|
||||
public async Task<ActionResult> 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
|
||||
// DTO 類別
|
||||
public class CreateFlashcardRequest
|
||||
{
|
||||
public string Word { get; set; } = string.Empty;
|
||||
|
|
@ -674,5 +269,4 @@ public class CreateFlashcardRequest
|
|||
public string Pronunciation { get; set; } = string.Empty;
|
||||
public string Example { get; set; } = string.Empty;
|
||||
public string? ExampleTranslation { get; set; }
|
||||
public List<string>? Synonyms { get; set; }
|
||||
}
|
||||
|
|
@ -1,755 +0,0 @@
|
|||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using DramaLing.Api.Data;
|
||||
using DramaLing.Api.Models.Entities;
|
||||
using DramaLing.Api.Services;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
|
||||
namespace DramaLing.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
[Authorize]
|
||||
public class StudyController : ControllerBase
|
||||
{
|
||||
private readonly DramaLingDbContext _context;
|
||||
private readonly IAuthService _authService;
|
||||
private readonly ILogger<StudyController> _logger;
|
||||
|
||||
public StudyController(
|
||||
DramaLingDbContext context,
|
||||
IAuthService authService,
|
||||
ILogger<StudyController> logger)
|
||||
{
|
||||
_context = context;
|
||||
_authService = authService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 獲取待複習的詞卡 (支援 /frontend/app/learn/page.tsx)
|
||||
/// </summary>
|
||||
[HttpGet("due-cards")]
|
||||
public async Task<ActionResult> GetDueCards(
|
||||
[FromQuery] int limit = 50,
|
||||
[FromQuery] string? mode = null,
|
||||
[FromQuery] bool includeNew = true)
|
||||
{
|
||||
try
|
||||
{
|
||||
var userId = await _authService.GetUserIdFromTokenAsync(Request.Headers.Authorization);
|
||||
if (userId == null)
|
||||
return Unauthorized(new { Success = false, Error = "Invalid token" });
|
||||
|
||||
var today = DateTime.Today;
|
||||
var query = _context.Flashcards
|
||||
.Where(f => f.UserId == userId);
|
||||
|
||||
// 篩選到期和新詞卡
|
||||
if (includeNew)
|
||||
{
|
||||
// 包含到期詞卡和新詞卡
|
||||
query = query.Where(f => f.NextReviewDate <= today || f.Repetitions == 0);
|
||||
}
|
||||
else
|
||||
{
|
||||
// 只包含到期詞卡
|
||||
query = query.Where(f => f.NextReviewDate <= today);
|
||||
}
|
||||
|
||||
var dueCards = await query.Take(limit * 2).ToListAsync(); // 取更多用於排序
|
||||
|
||||
// 計算優先級並排序
|
||||
var cardsWithPriority = dueCards.Select(card => new
|
||||
{
|
||||
Card = card,
|
||||
Priority = ReviewPriorityCalculator.CalculatePriority(
|
||||
card.NextReviewDate,
|
||||
card.EasinessFactor,
|
||||
card.Repetitions
|
||||
),
|
||||
IsDue = ReviewPriorityCalculator.ShouldReview(card.NextReviewDate),
|
||||
DaysOverdue = Math.Max(0, (today - card.NextReviewDate).Days)
|
||||
}).OrderByDescending(x => x.Priority).Take(limit);
|
||||
|
||||
var result = cardsWithPriority.Select(x => new
|
||||
{
|
||||
x.Card.Id,
|
||||
x.Card.Word,
|
||||
x.Card.Translation,
|
||||
x.Card.Definition,
|
||||
x.Card.PartOfSpeech,
|
||||
x.Card.Pronunciation,
|
||||
x.Card.Example,
|
||||
x.Card.ExampleTranslation,
|
||||
x.Card.MasteryLevel,
|
||||
x.Card.NextReviewDate,
|
||||
x.Card.DifficultyLevel,
|
||||
CardSet = new
|
||||
{
|
||||
Name = "Default",
|
||||
Color = "bg-blue-500"
|
||||
},
|
||||
x.Priority,
|
||||
x.IsDue,
|
||||
x.DaysOverdue
|
||||
}).ToList();
|
||||
|
||||
// 統計資訊
|
||||
var totalDue = await _context.Flashcards
|
||||
.Where(f => f.UserId == userId && f.NextReviewDate <= today)
|
||||
.CountAsync();
|
||||
|
||||
var totalCards = await _context.Flashcards
|
||||
.Where(f => f.UserId == userId)
|
||||
.CountAsync();
|
||||
|
||||
var newCards = await _context.Flashcards
|
||||
.Where(f => f.UserId == userId && f.Repetitions == 0)
|
||||
.CountAsync();
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
Success = true,
|
||||
Data = new
|
||||
{
|
||||
Cards = result,
|
||||
TotalDue = totalDue,
|
||||
TotalCards = totalCards,
|
||||
NewCards = newCards
|
||||
}
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error fetching due cards for user");
|
||||
return StatusCode(500, new
|
||||
{
|
||||
Success = false,
|
||||
Error = "Failed to fetch due cards",
|
||||
Timestamp = DateTime.UtcNow
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 開始學習會話
|
||||
/// </summary>
|
||||
[HttpPost("sessions")]
|
||||
public async Task<ActionResult> CreateStudySession([FromBody] CreateStudySessionRequest request)
|
||||
{
|
||||
try
|
||||
{
|
||||
var userId = await _authService.GetUserIdFromTokenAsync(Request.Headers.Authorization);
|
||||
if (userId == null)
|
||||
return Unauthorized(new { Success = false, Error = "Invalid token" });
|
||||
|
||||
// 基本驗證
|
||||
if (string.IsNullOrEmpty(request.Mode) ||
|
||||
!new[] { "flip", "quiz", "fill", "listening", "speaking" }.Contains(request.Mode))
|
||||
{
|
||||
return BadRequest(new { Success = false, Error = "Invalid study mode" });
|
||||
}
|
||||
|
||||
if (request.CardIds == null || request.CardIds.Count == 0)
|
||||
{
|
||||
return BadRequest(new { Success = false, Error = "Card IDs are required" });
|
||||
}
|
||||
|
||||
if (request.CardIds.Count > 50)
|
||||
{
|
||||
return BadRequest(new { Success = false, Error = "Cannot study more than 50 cards in one session" });
|
||||
}
|
||||
|
||||
// 驗證詞卡是否屬於用戶
|
||||
var userCards = await _context.Flashcards
|
||||
.Where(f => f.UserId == userId && request.CardIds.Contains(f.Id))
|
||||
.CountAsync();
|
||||
|
||||
if (userCards != request.CardIds.Count)
|
||||
{
|
||||
return BadRequest(new { Success = false, Error = "Some cards not found or not accessible" });
|
||||
}
|
||||
|
||||
// 建立學習會話
|
||||
var session = new StudySession
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = userId.Value,
|
||||
SessionType = request.Mode,
|
||||
TotalCards = request.CardIds.Count,
|
||||
StartedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
_context.StudySessions.Add(session);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
// 獲取詞卡詳細資訊
|
||||
var cards = await _context.Flashcards
|
||||
.Where(f => f.UserId == userId && request.CardIds.Contains(f.Id))
|
||||
.ToListAsync();
|
||||
|
||||
// 按照請求的順序排列
|
||||
var orderedCards = request.CardIds
|
||||
.Select(id => cards.FirstOrDefault(c => c.Id == id))
|
||||
.Where(c => c != null)
|
||||
.ToList();
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
Success = true,
|
||||
Data = new
|
||||
{
|
||||
SessionId = session.Id,
|
||||
SessionType = request.Mode,
|
||||
Cards = orderedCards.Select(c => new
|
||||
{
|
||||
c.Id,
|
||||
c.Word,
|
||||
c.Translation,
|
||||
c.Definition,
|
||||
c.PartOfSpeech,
|
||||
c.Pronunciation,
|
||||
c.Example,
|
||||
c.ExampleTranslation,
|
||||
c.MasteryLevel,
|
||||
c.EasinessFactor,
|
||||
c.Repetitions,
|
||||
CardSet = new { Name = "Default", Color = "bg-blue-500" }
|
||||
}),
|
||||
TotalCards = orderedCards.Count,
|
||||
StartedAt = session.StartedAt
|
||||
},
|
||||
Message = $"Study session started with {orderedCards.Count} cards"
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error creating study session");
|
||||
return StatusCode(500, new
|
||||
{
|
||||
Success = false,
|
||||
Error = "Failed to create study session",
|
||||
Timestamp = DateTime.UtcNow
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 記錄學習結果 (支援 SM-2 算法)
|
||||
/// </summary>
|
||||
[HttpPost("sessions/{sessionId}/record")]
|
||||
public async Task<ActionResult> RecordStudyResult(
|
||||
Guid sessionId,
|
||||
[FromBody] RecordStudyResultRequest request)
|
||||
{
|
||||
try
|
||||
{
|
||||
var userId = await _authService.GetUserIdFromTokenAsync(Request.Headers.Authorization);
|
||||
if (userId == null)
|
||||
return Unauthorized(new { Success = false, Error = "Invalid token" });
|
||||
|
||||
// 基本驗證
|
||||
if (request.QualityRating < 1 || request.QualityRating > 5)
|
||||
{
|
||||
return BadRequest(new { Success = false, Error = "Quality rating must be between 1 and 5" });
|
||||
}
|
||||
|
||||
// 驗證學習會話
|
||||
var session = await _context.StudySessions
|
||||
.FirstOrDefaultAsync(s => s.Id == sessionId && s.UserId == userId);
|
||||
|
||||
if (session == null)
|
||||
{
|
||||
return NotFound(new { Success = false, Error = "Study session not found" });
|
||||
}
|
||||
|
||||
// 驗證詞卡
|
||||
var flashcard = await _context.Flashcards
|
||||
.FirstOrDefaultAsync(f => f.Id == request.FlashcardId && f.UserId == userId);
|
||||
|
||||
if (flashcard == null)
|
||||
{
|
||||
return NotFound(new { Success = false, Error = "Flashcard not found" });
|
||||
}
|
||||
|
||||
// 計算新的 SM-2 參數
|
||||
var sm2Input = new SM2Input(
|
||||
request.QualityRating,
|
||||
flashcard.EasinessFactor,
|
||||
flashcard.Repetitions,
|
||||
flashcard.IntervalDays
|
||||
);
|
||||
|
||||
var sm2Result = SM2Algorithm.Calculate(sm2Input);
|
||||
|
||||
// 記錄學習結果
|
||||
var studyRecord = new StudyRecord
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = userId.Value,
|
||||
FlashcardId = request.FlashcardId,
|
||||
SessionId = sessionId,
|
||||
StudyMode = session.SessionType,
|
||||
QualityRating = request.QualityRating,
|
||||
ResponseTimeMs = request.ResponseTimeMs,
|
||||
UserAnswer = request.UserAnswer,
|
||||
IsCorrect = request.IsCorrect,
|
||||
PreviousEasinessFactor = sm2Input.EasinessFactor,
|
||||
NewEasinessFactor = sm2Result.EasinessFactor,
|
||||
PreviousIntervalDays = sm2Input.IntervalDays,
|
||||
NewIntervalDays = sm2Result.IntervalDays,
|
||||
PreviousRepetitions = sm2Input.Repetitions,
|
||||
NewRepetitions = sm2Result.Repetitions,
|
||||
NextReviewDate = sm2Result.NextReviewDate,
|
||||
StudiedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
_context.StudyRecords.Add(studyRecord);
|
||||
|
||||
// 更新詞卡的 SM-2 參數
|
||||
flashcard.EasinessFactor = sm2Result.EasinessFactor;
|
||||
flashcard.Repetitions = sm2Result.Repetitions;
|
||||
flashcard.IntervalDays = sm2Result.IntervalDays;
|
||||
flashcard.NextReviewDate = sm2Result.NextReviewDate;
|
||||
flashcard.MasteryLevel = SM2Algorithm.CalculateMastery(sm2Result.Repetitions, sm2Result.EasinessFactor);
|
||||
flashcard.TimesReviewed++;
|
||||
if (request.IsCorrect) flashcard.TimesCorrect++;
|
||||
flashcard.LastReviewedAt = DateTime.UtcNow;
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
Success = true,
|
||||
Data = new
|
||||
{
|
||||
RecordId = studyRecord.Id,
|
||||
NextReviewDate = sm2Result.NextReviewDate.ToString("yyyy-MM-dd"),
|
||||
NewIntervalDays = sm2Result.IntervalDays,
|
||||
NewMasteryLevel = flashcard.MasteryLevel,
|
||||
EasinessFactor = sm2Result.EasinessFactor,
|
||||
Repetitions = sm2Result.Repetitions,
|
||||
QualityDescription = SM2Algorithm.GetQualityDescription(request.QualityRating)
|
||||
},
|
||||
Message = $"Study record saved. Next review in {sm2Result.IntervalDays} day(s)"
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error recording study result");
|
||||
return StatusCode(500, new
|
||||
{
|
||||
Success = false,
|
||||
Error = "Failed to record study result",
|
||||
Timestamp = DateTime.UtcNow
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 完成學習會話
|
||||
/// </summary>
|
||||
[HttpPost("sessions/{sessionId}/complete")]
|
||||
public async Task<ActionResult> CompleteStudySession(
|
||||
Guid sessionId,
|
||||
[FromBody] CompleteStudySessionRequest request)
|
||||
{
|
||||
try
|
||||
{
|
||||
var userId = await _authService.GetUserIdFromTokenAsync(Request.Headers.Authorization);
|
||||
if (userId == null)
|
||||
return Unauthorized(new { Success = false, Error = "Invalid token" });
|
||||
|
||||
// 驗證會話
|
||||
var session = await _context.StudySessions
|
||||
.FirstOrDefaultAsync(s => s.Id == sessionId && s.UserId == userId);
|
||||
|
||||
if (session == null)
|
||||
{
|
||||
return NotFound(new { Success = false, Error = "Study session not found" });
|
||||
}
|
||||
|
||||
// 計算會話統計
|
||||
var sessionRecords = await _context.StudyRecords
|
||||
.Where(r => r.SessionId == sessionId && r.UserId == userId)
|
||||
.ToListAsync();
|
||||
|
||||
var correctCount = sessionRecords.Count(r => r.IsCorrect);
|
||||
var averageResponseTime = sessionRecords.Any(r => r.ResponseTimeMs.HasValue)
|
||||
? (int)sessionRecords.Where(r => r.ResponseTimeMs.HasValue).Average(r => r.ResponseTimeMs!.Value)
|
||||
: 0;
|
||||
|
||||
// 更新會話
|
||||
session.EndedAt = DateTime.UtcNow;
|
||||
session.CorrectCount = correctCount;
|
||||
session.DurationSeconds = request.DurationSeconds;
|
||||
session.AverageResponseTimeMs = averageResponseTime;
|
||||
|
||||
// 更新或建立每日統計
|
||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||
var dailyStats = await _context.DailyStats
|
||||
.FirstOrDefaultAsync(ds => ds.UserId == userId && ds.Date == today);
|
||||
|
||||
if (dailyStats == null)
|
||||
{
|
||||
dailyStats = new DailyStats
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = userId.Value,
|
||||
Date = today
|
||||
};
|
||||
_context.DailyStats.Add(dailyStats);
|
||||
}
|
||||
|
||||
dailyStats.WordsStudied += sessionRecords.Count;
|
||||
dailyStats.WordsCorrect += correctCount;
|
||||
dailyStats.StudyTimeSeconds += request.DurationSeconds;
|
||||
dailyStats.SessionCount++;
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
// 計算會話統計
|
||||
var accuracy = sessionRecords.Count > 0
|
||||
? (int)Math.Round((double)correctCount / sessionRecords.Count * 100)
|
||||
: 0;
|
||||
|
||||
var averageTimePerCard = request.DurationSeconds > 0 && sessionRecords.Count > 0
|
||||
? request.DurationSeconds / sessionRecords.Count
|
||||
: 0;
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
Success = true,
|
||||
Data = new
|
||||
{
|
||||
SessionId = sessionId,
|
||||
TotalCards = session.TotalCards,
|
||||
CardsStudied = sessionRecords.Count,
|
||||
CorrectAnswers = correctCount,
|
||||
AccuracyPercentage = accuracy,
|
||||
DurationSeconds = request.DurationSeconds,
|
||||
AverageTimePerCard = averageTimePerCard,
|
||||
AverageResponseTimeMs = averageResponseTime,
|
||||
StartedAt = session.StartedAt,
|
||||
EndedAt = session.EndedAt
|
||||
},
|
||||
Message = $"Study session completed! {correctCount}/{sessionRecords.Count} correct ({accuracy}%)"
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error completing study session");
|
||||
return StatusCode(500, new
|
||||
{
|
||||
Success = false,
|
||||
Error = "Failed to complete study session",
|
||||
Timestamp = DateTime.UtcNow
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 獲取智能複習排程
|
||||
/// </summary>
|
||||
[HttpGet("schedule")]
|
||||
public async Task<ActionResult> GetReviewSchedule(
|
||||
[FromQuery] bool includePlan = true,
|
||||
[FromQuery] bool includeStats = true)
|
||||
{
|
||||
try
|
||||
{
|
||||
var userId = await _authService.GetUserIdFromTokenAsync(Request.Headers.Authorization);
|
||||
if (userId == null)
|
||||
return Unauthorized(new { Success = false, Error = "Invalid token" });
|
||||
|
||||
// 獲取用戶設定
|
||||
var settings = await _context.UserSettings
|
||||
.FirstOrDefaultAsync(s => s.UserId == userId);
|
||||
var dailyGoal = settings?.DailyGoal ?? 20;
|
||||
|
||||
// 獲取所有詞卡
|
||||
var allCards = await _context.Flashcards
|
||||
.Where(f => f.UserId == userId)
|
||||
.ToListAsync();
|
||||
|
||||
var today = DateTime.Today;
|
||||
|
||||
// 分類詞卡
|
||||
var dueToday = allCards.Where(c => c.NextReviewDate == today).ToList();
|
||||
var overdue = allCards.Where(c => c.NextReviewDate < today && c.Repetitions > 0).ToList();
|
||||
var upcoming = allCards.Where(c => c.NextReviewDate > today && c.NextReviewDate <= today.AddDays(7)).ToList();
|
||||
var newCards = allCards.Where(c => c.Repetitions == 0).ToList();
|
||||
|
||||
// 建立回應物件
|
||||
var responseData = new Dictionary<string, object>
|
||||
{
|
||||
["Schedule"] = new
|
||||
{
|
||||
DueToday = dueToday.Count,
|
||||
Overdue = overdue.Count,
|
||||
Upcoming = upcoming.Count,
|
||||
NewCards = newCards.Count
|
||||
}
|
||||
};
|
||||
|
||||
// 生成學習計劃
|
||||
if (includePlan)
|
||||
{
|
||||
var recommendedCards = overdue.Take(dailyGoal / 2)
|
||||
.Concat(dueToday.Take(dailyGoal / 3))
|
||||
.Concat(newCards.Take(Math.Min(5, dailyGoal / 4)))
|
||||
.Take(dailyGoal)
|
||||
.Select(c => new
|
||||
{
|
||||
c.Id,
|
||||
c.Word,
|
||||
c.Translation,
|
||||
c.MasteryLevel,
|
||||
c.NextReviewDate,
|
||||
PriorityReason = c.Repetitions == 0 ? "new_card" :
|
||||
c.NextReviewDate < today ? "overdue" : "due_today"
|
||||
});
|
||||
|
||||
responseData["StudyPlan"] = new
|
||||
{
|
||||
RecommendedCards = recommendedCards,
|
||||
Breakdown = new
|
||||
{
|
||||
Overdue = Math.Min(overdue.Count, dailyGoal / 2),
|
||||
DueToday = Math.Min(dueToday.Count, dailyGoal / 3),
|
||||
NewCards = Math.Min(newCards.Count, 5)
|
||||
},
|
||||
EstimatedTimeMinutes = recommendedCards.Count() * 1,
|
||||
DailyGoal = dailyGoal
|
||||
};
|
||||
}
|
||||
|
||||
// 計算統計
|
||||
if (includeStats)
|
||||
{
|
||||
responseData["Statistics"] = new
|
||||
{
|
||||
TotalCards = allCards.Count,
|
||||
MasteredCards = allCards.Count(c => c.MasteryLevel >= 80),
|
||||
LearningCards = allCards.Count(c => c.MasteryLevel >= 40 && c.MasteryLevel < 80),
|
||||
NewCardsCount = newCards.Count,
|
||||
AverageMastery = allCards.Count > 0 ? (int)allCards.Average(c => c.MasteryLevel) : 0,
|
||||
RetentionRate = allCards.Count(c => c.Repetitions > 0) > 0
|
||||
? (int)Math.Round((double)allCards.Count(c => c.MasteryLevel >= 60) / allCards.Count(c => c.Repetitions > 0) * 100)
|
||||
: 0
|
||||
};
|
||||
}
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
Success = true,
|
||||
Data = responseData
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error fetching review schedule");
|
||||
return StatusCode(500, new
|
||||
{
|
||||
Success = false,
|
||||
Error = "Failed to fetch review schedule",
|
||||
Timestamp = DateTime.UtcNow
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 獲取已完成的測驗記錄 (支援學習狀態恢復)
|
||||
/// </summary>
|
||||
[HttpGet("completed-tests")]
|
||||
public async Task<ActionResult> GetCompletedTests([FromQuery] string? cardIds = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var userId = await _authService.GetUserIdFromTokenAsync(Request.Headers.Authorization);
|
||||
if (userId == null)
|
||||
return Unauthorized(new { Success = false, Error = "Invalid token" });
|
||||
|
||||
var query = _context.StudyRecords.Where(r => r.UserId == userId);
|
||||
|
||||
// 如果提供了詞卡ID列表,則篩選
|
||||
if (!string.IsNullOrEmpty(cardIds))
|
||||
{
|
||||
var cardIdList = cardIds.Split(',')
|
||||
.Where(id => Guid.TryParse(id, out _))
|
||||
.Select(Guid.Parse)
|
||||
.ToList();
|
||||
|
||||
if (cardIdList.Any())
|
||||
{
|
||||
query = query.Where(r => cardIdList.Contains(r.FlashcardId));
|
||||
}
|
||||
}
|
||||
|
||||
var completedTests = await query
|
||||
.Select(r => new
|
||||
{
|
||||
FlashcardId = r.FlashcardId,
|
||||
TestType = r.StudyMode,
|
||||
IsCorrect = r.IsCorrect,
|
||||
CompletedAt = r.StudiedAt,
|
||||
UserAnswer = r.UserAnswer
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
_logger.LogInformation("Retrieved {Count} completed tests for user {UserId}",
|
||||
completedTests.Count, userId);
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
Success = true,
|
||||
Data = completedTests
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error retrieving completed tests for user");
|
||||
return StatusCode(500, new
|
||||
{
|
||||
Success = false,
|
||||
Error = "Failed to retrieve completed tests",
|
||||
Timestamp = DateTime.UtcNow
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 直接記錄測驗完成狀態 (不觸發SM2更新)
|
||||
/// </summary>
|
||||
[HttpPost("record-test")]
|
||||
public async Task<ActionResult> RecordTestCompletion([FromBody] RecordTestRequest request)
|
||||
{
|
||||
try
|
||||
{
|
||||
var userId = await _authService.GetUserIdFromTokenAsync(Request.Headers.Authorization);
|
||||
if (userId == null)
|
||||
{
|
||||
_logger.LogWarning("RecordTest failed: Invalid or missing token");
|
||||
return Unauthorized(new { Success = false, Error = "Invalid token" });
|
||||
}
|
||||
|
||||
_logger.LogInformation("Recording test for user {UserId}: FlashcardId={FlashcardId}, TestType={TestType}",
|
||||
userId, request.FlashcardId, request.TestType);
|
||||
|
||||
// 驗證測驗類型
|
||||
var validTestTypes = new[] { "flip-memory", "vocab-choice", "vocab-listening",
|
||||
"sentence-listening", "sentence-fill", "sentence-reorder", "sentence-speaking" };
|
||||
if (!validTestTypes.Contains(request.TestType))
|
||||
{
|
||||
_logger.LogWarning("Invalid test type: {TestType}", request.TestType);
|
||||
return BadRequest(new { Success = false, Error = "Invalid test type" });
|
||||
}
|
||||
|
||||
// 先檢查詞卡是否存在
|
||||
var flashcard = await _context.Flashcards
|
||||
.FirstOrDefaultAsync(f => f.Id == request.FlashcardId);
|
||||
|
||||
if (flashcard == null)
|
||||
{
|
||||
_logger.LogWarning("Flashcard {FlashcardId} does not exist", request.FlashcardId);
|
||||
return NotFound(new { Success = false, Error = "Flashcard does not exist" });
|
||||
}
|
||||
|
||||
// 再檢查詞卡是否屬於用戶
|
||||
if (flashcard.UserId != userId)
|
||||
{
|
||||
_logger.LogWarning("Flashcard {FlashcardId} does not belong to user {UserId}, actual owner: {ActualOwner}",
|
||||
request.FlashcardId, userId, flashcard.UserId);
|
||||
return Forbid();
|
||||
}
|
||||
|
||||
// 檢查是否已經完成過這個測驗
|
||||
var existingRecord = await _context.StudyRecords
|
||||
.FirstOrDefaultAsync(r => r.UserId == userId &&
|
||||
r.FlashcardId == request.FlashcardId &&
|
||||
r.StudyMode == request.TestType);
|
||||
|
||||
if (existingRecord != null)
|
||||
{
|
||||
return Conflict(new { Success = false, Error = "Test already completed",
|
||||
CompletedAt = existingRecord.StudiedAt });
|
||||
}
|
||||
|
||||
// 記錄測驗完成狀態
|
||||
var studyRecord = new StudyRecord
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = userId.Value,
|
||||
FlashcardId = request.FlashcardId,
|
||||
SessionId = Guid.NewGuid(), // 臨時會話ID
|
||||
StudyMode = request.TestType, // 記錄具體測驗類型
|
||||
QualityRating = request.ConfidenceLevel ?? (request.IsCorrect ? 4 : 2),
|
||||
ResponseTimeMs = request.ResponseTimeMs,
|
||||
UserAnswer = request.UserAnswer,
|
||||
IsCorrect = request.IsCorrect,
|
||||
StudiedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
_context.StudyRecords.Add(studyRecord);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("Recorded test completion: {TestType} for {Word}, correct: {IsCorrect}",
|
||||
request.TestType, flashcard.Word, request.IsCorrect);
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
Success = true,
|
||||
Data = new
|
||||
{
|
||||
RecordId = studyRecord.Id,
|
||||
TestType = request.TestType,
|
||||
IsCorrect = request.IsCorrect,
|
||||
CompletedAt = studyRecord.StudiedAt
|
||||
},
|
||||
Message = $"Test {request.TestType} recorded successfully"
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error recording test completion");
|
||||
return StatusCode(500, new
|
||||
{
|
||||
Success = false,
|
||||
Error = "Failed to record test completion",
|
||||
Timestamp = DateTime.UtcNow
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Request DTOs
|
||||
public class CreateStudySessionRequest
|
||||
{
|
||||
public string Mode { get; set; } = string.Empty; // flip, quiz, fill, listening, speaking
|
||||
public List<Guid> CardIds { get; set; } = new();
|
||||
}
|
||||
|
||||
public class RecordStudyResultRequest
|
||||
{
|
||||
public Guid FlashcardId { get; set; }
|
||||
public int QualityRating { get; set; } // 1-5
|
||||
public int? ResponseTimeMs { get; set; }
|
||||
public string? UserAnswer { get; set; }
|
||||
public bool IsCorrect { get; set; }
|
||||
}
|
||||
|
||||
public class CompleteStudySessionRequest
|
||||
{
|
||||
public int DurationSeconds { get; set; }
|
||||
}
|
||||
|
||||
public class RecordTestRequest
|
||||
{
|
||||
public Guid FlashcardId { get; set; }
|
||||
public string TestType { get; set; } = string.Empty; // flip-memory, vocab-choice, etc.
|
||||
public bool IsCorrect { get; set; }
|
||||
public string? UserAnswer { get; set; }
|
||||
public int? ConfidenceLevel { get; set; } // 1-5
|
||||
public int? ResponseTimeMs { get; set; }
|
||||
}
|
||||
|
|
@ -1,276 +0,0 @@
|
|||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using DramaLing.Api.Services;
|
||||
|
||||
namespace DramaLing.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/study/sessions")]
|
||||
[Authorize]
|
||||
public class StudySessionController : ControllerBase
|
||||
{
|
||||
private readonly IStudySessionService _studySessionService;
|
||||
private readonly IAuthService _authService;
|
||||
private readonly ILogger<StudySessionController> _logger;
|
||||
|
||||
public StudySessionController(
|
||||
IStudySessionService studySessionService,
|
||||
IAuthService authService,
|
||||
ILogger<StudySessionController> logger)
|
||||
{
|
||||
_studySessionService = studySessionService;
|
||||
_authService = authService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 開始新的學習會話
|
||||
/// </summary>
|
||||
[HttpPost("start")]
|
||||
public async Task<ActionResult> StartSession()
|
||||
{
|
||||
try
|
||||
{
|
||||
var userId = await _authService.GetUserIdFromTokenAsync(Request.Headers.Authorization);
|
||||
if (userId == null)
|
||||
return Unauthorized(new { Success = false, Error = "Invalid token" });
|
||||
|
||||
var session = await _studySessionService.StartSessionAsync(userId.Value);
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
Success = true,
|
||||
Data = new
|
||||
{
|
||||
SessionId = session.Id,
|
||||
TotalCards = session.TotalCards,
|
||||
TotalTests = session.TotalTests,
|
||||
CurrentCardIndex = session.CurrentCardIndex,
|
||||
CurrentTestType = session.CurrentTestType,
|
||||
StartedAt = session.StartedAt
|
||||
},
|
||||
Message = $"Study session started with {session.TotalCards} cards and {session.TotalTests} tests"
|
||||
});
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return BadRequest(new { Success = false, Error = ex.Message });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error starting study session");
|
||||
return StatusCode(500, new
|
||||
{
|
||||
Success = false,
|
||||
Error = "Failed to start study session",
|
||||
Timestamp = DateTime.UtcNow
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 獲取當前測驗
|
||||
/// </summary>
|
||||
[HttpGet("{sessionId}/current-test")]
|
||||
public async Task<ActionResult> GetCurrentTest(Guid sessionId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var userId = await _authService.GetUserIdFromTokenAsync(Request.Headers.Authorization);
|
||||
if (userId == null)
|
||||
return Unauthorized(new { Success = false, Error = "Invalid token" });
|
||||
|
||||
var currentTest = await _studySessionService.GetCurrentTestAsync(sessionId);
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
Success = true,
|
||||
Data = currentTest
|
||||
});
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return BadRequest(new { Success = false, Error = ex.Message });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error getting current test for session {SessionId}", sessionId);
|
||||
return StatusCode(500, new
|
||||
{
|
||||
Success = false,
|
||||
Error = "Failed to get current test",
|
||||
Timestamp = DateTime.UtcNow
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 提交測驗結果
|
||||
/// </summary>
|
||||
[HttpPost("{sessionId}/submit-test")]
|
||||
public async Task<ActionResult> SubmitTest(Guid sessionId, [FromBody] SubmitTestRequestDto request)
|
||||
{
|
||||
try
|
||||
{
|
||||
var userId = await _authService.GetUserIdFromTokenAsync(Request.Headers.Authorization);
|
||||
if (userId == null)
|
||||
return Unauthorized(new { Success = false, Error = "Invalid token" });
|
||||
|
||||
// 基本驗證
|
||||
if (string.IsNullOrEmpty(request.TestType))
|
||||
{
|
||||
return BadRequest(new { Success = false, Error = "Test type is required" });
|
||||
}
|
||||
|
||||
if (request.ConfidenceLevel.HasValue && (request.ConfidenceLevel < 1 || request.ConfidenceLevel > 5))
|
||||
{
|
||||
return BadRequest(new { Success = false, Error = "Confidence level must be between 1 and 5" });
|
||||
}
|
||||
|
||||
var response = await _studySessionService.SubmitTestAsync(sessionId, request);
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
Success = response.Success,
|
||||
Data = new
|
||||
{
|
||||
IsCardCompleted = response.IsCardCompleted,
|
||||
Progress = response.Progress
|
||||
},
|
||||
Message = response.IsCardCompleted ? "Card completed!" : "Test recorded successfully"
|
||||
});
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return BadRequest(new { Success = false, Error = ex.Message });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error submitting test for session {SessionId}", sessionId);
|
||||
return StatusCode(500, new
|
||||
{
|
||||
Success = false,
|
||||
Error = "Failed to submit test result",
|
||||
Timestamp = DateTime.UtcNow
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 獲取下一個測驗
|
||||
/// </summary>
|
||||
[HttpGet("{sessionId}/next-test")]
|
||||
public async Task<ActionResult> GetNextTest(Guid sessionId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var userId = await _authService.GetUserIdFromTokenAsync(Request.Headers.Authorization);
|
||||
if (userId == null)
|
||||
return Unauthorized(new { Success = false, Error = "Invalid token" });
|
||||
|
||||
var nextTest = await _studySessionService.GetNextTestAsync(sessionId);
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
Success = true,
|
||||
Data = nextTest
|
||||
});
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return BadRequest(new { Success = false, Error = ex.Message });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error getting next test for session {SessionId}", sessionId);
|
||||
return StatusCode(500, new
|
||||
{
|
||||
Success = false,
|
||||
Error = "Failed to get next test",
|
||||
Timestamp = DateTime.UtcNow
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 獲取詳細進度
|
||||
/// </summary>
|
||||
[HttpGet("{sessionId}/progress")]
|
||||
public async Task<ActionResult> GetProgress(Guid sessionId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var userId = await _authService.GetUserIdFromTokenAsync(Request.Headers.Authorization);
|
||||
if (userId == null)
|
||||
return Unauthorized(new { Success = false, Error = "Invalid token" });
|
||||
|
||||
var progress = await _studySessionService.GetProgressAsync(sessionId);
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
Success = true,
|
||||
Data = progress
|
||||
});
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return BadRequest(new { Success = false, Error = ex.Message });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error getting progress for session {SessionId}", sessionId);
|
||||
return StatusCode(500, new
|
||||
{
|
||||
Success = false,
|
||||
Error = "Failed to get progress",
|
||||
Timestamp = DateTime.UtcNow
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 完成學習會話
|
||||
/// </summary>
|
||||
[HttpPut("{sessionId}/complete")]
|
||||
public async Task<ActionResult> CompleteSession(Guid sessionId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var userId = await _authService.GetUserIdFromTokenAsync(Request.Headers.Authorization);
|
||||
if (userId == null)
|
||||
return Unauthorized(new { Success = false, Error = "Invalid token" });
|
||||
|
||||
var session = await _studySessionService.CompleteSessionAsync(sessionId);
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
Success = true,
|
||||
Data = new
|
||||
{
|
||||
SessionId = session.Id,
|
||||
CompletedAt = session.EndedAt,
|
||||
TotalCards = session.TotalCards,
|
||||
CompletedCards = session.CompletedCards,
|
||||
TotalTests = session.TotalTests,
|
||||
CompletedTests = session.CompletedTests,
|
||||
DurationSeconds = session.DurationSeconds
|
||||
},
|
||||
Message = "Study session completed successfully"
|
||||
});
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return BadRequest(new { Success = false, Error = ex.Message });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error completing session {SessionId}", sessionId);
|
||||
return StatusCode(500, new
|
||||
{
|
||||
Success = false,
|
||||
Error = "Failed to complete session",
|
||||
Timestamp = DateTime.UtcNow
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -16,10 +16,7 @@ public class DramaLingDbContext : DbContext
|
|||
public DbSet<Flashcard> Flashcards { get; set; }
|
||||
public DbSet<Tag> Tags { get; set; }
|
||||
public DbSet<FlashcardTag> FlashcardTags { get; set; }
|
||||
public DbSet<StudySession> StudySessions { get; set; }
|
||||
public DbSet<StudyRecord> StudyRecords { get; set; }
|
||||
public DbSet<StudyCard> StudyCards { get; set; }
|
||||
public DbSet<TestResult> TestResults { get; set; }
|
||||
// StudyRecord removed - study system cleaned
|
||||
public DbSet<ErrorReport> ErrorReports { get; set; }
|
||||
public DbSet<DailyStats> DailyStats { get; set; }
|
||||
public DbSet<SentenceAnalysisCache> SentenceAnalysisCache { get; set; }
|
||||
|
|
@ -42,10 +39,7 @@ public class DramaLingDbContext : DbContext
|
|||
modelBuilder.Entity<Flashcard>().ToTable("flashcards");
|
||||
modelBuilder.Entity<Tag>().ToTable("tags");
|
||||
modelBuilder.Entity<FlashcardTag>().ToTable("flashcard_tags");
|
||||
modelBuilder.Entity<StudySession>().ToTable("study_sessions");
|
||||
modelBuilder.Entity<StudyRecord>().ToTable("study_records");
|
||||
modelBuilder.Entity<StudyCard>().ToTable("study_cards");
|
||||
modelBuilder.Entity<TestResult>().ToTable("test_results");
|
||||
// StudyRecord table removed
|
||||
modelBuilder.Entity<ErrorReport>().ToTable("error_reports");
|
||||
modelBuilder.Entity<DailyStats>().ToTable("daily_stats");
|
||||
modelBuilder.Entity<AudioCache>().ToTable("audio_cache");
|
||||
|
|
@ -59,7 +53,7 @@ public class DramaLingDbContext : DbContext
|
|||
// 配置屬性名稱 (snake_case)
|
||||
ConfigureUserEntity(modelBuilder);
|
||||
ConfigureFlashcardEntity(modelBuilder);
|
||||
ConfigureStudyEntities(modelBuilder);
|
||||
// ConfigureStudyEntities removed
|
||||
ConfigureTagEntities(modelBuilder);
|
||||
ConfigureErrorReportEntity(modelBuilder);
|
||||
ConfigureDailyStatsEntity(modelBuilder);
|
||||
|
|
@ -133,20 +127,10 @@ public class DramaLingDbContext : DbContext
|
|||
|
||||
private void ConfigureStudyEntities(ModelBuilder modelBuilder)
|
||||
{
|
||||
var sessionEntity = modelBuilder.Entity<StudySession>();
|
||||
sessionEntity.Property(s => s.UserId).HasColumnName("user_id");
|
||||
sessionEntity.Property(s => s.SessionType).HasColumnName("session_type");
|
||||
sessionEntity.Property(s => s.StartedAt).HasColumnName("started_at");
|
||||
sessionEntity.Property(s => s.EndedAt).HasColumnName("ended_at");
|
||||
sessionEntity.Property(s => s.TotalCards).HasColumnName("total_cards");
|
||||
sessionEntity.Property(s => s.CorrectCount).HasColumnName("correct_count");
|
||||
sessionEntity.Property(s => s.DurationSeconds).HasColumnName("duration_seconds");
|
||||
sessionEntity.Property(s => s.AverageResponseTimeMs).HasColumnName("average_response_time_ms");
|
||||
|
||||
var recordEntity = modelBuilder.Entity<StudyRecord>();
|
||||
recordEntity.Property(r => r.UserId).HasColumnName("user_id");
|
||||
recordEntity.Property(r => r.FlashcardId).HasColumnName("flashcard_id");
|
||||
recordEntity.Property(r => r.SessionId).HasColumnName("session_id");
|
||||
// SessionId property removed
|
||||
recordEntity.Property(r => r.StudyMode).HasColumnName("study_mode");
|
||||
recordEntity.Property(r => r.QualityRating).HasColumnName("quality_rating");
|
||||
recordEntity.Property(r => r.ResponseTimeMs).HasColumnName("response_time_ms");
|
||||
|
|
@ -208,11 +192,6 @@ public class DramaLingDbContext : DbContext
|
|||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
// Study relationships
|
||||
modelBuilder.Entity<StudySession>()
|
||||
.HasOne(ss => ss.User)
|
||||
.WithMany(u => u.StudySessions)
|
||||
.HasForeignKey(ss => ss.UserId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
modelBuilder.Entity<StudyRecord>()
|
||||
.HasOne(sr => sr.Flashcard)
|
||||
|
|
@ -333,15 +312,14 @@ public class DramaLingDbContext : DbContext
|
|||
pronunciationEntity.Property(pa => pa.ProsodyScore).HasColumnName("prosody_score");
|
||||
pronunciationEntity.Property(pa => pa.PhonemeScores).HasColumnName("phoneme_scores");
|
||||
pronunciationEntity.Property(pa => pa.Suggestions).HasColumnName("suggestions");
|
||||
pronunciationEntity.Property(pa => pa.StudySessionId).HasColumnName("study_session_id");
|
||||
// StudySessionId removed
|
||||
pronunciationEntity.Property(pa => pa.PracticeMode).HasColumnName("practice_mode");
|
||||
pronunciationEntity.Property(pa => pa.CreatedAt).HasColumnName("created_at");
|
||||
|
||||
pronunciationEntity.HasIndex(pa => new { pa.UserId, pa.FlashcardId })
|
||||
.HasDatabaseName("IX_PronunciationAssessment_UserFlashcard");
|
||||
|
||||
pronunciationEntity.HasIndex(pa => pa.StudySessionId)
|
||||
.HasDatabaseName("IX_PronunciationAssessment_Session");
|
||||
// StudySessionId index removed
|
||||
|
||||
// UserAudioPreferences configuration
|
||||
var audioPrefsEntity = modelBuilder.Entity<UserAudioPreferences>();
|
||||
|
|
@ -371,11 +349,7 @@ public class DramaLingDbContext : DbContext
|
|||
.HasForeignKey(pa => pa.FlashcardId)
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
modelBuilder.Entity<PronunciationAssessment>()
|
||||
.HasOne(pa => pa.StudySession)
|
||||
.WithMany()
|
||||
.HasForeignKey(pa => pa.StudySessionId)
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
// StudySession relationship removed
|
||||
|
||||
// UserAudioPreferences relationship
|
||||
modelBuilder.Entity<UserAudioPreferences>()
|
||||
|
|
|
|||
|
|
@ -1,124 +0,0 @@
|
|||
namespace DramaLing.Api.Models.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// 智能複習系統配置選項
|
||||
/// </summary>
|
||||
public class SpacedRepetitionOptions
|
||||
{
|
||||
public const string SectionName = "SpacedRepetition";
|
||||
|
||||
/// <summary>
|
||||
/// 間隔增長係數 (基於演算法規格書)
|
||||
/// </summary>
|
||||
public GrowthFactors GrowthFactors { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 逾期懲罰係數
|
||||
/// </summary>
|
||||
public OverduePenalties OverduePenalties { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 記憶衰減率 (每天百分比)
|
||||
/// </summary>
|
||||
public double MemoryDecayRate { get; set; } = 0.05;
|
||||
|
||||
/// <summary>
|
||||
/// 最大間隔天數
|
||||
/// </summary>
|
||||
public int MaxInterval { get; set; } = 365;
|
||||
|
||||
/// <summary>
|
||||
/// A1學習者保護門檻
|
||||
/// </summary>
|
||||
public int A1ProtectionLevel { get; set; } = 20;
|
||||
|
||||
/// <summary>
|
||||
/// 新用戶預設程度
|
||||
/// </summary>
|
||||
public int DefaultUserLevel { get; set; } = 50;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 間隔增長係數配置
|
||||
/// </summary>
|
||||
public class GrowthFactors
|
||||
{
|
||||
/// <summary>
|
||||
/// 短期間隔係數 (≤7天)
|
||||
/// </summary>
|
||||
public double ShortTerm { get; set; } = 1.8;
|
||||
|
||||
/// <summary>
|
||||
/// 中期間隔係數 (8-30天)
|
||||
/// </summary>
|
||||
public double MediumTerm { get; set; } = 1.4;
|
||||
|
||||
/// <summary>
|
||||
/// 長期間隔係數 (31-90天)
|
||||
/// </summary>
|
||||
public double LongTerm { get; set; } = 1.2;
|
||||
|
||||
/// <summary>
|
||||
/// 超長期間隔係數 (>90天)
|
||||
/// </summary>
|
||||
public double VeryLongTerm { get; set; } = 1.1;
|
||||
|
||||
/// <summary>
|
||||
/// 根據當前間隔獲取增長係數
|
||||
/// </summary>
|
||||
/// <param name="currentInterval">當前間隔天數</param>
|
||||
/// <returns>對應的增長係數</returns>
|
||||
public double GetGrowthFactor(int currentInterval)
|
||||
{
|
||||
return currentInterval switch
|
||||
{
|
||||
<= 7 => ShortTerm,
|
||||
<= 30 => MediumTerm,
|
||||
<= 90 => LongTerm,
|
||||
_ => VeryLongTerm
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 逾期懲罰係數配置
|
||||
/// </summary>
|
||||
public class OverduePenalties
|
||||
{
|
||||
/// <summary>
|
||||
/// 輕度逾期係數 (1-3天)
|
||||
/// </summary>
|
||||
public double Light { get; set; } = 0.9;
|
||||
|
||||
/// <summary>
|
||||
/// 中度逾期係數 (4-7天)
|
||||
/// </summary>
|
||||
public double Medium { get; set; } = 0.75;
|
||||
|
||||
/// <summary>
|
||||
/// 重度逾期係數 (8-30天)
|
||||
/// </summary>
|
||||
public double Heavy { get; set; } = 0.5;
|
||||
|
||||
/// <summary>
|
||||
/// 極度逾期係數 (>30天)
|
||||
/// </summary>
|
||||
public double Extreme { get; set; } = 0.3;
|
||||
|
||||
/// <summary>
|
||||
/// 根據逾期天數獲取懲罰係數
|
||||
/// </summary>
|
||||
/// <param name="overdueDays">逾期天數</param>
|
||||
/// <returns>對應的懲罰係數</returns>
|
||||
public double GetPenaltyFactor(int overdueDays)
|
||||
{
|
||||
return overdueDays switch
|
||||
{
|
||||
<= 0 => 1.0, // 準時,無懲罰
|
||||
<= 3 => Light, // 輕度逾期
|
||||
<= 7 => Medium, // 中度逾期
|
||||
<= 30 => Heavy, // 重度逾期
|
||||
_ => Extreme // 極度逾期
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace DramaLing.Api.Models.DTOs.SpacedRepetition;
|
||||
|
||||
/// <summary>
|
||||
/// 自動選擇最適合複習模式請求 (基於CEFR等級)
|
||||
/// </summary>
|
||||
public class OptimalModeRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 學習者CEFR等級 (A1, A2, B1, B2, C1, C2)
|
||||
/// </summary>
|
||||
[Required]
|
||||
[MaxLength(10)]
|
||||
public string UserCEFRLevel { get; set; } = "B1";
|
||||
|
||||
/// <summary>
|
||||
/// 詞彙CEFR等級 (A1, A2, B1, B2, C1, C2)
|
||||
/// </summary>
|
||||
[Required]
|
||||
[MaxLength(10)]
|
||||
public string WordCEFRLevel { get; set; } = "B1";
|
||||
|
||||
/// <summary>
|
||||
/// 是否包含歷史記錄進行智能避重
|
||||
/// </summary>
|
||||
public bool IncludeHistory { get; set; } = true;
|
||||
}
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
namespace DramaLing.Api.Models.DTOs.SpacedRepetition;
|
||||
|
||||
/// <summary>
|
||||
/// 題目數據響應
|
||||
/// </summary>
|
||||
public class QuestionData
|
||||
{
|
||||
/// <summary>
|
||||
/// 題型類型
|
||||
/// </summary>
|
||||
public string QuestionType { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 選擇題選項 (用於vocab-choice, sentence-listening)
|
||||
/// </summary>
|
||||
public string[]? Options { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 正確答案
|
||||
/// </summary>
|
||||
public string CorrectAnswer { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 音頻URL (用於聽力題)
|
||||
/// </summary>
|
||||
public string? AudioUrl { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 完整例句 (用於sentence-listening)
|
||||
/// </summary>
|
||||
public string? Sentence { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 挖空例句 (用於sentence-fill)
|
||||
/// </summary>
|
||||
public string? BlankedSentence { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 打亂的單字 (用於sentence-reorder)
|
||||
/// </summary>
|
||||
public string[]? ScrambledWords { get; set; }
|
||||
}
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace DramaLing.Api.Models.DTOs.SpacedRepetition;
|
||||
|
||||
/// <summary>
|
||||
/// 題目生成請求
|
||||
/// </summary>
|
||||
public class QuestionRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 題型類型
|
||||
/// </summary>
|
||||
[Required]
|
||||
[RegularExpression("^(vocab-choice|sentence-listening|sentence-fill|sentence-reorder)$")]
|
||||
public string QuestionType { get; set; } = string.Empty;
|
||||
}
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
namespace DramaLing.Api.Models.DTOs.SpacedRepetition;
|
||||
|
||||
/// <summary>
|
||||
/// 智能複習模式選擇結果
|
||||
/// </summary>
|
||||
public class ReviewModeResult
|
||||
{
|
||||
/// <summary>
|
||||
/// 系統選擇的複習模式
|
||||
/// </summary>
|
||||
public string SelectedMode { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 選擇原因說明
|
||||
/// </summary>
|
||||
public string Reason { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 可用的複習模式列表
|
||||
/// </summary>
|
||||
public string[] AvailableModes { get; set; } = Array.Empty<string>();
|
||||
|
||||
/// <summary>
|
||||
/// 適配情境描述
|
||||
/// </summary>
|
||||
public string AdaptationContext { get; set; } = string.Empty;
|
||||
}
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace DramaLing.Api.Models.DTOs.SpacedRepetition;
|
||||
|
||||
/// <summary>
|
||||
/// 復習結果提交請求
|
||||
/// </summary>
|
||||
public class ReviewRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 答題是否正確
|
||||
/// </summary>
|
||||
[Required]
|
||||
public bool IsCorrect { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 信心程度 (1-5,翻卡題必須)
|
||||
/// </summary>
|
||||
[Range(1, 5)]
|
||||
public int? ConfidenceLevel { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 題型類型
|
||||
/// </summary>
|
||||
[Required]
|
||||
[RegularExpression("^(flip-memory|vocab-choice|vocab-listening|sentence-listening|sentence-fill|sentence-reorder|sentence-speaking)$")]
|
||||
public string QuestionType { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 用戶的答案 (可選)
|
||||
/// </summary>
|
||||
public string? UserAnswer { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 答題時間 (毫秒)
|
||||
/// </summary>
|
||||
public long? TimeTaken { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 時間戳記
|
||||
/// </summary>
|
||||
public long Timestamp { get; set; } = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||
}
|
||||
|
|
@ -1,52 +0,0 @@
|
|||
namespace DramaLing.Api.Models.DTOs.SpacedRepetition;
|
||||
|
||||
/// <summary>
|
||||
/// 復習結果響應
|
||||
/// </summary>
|
||||
public class ReviewResult
|
||||
{
|
||||
/// <summary>
|
||||
/// 新的間隔天數
|
||||
/// </summary>
|
||||
public int NewInterval { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 下次復習日期
|
||||
/// </summary>
|
||||
public DateTime NextReviewDate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 更新後的熟悉度
|
||||
/// </summary>
|
||||
public int MasteryLevel { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 當前熟悉度 (考慮衰減)
|
||||
/// </summary>
|
||||
public int CurrentMasteryLevel { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否逾期
|
||||
/// </summary>
|
||||
public bool IsOverdue { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 逾期天數
|
||||
/// </summary>
|
||||
public int OverdueDays { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 表現係數 (調試用)
|
||||
/// </summary>
|
||||
public double PerformanceFactor { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 增長係數 (調試用)
|
||||
/// </summary>
|
||||
public double GrowthFactor { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 逾期懲罰係數 (調試用)
|
||||
/// </summary>
|
||||
public double PenaltyFactor { get; set; }
|
||||
}
|
||||
|
|
@ -2,6 +2,9 @@ using System.ComponentModel.DataAnnotations;
|
|||
|
||||
namespace DramaLing.Api.Models.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// 簡化的詞卡實體 - 移除所有複習功能
|
||||
/// </summary>
|
||||
public class Flashcard
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
|
|
@ -16,8 +19,7 @@ public class Flashcard
|
|||
[Required]
|
||||
public string Translation { get; set; } = string.Empty;
|
||||
|
||||
[Required]
|
||||
public string Definition { get; set; } = string.Empty;
|
||||
public string? Definition { get; set; }
|
||||
|
||||
[MaxLength(50)]
|
||||
public string? PartOfSpeech { get; set; }
|
||||
|
|
@ -29,32 +31,7 @@ public class Flashcard
|
|||
|
||||
public string? ExampleTranslation { get; set; }
|
||||
|
||||
[MaxLength(1000)]
|
||||
public string? FilledQuestionText { get; set; }
|
||||
|
||||
[MaxLength(2000)]
|
||||
public string? Synonyms { get; set; } // JSON 格式儲存同義詞列表
|
||||
|
||||
// SM-2 算法參數
|
||||
public float EasinessFactor { get; set; } = 2.5f;
|
||||
|
||||
public int Repetitions { get; set; } = 0;
|
||||
|
||||
public int IntervalDays { get; set; } = 1;
|
||||
|
||||
public DateTime NextReviewDate { get; set; } = DateTime.Today;
|
||||
|
||||
// 學習統計
|
||||
[Range(0, 100)]
|
||||
public int MasteryLevel { get; set; } = 0;
|
||||
|
||||
public int TimesReviewed { get; set; } = 0;
|
||||
|
||||
public int TimesCorrect { get; set; } = 0;
|
||||
|
||||
public DateTime? LastReviewedAt { get; set; }
|
||||
|
||||
// 狀態
|
||||
// 基本狀態
|
||||
public bool IsFavorite { get; set; } = false;
|
||||
|
||||
public bool IsArchived { get; set; } = false;
|
||||
|
|
@ -62,20 +39,11 @@ public class Flashcard
|
|||
[MaxLength(10)]
|
||||
public string? DifficultyLevel { get; set; } // A1, A2, B1, B2, C1, C2
|
||||
|
||||
// 🆕 智能複習系統欄位
|
||||
// UserLevel和WordLevel已移除 - 改用即時CEFR轉換
|
||||
|
||||
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;
|
||||
|
||||
// Navigation Properties
|
||||
public virtual User User { get; set; } = null!;
|
||||
public virtual ICollection<StudyRecord> StudyRecords { get; set; } = new List<StudyRecord>();
|
||||
public virtual ICollection<FlashcardTag> FlashcardTags { get; set; } = new List<FlashcardTag>();
|
||||
public virtual ICollection<ErrorReport> ErrorReports { get; set; } = new List<ErrorReport>();
|
||||
public virtual ICollection<FlashcardExampleImage> FlashcardExampleImages { get; set; } = new List<FlashcardExampleImage>();
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ public class PronunciationAssessment
|
|||
public string[]? Suggestions { get; set; }
|
||||
|
||||
// 學習情境
|
||||
public Guid? StudySessionId { get; set; }
|
||||
// StudySessionId removed
|
||||
|
||||
[MaxLength(20)]
|
||||
public string PracticeMode { get; set; } = "word"; // 'word', 'sentence', 'conversation'
|
||||
|
|
@ -39,5 +39,5 @@ public class PronunciationAssessment
|
|||
// Navigation properties
|
||||
public User User { get; set; } = null!;
|
||||
public Flashcard? Flashcard { get; set; }
|
||||
public StudySession? StudySession { get; set; }
|
||||
// StudySession reference removed
|
||||
}
|
||||
|
|
@ -1,95 +0,0 @@
|
|||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace DramaLing.Api.Models.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// 學習會話中的詞卡進度追蹤
|
||||
/// </summary>
|
||||
public class StudyCard
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
|
||||
public Guid StudySessionId { get; set; }
|
||||
|
||||
public Guid FlashcardId { get; set; }
|
||||
|
||||
[Required]
|
||||
[MaxLength(100)]
|
||||
public string Word { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 該詞卡預定的測驗類型列表 (JSON序列化)
|
||||
/// 例如: ["flip-memory", "vocab-choice", "sentence-fill"]
|
||||
/// </summary>
|
||||
[Required]
|
||||
public string PlannedTestsJson { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 詞卡在會話中的順序
|
||||
/// </summary>
|
||||
public int Order { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否已完成所有測驗
|
||||
/// </summary>
|
||||
public bool IsCompleted { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// 詞卡學習開始時間
|
||||
/// </summary>
|
||||
public DateTime StartedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
/// <summary>
|
||||
/// 詞卡學習完成時間
|
||||
/// </summary>
|
||||
public DateTime? CompletedAt { get; set; }
|
||||
|
||||
// Navigation Properties
|
||||
public virtual StudySession StudySession { get; set; } = null!;
|
||||
public virtual Flashcard Flashcard { get; set; } = null!;
|
||||
public virtual ICollection<TestResult> TestResults { get; set; } = new List<TestResult>();
|
||||
|
||||
// Helper Properties (不映射到資料庫)
|
||||
public List<string> PlannedTests
|
||||
{
|
||||
get => string.IsNullOrEmpty(PlannedTestsJson)
|
||||
? new List<string>()
|
||||
: System.Text.Json.JsonSerializer.Deserialize<List<string>>(PlannedTestsJson) ?? new List<string>();
|
||||
set => PlannedTestsJson = System.Text.Json.JsonSerializer.Serialize(value);
|
||||
}
|
||||
|
||||
public int CompletedTestsCount => TestResults?.Count ?? 0;
|
||||
public int PlannedTestsCount => PlannedTests.Count;
|
||||
public bool AllTestsCompleted => CompletedTestsCount >= PlannedTestsCount;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 詞卡內的測驗結果記錄
|
||||
/// </summary>
|
||||
public class TestResult
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
|
||||
public Guid StudyCardId { get; set; }
|
||||
|
||||
[Required]
|
||||
[MaxLength(50)]
|
||||
public string TestType { get; set; } = string.Empty; // flip-memory, vocab-choice, etc.
|
||||
|
||||
public bool IsCorrect { get; set; }
|
||||
|
||||
public string? UserAnswer { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 信心等級 (1-5, 主要用於翻卡記憶測驗)
|
||||
/// </summary>
|
||||
[Range(1, 5)]
|
||||
public int? ConfidenceLevel { get; set; }
|
||||
|
||||
public int ResponseTimeMs { get; set; }
|
||||
|
||||
public DateTime CompletedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
// Navigation Properties
|
||||
public virtual StudyCard StudyCard { get; set; } = null!;
|
||||
}
|
||||
|
|
@ -1,116 +0,0 @@
|
|||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace DramaLing.Api.Models.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// 會話狀態枚舉
|
||||
/// </summary>
|
||||
public enum SessionStatus
|
||||
{
|
||||
Active, // 進行中
|
||||
Completed, // 已完成
|
||||
Paused, // 暫停
|
||||
Abandoned // 放棄
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 學習會話實體 (擴展版本)
|
||||
/// </summary>
|
||||
public class StudySession
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
|
||||
public Guid UserId { get; set; }
|
||||
|
||||
[Required]
|
||||
[MaxLength(50)]
|
||||
public string SessionType { get; set; } = string.Empty; // flip, quiz, fill, listening, speaking
|
||||
|
||||
public DateTime StartedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
public DateTime? EndedAt { get; set; }
|
||||
|
||||
public int TotalCards { get; set; } = 0;
|
||||
|
||||
public int CorrectCount { get; set; } = 0;
|
||||
|
||||
public int DurationSeconds { get; set; } = 0;
|
||||
|
||||
public int AverageResponseTimeMs { get; set; } = 0;
|
||||
|
||||
/// <summary>
|
||||
/// 會話狀態
|
||||
/// </summary>
|
||||
public SessionStatus Status { get; set; } = SessionStatus.Active;
|
||||
|
||||
/// <summary>
|
||||
/// 當前詞卡索引 (從0開始)
|
||||
/// </summary>
|
||||
public int CurrentCardIndex { get; set; } = 0;
|
||||
|
||||
/// <summary>
|
||||
/// 當前測驗類型
|
||||
/// </summary>
|
||||
[MaxLength(50)]
|
||||
public string? CurrentTestType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 總測驗數量 (所有詞卡的測驗總和)
|
||||
/// </summary>
|
||||
public int TotalTests { get; set; } = 0;
|
||||
|
||||
/// <summary>
|
||||
/// 已完成測驗數量
|
||||
/// </summary>
|
||||
public int CompletedTests { get; set; } = 0;
|
||||
|
||||
/// <summary>
|
||||
/// 已完成詞卡數量
|
||||
/// </summary>
|
||||
public int CompletedCards { get; set; } = 0;
|
||||
|
||||
// Navigation Properties
|
||||
public virtual User User { get; set; } = null!;
|
||||
public virtual ICollection<StudyRecord> StudyRecords { get; set; } = new List<StudyRecord>();
|
||||
public virtual ICollection<StudyCard> StudyCards { get; set; } = new List<StudyCard>();
|
||||
}
|
||||
|
||||
public class StudyRecord
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
|
||||
public Guid UserId { get; set; }
|
||||
|
||||
public Guid FlashcardId { get; set; }
|
||||
|
||||
public Guid SessionId { get; set; }
|
||||
|
||||
[Required]
|
||||
[MaxLength(50)]
|
||||
public string StudyMode { get; set; } = string.Empty;
|
||||
|
||||
[Range(1, 5)]
|
||||
public int QualityRating { get; set; }
|
||||
|
||||
public int? ResponseTimeMs { get; set; }
|
||||
|
||||
public string? UserAnswer { get; set; }
|
||||
|
||||
public bool IsCorrect { get; set; }
|
||||
|
||||
// SM-2 算法記錄
|
||||
public float PreviousEasinessFactor { get; set; }
|
||||
public float NewEasinessFactor { get; set; }
|
||||
public int PreviousIntervalDays { get; set; }
|
||||
public int NewIntervalDays { get; set; }
|
||||
public int PreviousRepetitions { get; set; }
|
||||
public int NewRepetitions { get; set; }
|
||||
public DateTime NextReviewDate { get; set; }
|
||||
|
||||
public DateTime StudiedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
// Navigation Properties
|
||||
public virtual User User { get; set; } = null!;
|
||||
public virtual Flashcard Flashcard { get; set; } = null!;
|
||||
public virtual StudySession Session { get; set; } = null!;
|
||||
}
|
||||
|
|
@ -45,7 +45,7 @@ public class User
|
|||
// Navigation Properties
|
||||
public virtual ICollection<Flashcard> Flashcards { get; set; } = new List<Flashcard>();
|
||||
public virtual UserSettings? Settings { get; set; }
|
||||
public virtual ICollection<StudySession> StudySessions { get; set; } = new List<StudySession>();
|
||||
// StudySession collection removed
|
||||
public virtual ICollection<ErrorReport> ErrorReports { get; set; } = new List<ErrorReport>();
|
||||
public virtual ICollection<DailyStats> DailyStats { get; set; } = new List<DailyStats>();
|
||||
}
|
||||
|
|
@ -88,21 +88,12 @@ builder.Services.AddHttpClient<IGeminiService, GeminiService>();
|
|||
builder.Services.AddScoped<IAnalysisService, AnalysisService>();
|
||||
builder.Services.AddScoped<IUsageTrackingService, UsageTrackingService>();
|
||||
builder.Services.AddScoped<IAzureSpeechService, AzureSpeechService>();
|
||||
// 智能填空題系統服務
|
||||
builder.Services.AddScoped<IWordVariationService, WordVariationService>();
|
||||
builder.Services.AddScoped<IBlankGenerationService, BlankGenerationService>();
|
||||
// 智能填空題系統服務已移除
|
||||
builder.Services.AddScoped<IAudioCacheService, AudioCacheService>();
|
||||
|
||||
// 🆕 智能複習服務註冊
|
||||
builder.Services.Configure<SpacedRepetitionOptions>(
|
||||
builder.Configuration.GetSection(SpacedRepetitionOptions.SectionName));
|
||||
builder.Services.AddScoped<ISpacedRepetitionService, SpacedRepetitionService>();
|
||||
builder.Services.AddScoped<IReviewTypeSelectorService, ReviewTypeSelectorService>();
|
||||
builder.Services.AddScoped<IQuestionGeneratorService, QuestionGeneratorService>();
|
||||
// 智能複習服務已移除,準備重新實施
|
||||
|
||||
// 🆕 學習會話服務註冊
|
||||
builder.Services.AddScoped<IStudySessionService, StudySessionService>();
|
||||
builder.Services.AddScoped<IReviewModeSelector, ReviewModeSelector>();
|
||||
// 學習會話服務已清理移除
|
||||
|
||||
// 🆕 選項詞彙庫服務註冊
|
||||
builder.Services.Configure<OptionsVocabularyOptions>(
|
||||
|
|
|
|||
|
|
@ -1,138 +0,0 @@
|
|||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace DramaLing.Api.Services;
|
||||
|
||||
public interface IBlankGenerationService
|
||||
{
|
||||
Task<string?> GenerateBlankQuestionAsync(string word, string example);
|
||||
string? TryProgrammaticBlank(string word, string example);
|
||||
Task<string?> GenerateAIBlankAsync(string word, string example);
|
||||
bool HasValidBlank(string blankQuestion);
|
||||
}
|
||||
|
||||
public class BlankGenerationService : IBlankGenerationService
|
||||
{
|
||||
private readonly IWordVariationService _wordVariationService;
|
||||
private readonly IGeminiService _geminiService;
|
||||
private readonly ILogger<BlankGenerationService> _logger;
|
||||
|
||||
public BlankGenerationService(
|
||||
IWordVariationService wordVariationService,
|
||||
IGeminiService geminiService,
|
||||
ILogger<BlankGenerationService> logger)
|
||||
{
|
||||
_wordVariationService = wordVariationService;
|
||||
_geminiService = geminiService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<string?> GenerateBlankQuestionAsync(string word, string example)
|
||||
{
|
||||
if (string.IsNullOrEmpty(word) || string.IsNullOrEmpty(example))
|
||||
{
|
||||
_logger.LogWarning("Invalid input - word or example is null/empty");
|
||||
return null;
|
||||
}
|
||||
|
||||
_logger.LogInformation("Generating blank question for word: {Word}, example: {Example}",
|
||||
word, example);
|
||||
|
||||
// Step 1: 嘗試程式碼挖空
|
||||
var programmaticResult = TryProgrammaticBlank(word, example);
|
||||
if (!string.IsNullOrEmpty(programmaticResult))
|
||||
{
|
||||
_logger.LogInformation("Successfully generated programmatic blank for word: {Word}", word);
|
||||
return programmaticResult;
|
||||
}
|
||||
|
||||
// Step 2: 程式碼挖空失敗,嘗試 AI 挖空
|
||||
_logger.LogInformation("Programmatic blank failed for word: {Word}, trying AI blank", word);
|
||||
var aiResult = await GenerateAIBlankAsync(word, example);
|
||||
|
||||
if (!string.IsNullOrEmpty(aiResult))
|
||||
{
|
||||
_logger.LogInformation("Successfully generated AI blank for word: {Word}", word);
|
||||
return aiResult;
|
||||
}
|
||||
|
||||
_logger.LogWarning("Both programmatic and AI blank generation failed for word: {Word}", word);
|
||||
return null;
|
||||
}
|
||||
|
||||
public string? TryProgrammaticBlank(string word, string example)
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.LogDebug("Attempting programmatic blank for word: {Word}", word);
|
||||
|
||||
// 1. 完全匹配 (不區分大小寫)
|
||||
var exactMatch = Regex.Replace(example, $@"\b{Regex.Escape(word)}\b", "____", RegexOptions.IgnoreCase);
|
||||
if (exactMatch != example)
|
||||
{
|
||||
_logger.LogDebug("Exact match blank successful for word: {Word}", word);
|
||||
return exactMatch;
|
||||
}
|
||||
|
||||
// 2. 常見變形處理
|
||||
var variations = _wordVariationService.GetCommonVariations(word);
|
||||
foreach(var variation in variations)
|
||||
{
|
||||
var variantMatch = Regex.Replace(example, $@"\b{Regex.Escape(variation)}\b", "____", RegexOptions.IgnoreCase);
|
||||
if (variantMatch != example)
|
||||
{
|
||||
_logger.LogDebug("Variation match blank successful for word: {Word}, variation: {Variation}",
|
||||
word, variation);
|
||||
return variantMatch;
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogDebug("Programmatic blank failed for word: {Word}", word);
|
||||
return null; // 挖空失敗
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error in programmatic blank for word: {Word}", word);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<string?> GenerateAIBlankAsync(string word, string example)
|
||||
{
|
||||
try
|
||||
{
|
||||
var prompt = $@"
|
||||
請將以下例句中與詞彙「{word}」相關的詞挖空,用____替代:
|
||||
|
||||
詞彙: {word}
|
||||
例句: {example}
|
||||
|
||||
規則:
|
||||
1. 只挖空與目標詞彙相關的詞(包含變形、時態、複數等)
|
||||
2. 用____替代被挖空的詞
|
||||
3. 保持句子其他部分不變
|
||||
4. 直接返回挖空後的句子,不要額外說明
|
||||
|
||||
挖空後的句子:";
|
||||
|
||||
_logger.LogInformation("Generating AI blank for word: {Word}, example: {Example}",
|
||||
word, example);
|
||||
|
||||
// 暫時使用程式碼邏輯,AI 功能將在後續版本實現
|
||||
// TODO: 整合 Gemini API 進行智能挖空
|
||||
_logger.LogInformation("AI blank generation not yet implemented, returning null");
|
||||
return null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error generating AI blank for word: {Word}", word);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public bool HasValidBlank(string blankQuestion)
|
||||
{
|
||||
var isValid = !string.IsNullOrEmpty(blankQuestion) && blankQuestion.Contains("____");
|
||||
_logger.LogDebug("Validating blank question: {IsValid}", isValid);
|
||||
return isValid;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,254 +0,0 @@
|
|||
using DramaLing.Api.Services;
|
||||
|
||||
namespace DramaLing.Api.Services.Domain.Learning;
|
||||
|
||||
/// <summary>
|
||||
/// 間隔重複學習服務介面
|
||||
/// </summary>
|
||||
public interface ISpacedRepetitionService
|
||||
{
|
||||
/// <summary>
|
||||
/// 計算下次複習時間
|
||||
/// </summary>
|
||||
Task<ReviewSchedule> CalculateNextReviewAsync(ReviewInput input);
|
||||
|
||||
/// <summary>
|
||||
/// 更新學習進度
|
||||
/// </summary>
|
||||
Task<StudyProgress> UpdateStudyProgressAsync(Guid flashcardId, int qualityRating, Guid userId);
|
||||
|
||||
/// <summary>
|
||||
/// 取得今日應複習的詞卡
|
||||
/// </summary>
|
||||
Task<IEnumerable<ReviewCard>> GetDueCardsAsync(Guid userId, int limit = 20);
|
||||
|
||||
/// <summary>
|
||||
/// 取得學習統計
|
||||
/// </summary>
|
||||
Task<LearningAnalytics> GetLearningAnalyticsAsync(Guid userId);
|
||||
|
||||
/// <summary>
|
||||
/// 優化學習序列
|
||||
/// </summary>
|
||||
Task<OptimizedStudyPlan> GenerateStudyPlanAsync(Guid userId, int targetMinutes);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 複習輸入參數
|
||||
/// </summary>
|
||||
public class ReviewInput
|
||||
{
|
||||
public Guid FlashcardId { get; set; }
|
||||
public Guid UserId { get; set; }
|
||||
public int QualityRating { get; set; } // 1-5 (SM2 標準)
|
||||
public int CurrentRepetitions { get; set; }
|
||||
public float CurrentEasinessFactor { get; set; }
|
||||
public int CurrentIntervalDays { get; set; }
|
||||
public DateTime LastReviewDate { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 複習排程結果
|
||||
/// </summary>
|
||||
public class ReviewSchedule
|
||||
{
|
||||
public DateTime NextReviewDate { get; set; }
|
||||
public int NewIntervalDays { get; set; }
|
||||
public float NewEasinessFactor { get; set; }
|
||||
public int NewRepetitions { get; set; }
|
||||
public int NewMasteryLevel { get; set; }
|
||||
public string RecommendedAction { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 學習進度
|
||||
/// </summary>
|
||||
public class StudyProgress
|
||||
{
|
||||
public Guid FlashcardId { get; set; }
|
||||
public bool IsImproved { get; set; }
|
||||
public int PreviousMasteryLevel { get; set; }
|
||||
public int NewMasteryLevel { get; set; }
|
||||
public DateTime NextReviewDate { get; set; }
|
||||
public string ProgressMessage { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 複習卡片
|
||||
/// </summary>
|
||||
public class ReviewCard
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public string Word { get; set; } = string.Empty;
|
||||
public string Translation { get; set; } = string.Empty;
|
||||
public string DifficultyLevel { get; set; } = string.Empty;
|
||||
public int MasteryLevel { get; set; }
|
||||
public DateTime NextReviewDate { get; set; }
|
||||
public int DaysSinceLastReview { get; set; }
|
||||
public int ReviewPriority { get; set; } // 1-5 (5 最高)
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 學習分析
|
||||
/// </summary>
|
||||
public class LearningAnalytics
|
||||
{
|
||||
public int TotalCards { get; set; }
|
||||
public int DueCards { get; set; }
|
||||
public int OverdueCards { get; set; }
|
||||
public int MasteredCards { get; set; }
|
||||
public double RetentionRate { get; set; }
|
||||
public TimeSpan AverageStudyInterval { get; set; }
|
||||
public Dictionary<string, int> DifficultyDistribution { get; set; } = new();
|
||||
public List<DailyStudyStats> RecentPerformance { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 每日學習統計
|
||||
/// </summary>
|
||||
public class DailyStudyStats
|
||||
{
|
||||
public DateOnly Date { get; set; }
|
||||
public int CardsReviewed { get; set; }
|
||||
public int CorrectAnswers { get; set; }
|
||||
public double AccuracyRate => CardsReviewed > 0 ? (double)CorrectAnswers / CardsReviewed : 0;
|
||||
public TimeSpan StudyDuration { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 優化學習計劃
|
||||
/// </summary>
|
||||
public class OptimizedStudyPlan
|
||||
{
|
||||
public IEnumerable<ReviewCard> RecommendedCards { get; set; } = new List<ReviewCard>();
|
||||
public int EstimatedMinutes { get; set; }
|
||||
public string StudyFocus { get; set; } = string.Empty; // "複習", "新學習", "加強練習"
|
||||
public Dictionary<string, int> LevelBreakdown { get; set; } = new();
|
||||
public string RecommendationReason { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 間隔重複學習服務實作
|
||||
/// </summary>
|
||||
public class SpacedRepetitionService : ISpacedRepetitionService
|
||||
{
|
||||
private readonly ILogger<SpacedRepetitionService> _logger;
|
||||
|
||||
public SpacedRepetitionService(ILogger<SpacedRepetitionService> logger)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public Task<ReviewSchedule> CalculateNextReviewAsync(ReviewInput input)
|
||||
{
|
||||
try
|
||||
{
|
||||
// 使用現有的 SM2Algorithm
|
||||
var sm2Input = new SM2Input(
|
||||
input.QualityRating,
|
||||
input.CurrentEasinessFactor,
|
||||
input.CurrentRepetitions,
|
||||
input.CurrentIntervalDays
|
||||
);
|
||||
|
||||
var sm2Result = SM2Algorithm.Calculate(sm2Input);
|
||||
|
||||
var schedule = new ReviewSchedule
|
||||
{
|
||||
NextReviewDate = sm2Result.NextReviewDate,
|
||||
NewIntervalDays = sm2Result.IntervalDays,
|
||||
NewEasinessFactor = sm2Result.EasinessFactor,
|
||||
NewRepetitions = sm2Result.Repetitions,
|
||||
NewMasteryLevel = CalculateMasteryLevel(sm2Result.EasinessFactor, sm2Result.Repetitions),
|
||||
RecommendedAction = GetRecommendedAction(input.QualityRating)
|
||||
};
|
||||
|
||||
return Task.FromResult(schedule);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error calculating next review for flashcard {FlashcardId}", input.FlashcardId);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public Task<StudyProgress> UpdateStudyProgressAsync(Guid flashcardId, int qualityRating, Guid userId)
|
||||
{
|
||||
// 這裡應該整合 Repository 來獲取和更新詞卡數據
|
||||
// 暫時返回模擬結果
|
||||
var progress = new StudyProgress
|
||||
{
|
||||
FlashcardId = flashcardId,
|
||||
IsImproved = qualityRating >= 3,
|
||||
ProgressMessage = GetProgressMessage(qualityRating)
|
||||
};
|
||||
|
||||
return Task.FromResult(progress);
|
||||
}
|
||||
|
||||
public Task<IEnumerable<ReviewCard>> GetDueCardsAsync(Guid userId, int limit = 20)
|
||||
{
|
||||
// 需要整合 Repository 來實作
|
||||
var cards = new List<ReviewCard>();
|
||||
return Task.FromResult<IEnumerable<ReviewCard>>(cards);
|
||||
}
|
||||
|
||||
public Task<LearningAnalytics> GetLearningAnalyticsAsync(Guid userId)
|
||||
{
|
||||
// 需要整合 Repository 來實作
|
||||
var analytics = new LearningAnalytics();
|
||||
return Task.FromResult(analytics);
|
||||
}
|
||||
|
||||
public Task<OptimizedStudyPlan> GenerateStudyPlanAsync(Guid userId, int targetMinutes)
|
||||
{
|
||||
// 需要整合 Repository 和 AI 服務來實作
|
||||
var plan = new OptimizedStudyPlan
|
||||
{
|
||||
EstimatedMinutes = targetMinutes,
|
||||
StudyFocus = "複習",
|
||||
RecommendationReason = "基於間隔重複算法的個人化推薦"
|
||||
};
|
||||
|
||||
return Task.FromResult(plan);
|
||||
}
|
||||
|
||||
#region 私有方法
|
||||
|
||||
private int CalculateMasteryLevel(float easinessFactor, int repetitions)
|
||||
{
|
||||
// 根據難度係數和重複次數計算掌握程度
|
||||
if (repetitions >= 5 && easinessFactor >= 2.3f) return 5; // 完全掌握
|
||||
if (repetitions >= 3 && easinessFactor >= 2.0f) return 4; // 熟練
|
||||
if (repetitions >= 2 && easinessFactor >= 1.8f) return 3; // 理解
|
||||
if (repetitions >= 1) return 2; // 認識
|
||||
return 1; // 新學習
|
||||
}
|
||||
|
||||
private string GetRecommendedAction(int qualityRating)
|
||||
{
|
||||
return qualityRating switch
|
||||
{
|
||||
1 => "建議重新學習此詞彙",
|
||||
2 => "需要額外練習",
|
||||
3 => "繼續複習",
|
||||
4 => "掌握良好",
|
||||
5 => "完全掌握",
|
||||
_ => "繼續學習"
|
||||
};
|
||||
}
|
||||
|
||||
private string GetProgressMessage(int qualityRating)
|
||||
{
|
||||
return qualityRating switch
|
||||
{
|
||||
1 or 2 => "需要加強練習,別氣餒!",
|
||||
3 => "不錯的進步!",
|
||||
4 => "很好!掌握得不錯",
|
||||
5 => "太棒了!完全掌握",
|
||||
_ => "繼續努力學習!"
|
||||
};
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
|
@ -1,284 +0,0 @@
|
|||
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 "困難詞彙";
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -1,83 +0,0 @@
|
|||
namespace DramaLing.Api.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 測驗模式選擇服務介面
|
||||
/// </summary>
|
||||
public interface IReviewModeSelector
|
||||
{
|
||||
List<string> GetPlannedTests(string userCEFRLevel, string wordCEFRLevel);
|
||||
string GetNextTestType(List<string> plannedTests, List<string> completedTestTypes);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 測驗模式選擇服務實現
|
||||
/// </summary>
|
||||
public class ReviewModeSelector : IReviewModeSelector
|
||||
{
|
||||
private readonly ILogger<ReviewModeSelector> _logger;
|
||||
|
||||
public ReviewModeSelector(ILogger<ReviewModeSelector> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 根據CEFR等級獲取預定的測驗類型列表
|
||||
/// </summary>
|
||||
public List<string> GetPlannedTests(string userCEFRLevel, string wordCEFRLevel)
|
||||
{
|
||||
var userLevel = GetCEFRLevel(userCEFRLevel);
|
||||
var wordLevel = GetCEFRLevel(wordCEFRLevel);
|
||||
var difficulty = wordLevel - userLevel;
|
||||
|
||||
_logger.LogDebug("Planning tests for user {UserCEFR} vs word {WordCEFR}, difficulty: {Difficulty}",
|
||||
userCEFRLevel, wordCEFRLevel, difficulty);
|
||||
|
||||
if (userCEFRLevel == "A1")
|
||||
{
|
||||
// A1學習者:基礎保護機制
|
||||
return new List<string> { "flip-memory", "vocab-choice", "vocab-listening" };
|
||||
}
|
||||
else if (difficulty < -10)
|
||||
{
|
||||
// 簡單詞彙:應用練習
|
||||
return new List<string> { "sentence-fill", "sentence-reorder" };
|
||||
}
|
||||
else if (difficulty >= -10 && difficulty <= 10)
|
||||
{
|
||||
// 適中詞彙:全方位練習
|
||||
return new List<string> { "sentence-fill", "sentence-reorder", "sentence-speaking" };
|
||||
}
|
||||
else
|
||||
{
|
||||
// 困難詞彙:基礎重建
|
||||
return new List<string> { "flip-memory", "vocab-choice" };
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 獲取下一個測驗類型
|
||||
/// </summary>
|
||||
public string GetNextTestType(List<string> plannedTests, List<string> completedTestTypes)
|
||||
{
|
||||
var nextTest = plannedTests.FirstOrDefault(test => !completedTestTypes.Contains(test));
|
||||
return nextTest ?? string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// CEFR等級轉換為數值
|
||||
/// </summary>
|
||||
private int GetCEFRLevel(string cefrLevel)
|
||||
{
|
||||
return cefrLevel switch
|
||||
{
|
||||
"A1" => 20,
|
||||
"A2" => 35,
|
||||
"B1" => 50,
|
||||
"B2" => 65,
|
||||
"C1" => 80,
|
||||
"C2" => 95,
|
||||
_ => 50 // 預設B1
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -1,241 +0,0 @@
|
|||
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;
|
||||
|
||||
/// <summary>
|
||||
/// 智能複習題型選擇服務介面 (基於CEFR等級)
|
||||
/// </summary>
|
||||
public interface IReviewTypeSelectorService
|
||||
{
|
||||
Task<ReviewModeResult> SelectOptimalReviewModeAsync(Guid flashcardId, string userCEFRLevel, string wordCEFRLevel);
|
||||
string[] GetAvailableReviewTypes(string userCEFRLevel, string wordCEFRLevel);
|
||||
bool IsA1Learner(string userCEFRLevel);
|
||||
string GetAdaptationContext(string userCEFRLevel, string wordCEFRLevel);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 智能複習題型選擇服務實現
|
||||
/// </summary>
|
||||
public class ReviewTypeSelectorService : IReviewTypeSelectorService
|
||||
{
|
||||
private readonly DramaLingDbContext _context;
|
||||
private readonly ILogger<ReviewTypeSelectorService> _logger;
|
||||
private readonly SpacedRepetitionOptions _options;
|
||||
|
||||
public ReviewTypeSelectorService(
|
||||
DramaLingDbContext context,
|
||||
ILogger<ReviewTypeSelectorService> logger,
|
||||
IOptions<SpacedRepetitionOptions> options)
|
||||
{
|
||||
_context = context;
|
||||
_logger = logger;
|
||||
_options = options.Value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 智能選擇最適合的複習模式 (基於CEFR等級)
|
||||
/// </summary>
|
||||
public async Task<ReviewModeResult> SelectOptimalReviewModeAsync(Guid flashcardId, string userCEFRLevel, string wordCEFRLevel)
|
||||
{
|
||||
_logger.LogInformation("Selecting optimal review mode for flashcard {FlashcardId}, userCEFR: {UserCEFR}, wordCEFR: {WordCEFR}",
|
||||
flashcardId, userCEFRLevel, wordCEFRLevel);
|
||||
|
||||
// 即時轉換CEFR等級為數值進行計算
|
||||
var userLevel = CEFRMappingService.GetWordLevel(userCEFRLevel);
|
||||
var wordLevel = CEFRMappingService.GetWordLevel(wordCEFRLevel);
|
||||
|
||||
_logger.LogInformation("CEFR converted to levels: {UserCEFR}→{UserLevel}, {WordCEFR}→{WordLevel}",
|
||||
userCEFRLevel, userLevel, wordCEFRLevel, 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
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 四情境自動適配:根據學習者程度和詞彙難度決定可用題型
|
||||
/// </summary>
|
||||
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" };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 智能避重邏輯:避免連續使用相同題型
|
||||
/// </summary>
|
||||
private async Task<string[]> ApplyAntiRepetitionLogicAsync(Guid flashcardId, string[] availableModes)
|
||||
{
|
||||
try
|
||||
{
|
||||
var flashcard = await _context.Flashcards.FindAsync(flashcardId);
|
||||
if (flashcard?.ReviewHistory == null)
|
||||
return availableModes;
|
||||
|
||||
var history = JsonSerializer.Deserialize<List<ReviewRecord>>(flashcard.ReviewHistory) ?? new List<ReviewRecord>();
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 權重選擇模式 (A1學習者有權重,其他隨機)
|
||||
/// </summary>
|
||||
private string SelectModeWithWeights(string[] modes, int userLevel)
|
||||
{
|
||||
if (userLevel <= _options.A1ProtectionLevel)
|
||||
{
|
||||
// A1學習者權重分配
|
||||
var weights = new Dictionary<string, double>
|
||||
{
|
||||
{ "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)];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 權重隨機選擇
|
||||
/// </summary>
|
||||
private string WeightedRandomSelect(string[] items, Dictionary<string, double> 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]; // 備用返回
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 新增CEFR字符串版本的方法
|
||||
/// </summary>
|
||||
public string[] GetAvailableReviewTypes(string userCEFRLevel, string wordCEFRLevel)
|
||||
{
|
||||
var userLevel = CEFRMappingService.GetWordLevel(userCEFRLevel);
|
||||
var wordLevel = CEFRMappingService.GetWordLevel(wordCEFRLevel);
|
||||
return GetAvailableReviewTypes(userLevel, wordLevel);
|
||||
}
|
||||
|
||||
public bool IsA1Learner(string userCEFRLevel) => userCEFRLevel == "A1";
|
||||
public bool IsA1Learner(int userLevel) => userLevel <= _options.A1ProtectionLevel;
|
||||
|
||||
public string GetAdaptationContext(string userCEFRLevel, string wordCEFRLevel)
|
||||
{
|
||||
var userLevel = CEFRMappingService.GetWordLevel(userCEFRLevel);
|
||||
var wordLevel = CEFRMappingService.GetWordLevel(wordCEFRLevel);
|
||||
return GetAdaptationContext(userLevel, wordLevel);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 獲取適配情境描述 (數值版本,內部使用)
|
||||
/// </summary>
|
||||
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 "困難詞彙";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 獲取選擇原因說明
|
||||
/// </summary>
|
||||
private string GetSelectionReason(string selectedMode, int userLevel, int wordLevel)
|
||||
{
|
||||
var context = GetAdaptationContext(userLevel, wordLevel);
|
||||
|
||||
return context switch
|
||||
{
|
||||
"A1學習者" => "A1學習者使用基礎題型建立信心",
|
||||
"簡單詞彙" => "簡單詞彙重點練習應用和拼寫",
|
||||
"適中詞彙" => "適中詞彙進行全方位練習",
|
||||
"困難詞彙" => "困難詞彙回歸基礎重建記憶",
|
||||
_ => "系統智能選擇"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 復習記錄 (用於ReviewHistory JSON序列化)
|
||||
/// </summary>
|
||||
public record ReviewRecord(string QuestionType, bool IsCorrect, DateTime Date);
|
||||
|
|
@ -1,162 +0,0 @@
|
|||
namespace DramaLing.Api.Services;
|
||||
|
||||
public record SM2Input(
|
||||
int Quality, // 1-5 評分
|
||||
float EasinessFactor, // 難度係數
|
||||
int Repetitions, // 重複次數
|
||||
int IntervalDays // 當前間隔天數
|
||||
);
|
||||
|
||||
public record SM2Result(
|
||||
float EasinessFactor, // 新的難度係數
|
||||
int Repetitions, // 新的重複次數
|
||||
int IntervalDays, // 新的間隔天數
|
||||
DateTime NextReviewDate // 下次複習日期
|
||||
);
|
||||
|
||||
public static class SM2Algorithm
|
||||
{
|
||||
// SM-2 算法常數
|
||||
private const float MIN_EASINESS_FACTOR = 1.3f;
|
||||
private const float MAX_EASINESS_FACTOR = 2.5f;
|
||||
private const float INITIAL_EASINESS_FACTOR = 2.5f;
|
||||
private const int MIN_INTERVAL = 1;
|
||||
private const int MAX_INTERVAL = 365;
|
||||
|
||||
/// <summary>
|
||||
/// 計算下次複習的間隔和參數
|
||||
/// </summary>
|
||||
public static SM2Result Calculate(SM2Input input)
|
||||
{
|
||||
var (quality, easinessFactor, repetitions, intervalDays) = input;
|
||||
|
||||
// 驗證輸入參數
|
||||
if (quality < 1 || quality > 5)
|
||||
throw new ArgumentException("Quality must be between 1 and 5", nameof(input));
|
||||
|
||||
// 更新難度係數
|
||||
var newEasinessFactor = UpdateEasinessFactor(easinessFactor, quality);
|
||||
|
||||
int newRepetitions;
|
||||
int newIntervalDays;
|
||||
|
||||
// 如果回答錯誤 (quality < 3),重置進度
|
||||
if (quality < 3)
|
||||
{
|
||||
newRepetitions = 0;
|
||||
newIntervalDays = 1;
|
||||
}
|
||||
else
|
||||
{
|
||||
// 如果回答正確,增加重複次數並計算新間隔
|
||||
newRepetitions = repetitions + 1;
|
||||
|
||||
newIntervalDays = newRepetitions switch
|
||||
{
|
||||
1 => 1,
|
||||
2 => 6,
|
||||
_ => (int)Math.Round(intervalDays * newEasinessFactor)
|
||||
};
|
||||
}
|
||||
|
||||
// 限制間隔範圍
|
||||
newIntervalDays = Math.Clamp(newIntervalDays, MIN_INTERVAL, MAX_INTERVAL);
|
||||
|
||||
// 計算下次複習日期
|
||||
var nextReviewDate = DateTime.Today.AddDays(newIntervalDays);
|
||||
|
||||
return new SM2Result(
|
||||
newEasinessFactor,
|
||||
newRepetitions,
|
||||
newIntervalDays,
|
||||
nextReviewDate
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 更新難度係數
|
||||
/// </summary>
|
||||
private static float UpdateEasinessFactor(float currentEF, int quality)
|
||||
{
|
||||
// SM-2 公式:EF' = EF + (0.1 - (5-q) * (0.08 + (5-q) * 0.02))
|
||||
var newEF = currentEF + (0.1f - (5 - quality) * (0.08f + (5 - quality) * 0.02f));
|
||||
|
||||
// 限制在有效範圍內
|
||||
return Math.Clamp(newEF, MIN_EASINESS_FACTOR, MAX_EASINESS_FACTOR);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 獲取初始參數(新詞卡)
|
||||
/// </summary>
|
||||
public static SM2Input GetInitialParameters()
|
||||
{
|
||||
return new SM2Input(
|
||||
Quality: 3,
|
||||
EasinessFactor: INITIAL_EASINESS_FACTOR,
|
||||
Repetitions: 0,
|
||||
IntervalDays: 1
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 根據評分獲取描述
|
||||
/// </summary>
|
||||
public static string GetQualityDescription(int quality)
|
||||
{
|
||||
return quality switch
|
||||
{
|
||||
1 => "完全不記得",
|
||||
2 => "有印象但錯誤",
|
||||
3 => "困難但正確",
|
||||
4 => "猶豫後正確",
|
||||
5 => "輕鬆正確",
|
||||
_ => "無效評分"
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 計算掌握度百分比
|
||||
/// </summary>
|
||||
public static int CalculateMastery(int repetitions, float easinessFactor)
|
||||
{
|
||||
// 基於重複次數和難度係數計算掌握度 (0-100)
|
||||
var baseScore = Math.Min(repetitions * 20, 80); // 重複次數最多貢獻80分
|
||||
var efficiencyBonus = Math.Min((easinessFactor - 1.3f) * 16.67f, 20f); // 難度係數最多貢獻20分
|
||||
|
||||
return Math.Min((int)Math.Round(baseScore + efficiencyBonus), 100);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 複習優先級計算器
|
||||
/// </summary>
|
||||
public static class ReviewPriorityCalculator
|
||||
{
|
||||
/// <summary>
|
||||
/// 計算複習優先級 (數字越大優先級越高)
|
||||
/// </summary>
|
||||
public static double CalculatePriority(DateTime nextReviewDate, float easinessFactor, int repetitions)
|
||||
{
|
||||
var now = DateTime.Today;
|
||||
var daysDiff = (now - nextReviewDate).Days;
|
||||
|
||||
// 過期天數的權重 (越過期優先級越高)
|
||||
var overdueWeight = Math.Max(0, daysDiff) * 10;
|
||||
|
||||
// 難度權重 (越難的優先級越高)
|
||||
var difficultyWeight = (3.8f - easinessFactor) * 5;
|
||||
|
||||
// 新詞權重 (新詞優先級較高)
|
||||
var newWordWeight = repetitions == 0 ? 20 : 0;
|
||||
|
||||
return overdueWeight + difficultyWeight + newWordWeight;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 獲取應該複習的詞卡
|
||||
/// </summary>
|
||||
public static bool ShouldReview(DateTime nextReviewDate)
|
||||
{
|
||||
return DateTime.Today >= nextReviewDate;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,227 +0,0 @@
|
|||
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;
|
||||
|
||||
/// <summary>
|
||||
/// 智能複習間隔重複服務介面
|
||||
/// </summary>
|
||||
public interface ISpacedRepetitionService
|
||||
{
|
||||
Task<ReviewResult> ProcessReviewAsync(Guid flashcardId, ReviewRequest request);
|
||||
int CalculateCurrentMasteryLevel(Flashcard flashcard);
|
||||
Task<List<Flashcard>> GetDueFlashcardsAsync(Guid userId, DateTime? date = null, int limit = 50);
|
||||
Task<Flashcard?> GetNextReviewCardAsync(Guid userId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 智能複習間隔重複服務實現 (基於現有SM2Algorithm擴展)
|
||||
/// </summary>
|
||||
public class SpacedRepetitionService : ISpacedRepetitionService
|
||||
{
|
||||
private readonly DramaLingDbContext _context;
|
||||
private readonly ILogger<SpacedRepetitionService> _logger;
|
||||
private readonly SpacedRepetitionOptions _options;
|
||||
|
||||
public SpacedRepetitionService(
|
||||
DramaLingDbContext context,
|
||||
ILogger<SpacedRepetitionService> logger,
|
||||
IOptions<SpacedRepetitionOptions> options)
|
||||
{
|
||||
_context = context;
|
||||
_logger = logger;
|
||||
_options = options.Value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 處理復習結果並更新間隔重複算法
|
||||
/// </summary>
|
||||
public async Task<ReviewResult> 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
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 計算當前熟悉度 (考慮記憶衰減)
|
||||
/// </summary>
|
||||
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));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 取得到期詞卡列表
|
||||
/// </summary>
|
||||
public async Task<List<Flashcard>> 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();
|
||||
|
||||
// UserLevel和WordLevel欄位已移除 - 改用即時CEFR轉換
|
||||
// 不需要初始化數值欄位
|
||||
|
||||
return dueCards;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 取得下一張需要復習的詞卡 (最高優先級)
|
||||
/// </summary>
|
||||
public async Task<Flashcard?> GetNextReviewCardAsync(Guid userId)
|
||||
{
|
||||
var dueCards = await GetDueFlashcardsAsync(userId, limit: 1);
|
||||
return dueCards.FirstOrDefault();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 應用增強的間隔重複邏輯 (基於演算法規格書)
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 根據題型和表現計算表現係數
|
||||
/// </summary>
|
||||
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
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 翻卡題信心等級映射
|
||||
/// </summary>
|
||||
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
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 從請求轉換為SM2Algorithm需要的品質分數
|
||||
/// </summary>
|
||||
private int GetQualityFromRequest(ReviewRequest request)
|
||||
{
|
||||
if (request.QuestionType == "flip-memory")
|
||||
{
|
||||
return request.ConfidenceLevel ?? 3;
|
||||
}
|
||||
|
||||
return request.IsCorrect ? 4 : 2; // 客觀題簡化映射
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 計算基礎熟悉度 (基於現有算法調整)
|
||||
/// </summary>
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,498 +0,0 @@
|
|||
using DramaLing.Api.Data;
|
||||
using DramaLing.Api.Models.Entities;
|
||||
using DramaLing.Api.Services;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace DramaLing.Api.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 學習會話服務介面
|
||||
/// </summary>
|
||||
public interface IStudySessionService
|
||||
{
|
||||
Task<StudySession> StartSessionAsync(Guid userId);
|
||||
Task<CurrentTestDto> GetCurrentTestAsync(Guid sessionId);
|
||||
Task<SubmitTestResponseDto> SubmitTestAsync(Guid sessionId, SubmitTestRequestDto request);
|
||||
Task<NextTestDto> GetNextTestAsync(Guid sessionId);
|
||||
Task<ProgressDto> GetProgressAsync(Guid sessionId);
|
||||
Task<StudySession> CompleteSessionAsync(Guid sessionId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 學習會話服務實現
|
||||
/// </summary>
|
||||
public class StudySessionService : IStudySessionService
|
||||
{
|
||||
private readonly DramaLingDbContext _context;
|
||||
private readonly ILogger<StudySessionService> _logger;
|
||||
private readonly IReviewModeSelector _reviewModeSelector;
|
||||
|
||||
public StudySessionService(
|
||||
DramaLingDbContext context,
|
||||
ILogger<StudySessionService> logger,
|
||||
IReviewModeSelector reviewModeSelector)
|
||||
{
|
||||
_context = context;
|
||||
_logger = logger;
|
||||
_reviewModeSelector = reviewModeSelector;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 開始新的學習會話
|
||||
/// </summary>
|
||||
public async Task<StudySession> StartSessionAsync(Guid userId)
|
||||
{
|
||||
_logger.LogInformation("Starting new study session for user {UserId}", userId);
|
||||
|
||||
// 獲取到期詞卡
|
||||
var dueCards = await GetDueCardsAsync(userId);
|
||||
if (!dueCards.Any())
|
||||
{
|
||||
throw new InvalidOperationException("No due cards available for study");
|
||||
}
|
||||
|
||||
// 獲取用戶CEFR等級
|
||||
var user = await _context.Users.FindAsync(userId);
|
||||
var userCEFRLevel = user?.EnglishLevel ?? "A2";
|
||||
|
||||
// 創建學習會話
|
||||
var session = new StudySession
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = userId,
|
||||
SessionType = "mixed", // 混合模式
|
||||
StartedAt = DateTime.UtcNow,
|
||||
Status = SessionStatus.Active,
|
||||
TotalCards = dueCards.Count,
|
||||
CurrentCardIndex = 0
|
||||
};
|
||||
|
||||
_context.StudySessions.Add(session);
|
||||
|
||||
// 為每張詞卡創建學習進度記錄
|
||||
int totalTests = 0;
|
||||
for (int i = 0; i < dueCards.Count; i++)
|
||||
{
|
||||
var card = dueCards[i];
|
||||
var wordCEFRLevel = card.DifficultyLevel ?? "A2";
|
||||
var plannedTests = _reviewModeSelector.GetPlannedTests(userCEFRLevel, wordCEFRLevel);
|
||||
|
||||
var studyCard = new StudyCard
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
StudySessionId = session.Id,
|
||||
FlashcardId = card.Id,
|
||||
Word = card.Word,
|
||||
PlannedTests = plannedTests,
|
||||
Order = i,
|
||||
StartedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
_context.StudyCards.Add(studyCard);
|
||||
totalTests += plannedTests.Count;
|
||||
}
|
||||
|
||||
session.TotalTests = totalTests;
|
||||
|
||||
// 設置第一個測驗
|
||||
if (session.StudyCards.Any())
|
||||
{
|
||||
var firstCard = session.StudyCards.OrderBy(c => c.Order).First();
|
||||
session.CurrentTestType = firstCard.PlannedTests.First();
|
||||
}
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("Study session created: {SessionId}, Cards: {CardCount}, Tests: {TestCount}",
|
||||
session.Id, session.TotalCards, session.TotalTests);
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 獲取當前測驗
|
||||
/// </summary>
|
||||
public async Task<CurrentTestDto> GetCurrentTestAsync(Guid sessionId)
|
||||
{
|
||||
var session = await GetSessionWithDetailsAsync(sessionId);
|
||||
if (session == null || session.Status != SessionStatus.Active)
|
||||
{
|
||||
throw new InvalidOperationException("Session not found or not active");
|
||||
}
|
||||
|
||||
var currentCard = session.StudyCards.OrderBy(c => c.Order).Skip(session.CurrentCardIndex).FirstOrDefault();
|
||||
if (currentCard == null)
|
||||
{
|
||||
throw new InvalidOperationException("No current card found");
|
||||
}
|
||||
|
||||
var flashcard = await _context.Flashcards
|
||||
.FirstOrDefaultAsync(f => f.Id == currentCard.FlashcardId);
|
||||
|
||||
return new CurrentTestDto
|
||||
{
|
||||
SessionId = sessionId,
|
||||
TestType = session.CurrentTestType ?? "flip-memory",
|
||||
Card = new CardDto
|
||||
{
|
||||
Id = flashcard!.Id,
|
||||
Word = flashcard.Word,
|
||||
Translation = flashcard.Translation,
|
||||
Definition = flashcard.Definition,
|
||||
Example = flashcard.Example,
|
||||
ExampleTranslation = flashcard.ExampleTranslation,
|
||||
Pronunciation = flashcard.Pronunciation,
|
||||
DifficultyLevel = flashcard.DifficultyLevel
|
||||
},
|
||||
Progress = new ProgressSummaryDto
|
||||
{
|
||||
CurrentCardIndex = session.CurrentCardIndex,
|
||||
TotalCards = session.TotalCards,
|
||||
CompletedTests = session.CompletedTests,
|
||||
TotalTests = session.TotalTests,
|
||||
CompletedCards = session.CompletedCards
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 提交測驗結果
|
||||
/// </summary>
|
||||
public async Task<SubmitTestResponseDto> SubmitTestAsync(Guid sessionId, SubmitTestRequestDto request)
|
||||
{
|
||||
var session = await GetSessionWithDetailsAsync(sessionId);
|
||||
if (session == null || session.Status != SessionStatus.Active)
|
||||
{
|
||||
throw new InvalidOperationException("Session not found or not active");
|
||||
}
|
||||
|
||||
var currentCard = session.StudyCards.OrderBy(c => c.Order).Skip(session.CurrentCardIndex).FirstOrDefault();
|
||||
if (currentCard == null)
|
||||
{
|
||||
throw new InvalidOperationException("No current card found");
|
||||
}
|
||||
|
||||
// 記錄測驗結果
|
||||
var testResult = new TestResult
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
StudyCardId = currentCard.Id,
|
||||
TestType = request.TestType,
|
||||
IsCorrect = request.IsCorrect,
|
||||
UserAnswer = request.UserAnswer,
|
||||
ConfidenceLevel = request.ConfidenceLevel,
|
||||
ResponseTimeMs = request.ResponseTimeMs,
|
||||
CompletedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
_context.TestResults.Add(testResult);
|
||||
|
||||
// 更新會話進度
|
||||
session.CompletedTests++;
|
||||
|
||||
// 檢查當前詞卡是否完成所有測驗
|
||||
var completedTestsForCard = await _context.TestResults
|
||||
.Where(tr => tr.StudyCardId == currentCard.Id)
|
||||
.CountAsync() + 1; // +1 因為當前測驗還未保存
|
||||
|
||||
if (completedTestsForCard >= currentCard.PlannedTestsCount)
|
||||
{
|
||||
// 詞卡完成,觸發SM2算法更新
|
||||
currentCard.IsCompleted = true;
|
||||
currentCard.CompletedAt = DateTime.UtcNow;
|
||||
session.CompletedCards++;
|
||||
|
||||
await UpdateFlashcardWithSM2Async(currentCard, testResult);
|
||||
}
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
return new SubmitTestResponseDto
|
||||
{
|
||||
Success = true,
|
||||
IsCardCompleted = currentCard.IsCompleted,
|
||||
Progress = new ProgressSummaryDto
|
||||
{
|
||||
CurrentCardIndex = session.CurrentCardIndex,
|
||||
TotalCards = session.TotalCards,
|
||||
CompletedTests = session.CompletedTests,
|
||||
TotalTests = session.TotalTests,
|
||||
CompletedCards = session.CompletedCards
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 獲取下一個測驗
|
||||
/// </summary>
|
||||
public async Task<NextTestDto> GetNextTestAsync(Guid sessionId)
|
||||
{
|
||||
var session = await GetSessionWithDetailsAsync(sessionId);
|
||||
if (session == null || session.Status != SessionStatus.Active)
|
||||
{
|
||||
throw new InvalidOperationException("Session not found or not active");
|
||||
}
|
||||
|
||||
var currentCard = session.StudyCards.OrderBy(c => c.Order).Skip(session.CurrentCardIndex).FirstOrDefault();
|
||||
if (currentCard == null)
|
||||
{
|
||||
return new NextTestDto { HasNextTest = false, Message = "All cards completed" };
|
||||
}
|
||||
|
||||
// 檢查當前詞卡是否還有未完成的測驗
|
||||
var completedTestTypes = await _context.TestResults
|
||||
.Where(tr => tr.StudyCardId == currentCard.Id)
|
||||
.Select(tr => tr.TestType)
|
||||
.ToListAsync();
|
||||
|
||||
var nextTestType = currentCard.PlannedTests.FirstOrDefault(t => !completedTestTypes.Contains(t));
|
||||
|
||||
if (nextTestType != null)
|
||||
{
|
||||
// 當前詞卡還有測驗
|
||||
session.CurrentTestType = nextTestType;
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
return new NextTestDto
|
||||
{
|
||||
HasNextTest = true,
|
||||
TestType = nextTestType,
|
||||
SameCard = true,
|
||||
Message = $"Next test: {nextTestType}"
|
||||
};
|
||||
}
|
||||
else
|
||||
{
|
||||
// 當前詞卡完成,移到下一張詞卡
|
||||
session.CurrentCardIndex++;
|
||||
|
||||
if (session.CurrentCardIndex < session.TotalCards)
|
||||
{
|
||||
var nextCard = session.StudyCards.OrderBy(c => c.Order).Skip(session.CurrentCardIndex).FirstOrDefault();
|
||||
session.CurrentTestType = nextCard?.PlannedTests.FirstOrDefault();
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
return new NextTestDto
|
||||
{
|
||||
HasNextTest = true,
|
||||
TestType = session.CurrentTestType!,
|
||||
SameCard = false,
|
||||
Message = "Moving to next card"
|
||||
};
|
||||
}
|
||||
else
|
||||
{
|
||||
// 所有詞卡完成
|
||||
session.Status = SessionStatus.Completed;
|
||||
session.EndedAt = DateTime.UtcNow;
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
return new NextTestDto
|
||||
{
|
||||
HasNextTest = false,
|
||||
Message = "Session completed"
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 獲取詳細進度
|
||||
/// </summary>
|
||||
public async Task<ProgressDto> GetProgressAsync(Guid sessionId)
|
||||
{
|
||||
var session = await GetSessionWithDetailsAsync(sessionId);
|
||||
if (session == null)
|
||||
{
|
||||
throw new InvalidOperationException("Session not found");
|
||||
}
|
||||
|
||||
var cardProgress = session.StudyCards.Select(card => new CardProgressDto
|
||||
{
|
||||
CardId = card.FlashcardId,
|
||||
Word = card.Word,
|
||||
PlannedTests = card.PlannedTests,
|
||||
CompletedTestsCount = card.TestResults.Count,
|
||||
IsCompleted = card.IsCompleted,
|
||||
Tests = card.TestResults.Select(tr => new TestProgressDto
|
||||
{
|
||||
TestType = tr.TestType,
|
||||
IsCorrect = tr.IsCorrect,
|
||||
CompletedAt = tr.CompletedAt
|
||||
}).ToList()
|
||||
}).ToList();
|
||||
|
||||
return new ProgressDto
|
||||
{
|
||||
SessionId = sessionId,
|
||||
Status = session.Status.ToString(),
|
||||
CurrentCardIndex = session.CurrentCardIndex,
|
||||
TotalCards = session.TotalCards,
|
||||
CompletedTests = session.CompletedTests,
|
||||
TotalTests = session.TotalTests,
|
||||
CompletedCards = session.CompletedCards,
|
||||
Cards = cardProgress
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 完成學習會話
|
||||
/// </summary>
|
||||
public async Task<StudySession> CompleteSessionAsync(Guid sessionId)
|
||||
{
|
||||
var session = await GetSessionWithDetailsAsync(sessionId);
|
||||
if (session == null)
|
||||
{
|
||||
throw new InvalidOperationException("Session not found");
|
||||
}
|
||||
|
||||
session.Status = SessionStatus.Completed;
|
||||
session.EndedAt = DateTime.UtcNow;
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("Study session completed: {SessionId}", sessionId);
|
||||
return session;
|
||||
}
|
||||
|
||||
// Helper Methods
|
||||
|
||||
private async Task<StudySession?> GetSessionWithDetailsAsync(Guid sessionId)
|
||||
{
|
||||
return await _context.StudySessions
|
||||
.Include(s => s.StudyCards)
|
||||
.ThenInclude(sc => sc.TestResults)
|
||||
.Include(s => s.StudyCards)
|
||||
.ThenInclude(sc => sc.Flashcard)
|
||||
.FirstOrDefaultAsync(s => s.Id == sessionId);
|
||||
}
|
||||
|
||||
private async Task<List<Flashcard>> GetDueCardsAsync(Guid userId, int limit = 50)
|
||||
{
|
||||
var today = DateTime.Today;
|
||||
return await _context.Flashcards
|
||||
.Where(f => f.UserId == userId &&
|
||||
(f.NextReviewDate <= today || f.Repetitions == 0))
|
||||
.OrderBy(f => f.NextReviewDate)
|
||||
.Take(limit)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
private async Task UpdateFlashcardWithSM2Async(StudyCard studyCard, TestResult latestResult)
|
||||
{
|
||||
var flashcard = await _context.Flashcards.FindAsync(studyCard.FlashcardId);
|
||||
if (flashcard == null) return;
|
||||
|
||||
// 計算詞卡的綜合表現
|
||||
var allResults = await _context.TestResults
|
||||
.Where(tr => tr.StudyCardId == studyCard.Id)
|
||||
.ToListAsync();
|
||||
|
||||
var correctCount = allResults.Count(r => r.IsCorrect);
|
||||
var totalTests = allResults.Count;
|
||||
var accuracy = totalTests > 0 ? (double)correctCount / totalTests : 0;
|
||||
|
||||
// 使用現有的SM2Algorithm
|
||||
var quality = accuracy >= 0.8 ? 5 : accuracy >= 0.6 ? 4 : accuracy >= 0.4 ? 3 : 2;
|
||||
var sm2Input = new SM2Input(quality, flashcard.EasinessFactor, flashcard.Repetitions, flashcard.IntervalDays);
|
||||
var sm2Result = SM2Algorithm.Calculate(sm2Input);
|
||||
|
||||
// 更新詞卡
|
||||
flashcard.EasinessFactor = sm2Result.EasinessFactor;
|
||||
flashcard.Repetitions = sm2Result.Repetitions;
|
||||
flashcard.IntervalDays = sm2Result.IntervalDays;
|
||||
flashcard.NextReviewDate = sm2Result.NextReviewDate;
|
||||
flashcard.MasteryLevel = SM2Algorithm.CalculateMastery(sm2Result.Repetitions, sm2Result.EasinessFactor);
|
||||
flashcard.TimesReviewed++;
|
||||
if (accuracy >= 0.7) flashcard.TimesCorrect++;
|
||||
flashcard.LastReviewedAt = DateTime.UtcNow;
|
||||
|
||||
_logger.LogInformation("Updated flashcard {Word} with SM2: Mastery={Mastery}, NextReview={NextReview}",
|
||||
flashcard.Word, flashcard.MasteryLevel, sm2Result.NextReviewDate);
|
||||
}
|
||||
}
|
||||
|
||||
// DTOs
|
||||
|
||||
public class CurrentTestDto
|
||||
{
|
||||
public Guid SessionId { get; set; }
|
||||
public string TestType { get; set; } = string.Empty;
|
||||
public CardDto Card { get; set; } = new();
|
||||
public ProgressSummaryDto Progress { get; set; } = new();
|
||||
}
|
||||
|
||||
public class SubmitTestRequestDto
|
||||
{
|
||||
public string TestType { get; set; } = string.Empty;
|
||||
public bool IsCorrect { get; set; }
|
||||
public string? UserAnswer { get; set; }
|
||||
public int? ConfidenceLevel { get; set; }
|
||||
public int ResponseTimeMs { get; set; }
|
||||
}
|
||||
|
||||
public class SubmitTestResponseDto
|
||||
{
|
||||
public bool Success { get; set; }
|
||||
public bool IsCardCompleted { get; set; }
|
||||
public ProgressSummaryDto Progress { get; set; } = new();
|
||||
public string Message { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public class NextTestDto
|
||||
{
|
||||
public bool HasNextTest { get; set; }
|
||||
public string? TestType { get; set; }
|
||||
public bool SameCard { get; set; }
|
||||
public string Message { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public class ProgressDto
|
||||
{
|
||||
public Guid SessionId { get; set; }
|
||||
public string Status { get; set; } = string.Empty;
|
||||
public int CurrentCardIndex { get; set; }
|
||||
public int TotalCards { get; set; }
|
||||
public int CompletedTests { get; set; }
|
||||
public int TotalTests { get; set; }
|
||||
public int CompletedCards { get; set; }
|
||||
public List<CardProgressDto> Cards { get; set; } = new();
|
||||
}
|
||||
|
||||
public class CardProgressDto
|
||||
{
|
||||
public Guid CardId { get; set; }
|
||||
public string Word { get; set; } = string.Empty;
|
||||
public List<string> PlannedTests { get; set; } = new();
|
||||
public int CompletedTestsCount { get; set; }
|
||||
public bool IsCompleted { get; set; }
|
||||
public List<TestProgressDto> Tests { get; set; } = new();
|
||||
}
|
||||
|
||||
public class TestProgressDto
|
||||
{
|
||||
public string TestType { get; set; } = string.Empty;
|
||||
public bool IsCorrect { get; set; }
|
||||
public DateTime CompletedAt { get; set; }
|
||||
}
|
||||
|
||||
public class ProgressSummaryDto
|
||||
{
|
||||
public int CurrentCardIndex { get; set; }
|
||||
public int TotalCards { get; set; }
|
||||
public int CompletedTests { get; set; }
|
||||
public int TotalTests { get; set; }
|
||||
public int CompletedCards { get; set; }
|
||||
}
|
||||
|
||||
public class CardDto
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public string Word { get; set; } = string.Empty;
|
||||
public string Translation { get; set; } = string.Empty;
|
||||
public string Definition { get; set; } = string.Empty;
|
||||
public string Example { get; set; } = string.Empty;
|
||||
public string ExampleTranslation { get; set; } = string.Empty;
|
||||
public string Pronunciation { get; set; } = string.Empty;
|
||||
public string DifficultyLevel { get; set; } = string.Empty;
|
||||
}
|
||||
|
|
@ -1,127 +0,0 @@
|
|||
namespace DramaLing.Api.Services;
|
||||
|
||||
public interface IWordVariationService
|
||||
{
|
||||
string[] GetCommonVariations(string word);
|
||||
bool IsVariationOf(string baseWord, string variation);
|
||||
}
|
||||
|
||||
public class WordVariationService : IWordVariationService
|
||||
{
|
||||
private readonly ILogger<WordVariationService> _logger;
|
||||
|
||||
public WordVariationService(ILogger<WordVariationService> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
private readonly Dictionary<string, string[]> CommonVariations = new()
|
||||
{
|
||||
["eat"] = ["eats", "ate", "eaten", "eating"],
|
||||
["go"] = ["goes", "went", "gone", "going"],
|
||||
["have"] = ["has", "had", "having"],
|
||||
["be"] = ["am", "is", "are", "was", "were", "been", "being"],
|
||||
["do"] = ["does", "did", "done", "doing"],
|
||||
["take"] = ["takes", "took", "taken", "taking"],
|
||||
["make"] = ["makes", "made", "making"],
|
||||
["come"] = ["comes", "came", "coming"],
|
||||
["see"] = ["sees", "saw", "seen", "seeing"],
|
||||
["get"] = ["gets", "got", "gotten", "getting"],
|
||||
["give"] = ["gives", "gave", "given", "giving"],
|
||||
["know"] = ["knows", "knew", "known", "knowing"],
|
||||
["think"] = ["thinks", "thought", "thinking"],
|
||||
["say"] = ["says", "said", "saying"],
|
||||
["tell"] = ["tells", "told", "telling"],
|
||||
["find"] = ["finds", "found", "finding"],
|
||||
["work"] = ["works", "worked", "working"],
|
||||
["feel"] = ["feels", "felt", "feeling"],
|
||||
["try"] = ["tries", "tried", "trying"],
|
||||
["ask"] = ["asks", "asked", "asking"],
|
||||
["need"] = ["needs", "needed", "needing"],
|
||||
["seem"] = ["seems", "seemed", "seeming"],
|
||||
["turn"] = ["turns", "turned", "turning"],
|
||||
["start"] = ["starts", "started", "starting"],
|
||||
["show"] = ["shows", "showed", "shown", "showing"],
|
||||
["hear"] = ["hears", "heard", "hearing"],
|
||||
["play"] = ["plays", "played", "playing"],
|
||||
["run"] = ["runs", "ran", "running"],
|
||||
["move"] = ["moves", "moved", "moving"],
|
||||
["live"] = ["lives", "lived", "living"],
|
||||
["believe"] = ["believes", "believed", "believing"],
|
||||
["hold"] = ["holds", "held", "holding"],
|
||||
["bring"] = ["brings", "brought", "bringing"],
|
||||
["happen"] = ["happens", "happened", "happening"],
|
||||
["write"] = ["writes", "wrote", "written", "writing"],
|
||||
["sit"] = ["sits", "sat", "sitting"],
|
||||
["stand"] = ["stands", "stood", "standing"],
|
||||
["lose"] = ["loses", "lost", "losing"],
|
||||
["pay"] = ["pays", "paid", "paying"],
|
||||
["meet"] = ["meets", "met", "meeting"],
|
||||
["include"] = ["includes", "included", "including"],
|
||||
["continue"] = ["continues", "continued", "continuing"],
|
||||
["set"] = ["sets", "setting"],
|
||||
["learn"] = ["learns", "learned", "learnt", "learning"],
|
||||
["change"] = ["changes", "changed", "changing"],
|
||||
["lead"] = ["leads", "led", "leading"],
|
||||
["understand"] = ["understands", "understood", "understanding"],
|
||||
["watch"] = ["watches", "watched", "watching"],
|
||||
["follow"] = ["follows", "followed", "following"],
|
||||
["stop"] = ["stops", "stopped", "stopping"],
|
||||
["create"] = ["creates", "created", "creating"],
|
||||
["speak"] = ["speaks", "spoke", "spoken", "speaking"],
|
||||
["read"] = ["reads", "reading"],
|
||||
["spend"] = ["spends", "spent", "spending"],
|
||||
["grow"] = ["grows", "grew", "grown", "growing"],
|
||||
["open"] = ["opens", "opened", "opening"],
|
||||
["walk"] = ["walks", "walked", "walking"],
|
||||
["win"] = ["wins", "won", "winning"],
|
||||
["offer"] = ["offers", "offered", "offering"],
|
||||
["remember"] = ["remembers", "remembered", "remembering"],
|
||||
["love"] = ["loves", "loved", "loving"],
|
||||
["consider"] = ["considers", "considered", "considering"],
|
||||
["appear"] = ["appears", "appeared", "appearing"],
|
||||
["buy"] = ["buys", "bought", "buying"],
|
||||
["wait"] = ["waits", "waited", "waiting"],
|
||||
["serve"] = ["serves", "served", "serving"],
|
||||
["die"] = ["dies", "died", "dying"],
|
||||
["send"] = ["sends", "sent", "sending"],
|
||||
["expect"] = ["expects", "expected", "expecting"],
|
||||
["build"] = ["builds", "built", "building"],
|
||||
["stay"] = ["stays", "stayed", "staying"],
|
||||
["fall"] = ["falls", "fell", "fallen", "falling"],
|
||||
["cut"] = ["cuts", "cutting"],
|
||||
["reach"] = ["reaches", "reached", "reaching"],
|
||||
["kill"] = ["kills", "killed", "killing"],
|
||||
["remain"] = ["remains", "remained", "remaining"]
|
||||
};
|
||||
|
||||
public string[] GetCommonVariations(string word)
|
||||
{
|
||||
if (string.IsNullOrEmpty(word))
|
||||
return Array.Empty<string>();
|
||||
|
||||
var lowercaseWord = word.ToLower();
|
||||
if (CommonVariations.TryGetValue(lowercaseWord, out var variations))
|
||||
{
|
||||
_logger.LogDebug("Found {Count} variations for word: {Word}", variations.Length, word);
|
||||
return variations;
|
||||
}
|
||||
|
||||
_logger.LogDebug("No variations found for word: {Word}", word);
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
|
||||
public bool IsVariationOf(string baseWord, string variation)
|
||||
{
|
||||
if (string.IsNullOrEmpty(baseWord) || string.IsNullOrEmpty(variation))
|
||||
return false;
|
||||
|
||||
var variations = GetCommonVariations(baseWord);
|
||||
var result = variations.Contains(variation.ToLower());
|
||||
|
||||
_logger.LogDebug("Checking if {Variation} is variation of {BaseWord}: {Result}",
|
||||
variation, baseWord, result);
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
|
@ -59,23 +59,5 @@
|
|||
"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
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -229,7 +229,7 @@ def weighted_select(types, weights):
|
|||
計算難度差異 (wordLevel - userLevel)
|
||||
↓
|
||||
判斷學習情境
|
||||
├── A1學習者 (≤20) → 基礎3題型池
|
||||
├── A1學習者 (user的cefr=A1) → 基礎3題型池
|
||||
├── 簡單詞彙 (<-10) → 應用2題型池
|
||||
├── 適中詞彙 (-10~10) → 全方位3題型池
|
||||
└── 困難詞彙 (>10) → 基礎2題型池
|
||||
|
|
|
|||
|
|
@ -0,0 +1,514 @@
|
|||
# 後端複習系統清空執行計劃
|
||||
|
||||
**目標**: 完全移除當前後端複雜的複習系統程式碼,準備重新實施簡潔版本
|
||||
**日期**: 2025-09-29
|
||||
**執行範圍**: DramaLing.Api 後端專案
|
||||
**風險等級**: 🟡 中等 (需要仔細執行以避免破壞核心功能)
|
||||
|
||||
---
|
||||
|
||||
## 🎯 **清空目標與範圍**
|
||||
|
||||
### **清空目標**
|
||||
1. **移除複雜的智能複習邏輯**: 包含 Session 概念、複雜隊列管理
|
||||
2. **保留核心詞卡功能**: 基本 CRUD 和簡單統計
|
||||
3. **為重新實施做準備**: 清潔的代碼基礎
|
||||
|
||||
### **保留功能**
|
||||
- ✅ 詞卡基本 CRUD (FlashcardsController)
|
||||
- ✅ 用戶認證 (AuthController)
|
||||
- ✅ AI 分析服務 (AIController)
|
||||
- ✅ 音訊服務 (AudioController)
|
||||
- ✅ 圖片生成 (ImageGenerationController)
|
||||
- ✅ 基礎統計 (StatsController)
|
||||
|
||||
---
|
||||
|
||||
## 🗄️ **資料庫結構盤點**
|
||||
|
||||
### **複習系統相關資料表**
|
||||
```sql
|
||||
📊 當前資料庫表結構分析:
|
||||
|
||||
✅ 需要保留的核心表:
|
||||
├── user_profiles 用戶基本資料
|
||||
├── flashcards 詞卡核心資料 (需簡化)
|
||||
├── study_records 學習記錄 (需簡化)
|
||||
├── daily_stats 每日統計
|
||||
├── audio_cache 音訊快取
|
||||
├── example_images 例句圖片
|
||||
├── flashcard_example_images 詞卡圖片關聯
|
||||
├── image_generation_requests 圖片生成請求
|
||||
├── options_vocabularies 選項詞彙庫
|
||||
├── error_reports 錯誤報告
|
||||
└── sentence_analysis_cache 句子分析快取
|
||||
|
||||
❌ 需要處理的複習相關表:
|
||||
├── study_sessions 學習會話表 🗑️ 刪除
|
||||
├── study_cards 會話詞卡表 🗑️ 刪除
|
||||
├── test_results 測驗結果表 🗑️ 刪除
|
||||
└── pronunciation_assessments 發音評估 ⚠️ 檢查關聯
|
||||
```
|
||||
|
||||
### **資料庫遷移文件**
|
||||
```
|
||||
📁 Migrations/
|
||||
├── 20250926053105_AddStudyCardAndTestResult.cs 🗑️ 需要回滾
|
||||
├── 20250926053105_AddStudyCardAndTestResult.Designer.cs 🗑️ 需要回滾
|
||||
├── 20250926061341_AddStudyRecordUniqueIndex.cs ⚠️ 可能保留
|
||||
├── 20250926061341_AddStudyRecordUniqueIndex.Designer.cs ⚠️ 可能保留
|
||||
└── 其他遷移文件 ✅ 保留
|
||||
```
|
||||
|
||||
### **資料庫清理策略**
|
||||
1. **StudyRecord 表處理**:
|
||||
- ✅ **保留基本結構**: 作為簡化的學習記錄
|
||||
- 🔧 **移除複雜欄位**: SM-2 相關的追蹤欄位
|
||||
- ⚠️ **保留核心欄位**: user_id, flashcard_id, study_mode, is_correct, studied_at
|
||||
|
||||
2. **複雜表結構移除**:
|
||||
- 🗑️ **study_sessions**: 完全移除 (Session 概念)
|
||||
- 🗑️ **study_cards**: 完全移除 (Session 相關)
|
||||
- 🗑️ **test_results**: 完全移除 (與 StudyRecord 重複)
|
||||
|
||||
3. **遷移文件處理**:
|
||||
- 📝 **創建回滾遷移**: 移除 study_sessions, study_cards, test_results 表
|
||||
- ✅ **保留核心遷移**: StudyRecord 基本結構保留
|
||||
|
||||
---
|
||||
|
||||
## 📋 **完整清空文件清單**
|
||||
|
||||
### 🗑️ **需要完全刪除的文件**
|
||||
|
||||
#### **服務層文件**
|
||||
```
|
||||
📁 Services/
|
||||
├── SpacedRepetitionService.cs 🗑️ 完全刪除 (8,574 bytes)
|
||||
├── ReviewTypeSelectorService.cs 🗑️ 完全刪除 (8,887 bytes)
|
||||
├── ReviewModeSelector.cs 🗑️ 完全刪除 (2,598 bytes)
|
||||
├── QuestionGeneratorService.cs 🗑️ 檢查後可能刪除
|
||||
└── BlankGenerationService.cs 🗑️ 檢查後可能刪除
|
||||
```
|
||||
|
||||
#### **DTO 相關文件**
|
||||
```
|
||||
📁 Models/DTOs/SpacedRepetition/
|
||||
├── ReviewModeResult.cs 🗑️ 完全刪除
|
||||
├── ReviewRequest.cs 🗑️ 完全刪除
|
||||
├── ReviewResult.cs 🗑️ 完全刪除
|
||||
└── 整個 SpacedRepetition 資料夾 🗑️ 完全刪除
|
||||
```
|
||||
|
||||
#### **配置文件**
|
||||
```
|
||||
📁 Models/Configuration/
|
||||
└── SpacedRepetitionOptions.cs 🗑️ 完全刪除
|
||||
```
|
||||
|
||||
#### **實體文件**
|
||||
```
|
||||
📁 Models/Entities/
|
||||
├── StudySession.cs 🗑️ 完全刪除 (如存在)
|
||||
├── StudyCard.cs 🗑️ 完全刪除 (如存在)
|
||||
└── TestResult.cs 🗑️ 完全刪除 (如存在)
|
||||
```
|
||||
|
||||
### ⚠️ **需要大幅簡化的文件**
|
||||
|
||||
#### **控制器文件**
|
||||
```
|
||||
📁 Controllers/
|
||||
└── StudyController.cs 🔧 大幅簡化
|
||||
├── 移除所有智能複習 API (due, next-review, optimal-mode, question, review)
|
||||
├── 保留基礎統計 (stats)
|
||||
├── 保留測驗記錄 (record-test, completed-tests)
|
||||
└── 移除複雜的 Session 邏輯
|
||||
```
|
||||
|
||||
#### **核心配置文件**
|
||||
```
|
||||
📁 根目錄文件
|
||||
├── Program.cs 🔧 移除複習服務註冊
|
||||
├── DramaLingDbContext.cs 🔧 移除複習相關配置
|
||||
└── appsettings.json 🔧 移除 SpacedRepetition 配置
|
||||
```
|
||||
|
||||
#### **實體文件**
|
||||
```
|
||||
📁 Models/Entities/
|
||||
├── Flashcard.cs 🔧 簡化複習相關屬性
|
||||
├── User.cs ✅ 基本保持不變
|
||||
└── StudyRecord.cs 🔧 簡化為基礎記錄
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔍 **詳細清空步驟**
|
||||
|
||||
### **第一階段:停止服務並備份**
|
||||
1. **停止當前運行的服務**
|
||||
```bash
|
||||
# 停止所有後端服務
|
||||
pkill -f "dotnet run"
|
||||
```
|
||||
|
||||
2. **創建備份分支** (可選)
|
||||
```bash
|
||||
git checkout -b backup/before-review-cleanup
|
||||
git add .
|
||||
git commit -m "backup: 清空前的複習系統狀態備份"
|
||||
```
|
||||
|
||||
### **第二階段:刪除服務層文件**
|
||||
```bash
|
||||
# 刪除智能複習服務
|
||||
rm Services/SpacedRepetitionService.cs
|
||||
rm Services/ReviewTypeSelectorService.cs
|
||||
rm Services/ReviewModeSelector.cs
|
||||
|
||||
# 檢查並決定是否刪除
|
||||
# rm Services/QuestionGeneratorService.cs # 可能被選項詞彙庫使用
|
||||
# rm Services/BlankGenerationService.cs # 可能被其他功能使用
|
||||
```
|
||||
|
||||
### **第三階段:刪除 DTO 和配置文件**
|
||||
```bash
|
||||
# 刪除整個 SpacedRepetition DTO 資料夾
|
||||
rm -rf Models/DTOs/SpacedRepetition/
|
||||
|
||||
# 刪除配置文件
|
||||
rm Models/Configuration/SpacedRepetitionOptions.cs
|
||||
```
|
||||
|
||||
### **第四階段:簡化 StudyController**
|
||||
需要手動編輯 `Controllers/StudyController.cs`:
|
||||
```csharp
|
||||
// 移除的內容:
|
||||
- 智能複習服務依賴注入 (ISpacedRepetitionService, IReviewTypeSelectorService 等)
|
||||
- 智能複習 API 方法 (GetDueCards, GetNextReview, GetOptimalReviewMode, GenerateQuestion, SubmitReview)
|
||||
- 複雜的 Session 相關邏輯
|
||||
|
||||
// 保留的內容:
|
||||
- 基礎統計 (GetStudyStats)
|
||||
- 測驗記錄 (RecordTestCompletion, GetCompletedTests)
|
||||
- 基礎認證和日誌功能
|
||||
```
|
||||
|
||||
### **第五階段:清理 Program.cs 服務註冊**
|
||||
```csharp
|
||||
// 移除的服務註冊:
|
||||
// builder.Services.AddScoped<ISpacedRepetitionService, SpacedRepetitionService>();
|
||||
// builder.Services.AddScoped<IReviewTypeSelectorService, ReviewTypeSelectorService>();
|
||||
// builder.Services.AddScoped<IQuestionGeneratorService, QuestionGeneratorService>();
|
||||
// builder.Services.Configure<SpacedRepetitionOptions>(...);
|
||||
```
|
||||
|
||||
### **第六階段:簡化資料模型**
|
||||
1. **簡化 Flashcard.cs**
|
||||
```csharp
|
||||
// 移除的屬性:
|
||||
- ReviewHistory
|
||||
- LastQuestionType
|
||||
- 複雜的 SM-2 算法屬性 (可選保留基礎的)
|
||||
|
||||
// 保留的屬性:
|
||||
- 基本詞卡內容 (Word, Translation, Definition 等)
|
||||
- 基礎學習狀態 (MasteryLevel, TimesReviewed)
|
||||
- 基礎複習間隔 (NextReviewDate, IntervalDays)
|
||||
```
|
||||
|
||||
2. **簡化 StudyRecord.cs**
|
||||
```csharp
|
||||
// 保留簡化版本:
|
||||
- 基本測驗記錄 (FlashcardId, TestType, IsCorrect)
|
||||
- 移除複雜的 SM-2 追蹤參數
|
||||
```
|
||||
|
||||
### **第七階段:資料庫結構清理**
|
||||
|
||||
#### **7.1 清理 DramaLingDbContext.cs**
|
||||
```csharp
|
||||
// 移除的 DbSet:
|
||||
- DbSet<StudySession> StudySessions 🗑️ 刪除
|
||||
- DbSet<StudyCard> StudyCards 🗑️ 刪除
|
||||
- DbSet<TestResult> TestResults 🗑️ 刪除
|
||||
|
||||
// 移除的 ToTable 配置:
|
||||
- .ToTable("study_sessions") 🗑️ 刪除
|
||||
- .ToTable("study_cards") 🗑️ 刪除
|
||||
- .ToTable("test_results") 🗑️ 刪除
|
||||
|
||||
// 移除的關聯配置:
|
||||
- StudySession 與 User 的關聯
|
||||
- StudyCard 與 StudySession 的關聯
|
||||
- TestResult 與 StudyCard 的關聯
|
||||
- PronunciationAssessment 與 StudySession 的關聯
|
||||
|
||||
// 簡化 StudyRecord 配置:
|
||||
- 移除複雜的 SM-2 追蹤欄位配置
|
||||
- 保留基本的 user_id, flashcard_id, study_mode 索引
|
||||
```
|
||||
|
||||
#### **7.2 創建資料庫清理遷移**
|
||||
```bash
|
||||
# 創建新的遷移來移除複雜表結構
|
||||
dotnet ef migrations add RemoveComplexStudyTables
|
||||
|
||||
# 在遷移中執行:
|
||||
migrationBuilder.DropTable("study_sessions");
|
||||
migrationBuilder.DropTable("study_cards");
|
||||
migrationBuilder.DropTable("test_results");
|
||||
|
||||
# 移除 PronunciationAssessment 中的 StudySessionId 欄位
|
||||
migrationBuilder.DropColumn("study_session_id", "pronunciation_assessments");
|
||||
```
|
||||
|
||||
#### **7.3 清理配置文件**
|
||||
```json
|
||||
// appsettings.json - 移除的配置段落:
|
||||
- "SpacedRepetition": { ... } 🗑️ 完全移除
|
||||
```
|
||||
|
||||
#### **7.4 簡化 Flashcard 實體**
|
||||
```csharp
|
||||
// Flashcard.cs - 移除的複習相關屬性:
|
||||
- ReviewHistory (JSON 複習歷史) 🗑️ 移除
|
||||
- LastQuestionType 🗑️ 移除
|
||||
- 複雜的 SM-2 追蹤欄位 (可選保留基礎的) ⚠️ 檢查
|
||||
|
||||
// 保留的基本屬性:
|
||||
- EasinessFactor, Repetitions, IntervalDays ✅ 保留 (基礎複習間隔)
|
||||
- NextReviewDate, MasteryLevel ✅ 保留 (基本狀態)
|
||||
- TimesReviewed, TimesCorrect ✅ 保留 (統計)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🧹 **清空後的目標架構**
|
||||
|
||||
### **簡化後的 API 端點**
|
||||
```
|
||||
📍 保留的端點:
|
||||
├── /api/flashcards/* 詞卡 CRUD (6個端點)
|
||||
├── /api/auth/* 用戶認證 (4個端點)
|
||||
├── /api/ai/* AI 分析 (3個端點)
|
||||
├── /api/audio/* 音訊服務 (4個端點)
|
||||
├── /api/ImageGeneration/* 圖片生成 (4個端點)
|
||||
├── /api/stats/* 統計分析 (3個端點)
|
||||
└── /api/study/* 簡化學習 (2個端點)
|
||||
├── GET /stats 學習統計
|
||||
└── POST /record-test 測驗記錄
|
||||
|
||||
❌ 移除的端點:
|
||||
├── /api/study/due (複雜的到期詞卡邏輯)
|
||||
├── /api/study/next-review (複雜的下一張邏輯)
|
||||
├── /api/study/{id}/optimal-mode (智能模式選擇)
|
||||
├── /api/study/{id}/question (題目生成)
|
||||
├── /api/study/{id}/review (複習結果提交)
|
||||
└── 所有 Session 相關端點
|
||||
```
|
||||
|
||||
### **簡化後的服務層**
|
||||
```
|
||||
📦 保留的服務:
|
||||
├── AuthService 認證服務
|
||||
├── GeminiService AI 分析
|
||||
├── AnalysisService 句子分析
|
||||
├── AzureSpeechService 語音服務
|
||||
├── AudioCacheService 音訊快取
|
||||
├── ImageGenerationOrchestrator 圖片生成
|
||||
├── ImageStorageService 圖片儲存
|
||||
├── UsageTrackingService 使用追蹤
|
||||
└── OptionsVocabularyService 選項詞彙庫
|
||||
|
||||
❌ 移除的服務:
|
||||
├── SpacedRepetitionService 間隔重複算法
|
||||
├── ReviewTypeSelectorService 複習題型選擇
|
||||
├── ReviewModeSelector 複習模式選擇
|
||||
├── StudySessionService 學習會話管理
|
||||
└── 相關的介面檔案
|
||||
```
|
||||
|
||||
### **簡化後的資料模型**
|
||||
```
|
||||
📊 核心實體 (簡化版):
|
||||
├── User 基本用戶資料
|
||||
├── Flashcard 基本詞卡 (移除複雜複習屬性)
|
||||
├── StudyRecord 簡化學習記錄
|
||||
├── DailyStats 基礎統計
|
||||
├── AudioCache 音訊快取
|
||||
├── ExampleImage 例句圖片
|
||||
├── OptionsVocabulary 選項詞彙庫
|
||||
└── ErrorReport 錯誤報告
|
||||
|
||||
❌ 移除的實體:
|
||||
├── StudySession 學習會話
|
||||
├── StudyCard 會話詞卡
|
||||
├── TestResult 測驗結果
|
||||
└── 複雜的複習相關實體
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚡ **預期清空效果**
|
||||
|
||||
### **代碼量減少**
|
||||
- **服務層**: 減少 ~20,000 行複雜邏輯
|
||||
- **DTO 層**: 減少 ~1,000 行傳輸物件
|
||||
- **控制器**: StudyController 從 583行 → ~100行
|
||||
- **總計**: 預計減少 ~25,000 行複雜代碼
|
||||
|
||||
### **API 端點簡化**
|
||||
- **移除端點**: 5-8 個複雜的智能複習端點
|
||||
- **保留端點**: ~25 個核心功能端點
|
||||
- **複雜度**: 從複雜多層依賴 → 簡單直接邏輯
|
||||
|
||||
### **系統複雜度**
|
||||
- **服務依賴**: 從 8個複習服務 → 0個
|
||||
- **資料實體**: 從 18個 → ~12個 核心實體
|
||||
- **配置項目**: 從複雜參數配置 → 基本配置
|
||||
|
||||
---
|
||||
|
||||
## 🛡️ **風險控制措施**
|
||||
|
||||
### **清空前檢查**
|
||||
1. **確認無前端依賴**: 檢查前端是否調用即將刪除的 API
|
||||
2. **資料備份**: 確保重要資料已備份
|
||||
3. **服務停止**: 確保所有相關服務已停止
|
||||
|
||||
### **分階段執行**
|
||||
1. **先註解服務註冊**: 在 Program.cs 中註解掉服務,確保編譯通過
|
||||
2. **逐步刪除文件**: 按依賴關係順序刪除
|
||||
3. **驗證編譯**: 每階段後驗證系統可編譯
|
||||
4. **功能測試**: 確保保留功能正常運作
|
||||
|
||||
### **回滾準備**
|
||||
1. **Git 分支備份**: 清空前創建備份分支
|
||||
2. **關鍵文件備份**: 手動備份重要配置文件
|
||||
3. **快速恢復腳本**: 準備快速恢復命令
|
||||
|
||||
---
|
||||
|
||||
## 📝 **執行步驟檢查清單**
|
||||
|
||||
### **準備階段**
|
||||
- [ ] 停止所有後端服務
|
||||
- [ ] 創建 Git 備份分支
|
||||
- [ ] 確認前端無依賴調用
|
||||
- [ ] 備份關鍵配置文件
|
||||
|
||||
### **刪除階段**
|
||||
- [ ] 註解 Program.cs 服務註冊
|
||||
- [ ] 刪除 SpacedRepetition DTO 資料夾
|
||||
- [ ] 刪除複習相關服務文件
|
||||
- [ ] 刪除配置文件
|
||||
- [ ] 簡化 StudyController
|
||||
|
||||
### **清理階段**
|
||||
- [ ] 清理 DramaLingDbContext 配置
|
||||
- [ ] 簡化 Flashcard 實體
|
||||
- [ ] 移除 appsettings 複習配置
|
||||
- [ ] 清理 using 語句
|
||||
|
||||
### **資料庫清理階段**
|
||||
- [ ] 創建清理遷移檔案
|
||||
- [ ] 執行資料庫清理遷移
|
||||
- [ ] 驗證資料表結構正確
|
||||
- [ ] 檢查資料完整性
|
||||
- [ ] 清理過時的遷移文件
|
||||
|
||||
### **驗證階段**
|
||||
- [ ] 編譯測試通過
|
||||
- [ ] 基礎 API 功能正常
|
||||
- [ ] 詞卡 CRUD 正常
|
||||
- [ ] 認證功能正常
|
||||
- [ ] 統計功能正常
|
||||
- [ ] 資料庫查詢正常
|
||||
|
||||
### **完成階段**
|
||||
- [ ] 提交清空變更
|
||||
- [ ] 更新架構文檔
|
||||
- [ ] 通知團隊清空完成
|
||||
- [ ] 準備重新實施
|
||||
|
||||
---
|
||||
|
||||
## 🚀 **清空後的架構優勢**
|
||||
|
||||
### **簡潔性**
|
||||
- **代碼可讀性**: 移除複雜邏輯後代碼更易理解
|
||||
- **維護性**: 減少相互依賴,更易維護
|
||||
- **除錯性**: 簡化的邏輯更容易除錯
|
||||
|
||||
### **可擴展性**
|
||||
- **重新設計**: 為新的簡潔設計提供清潔基礎
|
||||
- **模組化**: 功能模組更加獨立
|
||||
- **測試友善**: 簡化的邏輯更容易測試
|
||||
|
||||
### **效能提升**
|
||||
- **響應速度**: 移除複雜計算邏輯
|
||||
- **記憶體使用**: 減少複雜物件實例
|
||||
- **啟動速度**: 減少服務註冊和初始化
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ **風險評估與緩解**
|
||||
|
||||
### **高風險項目**
|
||||
1. **資料完整性**:
|
||||
- **風險**: 刪除實體可能影響資料庫
|
||||
- **緩解**: 先移除代碼引用,保留資料庫結構
|
||||
|
||||
2. **API 相容性**:
|
||||
- **風險**: 前端可能調用被刪除的 API
|
||||
- **緩解**: 清空前確認前端依賴關係
|
||||
|
||||
### **中風險項目**
|
||||
1. **編譯錯誤**:
|
||||
- **風險**: 刪除文件後可能有編譯錯誤
|
||||
- **緩解**: 分階段執行,每步驗證編譯
|
||||
|
||||
2. **功能缺失**:
|
||||
- **風險**: 意外刪除必要功能
|
||||
- **緩解**: 仔細檢查文件依賴關係
|
||||
|
||||
---
|
||||
|
||||
## 📊 **清空進度追蹤**
|
||||
|
||||
### **進度指標**
|
||||
- **文件刪除進度**: X / Y 個文件已刪除
|
||||
- **代碼行數減少**: 當前 / 目標 行數
|
||||
- **編譯狀態**: ✅ 通過 / ❌ 失敗
|
||||
- **功能測試**: X / Y 個核心功能正常
|
||||
|
||||
### **完成標準**
|
||||
- ✅ 所有複習相關文件已刪除
|
||||
- ✅ 系統可正常編譯運行
|
||||
- ✅ 核心功能 (詞卡 CRUD, 認證) 正常
|
||||
- ✅ API 端點從 ~30個 減少到 ~20個
|
||||
- ✅ 代碼複雜度大幅降低
|
||||
|
||||
---
|
||||
|
||||
## 🎉 **清空完成後的下一步**
|
||||
|
||||
### **立即後續工作**
|
||||
1. **更新技術文檔**: 反映清空後的架構
|
||||
2. **重新規劃**: 基於簡潔架構重新設計複習系統
|
||||
3. **前端調整**: 調整前端 API 調用 (如有必要)
|
||||
|
||||
### **重新實施準備**
|
||||
1. **需求重審**: 基於產品需求規格書重新設計
|
||||
2. **技術選型**: 選擇更簡潔的實施方案
|
||||
3. **組件化設計**: 按技術實作架構規格書實施
|
||||
|
||||
---
|
||||
|
||||
**執行負責人**: 開發團隊
|
||||
**預計執行時間**: 2-4 小時
|
||||
**風險等級**: 🟡 中等
|
||||
**回滾準備**: ✅ 已準備
|
||||
**執行狀態**: 📋 **待執行**
|
||||
Loading…
Reference in New Issue