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 > 30) { throw new UserException("A quantidade de flashcards deve estar entre 10 e 30 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 ragCards = cards .Select(card => BuildRagCard(card, now)) .ToList(); var subjectGroups = ragCards .GroupBy(card => card.Subject, StringComparer.OrdinalIgnoreCase) .OrderBy(group => group.Key, StringComparer.OrdinalIgnoreCase) .Select(subjectGroup => { var subSubjectGroups = subjectGroup .GroupBy(card => card.SubSubject, StringComparer.OrdinalIgnoreCase) .OrderBy(group => group.Key, StringComparer.OrdinalIgnoreCase) .Select(subSubjectGroup => { var subSubjectCards = subSubjectGroup.ToList(); return new FlashcardRagSubSubjectGroup { SubSubject = subSubjectGroup.Key, Summary = BuildSummary(subSubjectCards), Cards = subSubjectCards }; }) .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.Hard => "Crie perguntas mais desafiadoras, focando diferencas finas e cobrancas de prova.", _ => "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); if (cards.Count < amount) { throw new Exception($"Quantidade insuficiente de flashcards gerados para {filePath}. Esperado: {amount}. 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 FlashcardRagCard BuildRagCard(FlashcardCardWithLibrary card, DateTime referenceTime) { var subject = string.IsNullOrWhiteSpace(card.Subject) ? ExtractSubject(card.FilePath) : card.Subject; var subSubject = ExtractSubSubject(card.FilePath); var totalAnswers = card.CorrectCount + card.IncorrectCount; var performanceRate = totalAnswers == 0 ? 0 : (double)card.CorrectCount / totalAnswers; return new FlashcardRagCard { CardId = card.Id, LibraryId = card.LibraryId, FileName = card.FileName, Subject = subject, SubSubject = subSubject, Front = card.Front, Back = card.Back, CorrectCount = card.CorrectCount, IncorrectCount = card.IncorrectCount, TotalAnswers = totalAnswers, PerformanceRate = performanceRate, LastReviewedAt = card.LastReviewedAt, RagStatus = DetermineRagStatus(card.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 cards) { var cardList = cards.ToList(); var greenCount = cardList.Count(card => card.RagStatus == "Green"); var amberCount = cardList.Count(card => card.RagStatus == "Amber"); var redCount = cardList.Count(card => card.RagStatus == "Red"); var greyCount = cardList.Count(card => card.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 class FlashcardJsonPayload { public List Flashcards { get; set; } = []; } private class FlashcardJsonCard { public string Front { get; set; } = string.Empty; public string Back { get; set; } = string.Empty; } } }