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,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<IActionResult> 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<IActionResult> GetLibraries()
|
||||
{
|
||||
var libraries = await _flashcardService.GetLibrariesAsync();
|
||||
return Ok(libraries);
|
||||
}
|
||||
|
||||
[HttpGet("libraries/{id:long}")]
|
||||
public async Task<IActionResult> 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<IActionResult> BuildReviewSession([FromBody] FlashcardReviewSessionRequest request)
|
||||
{
|
||||
request ??= new FlashcardReviewSessionRequest();
|
||||
var cards = await _flashcardService.BuildReviewSessionAsync(request);
|
||||
return Ok(new { cards });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,9 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Dapper" Version="2.1.66" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.12" />
|
||||
<PackageReference Include="Npgsql" Version="9.0.4" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
9
Mindforge.API/Models/Flashcards/FlashcardDraftCard.cs
Normal file
9
Mindforge.API/Models/Flashcards/FlashcardDraftCard.cs
Normal file
@@ -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; }
|
||||
}
|
||||
}
|
||||
29
Mindforge.API/Models/Flashcards/FlashcardModels.cs
Normal file
29
Mindforge.API/Models/Flashcards/FlashcardModels.cs
Normal file
@@ -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<FlashcardCard> Cards { get; set; } = [];
|
||||
}
|
||||
}
|
||||
@@ -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<string> 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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace Mindforge.API.Models.Requests
|
||||
{
|
||||
public class FlashcardReviewSessionRequest
|
||||
{
|
||||
public List<long> LibraryIds { get; set; } = [];
|
||||
}
|
||||
}
|
||||
@@ -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<ILlmApiProvider, OpenAIApiProvider>();
|
||||
builder.Services.AddScoped<IAgentService, AgentService>();
|
||||
builder.Services.AddScoped<IFileService, FileService>();
|
||||
builder.Services.AddScoped<IFlashcardService, FlashcardService>();
|
||||
builder.Services.AddScoped<IFlashcardRepository, FlashcardRepository>();
|
||||
builder.Services.AddScoped<IGiteaService, GiteaService>();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
using (var scope = app.Services.CreateScope())
|
||||
{
|
||||
try
|
||||
{
|
||||
var flashcardRepository = scope.ServiceProvider.GetRequiredService<IFlashcardRepository>();
|
||||
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<Mindforge.API.Middlewares.ExceptionHandlingMiddleware>();
|
||||
@@ -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();
|
||||
|
||||
260
Mindforge.API/Repositories/FlashcardRepository.cs
Normal file
260
Mindforge.API/Repositories/FlashcardRepository.cs
Normal file
@@ -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<FlashcardLibraryDetails> UpsertLibraryAsync(
|
||||
string filePath,
|
||||
string fileName,
|
||||
string subject,
|
||||
string difficulty,
|
||||
IReadOnlyList<FlashcardDraftCard> cards)
|
||||
{
|
||||
await using var connection = CreateConnection();
|
||||
await connection.OpenAsync();
|
||||
await using var transaction = await connection.BeginTransactionAsync();
|
||||
|
||||
try
|
||||
{
|
||||
var existingId = await connection.ExecuteScalarAsync<long?>(
|
||||
"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<long>(
|
||||
"""
|
||||
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<IReadOnlyList<FlashcardLibrarySummary>> 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<FlashcardLibrarySummary>(sql);
|
||||
return rows.ToList();
|
||||
}
|
||||
|
||||
public async Task<FlashcardLibraryDetails?> 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<FlashcardLibrarySummary>(
|
||||
librarySql,
|
||||
new { LibraryId = libraryId });
|
||||
|
||||
if (library is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var cards = await connection.QueryAsync<FlashcardCard>(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<IReadOnlyList<FlashcardCard>> GetCardsForLibrariesAsync(IReadOnlyList<long> 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<FlashcardCard>(sql, new { LibraryIds = libraryIds.ToArray() });
|
||||
return cards.ToList();
|
||||
}
|
||||
|
||||
private NpgsqlConnection CreateConnection()
|
||||
{
|
||||
return new NpgsqlConnection(_connectionString);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user