Files
mindforge/Mindforge.API/Services/FlashcardService.cs
Jose Henrique 097ba577cf
All checks were successful
Mindforge API Build and Deploy / Build Mindforge API Image (push) Successful in 2m10s
Mindforge API Build and Deploy / Deploy Mindforge API (internal) (push) Successful in 8s
Mindforge Web Build and Deploy (internal) / Build Mindforge Web Image (push) Successful in 3m38s
Mindforge Web Build and Deploy (internal) / Deploy Mindforge Web (internal) (push) Successful in 8s
modifications
2026-06-01 19:29:01 -03:00

433 lines
16 KiB
C#

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<FlashcardService> _logger;
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true
};
public FlashcardService(
IAgentService agentService,
IGiteaService giteaService,
IFlashcardRepository flashcardRepository,
ILogger<FlashcardService> logger)
{
_agentService = agentService;
_giteaService = giteaService;
_flashcardRepository = flashcardRepository;
_logger = logger;
}
public async Task<IReadOnlyList<FlashcardLibraryDetails>> 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<FlashcardLibraryDetails>();
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<IReadOnlyList<FlashcardLibrarySummary>> GetLibrariesAsync()
{
return _flashcardRepository.GetLibrariesAsync();
}
public Task<FlashcardLibraryDetails?> GetLibraryByIdAsync(long libraryId)
{
return _flashcardRepository.GetLibraryByIdAsync(libraryId);
}
public async Task<IReadOnlyList<FlashcardCard>> 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<FlashcardRagDashboard> 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<IReadOnlyList<FlashcardDraftCard>> 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<FlashcardDraftCard> ParseFlashcardsFromJson(string rawResult)
{
var cleanJson = StripJsonCodeBlock(rawResult);
var payload = JsonSerializer.Deserialize<FlashcardJsonPayload>(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<List<FlashcardJsonCard>>(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<FlashcardCardWithLibrary> 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<FlashcardRagLibrary> 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<FlashcardJsonCard> Flashcards { get; set; } = [];
}
private class FlashcardJsonCard
{
public string Front { get; set; } = string.Empty;
public string Back { get; set; } = string.Empty;
}
}
}