using System.Text.Json; using Mindforge.API.Exceptions; using Mindforge.API.Models.Flashcards; using Mindforge.API.Models.Requests; using Mindforge.API.Services.Interfaces; namespace Mindforge.API.Services { public class FlashcardService : IFlashcardService { private readonly IAgentService _agentService; private readonly IGiteaService _giteaService; private readonly IFlashcardRepository _flashcardRepository; private readonly ILogger _logger; private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true }; public FlashcardService( IAgentService agentService, IGiteaService giteaService, IFlashcardRepository flashcardRepository, ILogger logger) { _agentService = agentService; _giteaService = giteaService; _flashcardRepository = flashcardRepository; _logger = logger; } public async Task> GenerateFlashcardsAsync(FlashcardGenerateRequest request) { if (request.FilePaths.Count == 0) { throw new UserException("Selecione ao menos um arquivo para gerar flashcards."); } if (request.Amount is < 10 or > 50) { throw new UserException("A quantidade de flashcards deve estar entre 10 e 50 por arquivo."); } var uniqueFilePaths = request.FilePaths .Where(path => !string.IsNullOrWhiteSpace(path)) .Select(path => path.Trim()) .Distinct(StringComparer.OrdinalIgnoreCase) .ToList(); if (uniqueFilePaths.Count == 0) { throw new UserException("Nenhum caminho de arquivo valido foi enviado."); } var generatedLibraries = new List(); foreach (var filePath in uniqueFilePaths) { var fileContent = await _giteaService.GetFileContentAsync(filePath); var fileName = Path.GetFileName(filePath); var subject = ExtractSubject(filePath); var cards = await GenerateCardsForFileAsync(filePath, fileContent, request.Amount, request.Difficulty); var library = await _flashcardRepository.UpsertLibraryAsync( filePath, fileName, subject, request.Difficulty.ToString(), cards); generatedLibraries.Add(library); } return generatedLibraries; } public Task> GetLibrariesAsync() { return _flashcardRepository.GetLibrariesAsync(); } public Task GetLibraryByIdAsync(long libraryId) { return _flashcardRepository.GetLibraryByIdAsync(libraryId); } public async Task> BuildReviewSessionAsync(FlashcardReviewSessionRequest request) { var libraryIds = (request.LibraryIds ?? []) .Where(id => id > 0) .Distinct() .ToList(); if (libraryIds.Count == 0) { throw new UserException("Selecione ao menos uma biblioteca para iniciar a revisao."); } return await _flashcardRepository.GetCardsForLibrariesAsync(libraryIds); } public async Task GetRagStatusAsync() { var now = DateTime.UtcNow; var cards = await _flashcardRepository.GetAllCardsWithLibraryAsync(); var ragLibraries = cards .GroupBy(card => card.LibraryId) .Select(group => BuildRagLibrary(group.ToList(), now)) .ToList(); var subjectGroups = ragLibraries .GroupBy(library => library.Subject, StringComparer.OrdinalIgnoreCase) .OrderBy(group => group.Key, StringComparer.OrdinalIgnoreCase) .Select(subjectGroup => { var subSubjectGroups = subjectGroup .GroupBy(library => library.SubSubject, StringComparer.OrdinalIgnoreCase) .OrderBy(group => group.Key, StringComparer.OrdinalIgnoreCase) .Select(subSubjectGroup => { var subSubjectLibraries = subSubjectGroup .OrderBy(library => StatusSortOrder(library.RagStatus)) .ThenBy(library => library.FileName, StringComparer.OrdinalIgnoreCase) .ToList(); return new FlashcardRagSubSubjectGroup { SubSubject = subSubjectGroup.Key, Summary = BuildSummary(subSubjectLibraries), Libraries = subSubjectLibraries }; }) .ToList(); return new FlashcardRagSubjectGroup { Subject = subjectGroup.Key, Summary = BuildSummary(subjectGroup), SubSubjects = subSubjectGroups }; }) .ToList(); return new FlashcardRagDashboard { GeneratedAt = now, Subjects = subjectGroups }; } public async Task RecordReviewAnswerAsync(long cardId, bool correct) { if (cardId <= 0) { throw new UserException("Card de revisao invalido."); } await _flashcardRepository.RecordReviewAnswerAsync(cardId, correct); } private async Task> GenerateCardsForFileAsync( string filePath, string fileContent, int amount, FlashcardDifficulty difficulty) { var difficultyInstruction = difficulty switch { FlashcardDifficulty.Medium => "Crie perguntas de nivel intermediario, combinando conceitos e exigindo raciocĂ­nio moderado.", _ => "Crie perguntas diretas e objetivas para facilitar memorizacao inicial." }; var systemPrompt = "Voce e um assistente educacional especializado em flashcards.\n" + $"Gere exatamente {amount} flashcards em Portugues do Brasil.\n" + "Cada item precisa conter:\n" + "- front: pergunta ou gatilho curto\n" + "- back: resposta clara, correta e objetiva\n" + $"{difficultyInstruction}\n" + "Responda apenas com JSON valido, sem markdown, no formato:\n" + "{\n" + " \"flashcards\": [\n" + " { \"front\": \"texto\", \"back\": \"texto\" }\n" + " ]\n" + "}"; var userPrompt = $""" Arquivo: {filePath} Conteudo: {fileContent} """; var rawResult = await _agentService.ProcessRequestAsync(systemPrompt, userPrompt); var cards = ParseFlashcardsFromJson(rawResult); var minimumAcceptable = (int)Math.Ceiling(amount * 0.75); if (cards.Count < minimumAcceptable) { throw new Exception($"Quantidade insuficiente de flashcards gerados para {filePath}. Esperado: {amount}. Minimo aceitavel: {minimumAcceptable}. Recebido: {cards.Count}."); } if (cards.Count > amount) { _logger.LogWarning( "Quantidade de flashcards acima do solicitado para {FilePath}. Solicitado: {Amount}. Recebido: {Generated}.", filePath, amount, cards.Count); cards = cards.Take(amount).ToList(); } for (var i = 0; i < cards.Count; i++) { cards[i].Position = i + 1; } return cards; } private static List ParseFlashcardsFromJson(string rawResult) { var cleanJson = StripJsonCodeBlock(rawResult); var payload = JsonSerializer.Deserialize(cleanJson, JsonOptions); if (payload?.Flashcards is { Count: > 0 }) { return payload.Flashcards .Where(card => !string.IsNullOrWhiteSpace(card.Front) && !string.IsNullOrWhiteSpace(card.Back)) .Select(card => new FlashcardDraftCard { Front = card.Front.Trim(), Back = card.Back.Trim() }) .ToList(); } var directList = JsonSerializer.Deserialize>(cleanJson, JsonOptions); if (directList is { Count: > 0 }) { return directList .Where(card => !string.IsNullOrWhiteSpace(card.Front) && !string.IsNullOrWhiteSpace(card.Back)) .Select(card => new FlashcardDraftCard { Front = card.Front.Trim(), Back = card.Back.Trim() }) .ToList(); } throw new Exception("Resposta de flashcards invalida. O modelo nao retornou JSON estruturado."); } private static string StripJsonCodeBlock(string value) { var trimmed = value.Trim(); if (!trimmed.StartsWith("```", StringComparison.Ordinal)) { return trimmed; } var lines = trimmed.Split('\n'); if (lines.Length <= 2) { return trimmed.Trim('`', '\r', '\n'); } return string.Join('\n', lines.Skip(1).Take(lines.Length - 2)).Trim(); } private static string ExtractSubject(string filePath) { var normalized = filePath.Replace('\\', '/'); var segments = normalized.Split('/', StringSplitOptions.RemoveEmptyEntries); if (segments.Length == 0) { return "Geral"; } var concursosIndex = Array.FindIndex( segments, segment => segment.Equals("concursos", StringComparison.OrdinalIgnoreCase) || segment.Equals("concurso", StringComparison.OrdinalIgnoreCase)); if (concursosIndex >= 0 && concursosIndex + 1 < segments.Length) { return segments[concursosIndex + 1]; } return segments[0]; } private static string ExtractSubSubject(string filePath) { var normalized = filePath.Replace('\\', '/'); var segments = normalized.Split('/', StringSplitOptions.RemoveEmptyEntries); if (segments.Length <= 2) { return "Geral"; } var concursosIndex = Array.FindIndex( segments, segment => segment.Equals("concursos", StringComparison.OrdinalIgnoreCase) || segment.Equals("concurso", StringComparison.OrdinalIgnoreCase)); var subjectIndex = concursosIndex >= 0 && concursosIndex + 1 < segments.Length ? concursosIndex + 1 : 0; var subSubjectStart = subjectIndex + 1; var subSubjectEnd = segments.Length - 1; if (subSubjectStart >= subSubjectEnd) { return "Geral"; } return string.Join(" / ", segments[subSubjectStart..subSubjectEnd]); } private static FlashcardRagLibrary BuildRagLibrary( IReadOnlyList cards, DateTime referenceTime) { var firstCard = cards[0]; var subject = string.IsNullOrWhiteSpace(firstCard.Subject) ? ExtractSubject(firstCard.FilePath) : firstCard.Subject; var subSubject = ExtractSubSubject(firstCard.FilePath); var correctCount = cards.Sum(card => card.CorrectCount); var incorrectCount = cards.Sum(card => card.IncorrectCount); var totalAnswers = correctCount + incorrectCount; var lastReviewedAt = cards.Max(card => card.LastReviewedAt); var performanceRate = totalAnswers == 0 ? 0 : (double)correctCount / totalAnswers; return new FlashcardRagLibrary { LibraryId = firstCard.LibraryId, FilePath = firstCard.FilePath, FileName = firstCard.FileName, Subject = subject, SubSubject = subSubject, CorrectCount = correctCount, IncorrectCount = incorrectCount, CardCount = cards.Count, TotalAnswers = totalAnswers, PerformanceRate = performanceRate, LastReviewedAt = lastReviewedAt, RagStatus = DetermineRagStatus(lastReviewedAt, performanceRate, referenceTime) }; } private static string DetermineRagStatus(DateTime? lastReviewedAt, double performanceRate, DateTime referenceTime) { if (!lastReviewedAt.HasValue) { return "Grey"; } var lastReviewUtc = lastReviewedAt.Value.ToUniversalTime(); var elapsedDays = (referenceTime.Date - lastReviewUtc.Date).TotalDays; if (elapsedDays >= 40 || performanceRate < 0.4) { return "Red"; } if (elapsedDays >= 30 || performanceRate <= 0.6) { return "Amber"; } if (elapsedDays < 30 && performanceRate > 0.6) { return "Green"; } return "Amber"; } private static FlashcardRagSummary BuildSummary(IEnumerable libraries) { var libraryList = libraries.ToList(); var greenCount = libraryList.Count(library => library.RagStatus == "Green"); var amberCount = libraryList.Count(library => library.RagStatus == "Amber"); var redCount = libraryList.Count(library => library.RagStatus == "Red"); var greyCount = libraryList.Count(library => library.RagStatus == "Grey"); var activeCount = greenCount + amberCount + redCount; var greenPercentage = activeCount == 0 ? 0 : Math.Round((double)greenCount / activeCount * 100, 2); var attentionPercentage = activeCount == 0 ? 0 : Math.Round((double)(amberCount + redCount) / activeCount * 100, 2); return new FlashcardRagSummary { GreenCount = greenCount, AmberCount = amberCount, RedCount = redCount, GreyCount = greyCount, ActiveCount = activeCount, GreenPercentage = greenPercentage, AttentionPercentage = attentionPercentage }; } private static int StatusSortOrder(string status) { return status switch { "Red" => 0, "Amber" => 1, "Green" => 2, _ => 3 }; } private class FlashcardJsonPayload { public List Flashcards { get; set; } = []; } private class FlashcardJsonCard { public string Front { get; set; } = string.Empty; public string Back { get; set; } = string.Empty; } } }