498 lines
17 KiB
C#
498 lines
17 KiB
C#
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;
|
||
} |