new flashcards
All checks were successful
Mindforge API Build and Deploy / Build Mindforge API Image (push) Successful in 4m4s
Mindforge Web Build and Deploy (internal) / Build Mindforge Web Image (push) Successful in 5m29s
Mindforge Web Build and Deploy (internal) / Deploy Mindforge Web (internal) (push) Successful in 9s
Mindforge API Build and Deploy / Deploy Mindforge API (internal) (push) Successful in 8s

This commit is contained in:
2026-05-30 11:59:19 -03:00
parent b9736293d3
commit b80d28f671
27 changed files with 1735 additions and 290 deletions

View File

@@ -1,4 +1,6 @@
using System.Threading.Tasks;
using System.Text.Json;
using Mindforge.API.Exceptions;
using Mindforge.API.Models.Flashcards;
using Mindforge.API.Models.Requests;
using Mindforge.API.Services.Interfaces;
@@ -7,59 +9,234 @@ 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, ILogger<FlashcardService> logger)
public FlashcardService(
IAgentService agentService,
IGiteaService giteaService,
IFlashcardRepository flashcardRepository,
ILogger<FlashcardService> logger)
{
_agentService = agentService;
_giteaService = giteaService;
_flashcardRepository = flashcardRepository;
_logger = logger;
}
public async Task<string> GenerateFlashcardsAsync(FlashcardGenerateRequest request)
public async Task<IReadOnlyList<FlashcardLibraryDetails>> GenerateFlashcardsAsync(FlashcardGenerateRequest request)
{
var extraPrompt = request.Mode switch
if (request.FilePaths.Count == 0)
{
FlashcardMode.Detailed => "Crie flashcards mais detalhados.",
FlashcardMode.Hyper => "Adicione também pequenas questões para fixação, para que o usuário possa testar seus conhecimentos. As questões devem ser curtas e objetivas, como se fosse cobradas em prova mesmo.",
_ => ""
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);
}
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."
};
string systemPrompt = $@"Você é um assistente educacional especializado em criar flashcards para o Anki.
Baseado no texto fornecido, crie exatamente {request.Amount} flashcards que focam nos conceitos mais importantes e difíceis.
A resposta FINAL deve ser APENAS no formato CSV, pronto para importação no Anki, sem nenhum texto adicional antes ou depois.
O formato CSV deve ter duas colunas: a frente da carta (pergunta/conceito) e o verso (resposta/explicação). Use ponto e vírgula (;) como separador. Não adicione o cabeçalho.
As perguntas e respostas devem estar estritamente em Português do Brasil.
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"
+ "}";
Exemplo de saída:
""Qual é a capital do Brasil?"";""Brasília""
""Qual é a maior cidade do Brasil?"";""São Paulo""
var userPrompt = $"""
Arquivo: {filePath}
Conteudo:
{fileContent}
""";
Com base no arquivo fornecido, crie exatamente {request.Amount} flashcards que focam nos conceitos mais importantes e difíceis.
{extraPrompt}
";
var rawResult = await _agentService.ProcessRequestAsync(systemPrompt, userPrompt);
var cards = ParseFlashcardsFromJson(rawResult);
string userPrompt = $"Arquivo: {request.FileName}\nConteúdo:\n{request.FileContent}";
var result = await _agentService.ProcessRequestAsync(systemPrompt, userPrompt);
var lines = result.Split('\n');
if (lines.Length == 0)
if (cards.Count < amount)
{
throw new Exception("Nenhum flashcard gerado.");
throw new Exception($"Quantidade insuficiente de flashcards gerados para {filePath}. Esperado: {amount}. Recebido: {cards.Count}.");
}
if (lines.Length > request.Amount)
if (cards.Count > amount)
{
_logger.LogWarning("Quantidade de flashcards excede o limite.");
_logger.LogWarning(
"Quantidade de flashcards acima do solicitado para {FilePath}. Solicitado: {Amount}. Recebido: {Generated}.",
filePath,
amount,
cards.Count);
cards = cards.Take(amount).ToList();
}
if (lines.Length < request.Amount)
for (var i = 0; i < cards.Count; i++)
{
_logger.LogWarning("Quantidade de flashcards abaixo do limite.");
cards[i].Position = i + 1;
}
return result;
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 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;
}
}
}