diff --git a/backend/DramaLing.Api/Controllers/AIController.cs b/backend/DramaLing.Api/Controllers/AIController.cs index ac02770..8d4cc23 100644 --- a/backend/DramaLing.Api/Controllers/AIController.cs +++ b/backend/DramaLing.Api/Controllers/AIController.cs @@ -15,17 +15,23 @@ public class AIController : ControllerBase private readonly DramaLingDbContext _context; private readonly IAuthService _authService; private readonly IGeminiService _geminiService; + private readonly IAnalysisCacheService _cacheService; + private readonly IUsageTrackingService _usageService; private readonly ILogger _logger; public AIController( DramaLingDbContext context, IAuthService authService, IGeminiService geminiService, + IAnalysisCacheService cacheService, + IUsageTrackingService usageService, ILogger logger) { _context = context; _authService = authService; _geminiService = geminiService; + _cacheService = cacheService; + _usageService = usageService; _logger = logger; } @@ -486,6 +492,864 @@ public class AIController : ControllerBase } } + /// + /// 句子分析API - 支援語法修正和高價值標記 + /// + [HttpPost("analyze-sentence")] + [AllowAnonymous] // 暫時無需認證,開發階段 + public async Task AnalyzeSentence([FromBody] AnalyzeSentenceRequest request) + { + try + { + // 基本驗證 + if (string.IsNullOrWhiteSpace(request.InputText)) + { + return BadRequest(new { Success = false, Error = "Input text is required" }); + } + + if (request.InputText.Length > 300) + { + return BadRequest(new { Success = false, Error = "Input text must be less than 300 characters for manual input" }); + } + + // 0. 檢查使用限制(使用模擬用戶ID) + var mockUserId = Guid.Parse("00000000-0000-0000-0000-000000000001"); // 模擬用戶ID + var canUse = await _usageService.CheckUsageLimitAsync(mockUserId, isPremium: false); + if (!canUse) + { + return StatusCode(429, new + { + Success = false, + Error = "免費用戶使用限制已達上限", + ErrorCode = "USAGE_LIMIT_EXCEEDED", + ResetInfo = new + { + WindowHours = 3, + Limit = 5 + } + }); + } + + // 1. 檢查快取 + var cachedAnalysis = await _cacheService.GetCachedAnalysisAsync(request.InputText); + if (cachedAnalysis != null && !request.ForceRefresh) + { + _logger.LogInformation("Using cached analysis for text hash: {TextHash}", cachedAnalysis.InputTextHash); + + // 解析快取的分析結果 + var cachedResult = System.Text.Json.JsonSerializer.Deserialize(cachedAnalysis.AnalysisResult); + + return Ok(new + { + Success = true, + Data = cachedResult, + Message = "句子分析完成(快取)", + Cached = true, + CacheHit = true + }); + } + + // 2. 執行真正的AI分析 + _logger.LogInformation("Calling Gemini AI for text: {InputText}", request.InputText); + + try + { + // 真正調用 Gemini AI 進行句子分析 + var aiAnalysis = await _geminiService.AnalyzeSentenceAsync(request.InputText); + + // 使用AI分析結果 + var finalText = aiAnalysis.GrammarCorrection.HasErrors ? + aiAnalysis.GrammarCorrection.CorrectedText : request.InputText; + + // 3. 準備AI分析響應資料 + var baseResponseData = new + { + AnalysisId = Guid.NewGuid(), + InputText = request.InputText, + GrammarCorrection = aiAnalysis.GrammarCorrection, + SentenceMeaning = new + { + Translation = aiAnalysis.Translation, + Explanation = aiAnalysis.Explanation + }, + FinalAnalysisText = finalText ?? request.InputText, + WordAnalysis = aiAnalysis.WordAnalysis, + HighValueWords = aiAnalysis.HighValueWords, + PhrasesDetected = new object[0] // 暫時簡化 + }; + + // 4. 存入快取(24小時TTL) + try + { + await _cacheService.SetCachedAnalysisAsync( + request.InputText, + baseResponseData, + TimeSpan.FromHours(24) + ); + _logger.LogInformation("AI analysis result cached for 24 hours"); + } + catch (Exception cacheEx) + { + _logger.LogWarning(cacheEx, "Failed to cache AI analysis result"); + } + + return Ok(new + { + Success = true, + Data = baseResponseData, + Message = "AI句子分析完成", + Cached = false, + CacheHit = false, + UsingAI = true + }); + } + catch (Exception aiEx) + { + _logger.LogWarning(aiEx, "Gemini AI failed, falling back to local analysis"); + + // AI 失敗時回退到本地分析 + var grammarCorrection = PerformGrammarCheck(request.InputText); + var finalText = grammarCorrection.HasErrors ? grammarCorrection.CorrectedText : request.InputText; + var analysis = await AnalyzeSentenceWithHighValueMarking(finalText ?? request.InputText); + + var fallbackData = new + { + AnalysisId = Guid.NewGuid(), + InputText = request.InputText, + GrammarCorrection = grammarCorrection, + SentenceMeaning = new + { + Translation = analysis.Translation, + Explanation = analysis.Explanation + }, + FinalAnalysisText = finalText, + WordAnalysis = analysis.WordAnalysis, + HighValueWords = analysis.HighValueWords, + PhrasesDetected = analysis.PhrasesDetected + }; + + return Ok(new + { + Success = true, + Data = fallbackData, + Message = "本地分析完成(AI不可用)", + Cached = false, + CacheHit = false, + UsingAI = false + }); + } + + } + catch (Exception ex) + { + _logger.LogError(ex, "Error in sentence analysis"); + return StatusCode(500, new + { + Success = false, + Error = "句子分析失敗", + Details = ex.Message, + Timestamp = DateTime.UtcNow + }); + } + } + + /// + /// 單字點擊查詢API + /// + [HttpPost("query-word")] + [AllowAnonymous] // 暫時無需認證,開發階段 + public async Task QueryWord([FromBody] QueryWordRequest request) + { + try + { + // 基本驗證 + if (string.IsNullOrWhiteSpace(request.Word)) + { + return BadRequest(new { Success = false, Error = "Word is required" }); + } + + // 模擬檢查是否為高價值詞彙 + var isHighValue = IsHighValueWord(request.Word, request.Sentence); + + if (isHighValue) + { + return Ok(new + { + Success = true, + Data = new + { + Word = request.Word, + IsHighValue = true, + WasPreAnalyzed = true, + CostIncurred = 0, + Analysis = GetHighValueWordAnalysis(request.Word) + }, + Message = "高價值詞彙查詢完成(免費)" + }); + } + else + { + // 低價值詞彙需要即時分析 + var analysis = await AnalyzeLowValueWord(request.Word, request.Sentence); + + return Ok(new + { + Success = true, + Data = new + { + Word = request.Word, + IsHighValue = false, + WasPreAnalyzed = false, + CostIncurred = 1, + Analysis = analysis, + UsageStatistics = new + { + RemainingAnalyses = 3, // 模擬扣除後剩餘 + CostType = "word_query" + } + }, + Message = "低價值詞彙查詢完成" + }); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error in word query"); + return StatusCode(500, new + { + Success = false, + Error = "詞彙查詢失敗", + Details = ex.Message, + Timestamp = DateTime.UtcNow + }); + } + } + + /// + /// 獲取快取統計資料 + /// + [HttpGet("cache-stats")] + [AllowAnonymous] + public async Task GetCacheStats() + { + try + { + var hitCount = await _cacheService.GetCacheHitCountAsync(); + var totalCacheItems = await _context.SentenceAnalysisCache + .Where(c => c.ExpiresAt > DateTime.UtcNow) + .CountAsync(); + + return Ok(new + { + Success = true, + Data = new + { + TotalCacheItems = totalCacheItems, + TotalCacheHits = hitCount, + CacheHitRate = totalCacheItems > 0 ? (double)hitCount / totalCacheItems : 0, + CacheSize = totalCacheItems + }, + Message = "快取統計資料" + }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting cache stats"); + return StatusCode(500, new + { + Success = false, + Error = "獲取快取統計失敗", + Timestamp = DateTime.UtcNow + }); + } + } + + /// + /// 清理過期快取 + /// + [HttpPost("cache-cleanup")] + [AllowAnonymous] + public async Task CleanupCache() + { + try + { + await _cacheService.CleanExpiredCacheAsync(); + + return Ok(new + { + Success = true, + Message = "過期快取清理完成" + }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error cleaning up cache"); + return StatusCode(500, new + { + Success = false, + Error = "快取清理失敗", + Timestamp = DateTime.UtcNow + }); + } + } + + /// + /// 獲取使用統計 + /// + [HttpGet("usage-stats")] + [AllowAnonymous] + public async Task GetUsageStats() + { + try + { + var mockUserId = Guid.Parse("00000000-0000-0000-0000-000000000001"); + var stats = await _usageService.GetUsageStatsAsync(mockUserId); + + return Ok(new + { + Success = true, + Data = stats, + Message = "使用統計資料" + }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting usage stats"); + return StatusCode(500, new + { + Success = false, + Error = "獲取使用統計失敗", + Timestamp = DateTime.UtcNow + }); + } + } + + #region 私有輔助方法 + + /// + /// 執行語法檢查 + /// + private GrammarCorrectionResult PerformGrammarCheck(string inputText) + { + // 模擬語法檢查邏輯 + if (inputText.ToLower().Contains("go to school yesterday") || + inputText.ToLower().Contains("meet my friends")) + { + return new GrammarCorrectionResult + { + HasErrors = true, + OriginalText = inputText, + CorrectedText = inputText.Replace("go to", "went to").Replace("meet my", "met my"), + Corrections = new List + { + new GrammarCorrection + { + Position = new Position { Start = 2, End = 4 }, + ErrorType = "tense_mismatch", + Original = "go", + Corrected = "went", + Reason = "過去式時態修正:句子中有 'yesterday',應使用過去式", + Severity = "high" + } + }, + ConfidenceScore = 0.95 + }; + } + + return new GrammarCorrectionResult + { + HasErrors = false, + OriginalText = inputText, + CorrectedText = null, + Corrections = new List(), + ConfidenceScore = 0.98 + }; + } + + /// + /// 句子分析並標記高價值詞彙 + /// + private async Task AnalyzeSentenceWithHighValueMarking(string text) + { + try + { + // 真正調用 Gemini AI 進行分析 + var prompt = $@" +請分析以下英文句子,提供詳細的中文翻譯和解釋: + +句子:{text} + +請按照以下格式回應: +1. 提供自然流暢的中文翻譯 +2. 解釋句子的語法結構、詞彙特點、使用場景 +3. 指出重要的學習要點 + +翻譯:[自然的中文翻譯] +解釋:[詳細的語法和詞彙解釋] +"; + + var generatedCards = await _geminiService.GenerateCardsAsync(prompt, "smart", 1); + + if (generatedCards.Count > 0) + { + var card = generatedCards[0]; + return new SentenceAnalysisResult + { + Translation = card.Translation, + Explanation = card.Definition, // 使用 AI 生成的定義作為解釋 + WordAnalysis = GenerateWordAnalysisForSentence(text), + HighValueWords = GetHighValueWordsForSentence(text), + PhrasesDetected = new[] + { + new + { + phrase = "AI generated phrase", + words = new[] { "example" }, + colorCode = "#F59E0B" + } + } + }; + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to call Gemini AI, falling back to local analysis"); + } + + // 如果 AI 調用失敗,回退到本地分析 + _logger.LogInformation("Using local analysis for: {Text}", text); + + // 根據輸入文本提供適當的翻譯 + var translation = text.ToLower() switch + { + var t when t.Contains("brought") && (t.Contains("meeting") || t.Contains("thing")) => "他在我們的會議中提出了這件事。", + var t when t.Contains("went") && t.Contains("school") => "我昨天去學校遇見了我的朋友們。", + var t when t.Contains("go") && t.Contains("yesterday") => "我昨天去學校遇見了我的朋友們。(原句有語法錯誤)", + var t when t.Contains("animals") && t.Contains("instincts") => "動物利用本能來尋找食物並保持安全。", + var t when t.Contains("cut") && t.Contains("slack") => "由於他剛入職,我認為我們應該對他寬容一些。", + var t when t.Contains("new") && t.Contains("job") => "由於他是新進員工,我們應該給他一些時間適應。", + var t when t.Contains("ashamed") && t.Contains("mistake") => "她為自己的錯誤感到羞愧並道歉。", + var t when t.Contains("felt") && t.Contains("apologized") => "她感到羞愧並為此道歉。", + var t when t.Contains("hello") => "你好。", + var t when t.Contains("test") => "這是一個測試句子。", + var t when t.Contains("how are you") => "你好嗎?", + var t when t.Contains("good morning") => "早安。", + var t when t.Contains("thank you") => "謝謝你。", + var t when t.Contains("weather") => "今天天氣如何?", + var t when t.Contains("beautiful") => "今天是美好的一天。", + var t when t.Contains("study") => "我正在學習英語。", + _ => TranslateGeneric(text) + }; + + var explanation = text.ToLower() switch + { + var t when t.Contains("brought") && (t.Contains("meeting") || t.Contains("thing")) => "這句話表達了在會議或討論中提出某個話題或議題的情況。'bring up'是一個常用的片語動詞。", + var t when t.Contains("school") && t.Contains("friends") => "這句話描述了過去發生的事情,表達了去學校並遇到朋友的經歷。重點在於過去式的使用。", + var t when t.Contains("animals") && t.Contains("instincts") => "這句話說明了動物的本能行為,展示了現在式的用法和動物相關詞彙。'instincts'是重要的學習詞彙。", + var t when t.Contains("cut") && t.Contains("slack") => "這句話包含習語'cut someone some slack',意思是對某人寬容一些。這是職場英語的常用表達。", + var t when t.Contains("new") && t.Contains("job") => "這句話涉及工作和新員工的情況,適合學習職場相關詞彙和表達方式。", + var t when t.Contains("ashamed") && t.Contains("mistake") => "這句話表達了情感和道歉的概念,展示了過去式的使用。'ashamed'和'apologized'是表達情感的重要詞彙。", + var t when t.Contains("felt") && t.Contains("apologized") => "這句話涉及情感表達和道歉行為,適合學習情感相關詞彙。", + var t when t.Contains("hello") => "這是最基本的英語問候語,適用於任何場合的初次見面或打招呼。", + var t when t.Contains("test") => "這是用於測試系統功能的示例句子,通常用於驗證程序運行是否正常。", + var t when t.Contains("how are you") => "這是詢問對方近況的禮貌用語,是英語中最常用的寒暄表達之一。", + var t when t.Contains("good morning") => "這是早晨時段使用的問候語,通常在上午使用,表現禮貌和友善。", + var t when t.Contains("thank you") => "這是表達感謝的基本用語,展現良好的禮貌和教養。", + _ => ExplainGeneric(text) + }; + + return new SentenceAnalysisResult + { + Translation = translation, + Explanation = explanation, + WordAnalysis = GenerateWordAnalysisForSentence(text), + HighValueWords = GetHighValueWordsForSentence(text), + PhrasesDetected = new[] + { + new + { + phrase = "bring up", + words = new[] { "brought", "up" }, + colorCode = "#F59E0B" + } + } + }; + } + + /// + /// 檢查是否為高價值詞彙 + /// + private bool IsHighValueWord(string word, string sentence) + { + var highValueWords = new[] { "brought", "up", "meeting", "agreed", "went", "yesterday", "met", "friends" }; + return highValueWords.Contains(word.ToLower()); + } + + /// + /// 獲取高價值詞彙分析 + /// + private object GetHighValueWordAnalysis(string word) + { + // 模擬高價值詞彙的預分析資料 + return new + { + word = word, + translation = "預分析的翻譯", + definition = "預分析的定義", + partOfSpeech = "verb", + pronunciation = "/example/", + synonyms = new[] { "example1", "example2" }, + antonyms = new[] { "opposite1" }, + isPhrase = false, + isHighValue = true, + learningPriority = "high", + difficultyLevel = "B1" + }; + } + + /// + /// 分析低價值詞彙 + /// + private async Task AnalyzeLowValueWord(string word, string sentence) + { + // 模擬即時AI分析 + await Task.Delay(200); + + return new + { + word = word, + translation = "即時分析的翻譯", + definition = "即時分析的定義", + partOfSpeech = "noun", + pronunciation = "/example/", + synonyms = new[] { "synonym1", "synonym2" }, + antonyms = new string[0], + isPhrase = false, + isHighValue = false, + learningPriority = "low", + difficultyLevel = "A1" + }; + } + + /// + /// 通用翻譯方法 + /// + private string TranslateGeneric(string text) + { + // 基於關鍵詞提供更好的翻譯 + var words = text.ToLower().Split(' '); + + if (words.Any(w => new[] { "ashamed", "mistake", "apologized" }.Contains(w))) + return "她為自己的錯誤感到羞愧並道歉。"; + + if (words.Any(w => new[] { "animals", "animal" }.Contains(w))) + return "動物相關的句子"; + + if (words.Any(w => new[] { "study", "learn", "learning" }.Contains(w))) + return "關於學習的句子"; + + if (words.Any(w => new[] { "work", "job", "office" }.Contains(w))) + return "關於工作的句子"; + + if (words.Any(w => new[] { "food", "eat", "restaurant" }.Contains(w))) + return "關於食物的句子"; + + if (words.Any(w => new[] { "happy", "sad", "angry", "excited" }.Contains(w))) + return "關於情感表達的句子"; + + // 使用簡單的詞彙替換進行基礎翻譯 + return PerformBasicTranslation(text); + } + + /// + /// 執行基礎翻譯 + /// + private string PerformBasicTranslation(string text) + { + var basicTranslations = new Dictionary + { + {"she", "她"}, {"he", "他"}, {"they", "他們"}, {"we", "我們"}, {"i", "我"}, + {"felt", "感到"}, {"feel", "感覺"}, {"was", "是"}, {"were", "是"}, {"is", "是"}, + {"ashamed", "羞愧"}, {"mistake", "錯誤"}, {"apologized", "道歉"}, + {"and", "和"}, {"of", "的"}, {"her", "她的"}, {"his", "他的"}, + {"the", "這個"}, {"a", "一個"}, {"an", "一個"}, + {"strong", "強烈的"}, {"wind", "風"}, {"knocked", "敲打"}, {"down", "倒下"}, + {"old", "老的"}, {"tree", "樹"}, {"in", "在"}, {"park", "公園"} + }; + + var words = text.Split(' '); + var translatedParts = new List(); + + foreach (var word in words) + { + var cleanWord = word.ToLower().Trim('.', ',', '!', '?', ';', ':'); + + if (basicTranslations.ContainsKey(cleanWord)) + { + translatedParts.Add(basicTranslations[cleanWord]); + } + else + { + // 保留英文單字,不要生硬翻譯 + translatedParts.Add(word); + } + } + + // 基本語序調整 + var result = string.Join(" ", translatedParts); + + // 針對常見句型進行語序調整 + if (text.ToLower().Contains("wind") && text.ToLower().Contains("tree")) + { + return "強風把公園裡的老樹吹倒了。"; + } + + if (text.ToLower().Contains("she") && text.ToLower().Contains("felt")) + { + return "她感到羞愧並為錯誤道歉。"; + } + + return result; + } + + /// + /// 通用解釋方法 + /// + private string ExplainGeneric(string text) + { + var words = text.ToLower().Split(' '); + + // 針對具體內容提供有意義的解釋 + if (words.Any(w => new[] { "wind", "storm", "weather" }.Contains(w))) + return "這句話描述了天氣現象,包含了自然災害相關的詞彙。適合學習天氣、自然現象的英語表達。"; + + if (words.Any(w => new[] { "tree", "forest", "plant" }.Contains(w))) + return "這句話涉及植物或自然環境,適合學習自然相關詞彙和描述環境的表達方式。"; + + if (words.Any(w => new[] { "animals", "animal" }.Contains(w))) + return "這句話涉及動物的行為或特徵,適合學習動物相關詞彙和生物學表達。"; + + if (words.Any(w => new[] { "study", "learn", "learning" }.Contains(w))) + return "這句話與學習相關,適合練習教育相關詞彙和表達方式。"; + + if (words.Any(w => new[] { "work", "job", "office" }.Contains(w))) + return "這句話涉及工作和職場情況,適合學習商務英語和職場表達。"; + + if (words.Any(w => new[] { "happy", "sad", "angry", "excited", "ashamed", "proud" }.Contains(w))) + return "這句話表達情感狀態,適合學習情感詞彙和心理描述的英語表達。"; + + if (words.Any(w => new[] { "house", "home", "room", "kitchen" }.Contains(w))) + return "這句話描述居住環境,適合學習家庭和住宅相關的詞彙。"; + + if (words.Any(w => new[] { "car", "drive", "road", "traffic" }.Contains(w))) + return "這句話涉及交通和駕駛,適合學習交通工具和出行相關詞彙。"; + + // 根據動詞時態提供語法解釋 + if (words.Any(w => w.EndsWith("ed"))) + return "這句話使用了過去式,展示了英語動詞變化的重要概念。適合練習不規則動詞變化。"; + + if (words.Any(w => w.EndsWith("ing"))) + return "這句話包含進行式或動名詞,展示了英語動詞的多種形式。適合學習進行式時態。"; + + // 根據句子長度和複雜度 + if (words.Length > 10) + return "這是一個複雜句子,包含多個子句或修飾語,適合提升英語閱讀理解能力。"; + + if (words.Length < 4) + return "這是一個簡短句子,適合初學者練習基礎詞彙和句型結構。"; + + return "這個句子展示了日常英語的實用表達,包含了重要的詞彙和語法結構,適合全面提升英語能力。"; + } + + /// + /// 動態生成句子的詞彙分析 + /// + private Dictionary GenerateWordAnalysisForSentence(string text) + { + var words = text.ToLower().Split(new[] { ' ', '.', ',', '!', '?' }, StringSplitOptions.RemoveEmptyEntries); + var analysis = new Dictionary(); + + foreach (var word in words) + { + var cleanWord = word.Trim(); + if (string.IsNullOrEmpty(cleanWord) || cleanWord.Length < 2) continue; + + // 判斷是否為高價值詞彙 + var isHighValue = IsHighValueWordDynamic(cleanWord); + var difficulty = GetWordDifficulty(cleanWord); + + analysis[cleanWord] = new + { + word = cleanWord, + translation = GetWordTranslation(cleanWord), + definition = GetWordDefinition(cleanWord), + partOfSpeech = GetPartOfSpeech(cleanWord), + pronunciation = $"/{cleanWord}/", // 簡化 + synonyms = GetSynonyms(cleanWord), + antonyms = new string[0], + isPhrase = false, + isHighValue = isHighValue, + learningPriority = isHighValue ? "high" : "low", + difficultyLevel = difficulty, + costIncurred = isHighValue ? 0 : 1 + }; + } + + return analysis; + } + + /// + /// 獲取句子的高價值詞彙列表 + /// + private string[] GetHighValueWordsForSentence(string text) + { + var words = text.ToLower().Split(new[] { ' ', '.', ',', '!', '?' }, StringSplitOptions.RemoveEmptyEntries); + return words.Where(w => IsHighValueWordDynamic(w.Trim())).ToArray(); + } + + /// + /// 動態判斷高價值詞彙 + /// + private bool IsHighValueWordDynamic(string word) + { + // B1+ 詞彙或特殊概念詞彙視為高價值 + var highValueWords = new[] + { + "animals", "instincts", "safe", "food", "find", + "brought", "meeting", "agreed", "thing", + "study", "learn", "important", "necessary", + "beautiful", "wonderful", "amazing", + "problem", "solution", "different", "special", + "cut", "slack", "job", "new", "think", "should", + "ashamed", "mistake", "apologized", "felt", + "strong", "wind", "knocked", "tree", "park" + }; + + return highValueWords.Contains(word.ToLower()); + } + + /// + /// 獲取詞彙翻譯 + /// + private string GetWordTranslation(string word) + { + return word.ToLower() switch + { + "animals" => "動物", + "use" => "使用", + "their" => "他們的", + "instincts" => "本能", + "to" => "去、到", + "find" => "尋找", + "food" => "食物", + "and" => "和", + "stay" => "保持", + "safe" => "安全", + "brought" => "帶來、提出", + "thing" => "事情", + "meeting" => "會議", + "agreed" => "同意", + "since" => "因為、自從", + "he" => "他", + "is" => "是", + "new" => "新的", + "job" => "工作", + "think" => "認為", + "we" => "我們", + "should" => "應該", + "cut" => "切、減少", + "him" => "他", + "some" => "一些", + "slack" => "鬆懈、寬容", + "felt" => "感到", + "ashamed" => "羞愧", + "mistake" => "錯誤", + "apologized" => "道歉", + "strong" => "強烈的", + "wind" => "風", + "knocked" => "敲打、撞倒", + "down" => "向下", + "old" => "老的", + "tree" => "樹", + "park" => "公園", + _ => $"{word}" + }; + } + + /// + /// 獲取詞彙定義 + /// + private string GetWordDefinition(string word) + { + return word.ToLower() switch + { + "animals" => "Living creatures that can move and feel", + "instincts" => "Natural behavior that animals are born with", + "safe" => "Not in danger; protected from harm", + "food" => "Things that people and animals eat", + "find" => "To discover or locate something", + _ => $"Definition of {word}" + }; + } + + /// + /// 獲取詞性 + /// + private string GetPartOfSpeech(string word) + { + return word.ToLower() switch + { + "animals" => "noun", + "use" => "verb", + "their" => "pronoun", + "instincts" => "noun", + "find" => "verb", + "food" => "noun", + "and" => "conjunction", + "stay" => "verb", + "safe" => "adjective", + _ => "noun" + }; + } + + /// + /// 獲取同義詞 + /// + private string[] GetSynonyms(string word) + { + return word.ToLower() switch + { + "animals" => new[] { "creatures", "beings" }, + "instincts" => new[] { "intuition", "impulse" }, + "safe" => new[] { "secure", "protected" }, + "food" => new[] { "nourishment", "sustenance" }, + "find" => new[] { "locate", "discover" }, + _ => new[] { "synonym1", "synonym2" } + }; + } + + /// + /// 獲取詞彙難度 + /// + private string GetWordDifficulty(string word) + { + return word.ToLower() switch + { + "animals" => "A2", + "instincts" => "B2", + "safe" => "A1", + "food" => "A1", + "find" => "A1", + "use" => "A1", + "their" => "A1", + "and" => "A1", + "stay" => "A2", + _ => "A1" + }; + } + + #endregion + /// /// 生成模擬資料 (開發階段使用) /// @@ -555,4 +1419,53 @@ public class ValidateCardRequest public class TestSaveCardsRequest { public List SelectedCards { get; set; } = new(); +} + +// 新增的API請求/響應 DTOs +public class AnalyzeSentenceRequest +{ + public string InputText { get; set; } = string.Empty; + public bool ForceRefresh { get; set; } = false; + public string AnalysisMode { get; set; } = "full"; +} + +public class QueryWordRequest +{ + public string Word { get; set; } = string.Empty; + public string Sentence { get; set; } = string.Empty; + public Guid? AnalysisId { get; set; } +} + +public class GrammarCorrectionResult +{ + public bool HasErrors { get; set; } + public string OriginalText { get; set; } = string.Empty; + public string? CorrectedText { get; set; } + public List Corrections { get; set; } = new(); + public double ConfidenceScore { get; set; } +} + +public class GrammarCorrection +{ + public Position Position { get; set; } = new(); + public string ErrorType { get; set; } = string.Empty; + public string Original { get; set; } = string.Empty; + public string Corrected { get; set; } = string.Empty; + public string Reason { get; set; } = string.Empty; + public string Severity { get; set; } = string.Empty; +} + +public class Position +{ + public int Start { get; set; } + public int End { get; set; } +} + +public class SentenceAnalysisResult +{ + public string Translation { get; set; } = string.Empty; + public string Explanation { get; set; } = string.Empty; + public Dictionary WordAnalysis { get; set; } = new(); + public string[] HighValueWords { get; set; } = Array.Empty(); + public object[] PhrasesDetected { get; set; } = Array.Empty(); } \ No newline at end of file diff --git a/backend/DramaLing.Api/Data/DramaLingDbContext.cs b/backend/DramaLing.Api/Data/DramaLingDbContext.cs index 9246ce9..de7bf38 100644 --- a/backend/DramaLing.Api/Data/DramaLingDbContext.cs +++ b/backend/DramaLing.Api/Data/DramaLingDbContext.cs @@ -21,6 +21,8 @@ public class DramaLingDbContext : DbContext public DbSet StudyRecords { get; set; } public DbSet ErrorReports { get; set; } public DbSet DailyStats { get; set; } + public DbSet SentenceAnalysisCache { get; set; } + public DbSet WordQueryUsageStats { get; set; } protected override void OnModelCreating(ModelBuilder modelBuilder) { @@ -242,5 +244,34 @@ public class DramaLingDbContext : DbContext .WithMany(u => u.DailyStats) .HasForeignKey(ds => ds.UserId) .OnDelete(DeleteBehavior.Cascade); + + // Sentence analysis cache configuration + modelBuilder.Entity() + .HasIndex(sac => sac.InputTextHash) + .HasDatabaseName("IX_SentenceAnalysisCache_Hash"); + + modelBuilder.Entity() + .HasIndex(sac => sac.ExpiresAt) + .HasDatabaseName("IX_SentenceAnalysisCache_Expires"); + + modelBuilder.Entity() + .HasIndex(sac => new { sac.InputTextHash, sac.ExpiresAt }) + .HasDatabaseName("IX_SentenceAnalysisCache_Hash_Expires"); + + // Word query usage stats configuration + modelBuilder.Entity() + .HasOne(wq => wq.User) + .WithMany() + .HasForeignKey(wq => wq.UserId) + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .HasIndex(wq => new { wq.UserId, wq.Date }) + .IsUnique() + .HasDatabaseName("IX_WordQueryUsageStats_UserDate"); + + modelBuilder.Entity() + .HasIndex(wq => wq.CreatedAt) + .HasDatabaseName("IX_WordQueryUsageStats_CreatedAt"); } } \ No newline at end of file diff --git a/backend/DramaLing.Api/Migrations/20250917130019_AddSentenceAnalysisCache.Designer.cs b/backend/DramaLing.Api/Migrations/20250917130019_AddSentenceAnalysisCache.Designer.cs new file mode 100644 index 0000000..7805532 --- /dev/null +++ b/backend/DramaLing.Api/Migrations/20250917130019_AddSentenceAnalysisCache.Designer.cs @@ -0,0 +1,807 @@ +// +using System; +using DramaLing.Api.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace DramaLing.Api.Migrations +{ + [DbContext(typeof(DramaLingDbContext))] + [Migration("20250917130019_AddSentenceAnalysisCache")] + partial class AddSentenceAnalysisCache + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "8.0.10"); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.CardSet", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CardCount") + .HasColumnType("INTEGER"); + + b.Property("Color") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("IsDefault") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("card_sets", (string)null); + }); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.DailyStats", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AiApiCalls") + .HasColumnType("INTEGER") + .HasColumnName("ai_api_calls"); + + b.Property("CardsGenerated") + .HasColumnType("INTEGER") + .HasColumnName("cards_generated"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("Date") + .HasColumnType("TEXT"); + + b.Property("SessionCount") + .HasColumnType("INTEGER") + .HasColumnName("session_count"); + + b.Property("StudyTimeSeconds") + .HasColumnType("INTEGER") + .HasColumnName("study_time_seconds"); + + b.Property("UserId") + .HasColumnType("TEXT") + .HasColumnName("user_id"); + + b.Property("WordsCorrect") + .HasColumnType("INTEGER") + .HasColumnName("words_correct"); + + b.Property("WordsStudied") + .HasColumnType("INTEGER") + .HasColumnName("words_studied"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Date") + .IsUnique(); + + b.ToTable("daily_stats", (string)null); + }); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.ErrorReport", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AdminNotes") + .HasColumnType("TEXT") + .HasColumnName("admin_notes"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("FlashcardId") + .HasColumnType("TEXT") + .HasColumnName("flashcard_id"); + + b.Property("ReportType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT") + .HasColumnName("report_type"); + + b.Property("ResolvedAt") + .HasColumnType("TEXT") + .HasColumnName("resolved_at"); + + b.Property("ResolvedBy") + .HasColumnType("TEXT") + .HasColumnName("resolved_by"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("StudyMode") + .HasMaxLength(50) + .HasColumnType("TEXT") + .HasColumnName("study_mode"); + + b.Property("UserId") + .HasColumnType("TEXT") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("FlashcardId"); + + b.HasIndex("ResolvedBy"); + + b.HasIndex("UserId"); + + b.ToTable("error_reports", (string)null); + }); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.Flashcard", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CardSetId") + .HasColumnType("TEXT") + .HasColumnName("card_set_id"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("Definition") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DifficultyLevel") + .HasMaxLength(10) + .HasColumnType("TEXT") + .HasColumnName("difficulty_level"); + + b.Property("EasinessFactor") + .HasColumnType("REAL") + .HasColumnName("easiness_factor"); + + b.Property("Example") + .HasColumnType("TEXT"); + + b.Property("ExampleTranslation") + .HasColumnType("TEXT") + .HasColumnName("example_translation"); + + b.Property("IntervalDays") + .HasColumnType("INTEGER") + .HasColumnName("interval_days"); + + b.Property("IsArchived") + .HasColumnType("INTEGER") + .HasColumnName("is_archived"); + + b.Property("IsFavorite") + .HasColumnType("INTEGER") + .HasColumnName("is_favorite"); + + b.Property("LastReviewedAt") + .HasColumnType("TEXT") + .HasColumnName("last_reviewed_at"); + + b.Property("MasteryLevel") + .HasColumnType("INTEGER") + .HasColumnName("mastery_level"); + + b.Property("NextReviewDate") + .HasColumnType("TEXT") + .HasColumnName("next_review_date"); + + b.Property("PartOfSpeech") + .HasMaxLength(50) + .HasColumnType("TEXT") + .HasColumnName("part_of_speech"); + + b.Property("Pronunciation") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("Repetitions") + .HasColumnType("INTEGER"); + + b.Property("TimesCorrect") + .HasColumnType("INTEGER") + .HasColumnName("times_correct"); + + b.Property("TimesReviewed") + .HasColumnType("INTEGER") + .HasColumnName("times_reviewed"); + + b.Property("Translation") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT") + .HasColumnName("updated_at"); + + b.Property("UserId") + .HasColumnType("TEXT") + .HasColumnName("user_id"); + + b.Property("Word") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CardSetId"); + + b.HasIndex("UserId"); + + b.ToTable("flashcards", (string)null); + }); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.FlashcardTag", b => + { + b.Property("FlashcardId") + .HasColumnType("TEXT") + .HasColumnName("flashcard_id"); + + b.Property("TagId") + .HasColumnType("TEXT") + .HasColumnName("tag_id"); + + b.HasKey("FlashcardId", "TagId"); + + b.HasIndex("TagId"); + + b.ToTable("flashcard_tags", (string)null); + }); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.SentenceAnalysisCache", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AccessCount") + .HasColumnType("INTEGER"); + + b.Property("AnalysisResult") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CorrectedText") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.Property("GrammarCorrections") + .HasColumnType("TEXT"); + + b.Property("HasGrammarErrors") + .HasColumnType("INTEGER"); + + b.Property("HighValueWords") + .HasColumnType("TEXT"); + + b.Property("InputText") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("InputTextHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("LastAccessedAt") + .HasColumnType("TEXT"); + + b.Property("PhrasesDetected") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ExpiresAt") + .HasDatabaseName("IX_SentenceAnalysisCache_Expires"); + + b.HasIndex("InputTextHash") + .HasDatabaseName("IX_SentenceAnalysisCache_Hash"); + + b.HasIndex("InputTextHash", "ExpiresAt") + .HasDatabaseName("IX_SentenceAnalysisCache_Hash_Expires"); + + b.ToTable("SentenceAnalysisCache"); + }); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.StudyRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("FlashcardId") + .HasColumnType("TEXT") + .HasColumnName("flashcard_id"); + + b.Property("IsCorrect") + .HasColumnType("INTEGER") + .HasColumnName("is_correct"); + + b.Property("NewEasinessFactor") + .HasColumnType("REAL"); + + b.Property("NewIntervalDays") + .HasColumnType("INTEGER"); + + b.Property("NewRepetitions") + .HasColumnType("INTEGER"); + + b.Property("NextReviewDate") + .HasColumnType("TEXT"); + + b.Property("PreviousEasinessFactor") + .HasColumnType("REAL"); + + b.Property("PreviousIntervalDays") + .HasColumnType("INTEGER"); + + b.Property("PreviousRepetitions") + .HasColumnType("INTEGER"); + + b.Property("QualityRating") + .HasColumnType("INTEGER") + .HasColumnName("quality_rating"); + + b.Property("ResponseTimeMs") + .HasColumnType("INTEGER") + .HasColumnName("response_time_ms"); + + b.Property("SessionId") + .HasColumnType("TEXT") + .HasColumnName("session_id"); + + b.Property("StudiedAt") + .HasColumnType("TEXT") + .HasColumnName("studied_at"); + + b.Property("StudyMode") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT") + .HasColumnName("study_mode"); + + b.Property("UserAnswer") + .HasColumnType("TEXT") + .HasColumnName("user_answer"); + + b.Property("UserId") + .HasColumnType("TEXT") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("FlashcardId"); + + b.HasIndex("SessionId"); + + b.HasIndex("UserId"); + + b.ToTable("study_records", (string)null); + }); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.StudySession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AverageResponseTimeMs") + .HasColumnType("INTEGER") + .HasColumnName("average_response_time_ms"); + + b.Property("CorrectCount") + .HasColumnType("INTEGER") + .HasColumnName("correct_count"); + + b.Property("DurationSeconds") + .HasColumnType("INTEGER") + .HasColumnName("duration_seconds"); + + b.Property("EndedAt") + .HasColumnType("TEXT") + .HasColumnName("ended_at"); + + b.Property("SessionType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT") + .HasColumnName("session_type"); + + b.Property("StartedAt") + .HasColumnType("TEXT") + .HasColumnName("started_at"); + + b.Property("TotalCards") + .HasColumnType("INTEGER") + .HasColumnName("total_cards"); + + b.Property("UserId") + .HasColumnType("TEXT") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("study_sessions", (string)null); + }); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.Tag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Color") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("UsageCount") + .HasColumnType("INTEGER") + .HasColumnName("usage_count"); + + b.Property("UserId") + .HasColumnType("TEXT") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("tags", (string)null); + }); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AvatarUrl") + .HasColumnType("TEXT") + .HasColumnName("avatar_url"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("DisplayName") + .HasMaxLength(100) + .HasColumnType("TEXT") + .HasColumnName("display_name"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT") + .HasColumnName("email"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT") + .HasColumnName("password_hash"); + + b.Property("Preferences") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("preferences"); + + b.Property("SubscriptionType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT") + .HasColumnName("subscription_type"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT") + .HasColumnName("updated_at"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT") + .HasColumnName("username"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("user_profiles", (string)null); + }); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.UserSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AutoPlayAudio") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DailyGoal") + .HasColumnType("INTEGER"); + + b.Property("DifficultyPreference") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("ReminderEnabled") + .HasColumnType("INTEGER"); + + b.Property("ReminderTime") + .HasColumnType("TEXT"); + + b.Property("ShowPronunciation") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("user_settings", (string)null); + }); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.CardSet", b => + { + b.HasOne("DramaLing.Api.Models.Entities.User", "User") + .WithMany("CardSets") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.DailyStats", b => + { + b.HasOne("DramaLing.Api.Models.Entities.User", "User") + .WithMany("DailyStats") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.ErrorReport", b => + { + b.HasOne("DramaLing.Api.Models.Entities.Flashcard", "Flashcard") + .WithMany("ErrorReports") + .HasForeignKey("FlashcardId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("DramaLing.Api.Models.Entities.User", "ResolvedByUser") + .WithMany() + .HasForeignKey("ResolvedBy") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("DramaLing.Api.Models.Entities.User", "User") + .WithMany("ErrorReports") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Flashcard"); + + b.Navigation("ResolvedByUser"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.Flashcard", b => + { + b.HasOne("DramaLing.Api.Models.Entities.CardSet", "CardSet") + .WithMany("Flashcards") + .HasForeignKey("CardSetId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("DramaLing.Api.Models.Entities.User", "User") + .WithMany("Flashcards") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CardSet"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.FlashcardTag", b => + { + b.HasOne("DramaLing.Api.Models.Entities.Flashcard", "Flashcard") + .WithMany("FlashcardTags") + .HasForeignKey("FlashcardId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("DramaLing.Api.Models.Entities.Tag", "Tag") + .WithMany("FlashcardTags") + .HasForeignKey("TagId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Flashcard"); + + b.Navigation("Tag"); + }); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.StudyRecord", b => + { + b.HasOne("DramaLing.Api.Models.Entities.Flashcard", "Flashcard") + .WithMany("StudyRecords") + .HasForeignKey("FlashcardId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("DramaLing.Api.Models.Entities.StudySession", "Session") + .WithMany("StudyRecords") + .HasForeignKey("SessionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("DramaLing.Api.Models.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Flashcard"); + + b.Navigation("Session"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.StudySession", b => + { + b.HasOne("DramaLing.Api.Models.Entities.User", "User") + .WithMany("StudySessions") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.Tag", b => + { + b.HasOne("DramaLing.Api.Models.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.UserSettings", b => + { + b.HasOne("DramaLing.Api.Models.Entities.User", "User") + .WithOne("Settings") + .HasForeignKey("DramaLing.Api.Models.Entities.UserSettings", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.CardSet", b => + { + b.Navigation("Flashcards"); + }); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.Flashcard", b => + { + b.Navigation("ErrorReports"); + + b.Navigation("FlashcardTags"); + + b.Navigation("StudyRecords"); + }); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.StudySession", b => + { + b.Navigation("StudyRecords"); + }); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.Tag", b => + { + b.Navigation("FlashcardTags"); + }); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.User", b => + { + b.Navigation("CardSets"); + + b.Navigation("DailyStats"); + + b.Navigation("ErrorReports"); + + b.Navigation("Flashcards"); + + b.Navigation("Settings"); + + b.Navigation("StudySessions"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/DramaLing.Api/Migrations/20250917130019_AddSentenceAnalysisCache.cs b/backend/DramaLing.Api/Migrations/20250917130019_AddSentenceAnalysisCache.cs new file mode 100644 index 0000000..0d8b8a7 --- /dev/null +++ b/backend/DramaLing.Api/Migrations/20250917130019_AddSentenceAnalysisCache.cs @@ -0,0 +1,471 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace DramaLing.Api.Migrations +{ + /// + public partial class AddSentenceAnalysisCache : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "SentenceAnalysisCache", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + InputTextHash = table.Column(type: "TEXT", maxLength: 64, nullable: false), + InputText = table.Column(type: "TEXT", maxLength: 1000, nullable: false), + CorrectedText = table.Column(type: "TEXT", maxLength: 1000, nullable: true), + HasGrammarErrors = table.Column(type: "INTEGER", nullable: false), + GrammarCorrections = table.Column(type: "TEXT", nullable: true), + AnalysisResult = table.Column(type: "TEXT", nullable: false), + HighValueWords = table.Column(type: "TEXT", nullable: true), + PhrasesDetected = table.Column(type: "TEXT", nullable: true), + CreatedAt = table.Column(type: "TEXT", nullable: false), + ExpiresAt = table.Column(type: "TEXT", nullable: false), + AccessCount = table.Column(type: "INTEGER", nullable: false), + LastAccessedAt = table.Column(type: "TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_SentenceAnalysisCache", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "user_profiles", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + username = table.Column(type: "TEXT", maxLength: 50, nullable: false), + email = table.Column(type: "TEXT", maxLength: 255, nullable: false), + password_hash = table.Column(type: "TEXT", maxLength: 255, nullable: false), + display_name = table.Column(type: "TEXT", maxLength: 100, nullable: true), + avatar_url = table.Column(type: "TEXT", nullable: true), + subscription_type = table.Column(type: "TEXT", maxLength: 20, nullable: false), + preferences = table.Column(type: "TEXT", nullable: false), + created_at = table.Column(type: "TEXT", nullable: false), + updated_at = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_user_profiles", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "card_sets", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + UserId = table.Column(type: "TEXT", nullable: false), + Name = table.Column(type: "TEXT", maxLength: 255, nullable: false), + Description = table.Column(type: "TEXT", nullable: true), + Color = table.Column(type: "TEXT", maxLength: 50, nullable: false), + CardCount = table.Column(type: "INTEGER", nullable: false), + IsDefault = table.Column(type: "INTEGER", nullable: false), + CreatedAt = table.Column(type: "TEXT", nullable: false), + UpdatedAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_card_sets", x => x.Id); + table.ForeignKey( + name: "FK_card_sets_user_profiles_UserId", + column: x => x.UserId, + principalTable: "user_profiles", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "daily_stats", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + user_id = table.Column(type: "TEXT", nullable: false), + Date = table.Column(type: "TEXT", nullable: false), + words_studied = table.Column(type: "INTEGER", nullable: false), + words_correct = table.Column(type: "INTEGER", nullable: false), + study_time_seconds = table.Column(type: "INTEGER", nullable: false), + session_count = table.Column(type: "INTEGER", nullable: false), + cards_generated = table.Column(type: "INTEGER", nullable: false), + ai_api_calls = table.Column(type: "INTEGER", nullable: false), + created_at = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_daily_stats", x => x.Id); + table.ForeignKey( + name: "FK_daily_stats_user_profiles_user_id", + column: x => x.user_id, + principalTable: "user_profiles", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "study_sessions", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + user_id = table.Column(type: "TEXT", nullable: false), + session_type = table.Column(type: "TEXT", maxLength: 50, nullable: false), + started_at = table.Column(type: "TEXT", nullable: false), + ended_at = table.Column(type: "TEXT", nullable: true), + total_cards = table.Column(type: "INTEGER", nullable: false), + correct_count = table.Column(type: "INTEGER", nullable: false), + duration_seconds = table.Column(type: "INTEGER", nullable: false), + average_response_time_ms = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_study_sessions", x => x.Id); + table.ForeignKey( + name: "FK_study_sessions_user_profiles_user_id", + column: x => x.user_id, + principalTable: "user_profiles", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "tags", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + user_id = table.Column(type: "TEXT", nullable: false), + Name = table.Column(type: "TEXT", maxLength: 100, nullable: false), + Color = table.Column(type: "TEXT", maxLength: 50, nullable: false), + usage_count = table.Column(type: "INTEGER", nullable: false), + created_at = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_tags", x => x.Id); + table.ForeignKey( + name: "FK_tags_user_profiles_user_id", + column: x => x.user_id, + principalTable: "user_profiles", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "user_settings", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + UserId = table.Column(type: "TEXT", nullable: false), + DailyGoal = table.Column(type: "INTEGER", nullable: false), + ReminderTime = table.Column(type: "TEXT", nullable: false), + ReminderEnabled = table.Column(type: "INTEGER", nullable: false), + DifficultyPreference = table.Column(type: "TEXT", maxLength: 20, nullable: false), + AutoPlayAudio = table.Column(type: "INTEGER", nullable: false), + ShowPronunciation = table.Column(type: "INTEGER", nullable: false), + CreatedAt = table.Column(type: "TEXT", nullable: false), + UpdatedAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_user_settings", x => x.Id); + table.ForeignKey( + name: "FK_user_settings_user_profiles_UserId", + column: x => x.UserId, + principalTable: "user_profiles", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "flashcards", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + user_id = table.Column(type: "TEXT", nullable: false), + card_set_id = table.Column(type: "TEXT", nullable: false), + Word = table.Column(type: "TEXT", maxLength: 255, nullable: false), + Translation = table.Column(type: "TEXT", nullable: false), + Definition = table.Column(type: "TEXT", nullable: false), + part_of_speech = table.Column(type: "TEXT", maxLength: 50, nullable: true), + Pronunciation = table.Column(type: "TEXT", maxLength: 255, nullable: true), + Example = table.Column(type: "TEXT", nullable: true), + example_translation = table.Column(type: "TEXT", nullable: true), + easiness_factor = table.Column(type: "REAL", nullable: false), + Repetitions = table.Column(type: "INTEGER", nullable: false), + interval_days = table.Column(type: "INTEGER", nullable: false), + next_review_date = table.Column(type: "TEXT", nullable: false), + mastery_level = table.Column(type: "INTEGER", nullable: false), + times_reviewed = table.Column(type: "INTEGER", nullable: false), + times_correct = table.Column(type: "INTEGER", nullable: false), + last_reviewed_at = table.Column(type: "TEXT", nullable: true), + is_favorite = table.Column(type: "INTEGER", nullable: false), + is_archived = table.Column(type: "INTEGER", nullable: false), + difficulty_level = table.Column(type: "TEXT", maxLength: 10, nullable: true), + created_at = table.Column(type: "TEXT", nullable: false), + updated_at = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_flashcards", x => x.Id); + table.ForeignKey( + name: "FK_flashcards_card_sets_card_set_id", + column: x => x.card_set_id, + principalTable: "card_sets", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_flashcards_user_profiles_user_id", + column: x => x.user_id, + principalTable: "user_profiles", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "error_reports", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + user_id = table.Column(type: "TEXT", nullable: false), + flashcard_id = table.Column(type: "TEXT", nullable: false), + report_type = table.Column(type: "TEXT", maxLength: 100, nullable: false), + Description = table.Column(type: "TEXT", nullable: true), + study_mode = table.Column(type: "TEXT", maxLength: 50, nullable: true), + Status = table.Column(type: "TEXT", maxLength: 50, nullable: false), + admin_notes = table.Column(type: "TEXT", nullable: true), + resolved_at = table.Column(type: "TEXT", nullable: true), + resolved_by = table.Column(type: "TEXT", nullable: true), + created_at = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_error_reports", x => x.Id); + table.ForeignKey( + name: "FK_error_reports_flashcards_flashcard_id", + column: x => x.flashcard_id, + principalTable: "flashcards", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_error_reports_user_profiles_resolved_by", + column: x => x.resolved_by, + principalTable: "user_profiles", + principalColumn: "Id", + onDelete: ReferentialAction.SetNull); + table.ForeignKey( + name: "FK_error_reports_user_profiles_user_id", + column: x => x.user_id, + principalTable: "user_profiles", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "flashcard_tags", + columns: table => new + { + flashcard_id = table.Column(type: "TEXT", nullable: false), + tag_id = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_flashcard_tags", x => new { x.flashcard_id, x.tag_id }); + table.ForeignKey( + name: "FK_flashcard_tags_flashcards_flashcard_id", + column: x => x.flashcard_id, + principalTable: "flashcards", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_flashcard_tags_tags_tag_id", + column: x => x.tag_id, + principalTable: "tags", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "study_records", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + user_id = table.Column(type: "TEXT", nullable: false), + flashcard_id = table.Column(type: "TEXT", nullable: false), + session_id = table.Column(type: "TEXT", nullable: false), + study_mode = table.Column(type: "TEXT", maxLength: 50, nullable: false), + quality_rating = table.Column(type: "INTEGER", nullable: false), + response_time_ms = table.Column(type: "INTEGER", nullable: true), + user_answer = table.Column(type: "TEXT", nullable: true), + is_correct = table.Column(type: "INTEGER", nullable: false), + PreviousEasinessFactor = table.Column(type: "REAL", nullable: false), + NewEasinessFactor = table.Column(type: "REAL", nullable: false), + PreviousIntervalDays = table.Column(type: "INTEGER", nullable: false), + NewIntervalDays = table.Column(type: "INTEGER", nullable: false), + PreviousRepetitions = table.Column(type: "INTEGER", nullable: false), + NewRepetitions = table.Column(type: "INTEGER", nullable: false), + NextReviewDate = table.Column(type: "TEXT", nullable: false), + studied_at = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_study_records", x => x.Id); + table.ForeignKey( + name: "FK_study_records_flashcards_flashcard_id", + column: x => x.flashcard_id, + principalTable: "flashcards", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_study_records_study_sessions_session_id", + column: x => x.session_id, + principalTable: "study_sessions", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_study_records_user_profiles_user_id", + column: x => x.user_id, + principalTable: "user_profiles", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_card_sets_UserId", + table: "card_sets", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_daily_stats_user_id_Date", + table: "daily_stats", + columns: new[] { "user_id", "Date" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_error_reports_flashcard_id", + table: "error_reports", + column: "flashcard_id"); + + migrationBuilder.CreateIndex( + name: "IX_error_reports_resolved_by", + table: "error_reports", + column: "resolved_by"); + + migrationBuilder.CreateIndex( + name: "IX_error_reports_user_id", + table: "error_reports", + column: "user_id"); + + migrationBuilder.CreateIndex( + name: "IX_flashcard_tags_tag_id", + table: "flashcard_tags", + column: "tag_id"); + + migrationBuilder.CreateIndex( + name: "IX_flashcards_card_set_id", + table: "flashcards", + column: "card_set_id"); + + migrationBuilder.CreateIndex( + name: "IX_flashcards_user_id", + table: "flashcards", + column: "user_id"); + + migrationBuilder.CreateIndex( + name: "IX_SentenceAnalysisCache_Expires", + table: "SentenceAnalysisCache", + column: "ExpiresAt"); + + migrationBuilder.CreateIndex( + name: "IX_SentenceAnalysisCache_Hash", + table: "SentenceAnalysisCache", + column: "InputTextHash"); + + migrationBuilder.CreateIndex( + name: "IX_SentenceAnalysisCache_Hash_Expires", + table: "SentenceAnalysisCache", + columns: new[] { "InputTextHash", "ExpiresAt" }); + + migrationBuilder.CreateIndex( + name: "IX_study_records_flashcard_id", + table: "study_records", + column: "flashcard_id"); + + migrationBuilder.CreateIndex( + name: "IX_study_records_session_id", + table: "study_records", + column: "session_id"); + + migrationBuilder.CreateIndex( + name: "IX_study_records_user_id", + table: "study_records", + column: "user_id"); + + migrationBuilder.CreateIndex( + name: "IX_study_sessions_user_id", + table: "study_sessions", + column: "user_id"); + + migrationBuilder.CreateIndex( + name: "IX_tags_user_id", + table: "tags", + column: "user_id"); + + migrationBuilder.CreateIndex( + name: "IX_user_profiles_email", + table: "user_profiles", + column: "email", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_user_profiles_username", + table: "user_profiles", + column: "username", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_user_settings_UserId", + table: "user_settings", + column: "UserId", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "daily_stats"); + + migrationBuilder.DropTable( + name: "error_reports"); + + migrationBuilder.DropTable( + name: "flashcard_tags"); + + migrationBuilder.DropTable( + name: "SentenceAnalysisCache"); + + migrationBuilder.DropTable( + name: "study_records"); + + migrationBuilder.DropTable( + name: "user_settings"); + + migrationBuilder.DropTable( + name: "tags"); + + migrationBuilder.DropTable( + name: "flashcards"); + + migrationBuilder.DropTable( + name: "study_sessions"); + + migrationBuilder.DropTable( + name: "card_sets"); + + migrationBuilder.DropTable( + name: "user_profiles"); + } + } +} diff --git a/backend/DramaLing.Api/Migrations/DramaLingDbContextModelSnapshot.cs b/backend/DramaLing.Api/Migrations/DramaLingDbContextModelSnapshot.cs new file mode 100644 index 0000000..dd96e74 --- /dev/null +++ b/backend/DramaLing.Api/Migrations/DramaLingDbContextModelSnapshot.cs @@ -0,0 +1,804 @@ +// +using System; +using DramaLing.Api.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace DramaLing.Api.Migrations +{ + [DbContext(typeof(DramaLingDbContext))] + partial class DramaLingDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "8.0.10"); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.CardSet", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CardCount") + .HasColumnType("INTEGER"); + + b.Property("Color") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("IsDefault") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("card_sets", (string)null); + }); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.DailyStats", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AiApiCalls") + .HasColumnType("INTEGER") + .HasColumnName("ai_api_calls"); + + b.Property("CardsGenerated") + .HasColumnType("INTEGER") + .HasColumnName("cards_generated"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("Date") + .HasColumnType("TEXT"); + + b.Property("SessionCount") + .HasColumnType("INTEGER") + .HasColumnName("session_count"); + + b.Property("StudyTimeSeconds") + .HasColumnType("INTEGER") + .HasColumnName("study_time_seconds"); + + b.Property("UserId") + .HasColumnType("TEXT") + .HasColumnName("user_id"); + + b.Property("WordsCorrect") + .HasColumnType("INTEGER") + .HasColumnName("words_correct"); + + b.Property("WordsStudied") + .HasColumnType("INTEGER") + .HasColumnName("words_studied"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Date") + .IsUnique(); + + b.ToTable("daily_stats", (string)null); + }); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.ErrorReport", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AdminNotes") + .HasColumnType("TEXT") + .HasColumnName("admin_notes"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("FlashcardId") + .HasColumnType("TEXT") + .HasColumnName("flashcard_id"); + + b.Property("ReportType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT") + .HasColumnName("report_type"); + + b.Property("ResolvedAt") + .HasColumnType("TEXT") + .HasColumnName("resolved_at"); + + b.Property("ResolvedBy") + .HasColumnType("TEXT") + .HasColumnName("resolved_by"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("StudyMode") + .HasMaxLength(50) + .HasColumnType("TEXT") + .HasColumnName("study_mode"); + + b.Property("UserId") + .HasColumnType("TEXT") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("FlashcardId"); + + b.HasIndex("ResolvedBy"); + + b.HasIndex("UserId"); + + b.ToTable("error_reports", (string)null); + }); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.Flashcard", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CardSetId") + .HasColumnType("TEXT") + .HasColumnName("card_set_id"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("Definition") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DifficultyLevel") + .HasMaxLength(10) + .HasColumnType("TEXT") + .HasColumnName("difficulty_level"); + + b.Property("EasinessFactor") + .HasColumnType("REAL") + .HasColumnName("easiness_factor"); + + b.Property("Example") + .HasColumnType("TEXT"); + + b.Property("ExampleTranslation") + .HasColumnType("TEXT") + .HasColumnName("example_translation"); + + b.Property("IntervalDays") + .HasColumnType("INTEGER") + .HasColumnName("interval_days"); + + b.Property("IsArchived") + .HasColumnType("INTEGER") + .HasColumnName("is_archived"); + + b.Property("IsFavorite") + .HasColumnType("INTEGER") + .HasColumnName("is_favorite"); + + b.Property("LastReviewedAt") + .HasColumnType("TEXT") + .HasColumnName("last_reviewed_at"); + + b.Property("MasteryLevel") + .HasColumnType("INTEGER") + .HasColumnName("mastery_level"); + + b.Property("NextReviewDate") + .HasColumnType("TEXT") + .HasColumnName("next_review_date"); + + b.Property("PartOfSpeech") + .HasMaxLength(50) + .HasColumnType("TEXT") + .HasColumnName("part_of_speech"); + + b.Property("Pronunciation") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("Repetitions") + .HasColumnType("INTEGER"); + + b.Property("TimesCorrect") + .HasColumnType("INTEGER") + .HasColumnName("times_correct"); + + b.Property("TimesReviewed") + .HasColumnType("INTEGER") + .HasColumnName("times_reviewed"); + + b.Property("Translation") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT") + .HasColumnName("updated_at"); + + b.Property("UserId") + .HasColumnType("TEXT") + .HasColumnName("user_id"); + + b.Property("Word") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CardSetId"); + + b.HasIndex("UserId"); + + b.ToTable("flashcards", (string)null); + }); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.FlashcardTag", b => + { + b.Property("FlashcardId") + .HasColumnType("TEXT") + .HasColumnName("flashcard_id"); + + b.Property("TagId") + .HasColumnType("TEXT") + .HasColumnName("tag_id"); + + b.HasKey("FlashcardId", "TagId"); + + b.HasIndex("TagId"); + + b.ToTable("flashcard_tags", (string)null); + }); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.SentenceAnalysisCache", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AccessCount") + .HasColumnType("INTEGER"); + + b.Property("AnalysisResult") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CorrectedText") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.Property("GrammarCorrections") + .HasColumnType("TEXT"); + + b.Property("HasGrammarErrors") + .HasColumnType("INTEGER"); + + b.Property("HighValueWords") + .HasColumnType("TEXT"); + + b.Property("InputText") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("InputTextHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("LastAccessedAt") + .HasColumnType("TEXT"); + + b.Property("PhrasesDetected") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ExpiresAt") + .HasDatabaseName("IX_SentenceAnalysisCache_Expires"); + + b.HasIndex("InputTextHash") + .HasDatabaseName("IX_SentenceAnalysisCache_Hash"); + + b.HasIndex("InputTextHash", "ExpiresAt") + .HasDatabaseName("IX_SentenceAnalysisCache_Hash_Expires"); + + b.ToTable("SentenceAnalysisCache"); + }); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.StudyRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("FlashcardId") + .HasColumnType("TEXT") + .HasColumnName("flashcard_id"); + + b.Property("IsCorrect") + .HasColumnType("INTEGER") + .HasColumnName("is_correct"); + + b.Property("NewEasinessFactor") + .HasColumnType("REAL"); + + b.Property("NewIntervalDays") + .HasColumnType("INTEGER"); + + b.Property("NewRepetitions") + .HasColumnType("INTEGER"); + + b.Property("NextReviewDate") + .HasColumnType("TEXT"); + + b.Property("PreviousEasinessFactor") + .HasColumnType("REAL"); + + b.Property("PreviousIntervalDays") + .HasColumnType("INTEGER"); + + b.Property("PreviousRepetitions") + .HasColumnType("INTEGER"); + + b.Property("QualityRating") + .HasColumnType("INTEGER") + .HasColumnName("quality_rating"); + + b.Property("ResponseTimeMs") + .HasColumnType("INTEGER") + .HasColumnName("response_time_ms"); + + b.Property("SessionId") + .HasColumnType("TEXT") + .HasColumnName("session_id"); + + b.Property("StudiedAt") + .HasColumnType("TEXT") + .HasColumnName("studied_at"); + + b.Property("StudyMode") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT") + .HasColumnName("study_mode"); + + b.Property("UserAnswer") + .HasColumnType("TEXT") + .HasColumnName("user_answer"); + + b.Property("UserId") + .HasColumnType("TEXT") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("FlashcardId"); + + b.HasIndex("SessionId"); + + b.HasIndex("UserId"); + + b.ToTable("study_records", (string)null); + }); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.StudySession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AverageResponseTimeMs") + .HasColumnType("INTEGER") + .HasColumnName("average_response_time_ms"); + + b.Property("CorrectCount") + .HasColumnType("INTEGER") + .HasColumnName("correct_count"); + + b.Property("DurationSeconds") + .HasColumnType("INTEGER") + .HasColumnName("duration_seconds"); + + b.Property("EndedAt") + .HasColumnType("TEXT") + .HasColumnName("ended_at"); + + b.Property("SessionType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT") + .HasColumnName("session_type"); + + b.Property("StartedAt") + .HasColumnType("TEXT") + .HasColumnName("started_at"); + + b.Property("TotalCards") + .HasColumnType("INTEGER") + .HasColumnName("total_cards"); + + b.Property("UserId") + .HasColumnType("TEXT") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("study_sessions", (string)null); + }); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.Tag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Color") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("UsageCount") + .HasColumnType("INTEGER") + .HasColumnName("usage_count"); + + b.Property("UserId") + .HasColumnType("TEXT") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("tags", (string)null); + }); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AvatarUrl") + .HasColumnType("TEXT") + .HasColumnName("avatar_url"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("DisplayName") + .HasMaxLength(100) + .HasColumnType("TEXT") + .HasColumnName("display_name"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT") + .HasColumnName("email"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT") + .HasColumnName("password_hash"); + + b.Property("Preferences") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("preferences"); + + b.Property("SubscriptionType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT") + .HasColumnName("subscription_type"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT") + .HasColumnName("updated_at"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT") + .HasColumnName("username"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("user_profiles", (string)null); + }); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.UserSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AutoPlayAudio") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DailyGoal") + .HasColumnType("INTEGER"); + + b.Property("DifficultyPreference") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("ReminderEnabled") + .HasColumnType("INTEGER"); + + b.Property("ReminderTime") + .HasColumnType("TEXT"); + + b.Property("ShowPronunciation") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("user_settings", (string)null); + }); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.CardSet", b => + { + b.HasOne("DramaLing.Api.Models.Entities.User", "User") + .WithMany("CardSets") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.DailyStats", b => + { + b.HasOne("DramaLing.Api.Models.Entities.User", "User") + .WithMany("DailyStats") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.ErrorReport", b => + { + b.HasOne("DramaLing.Api.Models.Entities.Flashcard", "Flashcard") + .WithMany("ErrorReports") + .HasForeignKey("FlashcardId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("DramaLing.Api.Models.Entities.User", "ResolvedByUser") + .WithMany() + .HasForeignKey("ResolvedBy") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("DramaLing.Api.Models.Entities.User", "User") + .WithMany("ErrorReports") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Flashcard"); + + b.Navigation("ResolvedByUser"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.Flashcard", b => + { + b.HasOne("DramaLing.Api.Models.Entities.CardSet", "CardSet") + .WithMany("Flashcards") + .HasForeignKey("CardSetId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("DramaLing.Api.Models.Entities.User", "User") + .WithMany("Flashcards") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CardSet"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.FlashcardTag", b => + { + b.HasOne("DramaLing.Api.Models.Entities.Flashcard", "Flashcard") + .WithMany("FlashcardTags") + .HasForeignKey("FlashcardId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("DramaLing.Api.Models.Entities.Tag", "Tag") + .WithMany("FlashcardTags") + .HasForeignKey("TagId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Flashcard"); + + b.Navigation("Tag"); + }); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.StudyRecord", b => + { + b.HasOne("DramaLing.Api.Models.Entities.Flashcard", "Flashcard") + .WithMany("StudyRecords") + .HasForeignKey("FlashcardId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("DramaLing.Api.Models.Entities.StudySession", "Session") + .WithMany("StudyRecords") + .HasForeignKey("SessionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("DramaLing.Api.Models.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Flashcard"); + + b.Navigation("Session"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.StudySession", b => + { + b.HasOne("DramaLing.Api.Models.Entities.User", "User") + .WithMany("StudySessions") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.Tag", b => + { + b.HasOne("DramaLing.Api.Models.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.UserSettings", b => + { + b.HasOne("DramaLing.Api.Models.Entities.User", "User") + .WithOne("Settings") + .HasForeignKey("DramaLing.Api.Models.Entities.UserSettings", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.CardSet", b => + { + b.Navigation("Flashcards"); + }); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.Flashcard", b => + { + b.Navigation("ErrorReports"); + + b.Navigation("FlashcardTags"); + + b.Navigation("StudyRecords"); + }); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.StudySession", b => + { + b.Navigation("StudyRecords"); + }); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.Tag", b => + { + b.Navigation("FlashcardTags"); + }); + + modelBuilder.Entity("DramaLing.Api.Models.Entities.User", b => + { + b.Navigation("CardSets"); + + b.Navigation("DailyStats"); + + b.Navigation("ErrorReports"); + + b.Navigation("Flashcards"); + + b.Navigation("Settings"); + + b.Navigation("StudySessions"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/DramaLing.Api/Models/Entities/SentenceAnalysisCache.cs b/backend/DramaLing.Api/Models/Entities/SentenceAnalysisCache.cs new file mode 100644 index 0000000..ac88a71 --- /dev/null +++ b/backend/DramaLing.Api/Models/Entities/SentenceAnalysisCache.cs @@ -0,0 +1,41 @@ +using System.ComponentModel.DataAnnotations; + +namespace DramaLing.Api.Models.Entities; + +public class SentenceAnalysisCache +{ + [Key] + public Guid Id { get; set; } + + [Required] + [StringLength(64)] + public string InputTextHash { get; set; } = string.Empty; + + [Required] + [StringLength(1000)] + public string InputText { get; set; } = string.Empty; + + [StringLength(1000)] + public string? CorrectedText { get; set; } + + public bool HasGrammarErrors { get; set; } = false; + + public string? GrammarCorrections { get; set; } // JSON 格式 + + [Required] + public string AnalysisResult { get; set; } = string.Empty; // JSON 格式 + + public string? HighValueWords { get; set; } // JSON 格式,高價值詞彙列表 + + public string? PhrasesDetected { get; set; } // JSON 格式,檢測到的片語 + + [Required] + public DateTime CreatedAt { get; set; } + + [Required] + public DateTime ExpiresAt { get; set; } + + public int AccessCount { get; set; } = 0; + + public DateTime? LastAccessedAt { get; set; } +} \ No newline at end of file diff --git a/backend/DramaLing.Api/Models/Entities/WordQueryUsageStats.cs b/backend/DramaLing.Api/Models/Entities/WordQueryUsageStats.cs new file mode 100644 index 0000000..8f5ea3e --- /dev/null +++ b/backend/DramaLing.Api/Models/Entities/WordQueryUsageStats.cs @@ -0,0 +1,34 @@ +using System.ComponentModel.DataAnnotations; + +namespace DramaLing.Api.Models.Entities; + +public class WordQueryUsageStats +{ + [Key] + public Guid Id { get; set; } + + [Required] + public Guid UserId { get; set; } + + [Required] + public DateOnly Date { get; set; } + + public int SentenceAnalysisCount { get; set; } = 0; // 句子分析次數 + + public int HighValueWordClicks { get; set; } = 0; // 高價值詞彙點擊(免費) + + public int LowValueWordClicks { get; set; } = 0; // 低價值詞彙點擊(收費) + + public int TotalApiCalls { get; set; } = 0; // 總 API 調用次數 + + public int UniqueWordsQueried { get; set; } = 0; // 查詢的獨特詞彙數 + + [Required] + public DateTime CreatedAt { get; set; } + + [Required] + public DateTime UpdatedAt { get; set; } + + // Navigation properties + public User User { get; set; } = null!; +} \ No newline at end of file diff --git a/backend/DramaLing.Api/Program.cs b/backend/DramaLing.Api/Program.cs index 6624d7c..0f79559 100644 --- a/backend/DramaLing.Api/Program.cs +++ b/backend/DramaLing.Api/Program.cs @@ -36,6 +36,11 @@ else // Custom Services builder.Services.AddScoped(); builder.Services.AddHttpClient(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); + +// Background Services +builder.Services.AddHostedService(); // Authentication - 從環境變數讀取 JWT 配置 var supabaseUrl = Environment.GetEnvironmentVariable("DRAMALING_SUPABASE_URL") @@ -66,7 +71,7 @@ builder.Services.AddCors(options => { options.AddPolicy("AllowFrontend", policy => { - policy.WithOrigins("http://localhost:3000", "http://localhost:3001") + policy.WithOrigins("http://localhost:3000", "http://localhost:3001", "http://localhost:3002") .AllowAnyHeader() .AllowAnyMethod() .AllowCredentials() diff --git a/backend/DramaLing.Api/Services/AnalysisCacheService.cs b/backend/DramaLing.Api/Services/AnalysisCacheService.cs new file mode 100644 index 0000000..0c0a7a8 --- /dev/null +++ b/backend/DramaLing.Api/Services/AnalysisCacheService.cs @@ -0,0 +1,197 @@ +using Microsoft.EntityFrameworkCore; +using DramaLing.Api.Data; +using DramaLing.Api.Models.Entities; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; + +namespace DramaLing.Api.Services; + +public interface IAnalysisCacheService +{ + Task GetCachedAnalysisAsync(string inputText); + Task SetCachedAnalysisAsync(string inputText, object analysisResult, TimeSpan ttl); + Task InvalidateCacheAsync(string textHash); + Task GetCacheHitCountAsync(); + Task CleanExpiredCacheAsync(); +} + +public class AnalysisCacheService : IAnalysisCacheService +{ + private readonly DramaLingDbContext _context; + private readonly ILogger _logger; + + public AnalysisCacheService(DramaLingDbContext context, ILogger logger) + { + _context = context; + _logger = logger; + } + + /// + /// 獲取快取的分析結果 + /// + public async Task GetCachedAnalysisAsync(string inputText) + { + try + { + var textHash = GenerateTextHash(inputText); + var cached = await _context.SentenceAnalysisCache + .FirstOrDefaultAsync(c => c.InputTextHash == textHash && c.ExpiresAt > DateTime.UtcNow); + + if (cached != null) + { + // 更新訪問統計 + cached.AccessCount++; + cached.LastAccessedAt = DateTime.UtcNow; + await _context.SaveChangesAsync(); + + _logger.LogInformation("Cache hit for text hash: {TextHash}", textHash); + return cached; + } + + _logger.LogInformation("Cache miss for text hash: {TextHash}", textHash); + return null; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting cached analysis for text: {InputText}", inputText); + return null; + } + } + + /// + /// 設定快取分析結果 + /// + public async Task SetCachedAnalysisAsync(string inputText, object analysisResult, TimeSpan ttl) + { + try + { + var textHash = GenerateTextHash(inputText); + var expiresAt = DateTime.UtcNow.Add(ttl); + + // 檢查是否已存在 + var existing = await _context.SentenceAnalysisCache + .FirstOrDefaultAsync(c => c.InputTextHash == textHash); + + if (existing != null) + { + // 更新現有快取 + existing.AnalysisResult = JsonSerializer.Serialize(analysisResult); + existing.ExpiresAt = expiresAt; + existing.AccessCount++; + existing.LastAccessedAt = DateTime.UtcNow; + } + else + { + // 創建新快取項目 + var cacheItem = new SentenceAnalysisCache + { + Id = Guid.NewGuid(), + InputTextHash = textHash, + InputText = inputText, + AnalysisResult = JsonSerializer.Serialize(analysisResult), + CreatedAt = DateTime.UtcNow, + ExpiresAt = expiresAt, + AccessCount = 1, + LastAccessedAt = DateTime.UtcNow + }; + + _context.SentenceAnalysisCache.Add(cacheItem); + } + + await _context.SaveChangesAsync(); + + _logger.LogInformation("Cached analysis for text hash: {TextHash}, expires at: {ExpiresAt}", + textHash, expiresAt); + + return textHash; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error setting cached analysis for text: {InputText}", inputText); + throw; + } + } + + /// + /// 使快取失效 + /// + public async Task InvalidateCacheAsync(string textHash) + { + try + { + var cached = await _context.SentenceAnalysisCache + .FirstOrDefaultAsync(c => c.InputTextHash == textHash); + + if (cached != null) + { + _context.SentenceAnalysisCache.Remove(cached); + await _context.SaveChangesAsync(); + + _logger.LogInformation("Invalidated cache for text hash: {TextHash}", textHash); + return true; + } + + return false; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error invalidating cache for text hash: {TextHash}", textHash); + return false; + } + } + + /// + /// 獲取快取命中次數 + /// + public async Task GetCacheHitCountAsync() + { + try + { + return await _context.SentenceAnalysisCache + .Where(c => c.ExpiresAt > DateTime.UtcNow) + .SumAsync(c => c.AccessCount); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting cache hit count"); + return 0; + } + } + + /// + /// 清理過期的快取 + /// + public async Task CleanExpiredCacheAsync() + { + try + { + var expiredItems = await _context.SentenceAnalysisCache + .Where(c => c.ExpiresAt <= DateTime.UtcNow) + .ToListAsync(); + + if (expiredItems.Any()) + { + _context.SentenceAnalysisCache.RemoveRange(expiredItems); + await _context.SaveChangesAsync(); + + _logger.LogInformation("Cleaned {Count} expired cache items", expiredItems.Count); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error cleaning expired cache"); + } + } + + /// + /// 生成文本哈希值 + /// + private string GenerateTextHash(string inputText) + { + using var sha256 = SHA256.Create(); + var bytes = Encoding.UTF8.GetBytes(inputText.Trim().ToLower()); + var hash = sha256.ComputeHash(bytes); + return Convert.ToHexString(hash).ToLower(); + } +} \ No newline at end of file diff --git a/backend/DramaLing.Api/Services/CacheCleanupService.cs b/backend/DramaLing.Api/Services/CacheCleanupService.cs new file mode 100644 index 0000000..2f27450 --- /dev/null +++ b/backend/DramaLing.Api/Services/CacheCleanupService.cs @@ -0,0 +1,47 @@ +namespace DramaLing.Api.Services; + +public class CacheCleanupService : BackgroundService +{ + private readonly IServiceProvider _serviceProvider; + private readonly ILogger _logger; + private readonly TimeSpan _cleanupInterval = TimeSpan.FromHours(1); // 每小時清理一次 + + public CacheCleanupService(IServiceProvider serviceProvider, ILogger logger) + { + _serviceProvider = serviceProvider; + _logger = logger; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + _logger.LogInformation("Cache cleanup service started"); + + while (!stoppingToken.IsCancellationRequested) + { + try + { + using var scope = _serviceProvider.CreateScope(); + var cacheService = scope.ServiceProvider.GetRequiredService(); + + _logger.LogInformation("Starting cache cleanup..."); + await cacheService.CleanExpiredCacheAsync(); + _logger.LogInformation("Cache cleanup completed"); + + await Task.Delay(_cleanupInterval, stoppingToken); + } + catch (OperationCanceledException) + { + // 正常的服務停止 + break; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error during cache cleanup"); + // 出錯時等待較短時間後重試 + await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken); + } + } + + _logger.LogInformation("Cache cleanup service stopped"); + } +} \ No newline at end of file diff --git a/backend/DramaLing.Api/Services/GeminiService.cs b/backend/DramaLing.Api/Services/GeminiService.cs index cd528e1..4265627 100644 --- a/backend/DramaLing.Api/Services/GeminiService.cs +++ b/backend/DramaLing.Api/Services/GeminiService.cs @@ -8,6 +8,7 @@ public interface IGeminiService { Task> GenerateCardsAsync(string inputText, string extractionType, int cardCount); Task ValidateCardAsync(Flashcard card); + Task AnalyzeSentenceAsync(string inputText); } public class GeminiService : IGeminiService @@ -47,6 +48,65 @@ public class GeminiService : IGeminiService } } + /// + /// 真正的句子分析和翻譯 - 調用 Gemini AI + /// + public async Task AnalyzeSentenceAsync(string inputText) + { + try + { + if (string.IsNullOrEmpty(_apiKey) || _apiKey == "your-gemini-api-key-here") + { + throw new InvalidOperationException("Gemini API key not configured"); + } + + var prompt = $@" +請分析以下英文句子,提供完整的分析: + +句子:{inputText} + +請按照以下JSON格式回應,不要包含任何其他文字: + +{{ + ""translation"": ""自然流暢的繁體中文翻譯"", + ""explanation"": ""詳細解釋句子的語法結構、詞彙特點、使用場景和學習要點"", + ""grammarCorrection"": {{ + ""hasErrors"": false, + ""originalText"": ""{inputText}"", + ""correctedText"": null, + ""corrections"": [] + }}, + ""highValueWords"": [""重要詞彙1"", ""重要詞彙2""], + ""wordAnalysis"": {{ + ""單字"": {{ + ""translation"": ""中文翻譯"", + ""definition"": ""英文定義"", + ""partOfSpeech"": ""詞性"", + ""pronunciation"": ""音標"", + ""isHighValue"": true, + ""difficultyLevel"": ""CEFR等級"" + }} + }} +}} + +要求: +1. 翻譯要自然流暢,符合中文語法 +2. 解釋要具體有用,不要空泛 +3. 標記B1以上詞彙為高價值 +4. 如有語法錯誤請指出並修正 +5. 確保JSON格式正確 +"; + + var response = await CallGeminiApiAsync(prompt); + return ParseSentenceAnalysisResponse(response); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error analyzing sentence with Gemini API"); + throw; + } + } + public async Task ValidateCardAsync(Flashcard card) { try @@ -239,6 +299,91 @@ public class GeminiService : IGeminiService } } + /// + /// 解析 Gemini AI 句子分析響應 + /// + private SentenceAnalysisResponse ParseSentenceAnalysisResponse(string response) + { + try + { + var cleanText = response.Trim().Replace("```json", "").Replace("```", "").Trim(); + + if (!cleanText.StartsWith("{")) + { + var jsonStart = cleanText.IndexOf("{"); + if (jsonStart >= 0) + { + cleanText = cleanText[jsonStart..]; + } + } + + var jsonResponse = JsonSerializer.Deserialize(cleanText); + + return new SentenceAnalysisResponse + { + Translation = GetStringProperty(jsonResponse, "translation"), + Explanation = GetStringProperty(jsonResponse, "explanation"), + HighValueWords = GetArrayProperty(jsonResponse, "highValueWords"), + WordAnalysis = ParseWordAnalysisFromJson(jsonResponse), + GrammarCorrection = ParseGrammarCorrectionFromJson(jsonResponse) + }; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error parsing sentence analysis response: {Response}", response); + throw new InvalidOperationException($"Failed to parse AI sentence analysis: {ex.Message}"); + } + } + + private Dictionary ParseWordAnalysisFromJson(JsonElement jsonResponse) + { + var result = new Dictionary(); + + if (jsonResponse.TryGetProperty("wordAnalysis", out var wordAnalysisElement)) + { + foreach (var property in wordAnalysisElement.EnumerateObject()) + { + var word = property.Name; + var analysis = property.Value; + + result[word] = new WordAnalysisResult + { + Word = word, + Translation = GetStringProperty(analysis, "translation"), + Definition = GetStringProperty(analysis, "definition"), + PartOfSpeech = GetStringProperty(analysis, "partOfSpeech"), + Pronunciation = GetStringProperty(analysis, "pronunciation"), + IsHighValue = analysis.TryGetProperty("isHighValue", out var isHighValueElement) && isHighValueElement.GetBoolean(), + DifficultyLevel = GetStringProperty(analysis, "difficultyLevel") + }; + } + } + + return result; + } + + private GrammarCorrectionResult ParseGrammarCorrectionFromJson(JsonElement jsonResponse) + { + if (jsonResponse.TryGetProperty("grammarCorrection", out var grammarElement)) + { + return new GrammarCorrectionResult + { + HasErrors = grammarElement.TryGetProperty("hasErrors", out var hasErrorsElement) && hasErrorsElement.GetBoolean(), + OriginalText = GetStringProperty(grammarElement, "originalText"), + CorrectedText = GetStringProperty(grammarElement, "correctedText"), + Corrections = new List() // 簡化 + }; + } + + return new GrammarCorrectionResult + { + HasErrors = false, + OriginalText = "", + CorrectedText = null, + Corrections = new List() + }; + } + private static string GetStringProperty(JsonElement element, string propertyName) { return element.TryGetProperty(propertyName, out var prop) ? prop.GetString() ?? "" : ""; @@ -351,4 +496,41 @@ public class ValidationIssue public string Corrected { get; set; } = string.Empty; public string Reason { get; set; } = string.Empty; public string Severity { get; set; } = string.Empty; +} + +// 新增句子分析相關類型 +public class SentenceAnalysisResponse +{ + public string Translation { get; set; } = string.Empty; + public string Explanation { get; set; } = string.Empty; + public List HighValueWords { get; set; } = new(); + public Dictionary WordAnalysis { get; set; } = new(); + public GrammarCorrectionResult GrammarCorrection { get; set; } = new(); +} + +public class WordAnalysisResult +{ + public string Word { get; set; } = string.Empty; + public string Translation { get; set; } = string.Empty; + public string Definition { get; set; } = string.Empty; + public string PartOfSpeech { get; set; } = string.Empty; + public string Pronunciation { get; set; } = string.Empty; + public bool IsHighValue { get; set; } + public string DifficultyLevel { get; set; } = string.Empty; +} + +public class GrammarCorrectionResult +{ + public bool HasErrors { get; set; } + public string OriginalText { get; set; } = string.Empty; + public string? CorrectedText { get; set; } + public List Corrections { get; set; } = new(); +} + +public class GrammarCorrection +{ + public string ErrorType { get; set; } = string.Empty; + public string Original { get; set; } = string.Empty; + public string Corrected { get; set; } = string.Empty; + public string Reason { get; set; } = string.Empty; } \ No newline at end of file diff --git a/backend/DramaLing.Api/Services/UsageTrackingService.cs b/backend/DramaLing.Api/Services/UsageTrackingService.cs new file mode 100644 index 0000000..85247d7 --- /dev/null +++ b/backend/DramaLing.Api/Services/UsageTrackingService.cs @@ -0,0 +1,255 @@ +using Microsoft.EntityFrameworkCore; +using DramaLing.Api.Data; +using DramaLing.Api.Models.Entities; + +namespace DramaLing.Api.Services; + +public interface IUsageTrackingService +{ + Task CheckUsageLimitAsync(Guid userId, bool isPremium = false); + Task RecordSentenceAnalysisAsync(Guid userId); + Task RecordWordQueryAsync(Guid userId, bool wasHighValue); + Task GetUsageStatsAsync(Guid userId); +} + +public class UsageTrackingService : IUsageTrackingService +{ + private readonly DramaLingDbContext _context; + private readonly ILogger _logger; + + // 免費用戶限制 + private const int FREE_USER_ANALYSIS_LIMIT = 5; + private const int FREE_USER_RESET_HOURS = 3; + + public UsageTrackingService(DramaLingDbContext context, ILogger logger) + { + _context = context; + _logger = logger; + } + + /// + /// 檢查用戶使用限制 + /// + public async Task CheckUsageLimitAsync(Guid userId, bool isPremium = false) + { + try + { + if (isPremium) + { + return true; // 付費用戶無限制 + } + + var resetTime = DateTime.UtcNow.AddHours(-FREE_USER_RESET_HOURS); + var recentUsage = await _context.WordQueryUsageStats + .Where(stats => stats.UserId == userId && stats.CreatedAt >= resetTime) + .SumAsync(stats => stats.SentenceAnalysisCount + stats.LowValueWordClicks); + + var canUse = recentUsage < FREE_USER_ANALYSIS_LIMIT; + + _logger.LogInformation("Usage check for user {UserId}: {RecentUsage}/{Limit}, Can use: {CanUse}", + userId, recentUsage, FREE_USER_ANALYSIS_LIMIT, canUse); + + return canUse; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error checking usage limit for user {UserId}", userId); + return false; // 出錯時拒絕使用 + } + } + + /// + /// 記錄句子分析使用 + /// + public async Task RecordSentenceAnalysisAsync(Guid userId) + { + try + { + var today = DateOnly.FromDateTime(DateTime.Today); + var stats = await GetOrCreateTodayStatsAsync(userId, today); + + stats.SentenceAnalysisCount++; + stats.TotalApiCalls++; + stats.UpdatedAt = DateTime.UtcNow; + + await _context.SaveChangesAsync(); + + _logger.LogInformation("Recorded sentence analysis for user {UserId}, total today: {Count}", + userId, stats.SentenceAnalysisCount); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error recording sentence analysis for user {UserId}", userId); + } + } + + /// + /// 記錄單字查詢使用 + /// + public async Task RecordWordQueryAsync(Guid userId, bool wasHighValue) + { + try + { + var today = DateOnly.FromDateTime(DateTime.Today); + var stats = await GetOrCreateTodayStatsAsync(userId, today); + + if (wasHighValue) + { + stats.HighValueWordClicks++; + } + else + { + stats.LowValueWordClicks++; + stats.TotalApiCalls++; // 低價值詞彙需要API調用 + } + + stats.UniqueWordsQueried++; // 簡化:每次查詢都算一個獨特詞彙 + stats.UpdatedAt = DateTime.UtcNow; + + await _context.SaveChangesAsync(); + + _logger.LogInformation("Recorded word query for user {UserId}, high value: {IsHighValue}", + userId, wasHighValue); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error recording word query for user {UserId}", userId); + } + } + + /// + /// 獲取用戶使用統計 + /// + public async Task GetUsageStatsAsync(Guid userId) + { + try + { + var today = DateOnly.FromDateTime(DateTime.Today); + var resetTime = DateTime.UtcNow.AddHours(-FREE_USER_RESET_HOURS); + + // 今日統計 + var todayStats = await _context.WordQueryUsageStats + .FirstOrDefaultAsync(stats => stats.UserId == userId && stats.Date == today) + ?? new WordQueryUsageStats { UserId = userId, Date = today }; + + // 最近3小時使用量(用於限制檢查) + var recentUsage = await _context.WordQueryUsageStats + .Where(stats => stats.UserId == userId && stats.CreatedAt >= resetTime) + .SumAsync(stats => stats.SentenceAnalysisCount + stats.LowValueWordClicks); + + // 本週統計 + var weekStart = DateTime.Today.AddDays(-(int)DateTime.Today.DayOfWeek); + var weekStats = await _context.WordQueryUsageStats + .Where(stats => stats.UserId == userId && stats.Date >= DateOnly.FromDateTime(weekStart)) + .GroupBy(stats => 1) + .Select(g => new + { + TotalAnalysis = g.Sum(s => s.SentenceAnalysisCount), + TotalWordClicks = g.Sum(s => s.HighValueWordClicks + s.LowValueWordClicks), + TotalApiCalls = g.Sum(s => s.TotalApiCalls), + UniqueWords = g.Sum(s => s.UniqueWordsQueried) + }) + .FirstOrDefaultAsync(); + + return new UserUsageStats + { + UserId = userId, + Today = new DailyUsageStats + { + Date = today, + SentenceAnalysisCount = todayStats.SentenceAnalysisCount, + HighValueWordClicks = todayStats.HighValueWordClicks, + LowValueWordClicks = todayStats.LowValueWordClicks, + TotalApiCalls = todayStats.TotalApiCalls, + UniqueWordsQueried = todayStats.UniqueWordsQueried + }, + RecentUsage = new UsageLimitInfo + { + UsedInWindow = recentUsage, + WindowLimit = FREE_USER_ANALYSIS_LIMIT, + WindowHours = FREE_USER_RESET_HOURS, + ResetTime = DateTime.UtcNow.AddHours(FREE_USER_RESET_HOURS - + ((DateTime.UtcNow - resetTime).TotalHours % FREE_USER_RESET_HOURS)) + }, + ThisWeek = weekStats != null ? new WeeklyUsageStats + { + StartDate = DateOnly.FromDateTime(weekStart), + EndDate = DateOnly.FromDateTime(weekStart.AddDays(6)), + TotalSentenceAnalysis = weekStats.TotalAnalysis, + TotalWordClicks = weekStats.TotalWordClicks, + TotalApiCalls = weekStats.TotalApiCalls, + UniqueWordsQueried = weekStats.UniqueWords + } : new WeeklyUsageStats() + }; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting usage stats for user {UserId}", userId); + return new UserUsageStats { UserId = userId }; + } + } + + /// + /// 獲取或創建今日統計記錄 + /// + private async Task GetOrCreateTodayStatsAsync(Guid userId, DateOnly date) + { + var stats = await _context.WordQueryUsageStats + .FirstOrDefaultAsync(s => s.UserId == userId && s.Date == date); + + if (stats == null) + { + stats = new WordQueryUsageStats + { + Id = Guid.NewGuid(), + UserId = userId, + Date = date, + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow + }; + + _context.WordQueryUsageStats.Add(stats); + } + + return stats; + } +} + +// 回應用的 DTO 類別 +public class UserUsageStats +{ + public Guid UserId { get; set; } + public DailyUsageStats Today { get; set; } = new(); + public UsageLimitInfo RecentUsage { get; set; } = new(); + public WeeklyUsageStats ThisWeek { get; set; } = new(); +} + +public class DailyUsageStats +{ + public DateOnly Date { get; set; } + public int SentenceAnalysisCount { get; set; } + public int HighValueWordClicks { get; set; } + public int LowValueWordClicks { get; set; } + public int TotalApiCalls { get; set; } + public int UniqueWordsQueried { get; set; } +} + +public class UsageLimitInfo +{ + public int UsedInWindow { get; set; } + public int WindowLimit { get; set; } + public int WindowHours { get; set; } + public DateTime ResetTime { get; set; } + public bool CanUse => UsedInWindow < WindowLimit; + public int Remaining => Math.Max(0, WindowLimit - UsedInWindow); +} + +public class WeeklyUsageStats +{ + public DateOnly StartDate { get; set; } + public DateOnly EndDate { get; set; } + public int TotalSentenceAnalysis { get; set; } + public int TotalWordClicks { get; set; } + public int TotalApiCalls { get; set; } + public int UniqueWordsQueried { get; set; } +} \ No newline at end of file