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
243 lines
8.8 KiB
C#
243 lines
8.8 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);
|
|
}
|
|
|
|
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 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;
|
|
}
|
|
}
|
|
}
|