diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..345f89f --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,15 @@ +# Agents + +Have a read in `project-context.md` file, it will be the main source of truth for the project. Always keep it up to date with the latest project specs and architecture decisions. + +## UI specs +- All UI needs to be have a glassy-look, with a not-so-dark background +- Buttons needs to appear iOS-like and modern +- All text inputs, displays and content should appear with a modern typefont +- All texts needs to be in Brazilian Portuguese + +## Testing +Follow strictly the testing requirements: +- It should NOT have any form of integration testing, nor unit testing, or any kind of testing. +- The only type of testing that should be done is building the project - make sure it compiles, and it's enough. +- Don't create random tests or any kind of files that is related to testing. All testing will be done by a human later on. \ No newline at end of file diff --git a/Mindforge.API/Controllers/FlashcardController.cs b/Mindforge.API/Controllers/FlashcardController.cs index 8a64b3b..ffae75c 100644 --- a/Mindforge.API/Controllers/FlashcardController.cs +++ b/Mindforge.API/Controllers/FlashcardController.cs @@ -1,5 +1,3 @@ -using System; -using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc; using Mindforge.API.Exceptions; using Mindforge.API.Models.Requests; @@ -21,23 +19,41 @@ namespace Mindforge.API.Controllers [HttpPost("generate")] public async Task Generate([FromBody] FlashcardGenerateRequest request) { - if (string.IsNullOrWhiteSpace(request.FileContent) || request.Amount <= 0) + request ??= new FlashcardGenerateRequest(); + if (request.FilePaths is null || request.FilePaths.Count == 0) { - throw new UserException("FileContent is required and Amount must be greater than 0."); + throw new UserException("Selecione ao menos um arquivo para gerar flashcards."); } - try + var libraries = await _flashcardService.GenerateFlashcardsAsync(request); + return Ok(new { libraries }); + } + + [HttpGet("libraries")] + public async Task GetLibraries() + { + var libraries = await _flashcardService.GetLibrariesAsync(); + return Ok(libraries); + } + + [HttpGet("libraries/{id:long}")] + public async Task GetLibraryById([FromRoute] long id) + { + var library = await _flashcardService.GetLibraryByIdAsync(id); + if (library is null) { - var base64Bytes = Convert.FromBase64String(request.FileContent); - request.FileContent = System.Text.Encoding.UTF8.GetString(base64Bytes); - } - catch (FormatException) - { - throw new UserException("FileContent must be a valid base64 string."); + return NotFound(new { error = "Biblioteca de flashcards nao encontrada." }); } - var response = await _flashcardService.GenerateFlashcardsAsync(request); - return Ok(new { result = response }); + return Ok(library); + } + + [HttpPost("review-session")] + public async Task BuildReviewSession([FromBody] FlashcardReviewSessionRequest request) + { + request ??= new FlashcardReviewSessionRequest(); + var cards = await _flashcardService.BuildReviewSessionAsync(request); + return Ok(new { cards }); } } } diff --git a/Mindforge.API/Mindforge.API.csproj b/Mindforge.API/Mindforge.API.csproj index e292517..7867c63 100644 --- a/Mindforge.API/Mindforge.API.csproj +++ b/Mindforge.API/Mindforge.API.csproj @@ -7,7 +7,9 @@ + + diff --git a/Mindforge.API/Models/Flashcards/FlashcardDraftCard.cs b/Mindforge.API/Models/Flashcards/FlashcardDraftCard.cs new file mode 100644 index 0000000..742b199 --- /dev/null +++ b/Mindforge.API/Models/Flashcards/FlashcardDraftCard.cs @@ -0,0 +1,9 @@ +namespace Mindforge.API.Models.Flashcards +{ + public class FlashcardDraftCard + { + public string Front { get; set; } = string.Empty; + public string Back { get; set; } = string.Empty; + public int Position { get; set; } + } +} diff --git a/Mindforge.API/Models/Flashcards/FlashcardModels.cs b/Mindforge.API/Models/Flashcards/FlashcardModels.cs new file mode 100644 index 0000000..a4fcfaa --- /dev/null +++ b/Mindforge.API/Models/Flashcards/FlashcardModels.cs @@ -0,0 +1,29 @@ +namespace Mindforge.API.Models.Flashcards +{ + public class FlashcardLibrarySummary + { + public long Id { get; set; } + public string FilePath { get; set; } = string.Empty; + public string FileName { get; set; } = string.Empty; + public string Subject { get; set; } = string.Empty; + public string Difficulty { get; set; } = string.Empty; + public int CardCount { get; set; } + public DateTime CreatedAt { get; set; } + public DateTime UpdatedAt { get; set; } + } + + public class FlashcardCard + { + public long Id { get; set; } + public long LibraryId { get; set; } + public string Front { get; set; } = string.Empty; + public string Back { get; set; } = string.Empty; + public int Position { get; set; } + public DateTime CreatedAt { get; set; } + } + + public class FlashcardLibraryDetails : FlashcardLibrarySummary + { + public List Cards { get; set; } = []; + } +} diff --git a/Mindforge.API/Models/Requests/FlashcardGenerateRequest.cs b/Mindforge.API/Models/Requests/FlashcardGenerateRequest.cs index 216cab0..b08d18b 100644 --- a/Mindforge.API/Models/Requests/FlashcardGenerateRequest.cs +++ b/Mindforge.API/Models/Requests/FlashcardGenerateRequest.cs @@ -4,18 +4,15 @@ namespace Mindforge.API.Models.Requests { public class FlashcardGenerateRequest { - public string FileContent { get; set; } = string.Empty; - public string FileName { get; set; } = string.Empty; + public List FilePaths { get; set; } = []; public int Amount { get; set; } [JsonConverter(typeof(JsonStringEnumConverter))] - public FlashcardMode? Mode { get; set; } = FlashcardMode.Simple; + public FlashcardDifficulty Difficulty { get; set; } = FlashcardDifficulty.Easy; } - public enum FlashcardMode + public enum FlashcardDifficulty { - Basic, - Simple, - Detailed, - Hyper + Easy, + Hard } } diff --git a/Mindforge.API/Models/Requests/FlashcardReviewSessionRequest.cs b/Mindforge.API/Models/Requests/FlashcardReviewSessionRequest.cs new file mode 100644 index 0000000..1c94876 --- /dev/null +++ b/Mindforge.API/Models/Requests/FlashcardReviewSessionRequest.cs @@ -0,0 +1,7 @@ +namespace Mindforge.API.Models.Requests +{ + public class FlashcardReviewSessionRequest + { + public List LibraryIds { get; set; } = []; + } +} diff --git a/Mindforge.API/Program.cs b/Mindforge.API/Program.cs index 092d49f..e3a5d3b 100644 --- a/Mindforge.API/Program.cs +++ b/Mindforge.API/Program.cs @@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Builder; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Mindforge.API.Providers; +using Mindforge.API.Repositories; using Mindforge.API.Services; using Mindforge.API.Services.Interfaces; @@ -39,10 +40,25 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); var app = builder.Build(); +using (var scope = app.Services.CreateScope()) +{ + try + { + var flashcardRepository = scope.ServiceProvider.GetRequiredService(); + await flashcardRepository.EnsureSchemaAsync(); + app.Logger.LogInformation("Flashcard schema checked successfully."); + } + catch (Exception ex) + { + app.Logger.LogError(ex, "Could not initialize flashcard schema on startup."); + } +} + app.UseCors("AllowAll"); app.UseMiddleware(); @@ -80,4 +96,7 @@ if (string.IsNullOrEmpty(giteaRepoUrl)) if (string.IsNullOrEmpty(giteaAccessToken)) app.Logger.LogWarning("GITEA_ACCESS_TOKEN not found in configuration. Repository features will not work."); +if (string.IsNullOrEmpty(builder.Configuration.GetConnectionString("MindforgeDb"))) + app.Logger.LogWarning("MindforgeDb connection string not found. Falling back to default PostgreSQL connection."); + app.Run(); diff --git a/Mindforge.API/Repositories/FlashcardRepository.cs b/Mindforge.API/Repositories/FlashcardRepository.cs new file mode 100644 index 0000000..b748463 --- /dev/null +++ b/Mindforge.API/Repositories/FlashcardRepository.cs @@ -0,0 +1,260 @@ +using Dapper; +using Mindforge.API.Models.Flashcards; +using Mindforge.API.Services.Interfaces; +using Npgsql; + +namespace Mindforge.API.Repositories +{ + public class FlashcardRepository : IFlashcardRepository + { + private const string DefaultConnectionString = "Host=iris.haven;Database=mindforge;Username=root;Password=root"; + private readonly string _connectionString; + + public FlashcardRepository(IConfiguration configuration) + { + _connectionString = configuration.GetConnectionString("MindforgeDb") + ?? configuration["MINDFORGE_DB_CONNECTION"] + ?? DefaultConnectionString; + } + + public async Task EnsureSchemaAsync() + { + const string sql = """ + CREATE TABLE IF NOT EXISTS flashcard_libraries ( + id BIGSERIAL PRIMARY KEY, + file_path TEXT NOT NULL UNIQUE, + file_name TEXT NOT NULL, + subject TEXT NOT NULL, + difficulty TEXT NOT NULL, + card_count INTEGER NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + + CREATE TABLE IF NOT EXISTS flashcards ( + id BIGSERIAL PRIMARY KEY, + library_id BIGINT NOT NULL REFERENCES flashcard_libraries(id) ON DELETE CASCADE, + front TEXT NOT NULL, + back TEXT NOT NULL, + position INTEGER NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + + CREATE INDEX IF NOT EXISTS ix_flashcard_libraries_subject ON flashcard_libraries(subject); + CREATE INDEX IF NOT EXISTS ix_flashcards_library_id_position ON flashcards(library_id, position); + """; + + await using var connection = CreateConnection(); + await connection.OpenAsync(); + await connection.ExecuteAsync(sql); + } + + public async Task UpsertLibraryAsync( + string filePath, + string fileName, + string subject, + string difficulty, + IReadOnlyList cards) + { + await using var connection = CreateConnection(); + await connection.OpenAsync(); + await using var transaction = await connection.BeginTransactionAsync(); + + try + { + var existingId = await connection.ExecuteScalarAsync( + "SELECT id FROM flashcard_libraries WHERE file_path = @FilePath FOR UPDATE;", + new { FilePath = filePath }, + transaction); + + long libraryId; + if (existingId.HasValue) + { + libraryId = existingId.Value; + await connection.ExecuteAsync( + """ + UPDATE flashcard_libraries + SET file_name = @FileName, + subject = @Subject, + difficulty = @Difficulty, + card_count = @CardCount, + updated_at = NOW() + WHERE id = @LibraryId; + """, + new + { + FileName = fileName, + Subject = subject, + Difficulty = difficulty, + CardCount = cards.Count, + LibraryId = libraryId + }, + transaction); + + await connection.ExecuteAsync( + "DELETE FROM flashcards WHERE library_id = @LibraryId;", + new { LibraryId = libraryId }, + transaction); + } + else + { + libraryId = await connection.ExecuteScalarAsync( + """ + INSERT INTO flashcard_libraries (file_path, file_name, subject, difficulty, card_count) + VALUES (@FilePath, @FileName, @Subject, @Difficulty, @CardCount) + RETURNING id; + """, + new + { + FilePath = filePath, + FileName = fileName, + Subject = subject, + Difficulty = difficulty, + CardCount = cards.Count + }, + transaction); + } + + if (cards.Count > 0) + { + await connection.ExecuteAsync( + """ + INSERT INTO flashcards (library_id, front, back, position) + VALUES (@LibraryId, @Front, @Back, @Position); + """, + cards.Select(card => new + { + LibraryId = libraryId, + card.Front, + card.Back, + card.Position + }), + transaction); + } + + await transaction.CommitAsync(); + var details = await GetLibraryByIdAsync(libraryId); + if (details is null) + { + throw new InvalidOperationException("Biblioteca gerada nao encontrada apos persistencia."); + } + + return details; + } + catch + { + await transaction.RollbackAsync(); + throw; + } + } + + public async Task> GetLibrariesAsync() + { + const string sql = """ + SELECT + id AS Id, + file_path AS FilePath, + file_name AS FileName, + subject AS Subject, + difficulty AS Difficulty, + card_count AS CardCount, + created_at AS CreatedAt, + updated_at AS UpdatedAt + FROM flashcard_libraries + ORDER BY subject, file_name; + """; + + await using var connection = CreateConnection(); + await connection.OpenAsync(); + var rows = await connection.QueryAsync(sql); + return rows.ToList(); + } + + public async Task GetLibraryByIdAsync(long libraryId) + { + const string librarySql = """ + SELECT + id AS Id, + file_path AS FilePath, + file_name AS FileName, + subject AS Subject, + difficulty AS Difficulty, + card_count AS CardCount, + created_at AS CreatedAt, + updated_at AS UpdatedAt + FROM flashcard_libraries + WHERE id = @LibraryId; + """; + + const string cardsSql = """ + SELECT + id AS Id, + library_id AS LibraryId, + front AS Front, + back AS Back, + position AS Position, + created_at AS CreatedAt + FROM flashcards + WHERE library_id = @LibraryId + ORDER BY position; + """; + + await using var connection = CreateConnection(); + await connection.OpenAsync(); + + var library = await connection.QuerySingleOrDefaultAsync( + librarySql, + new { LibraryId = libraryId }); + + if (library is null) + { + return null; + } + + var cards = await connection.QueryAsync(cardsSql, new { LibraryId = libraryId }); + return new FlashcardLibraryDetails + { + Id = library.Id, + FilePath = library.FilePath, + FileName = library.FileName, + Subject = library.Subject, + Difficulty = library.Difficulty, + CardCount = library.CardCount, + CreatedAt = library.CreatedAt, + UpdatedAt = library.UpdatedAt, + Cards = cards.ToList() + }; + } + + public async Task> GetCardsForLibrariesAsync(IReadOnlyList libraryIds) + { + if (libraryIds.Count == 0) + { + return []; + } + + const string sql = """ + SELECT + id AS Id, + library_id AS LibraryId, + front AS Front, + back AS Back, + position AS Position, + created_at AS CreatedAt + FROM flashcards + WHERE library_id = ANY(@LibraryIds) + ORDER BY library_id, position; + """; + + await using var connection = CreateConnection(); + await connection.OpenAsync(); + var cards = await connection.QueryAsync(sql, new { LibraryIds = libraryIds.ToArray() }); + return cards.ToList(); + } + + private NpgsqlConnection CreateConnection() + { + return new NpgsqlConnection(_connectionString); + } + } +} diff --git a/Mindforge.API/Services/FlashcardService.cs b/Mindforge.API/Services/FlashcardService.cs index a5f187e..dab3419 100644 --- a/Mindforge.API/Services/FlashcardService.cs +++ b/Mindforge.API/Services/FlashcardService.cs @@ -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 _logger; + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true + }; - public FlashcardService(IAgentService agentService, ILogger logger) + public FlashcardService( + IAgentService agentService, + IGiteaService giteaService, + IFlashcardRepository flashcardRepository, + ILogger logger) { _agentService = agentService; + _giteaService = giteaService; + _flashcardRepository = flashcardRepository; _logger = logger; } - public async Task GenerateFlashcardsAsync(FlashcardGenerateRequest request) + public async Task> 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(); + 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> GetLibrariesAsync() + { + return _flashcardRepository.GetLibrariesAsync(); + } + + public Task GetLibraryByIdAsync(long libraryId) + { + return _flashcardRepository.GetLibraryByIdAsync(libraryId); + } + + public async Task> 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> 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 ParseFlashcardsFromJson(string rawResult) + { + var cleanJson = StripJsonCodeBlock(rawResult); + + var payload = JsonSerializer.Deserialize(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>(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 Flashcards { get; set; } = []; + } + + private class FlashcardJsonCard + { + public string Front { get; set; } = string.Empty; + public string Back { get; set; } = string.Empty; } } } diff --git a/Mindforge.API/Services/GiteaService.cs b/Mindforge.API/Services/GiteaService.cs index 0388e1a..b4e51bc 100644 --- a/Mindforge.API/Services/GiteaService.cs +++ b/Mindforge.API/Services/GiteaService.cs @@ -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> 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(branchJson, JsonOptions) @@ -61,6 +72,8 @@ namespace Mindforge.API.Services public async Task 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 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 BuildTree(List items) { var root = new List(); diff --git a/Mindforge.API/Services/Interfaces/IFlashcardRepository.cs b/Mindforge.API/Services/Interfaces/IFlashcardRepository.cs new file mode 100644 index 0000000..c549e86 --- /dev/null +++ b/Mindforge.API/Services/Interfaces/IFlashcardRepository.cs @@ -0,0 +1,18 @@ +using Mindforge.API.Models.Flashcards; + +namespace Mindforge.API.Services.Interfaces +{ + public interface IFlashcardRepository + { + Task EnsureSchemaAsync(); + Task UpsertLibraryAsync( + string filePath, + string fileName, + string subject, + string difficulty, + IReadOnlyList cards); + Task> GetLibrariesAsync(); + Task GetLibraryByIdAsync(long libraryId); + Task> GetCardsForLibrariesAsync(IReadOnlyList libraryIds); + } +} diff --git a/Mindforge.API/Services/Interfaces/IFlashcardService.cs b/Mindforge.API/Services/Interfaces/IFlashcardService.cs index ec8da2d..f559555 100644 --- a/Mindforge.API/Services/Interfaces/IFlashcardService.cs +++ b/Mindforge.API/Services/Interfaces/IFlashcardService.cs @@ -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 GenerateFlashcardsAsync(FlashcardGenerateRequest request); + Task> GenerateFlashcardsAsync(FlashcardGenerateRequest request); + Task> GetLibrariesAsync(); + Task GetLibraryByIdAsync(long libraryId); + Task> BuildReviewSessionAsync(FlashcardReviewSessionRequest request); } } diff --git a/Mindforge.API/appsettings.json b/Mindforge.API/appsettings.json index 85d8112..de7fc4a 100644 --- a/Mindforge.API/appsettings.json +++ b/Mindforge.API/appsettings.json @@ -6,6 +6,9 @@ } }, "AllowedHosts": "*", + "ConnectionStrings": { + "MindforgeDb": "Host=localhost;Port=3307;Database=mindforge;Username=root;Password=root" + }, "OPENAI_API_URL": "https://openrouter.ai/api/v1", "OPENAI_TOKEN": "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", "OPENAI_MODEL": "openai/gpt-5.4-mini", diff --git a/Mindforge.API/deploy/mindforge-api.yaml b/Mindforge.API/deploy/mindforge-api.yaml index 5462c09..5380226 100644 --- a/Mindforge.API/deploy/mindforge-api.yaml +++ b/Mindforge.API/deploy/mindforge-api.yaml @@ -39,6 +39,11 @@ spec: secretKeyRef: name: mindforge-secrets key: GITEA_ACCESS_TOKEN + - name: ConnectionStrings__MindforgeDb + valueFrom: + secretKeyRef: + name: mindforge-secrets + key: ConnectionStrings__MindforgeDb resources: requests: memory: "128Mi" diff --git a/Mindforge.Web/src/app.tsx b/Mindforge.Web/src/app.tsx index 3694a16..52fdb99 100644 --- a/Mindforge.Web/src/app.tsx +++ b/Mindforge.Web/src/app.tsx @@ -4,9 +4,10 @@ import { Header } from './components/Header'; import { Sidebar } from './components/Sidebar'; import { VerificadorComponent } from './components/VerificadorComponent'; import { FlashcardComponent } from './components/FlashcardComponent'; +import { FlashcardReviewComponent } from './components/FlashcardReviewComponent'; export function App() { - const [activeModule, setActiveModule] = useState<'home' | 'verificador' | 'flashcards'>('home'); + const [activeModule, setActiveModule] = useState<'home' | 'verificador' | 'flashcards' | 'revisao-flashcards'>('home'); return ( <> @@ -17,8 +18,8 @@ export function App() {
Mindforge Banner -

