timed revision
All checks were successful
Mindforge API Build and Deploy / Build Mindforge API Image (push) Successful in 3m58s
Mindforge API Build and Deploy / Deploy Mindforge API (internal) (push) Successful in 37s
Mindforge Web Build and Deploy (internal) / Build Mindforge Web Image (push) Successful in 5m19s
Mindforge Web Build and Deploy (internal) / Deploy Mindforge Web (internal) (push) Successful in 11s

This commit is contained in:
2026-06-01 19:08:48 -03:00
parent b80d28f671
commit f03bcc40e3
14 changed files with 1138 additions and 14 deletions

View File

@@ -36,6 +36,13 @@ namespace Mindforge.API.Controllers
return Ok(libraries);
}
[HttpGet("rag-status")]
public async Task<IActionResult> GetRagStatus()
{
var dashboard = await _flashcardService.GetRagStatusAsync();
return Ok(dashboard);
}
[HttpGet("libraries/{id:long}")]
public async Task<IActionResult> GetLibraryById([FromRoute] long id)
{
@@ -55,5 +62,13 @@ namespace Mindforge.API.Controllers
var cards = await _flashcardService.BuildReviewSessionAsync(request);
return Ok(new { cards });
}
[HttpPost("review-answer")]
public async Task<IActionResult> RecordReviewAnswer([FromBody] FlashcardReviewAnswerRequest request)
{
request ??= new FlashcardReviewAnswerRequest();
await _flashcardService.RecordReviewAnswerAsync(request.CardId, request.Correct);
return Ok(new { success = true });
}
}
}

View File

@@ -19,11 +19,69 @@ namespace Mindforge.API.Models.Flashcards
public string Front { get; set; } = string.Empty;
public string Back { get; set; } = string.Empty;
public int Position { get; set; }
public int CorrectCount { get; set; }
public int IncorrectCount { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime? LastReviewedAt { get; set; }
}
public class FlashcardLibraryDetails : FlashcardLibrarySummary
{
public List<FlashcardCard> Cards { get; set; } = [];
}
public class FlashcardCardWithLibrary : FlashcardCard
{
public string FilePath { get; set; } = string.Empty;
public string FileName { get; set; } = string.Empty;
public string Subject { get; set; } = string.Empty;
}
public class FlashcardRagDashboard
{
public DateTime GeneratedAt { get; set; }
public List<FlashcardRagSubjectGroup> Subjects { get; set; } = [];
}
public class FlashcardRagSubjectGroup
{
public string Subject { get; set; } = string.Empty;
public FlashcardRagSummary Summary { get; set; } = new();
public List<FlashcardRagSubSubjectGroup> SubSubjects { get; set; } = [];
}
public class FlashcardRagSubSubjectGroup
{
public string SubSubject { get; set; } = string.Empty;
public FlashcardRagSummary Summary { get; set; } = new();
public List<FlashcardRagCard> Cards { get; set; } = [];
}
public class FlashcardRagSummary
{
public int GreenCount { get; set; }
public int AmberCount { get; set; }
public int RedCount { get; set; }
public int GreyCount { get; set; }
public int ActiveCount { get; set; }
public double GreenPercentage { get; set; }
public double AttentionPercentage { get; set; }
}
public class FlashcardRagCard
{
public long CardId { get; set; }
public long LibraryId { get; set; }
public string FileName { get; set; } = string.Empty;
public string Subject { get; set; } = string.Empty;
public string SubSubject { get; set; } = string.Empty;
public string Front { get; set; } = string.Empty;
public string Back { get; set; } = string.Empty;
public int CorrectCount { get; set; }
public int IncorrectCount { get; set; }
public int TotalAnswers { get; set; }
public double PerformanceRate { get; set; }
public DateTime? LastReviewedAt { get; set; }
public string RagStatus { get; set; } = "Grey";
}
}

View File

@@ -0,0 +1,8 @@
namespace Mindforge.API.Models.Requests
{
public class FlashcardReviewAnswerRequest
{
public long CardId { get; set; }
public bool Correct { get; set; }
}
}

View File

