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
433 lines
16 KiB
C#
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;
|
|
}
|
|
}
|
|
}
|