Mindforge! - STAY HARD!

-

Sua ferramenta de forja mental e estudos.

+

Mindforge - Forja Mental

+

Sua ferramenta de estudos para concursos.

@@ -27,6 +28,9 @@ export function App() {
+
+ +
diff --git a/Mindforge.Web/src/components/FileTreeComponent.css b/Mindforge.Web/src/components/FileTreeComponent.css index 98a848c..f221126 100644 --- a/Mindforge.Web/src/components/FileTreeComponent.css +++ b/Mindforge.Web/src/components/FileTreeComponent.css @@ -64,7 +64,7 @@ } .tree-file-label input[type="checkbox"] { - accent-color: #7c6fcd; + accent-color: var(--color-accent); width: 14px; height: 14px; cursor: pointer; diff --git a/Mindforge.Web/src/components/FlashcardComponent.css b/Mindforge.Web/src/components/FlashcardComponent.css index 38fee80..4d9a453 100644 --- a/Mindforge.Web/src/components/FlashcardComponent.css +++ b/Mindforge.Web/src/components/FlashcardComponent.css @@ -1,6 +1,6 @@ .flashcard-container { width: 100%; - max-width: 800px; + max-width: 900px; margin: 0 auto; animation: slideUp 0.5s ease-out; display: flex; @@ -8,14 +8,21 @@ gap: 1.5rem; } +.flashcard-title { + font-size: 2.5rem; +} + .flashcard-form { display: flex; flex-direction: column; gap: 1.2rem; - background: rgba(255, 255, 255, 0.05); + background: rgba(255, 255, 255, 0.06); padding: 2rem; border-radius: 12px; - border: 1px solid rgba(255, 255, 255, 0.1); + border: 1px solid rgba(255, 255, 255, 0.14); + box-shadow: 0 14px 36px rgba(0, 0, 0, 0.2); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); } .input-group { @@ -30,47 +37,10 @@ color: var(--color-text-creamy); } -.text-area { - width: 100%; - min-height: 200px; - background: rgba(0, 0, 0, 0.2); - border: 1px solid rgba(255, 255, 255, 0.2); - border-radius: 8px; - padding: 1rem; - color: var(--color-text-creamy); - font-family: inherit; - font-size: 1rem; - resize: vertical; -} - -.text-area:focus { - outline: none; - border-color: var(--color-accent); -} - -.file-input-wrapper { - display: flex; - align-items: center; - gap: 1rem; -} - -.file-input { - display: none; -} - -.file-input-label { - background: var(--color-sidebar); - color: var(--color-text-creamy); - padding: 0.6rem 1.2rem; - border-radius: 6px; - cursor: pointer; - border: 1px solid rgba(255, 255, 255, 0.2); - transition: all 0.2s ease; -} - -.file-input-label:hover { - background: rgba(255, 255, 255, 0.1); - border-color: var(--color-accent); +.selection-meta { + font-size: 0.85rem; + color: rgba(255, 255, 255, 0.55); + margin-top: 0.4rem; } .slider-wrapper { @@ -86,72 +56,42 @@ width: 100%; height: 8px; background: rgba(0, 0, 0, 0.3); - border-radius: 4px; + border-radius: 999px; outline: none; } .slider-input::-webkit-slider-thumb { -webkit-appearance: none; appearance: none; - width: 20px; - height: 20px; + width: 22px; + height: 22px; border-radius: 50%; - background: var(--color-accent); + background: #f4f5f5; + border: 2px solid rgba(var(--color-accent-rgb), 0.9); + box-shadow: 0 6px 16px rgba(var(--color-accent-rgb), 0.35); cursor: pointer; - transition: transform 0.1s; -} - -.slider-input::-webkit-slider-thumb:hover { - transform: scale(1.2); } .amount-display { font-weight: 700; color: var(--color-accent); - min-width: 40px; + min-width: 44px; text-align: right; font-size: 1.1rem; } -/* Spinner */ -.spinner-container { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - padding: 3rem 0; - gap: 1rem; -} - -.spinner { - width: 50px; - height: 50px; - border: 4px solid rgba(255, 255, 255, 0.1); - border-left-color: var(--color-accent); - border-radius: 50%; - animation: spin 1s linear infinite; -} - -@keyframes spin { - 0% { transform: rotate(0deg); } - 100% { transform: rotate(360deg); } -} - -.success-message { - color: #7ee787; - text-align: center; - font-weight: 700; +.flashcard-error { + color: #ff9c96; margin-top: 1rem; - animation: fadeIn 0.5s ease-out; } .radio-group { display: flex; flex-wrap: wrap; - background: rgba(0, 0, 0, 0.3); + background: rgba(0, 0, 0, 0.28); padding: 4px; border-radius: 12px; - border: 1px solid rgba(255, 255, 255, 0.1); + border: 1px solid rgba(255, 255, 255, 0.12); width: fit-content; overflow: hidden; gap: 4px; @@ -159,8 +99,7 @@ .radio-item { position: relative; - flex: 1; - min-width: 100px; + min-width: 120px; } .radio-item input[type="radio"] { @@ -178,30 +117,104 @@ cursor: pointer; border-radius: 8px; font-size: 0.95rem; - font-weight: 600; - color: rgba(255, 255, 255, 0.6); - transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + font-weight: 700; + color: rgba(255, 255, 255, 0.65); + transition: all 0.25s ease; white-space: nowrap; } .radio-item input[type="radio"]:checked + .radio-label { background: var(--color-accent); - color: #000; - box-shadow: 0 4px 12px rgba(var(--color-accent-rgb), 0.3); + color: #012f3b; + box-shadow: 0 4px 12px rgba(var(--color-accent-rgb), 0.35); } -.radio-item:hover .radio-label:not(.radio-item input[type="radio"]:checked + .radio-label) { - background: rgba(255, 255, 255, 0.05); - color: #fff; +.radio-item:hover .radio-label { + color: rgba(255, 255, 255, 0.92); } -/* Animations for selection */ -.radio-item input[type="radio"]:checked + .radio-label { - animation: selectBounce 0.3s ease-out; +.spinner-container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 2rem 0; + gap: 1rem; } -@keyframes selectBounce { - 0% { transform: scale(0.95); } - 50% { transform: scale(1.02); } - 100% { transform: scale(1); } +.spinner { + width: 50px; + height: 50px; + border: 4px solid rgba(255, 255, 255, 0.12); + border-left-color: var(--color-accent); + border-radius: 50%; + animation: spin 1s linear infinite; +} + +.flashcard-result-panel { + background: rgba(255, 255, 255, 0.06); + border-radius: 12px; + border: 1px solid rgba(255, 255, 255, 0.14); + padding: 1.5rem; + text-align: left; +} + +.flashcard-result-panel h3 { + margin: 0 0 1rem 0; + font-size: 1.1rem; + color: rgba(255, 255, 255, 0.94); +} + +.flashcard-result-list { + display: grid; + grid-template-columns: 1fr; + gap: 0.85rem; +} + +.flashcard-result-item { + border: 1px solid rgba(255, 255, 255, 0.12); + background: rgba(0, 0, 0, 0.2); + border-radius: 10px; + padding: 0.85rem 1rem; +} + +.flashcard-result-header { + display: flex; + justify-content: space-between; + align-items: center; + gap: 1rem; + font-size: 0.95rem; +} + +.flashcard-result-header span { + color: rgba(255, 255, 255, 0.72); +} + +.flashcard-result-meta { + margin-top: 0.45rem; + display: flex; + flex-wrap: wrap; + gap: 0.85rem; + font-size: 0.85rem; + color: rgba(255, 255, 255, 0.65); +} + +@keyframes spin { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } +} + +@media (max-width: 720px) { + .flashcard-form { + padding: 1.2rem; + } + + .flashcard-result-header { + flex-direction: column; + align-items: flex-start; + } } diff --git a/Mindforge.Web/src/components/FlashcardComponent.tsx b/Mindforge.Web/src/components/FlashcardComponent.tsx index c65d37e..58916e1 100644 --- a/Mindforge.Web/src/components/FlashcardComponent.tsx +++ b/Mindforge.Web/src/components/FlashcardComponent.tsx @@ -1,181 +1,147 @@ import { useState } from 'preact/hooks'; -import { MindforgeApiService, type FlashcardMode } from '../services/MindforgeApiService'; +import { + MindforgeApiService, + type FlashcardDifficulty, + type FlashcardLibraryDetails, +} from '../services/MindforgeApiService'; import { FileTreeComponent } from './FileTreeComponent'; import { Button } from './Button'; import './FlashcardComponent.css'; -// Mapping of flashcard mode to its maximum allowed amount -const modeMax: Record = { - Basic: 25, - Simple: 30, - Detailed: 70, - Hyper: 130, -}; +const minAmount = 10; +const maxAmount = 30; -function utf8ToBase64(str: string): string { - const bytes = new TextEncoder().encode(str); - const binary = Array.from(bytes, byte => String.fromCharCode(byte)).join(''); - return window.btoa(binary); +function difficultyLabel(difficulty: string) { + return difficulty === 'Hard' ? 'Dificil' : 'Facil'; } export function FlashcardComponent() { const [selectedPaths, setSelectedPaths] = useState([]); const [amount, setAmount] = useState(20); - const [mode, setMode] = useState('Simple'); + const [difficulty, setDifficulty] = useState('Easy'); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); - const [success, setSuccess] = useState(false); - - const handleModeChange = (newMode: FlashcardMode) => { - setMode(newMode); - setAmount(20); - }; + const [generatedLibraries, setGeneratedLibraries] = useState([]); const handleGenerate = async () => { if (selectedPaths.length === 0) { - setError('Selecione pelo menos um arquivo do repositório para gerar os flashcards.'); + setError('Selecione pelo menos um arquivo do repositorio para gerar os flashcards.'); return; } setLoading(true); setError(null); - setSuccess(false); try { - // Fetch all selected files and merge their content - const fileContents = await Promise.all( - selectedPaths.map(path => MindforgeApiService.getFileContent(path)) - ); - - const mergedContent = fileContents.map(f => f.content).join('\n\n---\n\n'); - const mergedFileName = selectedPaths.length === 1 - ? (selectedPaths[0].split('/').pop() ?? 'merged.md') - : 'merged.md'; - - const base64Content = utf8ToBase64(mergedContent); - const res = await MindforgeApiService.generateFlashcards({ - fileContent: base64Content, - fileName: mergedFileName, + const response = await MindforgeApiService.generateFlashcards({ + filePaths: selectedPaths, amount, - mode, + difficulty, }); - downloadCSV(res.result); - setSuccess(true); + setGeneratedLibraries(response.libraries); } catch (err: any) { - setError(err.message || 'Ocorreu um erro ao gerar os flashcards.'); + setError(err?.message || 'Ocorreu um erro ao gerar os flashcards.'); } finally { setLoading(false); } }; - const downloadCSV = (content: string) => { - const bom = new Uint8Array([0xEF, 0xBB, 0xBF]); - const blob = new Blob([bom, content], { type: 'text/csv;charset=utf-8;' }); - const url = URL.createObjectURL(blob); - const link = document.createElement('a'); - link.href = url; - link.setAttribute('download', `flashcards_${Date.now()}.csv`); - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - URL.revokeObjectURL(url); - }; - return (
-

Gerador de Flashcards

-

Selecione os arquivos do repositório para gerar flashcards. Múltiplos arquivos serão combinados.

+

Gerador de Flashcards

+

Selecione os arquivos do repositorio para gerar bibliotecas de flashcards.

- + {selectedPaths.length > 0 && ( -
- {selectedPaths.length} arquivo{selectedPaths.length !== 1 ? 's' : ''} selecionado{selectedPaths.length !== 1 ? 's' : ''} - {selectedPaths.length > 1 ? ' — conteúdo será combinado' : ''} +
+ {selectedPaths.length} arquivo{selectedPaths.length !== 1 ? 's' : ''} selecionado + {selectedPaths.length !== 1 ? 's' : ''}
)}
- +
setAmount(parseInt((e.target as HTMLInputElement).value))} + onInput={(e) => setAmount(parseInt((e.target as HTMLInputElement).value, 10))} /> {amount}
- +
handleModeChange((e.target as HTMLInputElement).value as FlashcardMode)} + id="difficulty-easy" + name="difficulty" + value="Easy" + checked={difficulty === 'Easy'} + onChange={() => setDifficulty('Easy')} /> - +
handleModeChange((e.target as HTMLInputElement).value as FlashcardMode)} + id="difficulty-hard" + name="difficulty" + value="Hard" + checked={difficulty === 'Hard'} + onChange={() => setDifficulty('Hard')} /> - -
-
- handleModeChange((e.target as HTMLInputElement).value as FlashcardMode)} - /> - -
-
- handleModeChange((e.target as HTMLInputElement).value as FlashcardMode)} - /> - +
- {error &&
{error}
} - {success &&
Flashcards gerados com sucesso! O download deve ter começado.
} + {error &&
{error}
}
{loading && (
-

Extraindo os melhores conceitos para os seus flashcards. Aguarde...

+

Gerando os flashcards com IA e salvando no banco. Aguarde...

+
+ )} + + {generatedLibraries.length > 0 && ( +
+

Bibliotecas Geradas

+
+ {generatedLibraries.map((library) => ( +
+
+ {library.fileName} + {library.cardCount} cards +
+
+ Materia: {library.subject} + Dificuldade: {difficultyLabel(library.difficulty)} +
+
+ ))} +
)}
diff --git a/Mindforge.Web/src/components/FlashcardReviewComponent.css b/Mindforge.Web/src/components/FlashcardReviewComponent.css new file mode 100644 index 0000000..cfa7b62 --- /dev/null +++ b/Mindforge.Web/src/components/FlashcardReviewComponent.css @@ -0,0 +1,171 @@ +.review-container { + width: 100%; + max-width: 900px; + margin: 0 auto; + display: flex; + flex-direction: column; + gap: 1.2rem; + animation: slideUp 0.45s ease-out; +} + +.review-title { + font-size: 2.35rem; +} + +.review-error { + color: #ff9c96; + text-align: left; + background: rgba(255, 69, 58, 0.12); + border: 1px solid rgba(255, 69, 58, 0.4); + border-radius: 10px; + padding: 0.8rem 1rem; +} + +.review-select-panel, +.review-session-panel { + background: rgba(255, 255, 255, 0.06); + border: 1px solid rgba(255, 255, 255, 0.14); + border-radius: 12px; + padding: 1.25rem; + text-align: left; + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + box-shadow: 0 14px 36px rgba(0, 0, 0, 0.18); +} + +.review-state { + color: rgba(255, 255, 255, 0.72); + font-size: 0.95rem; +} + +.review-subjects { + display: grid; + gap: 1rem; +} + +.review-subject-section { + border: 1px solid rgba(255, 255, 255, 0.12); + background: rgba(0, 0, 0, 0.18); + border-radius: 10px; + padding: 0.9rem; +} + +.review-subject-section h3 { + margin: 0 0 0.8rem; + font-size: 1rem; + color: rgba(255, 255, 255, 0.95); +} + +.review-library-list { + display: grid; + gap: 0.6rem; +} + +.review-library-item { + display: flex; + align-items: flex-start; + gap: 0.7rem; + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 10px; + padding: 0.6rem 0.7rem; + cursor: pointer; + background: rgba(255, 255, 255, 0.03); +} + +.review-library-item input[type="checkbox"] { + margin-top: 0.15rem; + width: 16px; + height: 16px; + accent-color: var(--color-accent); +} + +.review-library-texts { + display: flex; + flex-direction: column; + gap: 0.2rem; +} + +.review-library-texts strong { + font-size: 0.95rem; +} + +.review-library-texts span { + color: rgba(255, 255, 255, 0.68); + font-size: 0.84rem; +} + +.review-actions { + margin-top: 1rem; + display: flex; + justify-content: flex-end; +} + +.review-progress { + display: flex; + align-items: center; + gap: 0.9rem; + margin-bottom: 0.8rem; +} + +.review-progress span { + font-size: 0.9rem; + color: rgba(255, 255, 255, 0.8); + min-width: 60px; +} + +.review-progress-bar { + width: 100%; + height: 8px; + border-radius: 999px; + background: rgba(0, 0, 0, 0.25); + overflow: hidden; +} + +.review-progress-fill { + height: 100%; + background: linear-gradient(90deg, rgba(var(--color-accent-rgb), 0.8), rgba(var(--color-accent-rgb), 1)); + border-radius: 999px; + transition: width 0.25s ease; +} + +.review-card { + border: 1px solid rgba(255, 255, 255, 0.16); + border-radius: 10px; + padding: 1rem; + background: rgba(0, 0, 0, 0.2); + min-height: 250px; +} + +.review-card header { + margin-bottom: 0.8rem; +} + +.review-card small { + color: rgba(255, 255, 255, 0.62); +} + +.review-card h3 { + margin: 0 0 0.4rem; + font-size: 0.98rem; + color: rgba(255, 255, 255, 0.9); +} + +.review-card p { + margin: 0 0 1rem; + font-size: 1rem; + line-height: 1.55; +} + +.review-session-actions { + margin-top: 1rem; + display: flex; + gap: 0.65rem; + flex-wrap: wrap; +} + +@media (max-width: 740px) { + .review-session-actions { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} diff --git a/Mindforge.Web/src/components/FlashcardReviewComponent.tsx b/Mindforge.Web/src/components/FlashcardReviewComponent.tsx new file mode 100644 index 0000000..bf5383f --- /dev/null +++ b/Mindforge.Web/src/components/FlashcardReviewComponent.tsx @@ -0,0 +1,233 @@ +import { useEffect, useMemo, useState } from 'preact/hooks'; +import { + MindforgeApiService, + type FlashcardCard, + type FlashcardLibrarySummary, +} from '../services/MindforgeApiService'; +import { Button } from './Button'; +import './FlashcardReviewComponent.css'; + +function groupLibrariesBySubject(libraries: FlashcardLibrarySummary[]) { + const grouped: Record = {}; + libraries.forEach((library) => { + const subject = library.subject || 'Geral'; + if (!grouped[subject]) { + grouped[subject] = []; + } + grouped[subject].push(library); + }); + + return Object.entries(grouped).sort(([a], [b]) => a.localeCompare(b)); +} + +function difficultyLabel(difficulty: string) { + return difficulty === 'Hard' ? 'Dificil' : 'Facil'; +} + +export function FlashcardReviewComponent() { + const [libraries, setLibraries] = useState([]); + const [selectedLibraryIds, setSelectedLibraryIds] = useState([]); + const [loadingLibraries, setLoadingLibraries] = useState(true); + const [loadingSession, setLoadingSession] = useState(false); + const [error, setError] = useState(null); + const [sessionCards, setSessionCards] = useState([]); + const [currentIndex, setCurrentIndex] = useState(0); + const [showAnswer, setShowAnswer] = useState(false); + + useEffect(() => { + let cancelled = false; + + async function loadLibraries() { + setLoadingLibraries(true); + setError(null); + + try { + const result = await MindforgeApiService.getFlashcardLibraries(); + if (!cancelled) { + setLibraries(result); + } + } catch (err: any) { + if (!cancelled) { + setError(err?.message || 'Falha ao carregar bibliotecas de flashcards.'); + } + } finally { + if (!cancelled) { + setLoadingLibraries(false); + } + } + } + + loadLibraries(); + return () => { + cancelled = true; + }; + }, []); + + const groupedLibraries = useMemo(() => groupLibrariesBySubject(libraries), [libraries]); + + const libraryById = useMemo(() => { + return new Map(libraries.map((library) => [library.id, library])); + }, [libraries]); + + const currentCard = sessionCards[currentIndex]; + const progressPercent = sessionCards.length > 0 + ? ((currentIndex + 1) / sessionCards.length) * 100 + : 0; + + const toggleLibrary = (libraryId: number) => { + if (selectedLibraryIds.includes(libraryId)) { + setSelectedLibraryIds(selectedLibraryIds.filter((id) => id !== libraryId)); + return; + } + + setSelectedLibraryIds([...selectedLibraryIds, libraryId]); + }; + + const startReviewSession = async () => { + if (selectedLibraryIds.length === 0) { + setError('Selecione ao menos uma biblioteca para iniciar a revisao.'); + return; + } + + setLoadingSession(true); + setError(null); + + try { + const response = await MindforgeApiService.createFlashcardReviewSession({ + libraryIds: selectedLibraryIds, + }); + + setSessionCards(response.cards); + setCurrentIndex(0); + setShowAnswer(false); + } catch (err: any) { + setError(err?.message || 'Falha ao iniciar sessao de revisao.'); + } finally { + setLoadingSession(false); + } + }; + + const endSession = () => { + setSessionCards([]); + setCurrentIndex(0); + setShowAnswer(false); + }; + + const goToPrevious = () => { + if (currentIndex === 0) { + return; + } + setCurrentIndex(currentIndex - 1); + setShowAnswer(false); + }; + + const goToNext = () => { + if (currentIndex >= sessionCards.length - 1) { + return; + } + setCurrentIndex(currentIndex + 1); + setShowAnswer(false); + }; + + return ( +
+

Revisao Flashcards

+

Escolha as bibliotecas para estudar e inicie uma sessao de revisao.

+ + {error &&
{error}
} + + {sessionCards.length === 0 && ( +
+ {loadingLibraries &&

Carregando bibliotecas...

} + {!loadingLibraries && libraries.length === 0 && ( +

Nenhuma biblioteca encontrada. Gere flashcards para comecar.

+ )} + + {!loadingLibraries && libraries.length > 0 && ( +
+ {groupedLibraries.map(([subject, subjectLibraries]) => ( +
+

{subject}

+
+ {subjectLibraries.map((library) => ( + + ))} +
+
+ ))} +
+ )} + +
+ +
+
+ )} + + {sessionCards.length > 0 && currentCard && ( +
+
+ {currentIndex + 1} / {sessionCards.length} +
+
+
+
+ +
+
+ + {libraryById.get(currentCard.libraryId)?.fileName || 'Arquivo'} - + {' '} + {libraryById.get(currentCard.libraryId)?.subject || 'Geral'} + +
+

Frente

+

{currentCard.front}

+ {showAnswer && ( + <> +

Verso

+

{currentCard.back}

+ + )} +
+ +
+ + + {!showAnswer && ( + + )} + + {showAnswer && ( + + )} + + +
+
+ )} +
+ ); +} diff --git a/Mindforge.Web/src/components/Sidebar.tsx b/Mindforge.Web/src/components/Sidebar.tsx index 387191a..7fd9f75 100644 --- a/Mindforge.Web/src/components/Sidebar.tsx +++ b/Mindforge.Web/src/components/Sidebar.tsx @@ -2,15 +2,15 @@ import { Button } from './Button'; import './Sidebar.css'; interface SidebarProps { - onModuleChange: (module: 'home' | 'verificador' | 'flashcards') => void; - activeModule: 'home' | 'verificador' | 'flashcards'; + onModuleChange: (module: 'home' | 'verificador' | 'flashcards' | 'revisao-flashcards') => void; + activeModule: 'home' | 'verificador' | 'flashcards' | 'revisao-flashcards'; } export function Sidebar({ onModuleChange, activeModule }: SidebarProps) { return ( ); diff --git a/Mindforge.Web/src/services/MindforgeApiService.ts b/Mindforge.Web/src/services/MindforgeApiService.ts index b715069..a2663e2 100644 --- a/Mindforge.Web/src/services/MindforgeApiService.ts +++ b/Mindforge.Web/src/services/MindforgeApiService.ts @@ -26,17 +26,66 @@ export interface CheckFileResponse { result: string; } +export type FlashcardDifficulty = 'Easy' | 'Hard'; + export interface GenerateFlashcardsRequest { - fileContent: string; - fileName: string; + filePaths: string[]; amount: number; - mode: FlashcardMode; + difficulty: FlashcardDifficulty; } -export type FlashcardMode = 'Basic' | 'Simple' | 'Detailed' | 'Hyper'; +export interface FlashcardCard { + id: number; + libraryId: number; + front: string; + back: string; + position: number; + createdAt: string; +} + +export interface FlashcardLibrarySummary { + id: number; + filePath: string; + fileName: string; + subject: string; + difficulty: string; + cardCount: number; + createdAt: string; + updatedAt: string; +} + +export interface FlashcardLibraryDetails extends FlashcardLibrarySummary { + cards: FlashcardCard[]; +} export interface GenerateFlashcardsResponse { - result: string; + libraries: FlashcardLibraryDetails[]; +} + +export interface FlashcardReviewSessionRequest { + libraryIds: number[]; +} + +export interface FlashcardReviewSessionResponse { + cards: FlashcardCard[]; +} + +async function throwIfNotOk(response: Response, fallback: string) { + if (response.ok) { + return; + } + + let apiMessage = ''; + try { + const body = await response.json(); + if (typeof body?.error === 'string' && body.error.length > 0) { + apiMessage = body.error; + } + } catch { + // Ignore parse failures and throw fallback below. + } + + throw new Error(apiMessage || fallback); } export const MindforgeApiService = { @@ -49,9 +98,7 @@ export const MindforgeApiService = { body: JSON.stringify(data), }); - if (!response.ok) { - throw new Error(`Error checking file: ${response.statusText}`); - } + await throwIfNotOk(response, `Erro ao validar arquivo: ${response.statusText}`); return response.json(); }, @@ -64,27 +111,50 @@ export const MindforgeApiService = { body: JSON.stringify(data), }); - if (!response.ok) { - throw new Error(`Error generating flashcards: ${response.statusText}`); - } + await throwIfNotOk(response, `Erro ao gerar flashcards: ${response.statusText}`); + return response.json(); + }, + + async getFlashcardLibraries(): Promise { + const response = await fetch(`${BASE_URL}/api/v1/flashcard/libraries`); + await throwIfNotOk(response, `Erro ao buscar bibliotecas de flashcards: ${response.statusText}`); + return response.json(); + }, + + async getFlashcardLibrary(id: number): Promise { + const response = await fetch(`${BASE_URL}/api/v1/flashcard/libraries/${id}`); + await throwIfNotOk(response, `Erro ao buscar biblioteca ${id}: ${response.statusText}`); + return response.json(); + }, + + async createFlashcardReviewSession(data: FlashcardReviewSessionRequest): Promise { + const response = await fetch(`${BASE_URL}/api/v1/flashcard/review-session`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(data), + }); + + await throwIfNotOk(response, `Erro ao iniciar revisao: ${response.statusText}`); return response.json(); }, async getRepositoryInfo(): Promise { const response = await fetch(`${BASE_URL}/api/v1/repository/info`); - if (!response.ok) throw new Error(`Error fetching repository info: ${response.statusText}`); + await throwIfNotOk(response, `Erro ao buscar info do repositorio: ${response.statusText}`); return response.json(); }, async getRepositoryTree(): Promise { const response = await fetch(`${BASE_URL}/api/v1/repository/tree`); - if (!response.ok) throw new Error(`Error fetching repository tree: ${response.statusText}`); + await throwIfNotOk(response, `Erro ao buscar arvore do repositorio: ${response.statusText}`); return response.json(); }, async getFileContent(path: string): Promise { const response = await fetch(`${BASE_URL}/api/v1/repository/file?path=${encodeURIComponent(path)}`); - if (!response.ok) throw new Error(`Error fetching file ${path}: ${response.statusText}`); + await throwIfNotOk(response, `Erro ao buscar arquivo ${path}: ${response.statusText}`); return response.json(); }, }; diff --git a/dev.ps1 b/dev.ps1 new file mode 100644 index 0000000..50fb991 --- /dev/null +++ b/dev.ps1 @@ -0,0 +1,11 @@ +# Starting Mindforge Development Environment + +# Start API +Write-Host "Starting Mindforge.API..." -ForegroundColor Cyan +Start-Process powershell -ArgumentList "-NoExit", "-Command", "dotnet run --project Mindforge.API" + +# Start Web +Write-Host "Starting Mindforge.Web..." -ForegroundColor Cyan +Start-Process powershell -ArgumentList "-NoExit", "-Command", "cd Mindforge.Web; npm run dev" + +Write-Host "Both projects are starting in separate windows." -ForegroundColor Green diff --git a/dev.sh b/dev.sh new file mode 100644 index 0000000..4f2322a --- /dev/null +++ b/dev.sh @@ -0,0 +1,19 @@ +#!/bin/bash + +# Function to handle script termination +cleanup() { + echo "Shutting down..." + kill $(jobs -p) + exit +} + +trap cleanup SIGINT SIGTERM + +echo "Starting Mindforge.API..." +(cd Mindforge.API && dotnet run) & + +echo "Starting Mindforge.Web..." +(cd Mindforge.Web && npm run dev) & + +echo "Both projects are running. Press Ctrl+C to stop both." +wait diff --git a/mindforge.png b/mindforge.png new file mode 100644 index 0000000..bf665e8 Binary files /dev/null and b/mindforge.png differ diff --git a/project-context.md b/project-context.md new file mode 100644 index 0000000..a252c2c --- /dev/null +++ b/project-context.md @@ -0,0 +1,368 @@ +# Mindforge + +## Visao Geral (Overview) + +Mindforge e uma ferramenta de estudo para auxiliar na preparacao para concursos publicos brasileiros. O sistema permite validar e gerar materiais de estudo a partir de arquivos Markdown hospedados em repositorios Git, utilizando IA (OpenRouter/OpenAI-compatible API) para processamento. + +A interface e em **portugues brasileiro** e possui um tema escuro com efeito vidro ("glassy-look"). + +--- + +## Tech Stack + +### Frontend (Mindforge.Web) +| Tecnologia | Versao | Finalidade | +|---|---|---| +| **Preact** | ^10.29.0 | UI library (lightweight React alternative) | +| **Vite** | ^8.0.1 | Build tool e dev server | +| **TypeScript** | ~5.9.3 | Tipagem estatica | +| **marked** | ^17.0.4 | Renderizacao Markdown -> HTML | +| **diff** | ^8.0.3 | Diff de texto (word-level) | +| **Google Fonts (Lato)** | 300/400/700 | Tipografia da interface | + +### Backend API (Mindforge.API) +| Tecnologia | Versao | Finalidade | +|---|---|---| +| **.NET 9** | net9.0 | ASP.NET Core Web API | +| **C#** | - | Linguagem | +| **Microsoft.AspNetCore.OpenApi** | 9.0.12 | Suporte OpenAPI/Swagger | + +### Cronjob (mindforge.cronjob) +| Tecnologia | Versao | Finalidade | +|---|---|---| +| **Go** | 1.22 | Linguagem | +| **godotenv** | v1.5.1 | Carregamento de arquivos .env | + +### Infraestrutura +| Tecnologia | Finalidade | +|---|---| +| **Docker** | Imagens multi-arch (amd64, arm64) | +| **Kubernetes** | Orquestracao (Deployments, CronJobs, Services, Ingress) | +| **Gitea** | Git hosting + Container registry (`git.ivanch.me`) | +| **Gitea Actions** | CI/CD (build & deploy no push para main) | +| **Nginx Ingress** | K8s ingress controller | +| **OpenRouter API** | Provedor LLM (endpoint OpenAI-compatible) | + +--- + +## Arquitetura (Architecture) + +O projeto e um **monorepo** com tres servicos independentes e deployaveis separadamente. + +``` +┌─────────────────────────────────────────────────────┐ +│ Kubernetes Cluster │ +│ ┌──────────────┐ ┌──────────────┐ ┌───────────┐ │ +│ │ mindforge-web │ │ mindforge-api│ │ cronjob │ │ +│ │ (Preact SPA) │──│ (.NET 9 API) │ │ (Go app) │ │ +│ │ port 80 │ │ port 8080 │ │ (weekly) │ │ +│ └──────────────┘ └──────┬───────┘ └─────┬─────┘ │ +│ │ │ │ │ +│ mindforge.haven api.mindforge.haven │ │ +└──────────────────────────┼─────────────────┼───────┘ + │ │ + ┌──────┴───────┐ ┌──────┴───────┐ + │ OpenRouter │ │ Gitea │ + │ (OpenAI API)│ │ (Git repos) │ + └──────────────┘ └──────┬───────┘ + │ + ┌────────┴────────┐ + │ Discord │ + │ Webhooks │ + └─────────────────┘ +``` + +### Servicos + +#### 1. Mindforge.Web (Frontend) +- **Tipo**: Single Page Application (SPA) +- **Funcao**: Interface do usuario para validacao de arquivos e geracao de flashcards +- **Modulos**: Home, Verificador, Flashcards +- **Roteamento**: State-based manual via `useState` (nao usa react-router). Componentes sao alternados com `display: block/none`. +- **Estado**: Apenas `useState`/`useEffect` locais. Sem store global, sem Context API. + +#### 2. Mindforge.API (Backend) +- **Tipo**: REST API +- **Padrao arquitetural**: Clean layered architecture + - **Controllers** -> validacao e roteamento HTTP + - **Services** -> logica de negocio (FileService, FlashcardService, AgentService, GiteaService) + - **Providers** -> abstracao de APIs externas (OpenAIApiProvider implementa ILlmApiProvider) + - **Middlewares** -> cross-cutting concerns (ExceptionHandlingMiddleware) + - **Models** -> DTOs e tipos de request/response +- **DI**: Todos servicos e providers registrados como Scoped +- **CORS**: Allow all origins +- **Auth**: Nenhuma no momento + +#### 3. Mindforge.Cronjob (Background Worker) +- **Tipo**: Kubernetes CronJob +- **Schedule**: Sabados as 9:00 AM +- **Pipeline**: + 1. Clona repositorio Git via SSH + 2. Encontra arquivos modificados nos ultimos 7 dias (`.md`, exceto `Conteudos.md`) + 3. Filtra commits de "refactor" via `git log` + 4. Ranqueia por linhas alteradas, seleciona top N (default 10) + 5. Gera resumo em texto com IA para cada arquivo + 6. Formata resumo em Markdown + 7. Envia para Discord via webhook (chunked em 1800 chars) +- **Error handling**: Reporta ao Haven Notify + +### Decisoes de Arquitetura (Architecture Decisions) + +1. **Sem banco de dados**: Totalmente stateless. Conteudo de estudo vive em repositorios Git, acessados via Gitea API em tempo de request. +2. **Git como content store**: Nao ha upload de arquivos - o conteudo e lido diretamente do repositorio Git. +3. **IA como engine de processamento**: Toda validacao e geracao e delegada ao LLM via OpenRouter. +4. **Provider abstraction**: `ILlmApiProvider` permite trocar o backend de IA sem alterar a logica de negocio. +5. **Monorepo com deploys independentes**: Cada servico tem seu proprio Dockerfile, manifesto K8s e pipeline CI. +6. **Roteamento manual no frontend**: Sem lib de router, toggle de visibilidade via estado. +7. **Preact em vez de React**: Bundle size menor. + +--- + +## Estrutura do Projeto (Project Structure) + +``` +mindforge/ +├── AGENTS.md # Instrucoes para agentes AI +├── project-context.md # Este arquivo - fonte da verdade do projeto +├── README.md +├── mindforge.png # Logo do projeto +├── dev.ps1 / dev.sh # Scripts de inicializacao dev +├── .gitea/workflows/ # CI/CD pipelines +│ ├── mindforge-api.yaml +│ ├── mindforge-web.yaml +│ └── mindforge-cronjob.yaml +│ +├── Mindforge.API/ # Backend .NET 9 +│ ├── Program.cs # Entry point: DI, CORS, middleware +│ ├── Controllers/ +│ │ ├── FileController.cs # POST /api/v1/file/check +│ │ ├── FlashcardController.cs # POST /api/v1/flashcard/generate +│ │ └── RepositoryController.cs # GET /api/v1/repository/* +│ ├── Services/ # Logica de negocio +│ │ ├── AgentService.cs +│ │ ├── FileService.cs +│ │ ├── FlashcardService.cs +│ │ ├── GiteaService.cs +│ │ └── Interfaces/ +│ ├── Providers/ +│ │ ├── ILlmApiProvider.cs # Interface do provedor LLM +│ │ └── OpenAIApiProvider.cs # Implementacao OpenAI/OpenRouter com retry +│ ├── Middlewares/ +│ │ └── ExceptionHandlingMiddleware.cs +│ ├── Models/ +│ │ ├── FileTreeNode.cs +│ │ └── Requests/ # DTOs de request +│ ├── Exceptions/ +│ │ └── UserException.cs # 400 Bad Request customizado +│ └── deploy/ +│ └── mindforge-api.yaml # Manifest K8s (Deployment + Service + Ingress) +│ +├── Mindforge.Web/ # Frontend Preact + Vite +│ ├── vite.config.ts +│ ├── tsconfig.app.json # Target ES2023, JSX preact, strict mode +│ ├── .env # VITE_API_BASE_URL=http://localhost:5123 +│ ├── src/ +│ │ ├── main.tsx # Entry point +│ │ ├── app.tsx # Componente raiz: header, sidebar, modulos +│ │ ├── app.css # Estilos hero, animacoes +│ │ ├── index.css # CSS variables, reset, layout base +│ │ ├── services/ +│ │ │ └── MindforgeApiService.ts # Cliente API (fetch, interfaces tipadas) +│ │ └── components/ +│ │ ├── Button.tsx / .css # Botao reutilizavel (primary/secondary) +│ │ ├── Header.tsx / .css # Header fixo com logo + nome do repo +│ │ ├── Sidebar.tsx / .css # Navegacao lateral +│ │ ├── VerificadorComponent.tsx / .css # Validador de arquivos +│ │ ├── FlashcardComponent.tsx / .css # Gerador de flashcards +│ │ └── FileTreeComponent.tsx / .css # Arvore de arquivos do repo +│ └── deploy/ +│ └── mindforge-web.yaml # Manifest K8s +│ +└── mindforge.cronjob/ # CronJob em Go + ├── cmd/mindforge.cronjob/main.go # Entry point + ├── internal/ + │ ├── agent/agent.go # Cria e formata resumos + │ ├── llm/ # Servico LLM + │ ├── git/git.go # Clone, diff, SSH + │ ├── errors/errors.go # Reporte de erros + │ └── message/messages.go # Notificacoes Discord + └── deploy/ + └── mindforge-cronjob.yaml # Manifest K8s CronJob +``` + +--- + +## API Endpoints + +### File Controller (`/api/v1/file`) +| Metodo | Rota | Descricao | +|---|---|---| +| POST | `/api/v1/file/check` | Valida linguagem ou conteudo de um arquivo | + +```json +// Request +{ + "fileContent": "string (conteudo do arquivo)", + "fileName": "string", + "checkType": "language" | "content" +} + +// Response +{ + "result": "string (resultado da validacao)" +} +``` + +### Flashcard Controller (`/api/v1/flashcard`) +| Metodo | Rota | Descricao | +|---|---|---| +| POST | `/api/v1/flashcard/generate` | Gera flashcards em CSV | + +```json +// Request +{ + "fileContent": "string", + "fileName": "string", + "amount": 10, + "mode": "Basic" | "Simple" | "Detailed" | "Hyper" +} + +// Response +{ + "result": "string (CSV: Frente;Verso\nFrente;Verso\n...)" +} +``` + +### Repository Controller (`/api/v1/repository`) +| Metodo | Rota | Descricao | +|---|---|---| +| GET | `/api/v1/repository/info` | Nome do repositorio | +| GET | `/api/v1/repository/tree` | Arvore de arquivos do repositorio | +| GET | `/api/v1/repository/file?path=...` | Conteudo de um arquivo especifico | + +--- + +## Convencoes de Desenvolvimento (Development Conventions) + +### Frontend (Preact/TypeScript) +- **Componentes**: Funcoes nomeadas exportadas com `export function Nome()`. Sem `export default`. +- **Props**: Interfaces TypeScript definidas no mesmo arquivo do componente. +- **Hooks**: `useState`, `useEffect` importados de `preact/hooks`. +- **Children**: Tipados como `ComponentChildren` de `preact`. +- **CSS**: Um arquivo `.css` por componente, importado diretamente. **Sem CSS Modules.** +- **Estado**: Apenas estado local (`useState`). Sem store global, sem Context API. +- **Roteamento**: Alternancia de componentes via `display: block/none` com estado `activeModule`. Nao usa react-router. +- **API**: Chamadas via `MindforgeApiService` (objeto singleton com metodos estaticos usando `fetch`). +- **TypeScript**: Strict mode, `erasableSyntaxOnly`, `verbatimModuleSyntax`, `noUnusedLocals`, `noUnusedParameters`. +- **Alias**: `react` e `react-dom` mapeados para `preact/compat/` no tsconfig. + +### Backend (C#/.NET 9) +- **Namespaces**: `Mindforge.API.Controllers`, `Mindforge.API.Services`, etc. +- **Interfaces**: Prefixo `I` (ex: `IFileService`, `ILlmApiProvider`). +- **DI**: Todos servicos registrados como `Scoped` em `Program.cs`. +- **Controllers**: Atributo `[Route("api/v1/...")]`. Metodos retornam `IActionResult`. +- **Tratamento de erros**: `ExceptionHandlingMiddleware` captura `UserException` (400) e excecoes genericas (500). + +### UI/UX +- **Idioma**: Todo texto em **portugues brasileiro**. +- **Tema**: Escuro com efeito vidro (glassy). `backdrop-filter: blur()`, fundos `rgba` semitransparentes. +- **Botoes**: Estilo iOS-like, modernos. Variantes `primary` (com blur) e `secondary` (transparente). +- **Tipografia**: Lato (Google Fonts), pesos 300/400/700. +- **Background**: `#005873` (azul petroleo escuro). Nao muito escuro. + +### CSS Variables (definidas em `index.css`) +```css +--color-bg: #005873; +--color-header: #0f0f0f; +--color-sidebar: #013a4c; +--color-text-creamy: #f4f5f5; +--color-accent: #00b4d8; +--color-accent-rgb: 0, 180, 216; +--color-accent-hover: #0096c7; +--font-main: 'Lato', sans-serif; +``` + +### Git +- Branches: `main` (producao) +- CI/CD: Gitea Actions dispara no push para main, build com Docker Buildx multi-arch, push para Gitea Container Registry, deploy no K8s. + +--- + +## Configuracao & Environment Variables + +### API (Mindforge.API) +| Variavel | Descricao | +|---|---| +| `OPENAI_API_URL` | URL do endpoint OpenAI-compatible (OpenRouter) | +| `OPENAI_TOKEN` | Token de autenticacao da API | +| `OPENAI_MODEL` | Modelo LLM a ser usado | +| `GITEA_REPO_URL` | URL do repositorio Gitea | +| `GITEA_ACCESS_TOKEN` | Token de acesso ao Gitea | + +### Web (Mindforge.Web) +| Variavel | Descricao | +|---|---| +| `VITE_API_BASE_URL` | URL base da API (default: `http://localhost:5123`) | + +### Cronjob +| Variavel | Descricao | +|---|---| +| `GIT_REPOSITORY` | URL do repo Git a clonar | +| `DISCORD_WEBHOOK_URL` | Webhook do Discord para notificacoes | +| `OPENAI_API_URL` | URL do endpoint OpenAI | +| `OPENAI_TOKEN` | Token de autenticacao | +| `HAVEN_NOTIFY_URL` | URL do servico de reporte de erros | + +## Repositório base para os arquivos +A base do repositório para os arquivos de estudo (compartilhado pelo Obsidian) é https://git.ivanch.me/ivanch/concurso. +Ele segue o padrão `[base]/concurso/[Matéria]/[SubMatéria*]/[Arquivo]`, onde [base] é a URL do git, concurso é o nome da pasta dentro do git base, Matéria é o nome da matéria (ex: `Direito Constitucional`), SubMatéria é o nome da submatéria (ex. `Poder Legislativo`) - podendo ser opcional e com múltiplas submatérias -, e Arquivo é o nome do arquivo (ex: `Câmara dos Deputados.md`). + +Exemplos: +- [base]/Concursos/Direito Constitucional/Poder Legislativo/Câmara dos Deputados.md +- [base]/Concursos/Direito Constitucional/Poder Legislativo/CPIs.md +- [base]/Concursos/Direito Constitucional/Ciência, Meio Ambiente e Índios.md +- [base]/Concursos/Direito Constitucional/Ciência, Meio Ambiente e Índios.md +- [base]/Concursos/TI/Analise e Engenharia de Dados/Data Lake e Data Warehouse.md +- [base]/Concursos/TI/Rede/VLAN.md +- [base]/Concursos/TI/Rede/Email/IMAP e POP3.md + +--- + +## Atualizacao - Flashcards Persistidos (2026-05-30) + +### Mudancas de Arquitetura +- A API deixa de ser totalmente stateless para o dominio de flashcards. +- Flashcards agora sao persistidos em PostgreSQL (`mindforge`) via `Dapper` + `Npgsql`. +- O schema e criado de forma idempotente no startup da API. + +### Tabelas de Flashcard +- `flashcard_libraries`: `id`, `file_path` (unico), `file_name`, `subject`, `difficulty`, `card_count`, `created_at`, `updated_at`. +- `flashcards`: `id`, `library_id`, `front`, `back`, `position`, `created_at`. + +### API de Flashcards (v1) +- `POST /api/v1/flashcard/generate` + Request: `{ filePaths: string[], amount: 10-30, difficulty: "Easy" | "Hard" }` + Comportamento: gera por arquivo selecionado, substitui biblioteca anterior do mesmo `file_path` e retorna bibliotecas com cards. +- `GET /api/v1/flashcard/libraries` + Lista todas as bibliotecas persistidas, com `subject` para agrupamento na UI. +- `GET /api/v1/flashcard/libraries/{id}` + Retorna detalhes de uma biblioteca com seus cards. +- `POST /api/v1/flashcard/review-session` + Request: `{ libraryIds: number[] }` + Retorna os cards combinados para sessao de revisao (sem repeticao espaciada nesta fase). + +### Frontend +- Modulo `Flashcards` atualizado para: + - selecionar multiplos arquivos; + - definir quantidade fixa por arquivo (10-30); + - definir dificuldade (`Facil`/`Dificil`); + - gerar e exibir resumo das bibliotecas salvas (sem download CSV). +- Novo modulo `Revisao Flashcards`: + - lista bibliotecas agrupadas por materia (`subject`); + - permite selecionar multiplas bibliotecas; + - inicia sessao estilo Anki simplificada (frente, revelar verso, anterior/proximo, progresso visual). + +### Novas Configuracoes +- `ConnectionStrings:MindforgeDb` para conexao PostgreSQL. +- Fallback local/default: + `Host=localhost;Port=3307;Database=mindforge;Username=root;Password=root`.