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
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:
@@ -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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace Mindforge.API.Models.Requests
|
||||
{
|
||||
public class FlashcardReviewAnswerRequest
|
||||
{
|
||||
public long CardId { get; set; }
|
||||
public bool Correct { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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; } = [];
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user