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
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:
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,10 +10,11 @@ namespace Mindforge.API.Services
|
||||
public class GiteaService : IGiteaService
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly string _baseUrl;
|
||||
private readonly string _owner;
|
||||
private readonly string _repo;
|
||||
private readonly string _token;
|
||||
private readonly string _baseUrl = string.Empty;
|
||||
private readonly string _owner = string.Empty;
|
||||
private readonly string _repo = string.Empty;
|
||||
private readonly string _token = string.Empty;
|
||||
private readonly bool _isConfigured;
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
@@ -25,25 +26,35 @@ namespace Mindforge.API.Services
|
||||
_httpClient = httpClient;
|
||||
|
||||
var repoUrl = configuration["GITEA_REPO_URL"];
|
||||
if (string.IsNullOrEmpty(repoUrl))
|
||||
throw new InvalidOperationException("GITEA_REPO_URL is not set in configuration.");
|
||||
var token = configuration["GITEA_ACCESS_TOKEN"];
|
||||
if (string.IsNullOrWhiteSpace(repoUrl) || string.IsNullOrWhiteSpace(token))
|
||||
{
|
||||
_isConfigured = false;
|
||||
return;
|
||||
}
|
||||
|
||||
_token = configuration["GITEA_ACCESS_TOKEN"]
|
||||
?? throw new InvalidOperationException("GITEA_ACCESS_TOKEN is not set in configuration.");
|
||||
|
||||
// Parse: https://host/owner/repo or https://host/owner/repo.git
|
||||
var normalizedUrl = repoUrl.TrimEnd('/').TrimEnd(".git".ToCharArray());
|
||||
var uri = new Uri(normalizedUrl);
|
||||
_baseUrl = $"{uri.Scheme}://{uri.Host}{(uri.IsDefaultPort ? "" : $":{uri.Port}")}";
|
||||
var parts = uri.AbsolutePath.Trim('/').Split('/');
|
||||
if (parts.Length < 2)
|
||||
{
|
||||
_isConfigured = false;
|
||||
return;
|
||||
}
|
||||
|
||||
_baseUrl = $"{uri.Scheme}://{uri.Host}{(uri.IsDefaultPort ? "" : $":{uri.Port}")}";
|
||||
_owner = parts[0];
|
||||
_repo = parts[1];
|
||||
_token = token;
|
||||
_isConfigured = true;
|
||||
}
|
||||
|
||||
public string GetRepositoryName() => _repo;
|
||||
public string GetRepositoryName() => _isConfigured ? _repo : "repositorio";
|
||||
|
||||
public async Task<List<FileTreeNode>> GetFileTreeAsync()
|
||||
{
|
||||
EnsureConfigured();
|
||||
|
||||
// Get the master branch to obtain the latest commit SHA
|
||||
var branchJson = await GetApiAsync($"/api/v1/repos/{_owner}/{_repo}/branches/master");
|
||||
var branch = JsonSerializer.Deserialize<GiteaBranch>(branchJson, JsonOptions)
|
||||
@@ -61,6 +72,8 @@ namespace Mindforge.API.Services
|
||||
|
||||
public async Task<string> GetFileContentAsync(string path)
|
||||
{
|
||||
EnsureConfigured();
|
||||
|
||||
var request = new HttpRequestMessage(HttpMethod.Get,
|
||||
$"{_baseUrl}/api/v1/repos/{_owner}/{_repo}/raw/{path}?ref=master");
|
||||
request.Headers.Add("Authorization", $"token {_token}");
|
||||
@@ -75,6 +88,8 @@ namespace Mindforge.API.Services
|
||||
|
||||
private async Task<string> GetApiAsync(string endpoint)
|
||||
{
|
||||
EnsureConfigured();
|
||||
|
||||
var request = new HttpRequestMessage(HttpMethod.Get, $"{_baseUrl}{endpoint}");
|
||||
request.Headers.Add("Authorization", $"token {_token}");
|
||||
var response = await _httpClient.SendAsync(request);
|
||||
@@ -82,6 +97,14 @@ namespace Mindforge.API.Services
|
||||
return await response.Content.ReadAsStringAsync();
|
||||
}
|
||||
|
||||
private void EnsureConfigured()
|
||||
{
|
||||
if (!_isConfigured)
|
||||
{
|
||||
throw new InvalidOperationException("Gitea nao configurado. Defina GITEA_REPO_URL e GITEA_ACCESS_TOKEN.");
|
||||
}
|
||||
}
|
||||
|
||||
private static List<FileTreeNode> BuildTree(List<GiteaTreeItem> items)
|
||||
{
|
||||
var root = new List<FileTreeNode>();
|
||||
|
||||
18
Mindforge.API/Services/Interfaces/IFlashcardRepository.cs
Normal file
18
Mindforge.API/Services/Interfaces/IFlashcardRepository.cs
Normal file
@@ -0,0 +1,18 @@
|
||||
using Mindforge.API.Models.Flashcards;
|
||||
|
||||
namespace Mindforge.API.Services.Interfaces
|
||||
{
|
||||
public interface IFlashcardRepository
|
||||
{
|
||||
Task EnsureSchemaAsync();
|
||||
Task<FlashcardLibraryDetails> UpsertLibraryAsync(
|
||||
string filePath,
|
||||
string fileName,
|
||||
string subject,
|
||||
string difficulty,
|
||||
IReadOnlyList<FlashcardDraftCard> cards);
|
||||
Task<IReadOnlyList<FlashcardLibrarySummary>> GetLibrariesAsync();
|
||||
Task<FlashcardLibraryDetails?> GetLibraryByIdAsync(long libraryId);
|
||||
Task<IReadOnlyList<FlashcardCard>> GetCardsForLibrariesAsync(IReadOnlyList<long> libraryIds);
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,13 @@
|
||||
using System.Threading.Tasks;
|
||||
using Mindforge.API.Models.Flashcards;
|
||||
using Mindforge.API.Models.Requests;
|
||||
|
||||
namespace Mindforge.API.Services.Interfaces
|
||||
{
|
||||
public interface IFlashcardService
|
||||
{
|
||||
Task<string> GenerateFlashcardsAsync(FlashcardGenerateRequest request);
|
||||
Task<IReadOnlyList<FlashcardLibraryDetails>> GenerateFlashcardsAsync(FlashcardGenerateRequest request);
|
||||
Task<IReadOnlyList<FlashcardLibrarySummary>> GetLibrariesAsync();
|
||||
Task<FlashcardLibraryDetails?> GetLibraryByIdAsync(long libraryId);
|
||||
Task<IReadOnlyList<FlashcardCard>> BuildReviewSessionAsync(FlashcardReviewSessionRequest request);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user