@@ -1,4 +1,5 @@
using Dapper;
using Mindforge.API.Exceptions;
using Mindforge.API.Models.Flashcards;
using Mindforge.API.Services.Interfaces;
using Npgsql;
@@ -37,11 +38,24 @@ namespace Mindforge.API.Repositories
front TEXT NOT NULL,
back TEXT NOT NULL,
position INTEGER NOT NULL,
correct_count INTEGER NOT NULL DEFAULT 0,
incorrect_count INTEGER NOT NULL DEFAULT 0,
last_reviewed_at TIMESTAMPTZ NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
ALTER TABLE flashcards
ADD COLUMN IF NOT EXISTS correct_count INTEGER NOT NULL DEFAULT 0;
ALTER TABLE flashcards
ADD COLUMN IF NOT EXISTS incorrect_count INTEGER NOT NULL DEFAULT 0;
ALTER TABLE flashcards
ADD COLUMN IF NOT EXISTS last_reviewed_at TIMESTAMPTZ NULL;
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);
CREATE INDEX IF NOT EXISTS ix_flashcards_last_reviewed_at ON flashcards(last_reviewed_at);
""";
await using var connection = CreateConnection();
@@ -193,6 +207,9 @@ namespace Mindforge.API.Repositories
front AS Front,
back AS Back,
position AS Position,
correct_count AS CorrectCount,
incorrect_count AS IncorrectCount,
last_reviewed_at AS LastReviewedAt,
created_at AS CreatedAt
FROM flashcards
WHERE library_id = @LibraryId
@@ -240,6 +257,9 @@ namespace Mindforge.API.Repositories
front AS Front,
back AS Back,
position AS Position,
correct_count AS CorrectCount,
incorrect_count AS IncorrectCount,
last_reviewed_at AS LastReviewedAt,
created_at AS CreatedAt
FROM flashcards
WHERE library_id = ANY(@LibraryIds)
@@ -252,6 +272,49 @@ namespace Mindforge.API.Repositories
return cards.ToList();
}
public async Task RecordReviewAnswerAsync(long cardId, bool correct)
{
var sql = correct
? "UPDATE flashcards SET correct_count = correct_count + 1, last_reviewed_at = NOW() WHERE id = @CardId;"
: "UPDATE flashcards SET incorrect_count = incorrect_count + 1, last_reviewed_at = NOW() WHERE id = @CardId;";
await using var connection = CreateConnection();
await connection.OpenAsync();
var affectedRows = await connection.ExecuteAsync(sql, new { CardId = cardId });
if (affectedRows == 0)
{
throw new UserException("Card de revisao nao encontrado para registrar resposta.");
}
}
public async Task<IReadOnlyList<FlashcardCardWithLibrary>> GetAllCardsWithLibraryAsync()
{
const string sql = """
SELECT
f.id AS Id,
f.library_id AS LibraryId,
f.front AS Front,
f.back AS Back,
f.position AS Position,
f.correct_count AS CorrectCount,
f.incorrect_count AS IncorrectCount,
f.last_reviewed_at AS LastReviewedAt,
f.created_at AS CreatedAt,
l.file_path AS FilePath,
l.file_name AS FileName,
l.subject AS Subject
FROM flashcards f
INNER JOIN flashcard_libraries l ON l.id = f.library_id
ORDER BY l.subject, l.file_name, f.position;
""";
await using var connection = CreateConnection();
await connection.OpenAsync();
var cards = await connection.QueryAsync<FlashcardCardWithLibrary>(sql);
return cards.ToList();
}
private NpgsqlConnection CreateConnection()
{
return new NpgsqlConnection(_connectionString);

View File

@@ -98,6 +98,61 @@ namespace Mindforge.API.Services
return await _flashcardRepository.GetCardsForLibrariesAsync(libraryIds);
}
public async Task<FlashcardRagDashboard> GetRagStatusAsync()
{
var now = DateTime.UtcNow;
var cards = await _flashcardRepository.GetAllCardsWithLibraryAsync();
var ragCards = cards
.Select(card => BuildRagCard(card, now))
.ToList();
var subjectGroups = ragCards
.GroupBy(card => card.Subject, StringComparer.OrdinalIgnoreCase)
.OrderBy(group => group.Key, StringComparer.OrdinalIgnoreCase)
.Select(subjectGroup =>
{
var subSubjectGroups = subjectGroup
.GroupBy(card => card.SubSubject, StringComparer.OrdinalIgnoreCase)
.OrderBy(group => group.Key, StringComparer.OrdinalIgnoreCase)
.Select(subSubjectGroup =>
{
var subSubjectCards = subSubjectGroup.ToList();
return new FlashcardRagSubSubjectGroup
{
SubSubject = subSubjectGroup.Key,
Summary = BuildSummary(subSubjectCards),
Cards = subSubjectCards
};
})
.ToList();
return new FlashcardRagSubjectGroup
{
Subject = subjectGroup.Key,
Summary = BuildSummary(subjectGroup),
SubSubjects = subSubjectGroups
};
})
.ToList();
return new FlashcardRagDashboard
{
GeneratedAt = now,
Subjects = subjectGroups
};
}
public async Task RecordReviewAnswerAsync(long cardId, bool correct)
{
if (cardId <= 0)
{
throw new UserException("Card de revisao invalido.");
}
await _flashcardRepository.RecordReviewAnswerAsync(cardId, correct);
}
private async Task<IReadOnlyList<FlashcardDraftCard>> GenerateCardsForFileAsync(
string filePath,
string fileContent,
@@ -228,6 +283,120 @@ namespace Mindforge.API.Services
return segments[0];
}
private static string ExtractSubSubject(string filePath)
{
var normalized = filePath.Replace('\\', '/');
var segments = normalized.Split('/', StringSplitOptions.RemoveEmptyEntries);
if (segments.Length <= 2)
{
return "Geral";
}
var concursosIndex = Array.FindIndex(
segments,
segment => segment.Equals("concursos", StringComparison.OrdinalIgnoreCase)
|| segment.Equals("concurso", StringComparison.OrdinalIgnoreCase));
var subjectIndex = concursosIndex >= 0 && concursosIndex + 1 < segments.Length
? concursosIndex + 1
: 0;
var subSubjectStart = subjectIndex + 1;
var subSubjectEnd = segments.Length - 1;
if (subSubjectStart >= subSubjectEnd)
{
return "Geral";
}
return string.Join(" / ", segments[subSubjectStart..subSubjectEnd]);
}
private static FlashcardRagCard BuildRagCard(FlashcardCardWithLibrary card, DateTime referenceTime)
{
var subject = string.IsNullOrWhiteSpace(card.Subject)
? ExtractSubject(card.FilePath)
: card.Subject;
var subSubject = ExtractSubSubject(card.FilePath);
var totalAnswers = card.CorrectCount + card.IncorrectCount;
var performanceRate = totalAnswers == 0
? 0
: (double)card.CorrectCount / totalAnswers;
return new FlashcardRagCard
{
CardId = card.Id,
LibraryId = card.LibraryId,
FileName = card.FileName,
Subject = subject,
SubSubject = subSubject,
Front = card.Front,
Back = card.Back,
CorrectCount = card.CorrectCount,
IncorrectCount = card.IncorrectCount,
TotalAnswers = totalAnswers,
PerformanceRate = performanceRate,
LastReviewedAt = card.LastReviewedAt,
RagStatus = DetermineRagStatus(card.LastReviewedAt, performanceRate, referenceTime)
};
}
private static string DetermineRagStatus(DateTime? lastReviewedAt, double performanceRate, DateTime referenceTime)
{
if (!lastReviewedAt.HasValue)
{
return "Grey";
}
var lastReviewUtc = lastReviewedAt.Value.ToUniversalTime();
var elapsedDays = (referenceTime.Date - lastReviewUtc.Date).TotalDays;
if (elapsedDays >= 40 || performanceRate < 0.4)
{
return "Red";
}
if (elapsedDays >= 30 || performanceRate <= 0.6)
{
return "Amber";
}
if (elapsedDays < 30 && performanceRate > 0.6)
{
return "Green";
}
return "Amber";
}
private static FlashcardRagSummary BuildSummary(IEnumerable<FlashcardRagCard> cards)
{
var cardList = cards.ToList();
var greenCount = cardList.Count(card => card.RagStatus == "Green");
var amberCount = cardList.Count(card => card.RagStatus == "Amber");
var redCount = cardList.Count(card => card.RagStatus == "Red");
var greyCount = cardList.Count(card => card.RagStatus == "Grey");
var activeCount = greenCount + amberCount + redCount;
var greenPercentage = activeCount == 0
? 0
: Math.Round((double)greenCount / activeCount * 100, 2);
var attentionPercentage = activeCount == 0
? 0
: Math.Round((double)(amberCount + redCount) / activeCount * 100, 2);
return new FlashcardRagSummary
{
GreenCount = greenCount,
AmberCount = amberCount,
RedCount = redCount,
GreyCount = greyCount,
ActiveCount = activeCount,
GreenPercentage = greenPercentage,
AttentionPercentage = attentionPercentage
};
}
private class FlashcardJsonPayload
{
public List<FlashcardJsonCard> Flashcards { get; set; } = [];

View File

@@ -14,5 +14,7 @@ namespace Mindforge.API.Services.Interfaces
Task<IReadOnlyList<FlashcardLibrarySummary>> GetLibrariesAsync();
Task<FlashcardLibraryDetails?> GetLibraryByIdAsync(long libraryId);
Task<IReadOnlyList<FlashcardCard>> GetCardsForLibrariesAsync(IReadOnlyList<long> libraryIds);
Task<IReadOnlyList<FlashcardCardWithLibrary>> GetAllCardsWithLibraryAsync();
Task RecordReviewAnswerAsync(long cardId, bool correct);
}
}

View File

@@ -9,5 +9,7 @@ namespace Mindforge.API.Services.Interfaces
Task<IReadOnlyList<FlashcardLibrarySummary>> GetLibrariesAsync();
Task<FlashcardLibraryDetails?> GetLibraryByIdAsync(long libraryId);
Task<IReadOnlyList<FlashcardCard>> BuildReviewSessionAsync(FlashcardReviewSessionRequest request);
Task<FlashcardRagDashboard> GetRagStatusAsync();
Task RecordReviewAnswerAsync(long cardId, bool correct);
}
}