All checks were successful
Mindforge API Build and Deploy / Build Mindforge API Image (push) Successful in 3m58s
Mindforge API Build and Deploy / Deploy Mindforge API (internal) (push) Successful in 37s
Mindforge Web Build and Deploy (internal) / Build Mindforge Web Image (push) Successful in 5m19s
Mindforge Web Build and Deploy (internal) / Deploy Mindforge Web (internal) (push) Successful in 11s
412 lines
15 KiB
C#
412 lines
15 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 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<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 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<FlashcardRagCard> 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<FlashcardJsonCard> Flashcards { get; set; } = [];
|
|
}
|
|
|
|
private class FlashcardJsonCard
|
|
{
|
|
public string Front { get; set; } = string.Empty;
|
|
public string Back { get; set; } = string.Empty;
|
|
}
|
|
}
|
|
}
|