Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
201 changes: 201 additions & 0 deletions src/SentenceStudio.Api/Activity/ActivityLogEndpoints.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
using System.Globalization;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using SentenceStudio.Contracts.Activity;
using SentenceStudio.Services.Progress;

namespace SentenceStudio.Api.Activity;

/// <summary>
/// <c>GET /api/v1/activity-log</c> — read-only Activity Log endpoint serving
/// the Flutter client. Wraps <see cref="IActivityLogService"/> and maps the
/// rich internal week/day/plan/entry records to the wire DTOs documented in
/// <c>specs/activity-log-api-spec.md</c>. No new business logic — clustering,
/// week rollup, and category mapping all happen in the shared service.
/// </summary>
public static class ActivityLogEndpoints
{
/// <summary>Maximum days the server will service in a single call. The Flutter
/// client requests at most ~56 days (8 weeks) up front, 4-week pages thereafter,
/// so 90 days is a comfortable ceiling.</summary>
public const int MaxRangeDays = 90;

public static IEndpointRouteBuilder MapActivityLog(this IEndpointRouteBuilder app)
{
app.MapGet("/api/v1/activity-log", GetActivityLogAsync)
.WithName("GetActivityLog")
.RequireAuthorization();
return app;
}

private static async Task<IResult> GetActivityLogAsync(
DateTime? fromUtc,
DateTime? toUtc,
string? filter,
IActivityLogService activityLogService,
ILoggerFactory loggerFactory,
CancellationToken ct)
{
var logger = loggerFactory.CreateLogger("ActivityLog");

if (fromUtc is null || toUtc is null)
{
return Results.Problem(
title: "Missing date range",
detail: "Both 'fromUtc' and 'toUtc' query parameters are required (ISO 8601 UTC).",
statusCode: StatusCodes.Status400BadRequest);
}

if (fromUtc > toUtc)
{
return Results.Problem(
title: "Invalid date range",
detail: "'fromUtc' must be on or before 'toUtc'.",
statusCode: StatusCodes.Status400BadRequest);
}

if ((toUtc.Value.Date - fromUtc.Value.Date).TotalDays > MaxRangeDays)
{
return Results.Problem(
title: "Date range too large",
detail: $"Range cannot exceed {MaxRangeDays} days. Paginate by passing narrower fromUtc/toUtc windows.",
statusCode: StatusCodes.Status400BadRequest);
}

ActivityCategory? categoryFilter = null;
if (!string.IsNullOrEmpty(filter))
{
if (!Enum.TryParse<ActivityCategory>(filter, ignoreCase: true, out var parsed))
{
return Results.Problem(
title: "Invalid filter",
detail: "'filter' must be 'Input', 'Output', or omitted.",
statusCode: StatusCodes.Status400BadRequest);
}
categoryFilter = parsed;
}

try
{
var weeks = await activityLogService.GetActivityLogAsync(
fromUtc.Value, toUtc.Value, categoryFilter, ct);
var wire = weeks.Select(MapWeek).ToList();
return Results.Ok(wire);
}
catch (UnauthorizedAccessException)
{
return Results.Unauthorized();
}
catch (Exception ex)
{
logger.LogError(ex, "GET /api/v1/activity-log failed");
return Results.Problem(
detail: "Failed to load activity log.",
statusCode: StatusCodes.Status500InternalServerError);
}
}

internal static ActivityLogWeekDto MapWeek(ActivityLogWeek week)
{
// Weeks: WeekEnd in the service model is the Monday-anchored start + 6
// days (i.e. Sunday at 00:00). The spec asks for Sunday 23:59:59 UTC so
// the half-open interval reads naturally on the client.
var weekEnd = week.WeekEnd.Date.AddDays(1).AddSeconds(-1);

return new ActivityLogWeekDto
{
WeekStart = DateTime.SpecifyKind(week.WeekStart.Date, DateTimeKind.Utc),
WeekEnd = DateTime.SpecifyKind(weekEnd, DateTimeKind.Utc),
TotalMinutes = week.TotalMinutes,
InputMinutes = week.InputMinutes,
OutputMinutes = week.OutputMinutes,
ActivityCount = week.ActivityCount,
PlansCompleted = week.PlansCompleted,
PlansTotal = week.PlansTotal,
Days = week.Days.Select(MapDay).ToList(),
};
}

internal static ActivityLogDayDto MapDay(ActivityLogDay day)
{
// Number generated plans within the day for display labels. Ad-hoc
// plans are labeled separately.
var generatedIdx = 0;
var plans = new List<ActivityLogPlanDto>(day.Plans.Count);
foreach (var plan in day.Plans)
{
var isAdhoc = plan.Items.Count > 0 && IsAdhocId(plan.Items[0].PlanItemId);
var displayName = isAdhoc
? "Ad-hoc"
: $"Plan {++generatedIdx}";

plans.Add(new ActivityLogPlanDto
{
IsAdhoc = isAdhoc,
PlanItemId = plan.Items.FirstOrDefault()?.PlanItemId ?? string.Empty,
DisplayName = displayName,
TotalMinutes = plan.TotalMinutesSpent,
Completed = plan.IsFullyCompleted,
Entries = plan.Items
.OrderBy(e => e.CompletedAt ?? DateTime.MaxValue)
.Select(MapEntry)
.ToList(),
});
}

// Per-day input/output minute totals (the service computes these at the
// week level but not the day level, so derive from entries).
var inputMinutes = day.Plans.Sum(p => p.Items
.Where(i => i.Category == ActivityCategory.Input)
.Sum(i => i.MinutesSpent));
var outputMinutes = day.Plans.Sum(p => p.Items
.Where(i => i.Category == ActivityCategory.Output)
.Sum(i => i.MinutesSpent));

return new ActivityLogDayDto
{
Date = day.Date.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture),
HasActivity = day.Plans.Count > 0,
InputMinutes = inputMinutes,
OutputMinutes = outputMinutes,
TotalMinutes = day.TotalMinutes,
AllPlansCompleted = day.AllPlansCompleted,
Plans = plans,
};
}

internal static ActivityLogEntryDto MapEntry(ActivityLogEntry entry)
{
var completedAt = entry.CompletedAt is { } ts
? DateTime.SpecifyKind(ts, DateTimeKind.Utc)
: (DateTime?)null;
// TitleKey/DescriptionKey are resource keys for now (e.g.
// "PlanItemReadingTitle"). Spec activity-log-api-spec.md explicitly
// permits this passthrough until server-side localization lands.
var title = !string.IsNullOrEmpty(entry.TitleKey)
? entry.TitleKey
: entry.ActivityType.ToString();
var description = entry.DescriptionKey ?? string.Empty;
return new ActivityLogEntryDto
{
PlanItemId = entry.PlanItemId ?? string.Empty,
ActivityType = entry.ActivityType.ToString(),
Category = entry.Category.ToString(),
MinutesSpent = entry.MinutesSpent,
EstimatedMinutes = entry.EstimatedMinutes,
IsCompleted = entry.IsCompleted,
CompletedAtUtc = completedAt,
ResourceTitle = entry.ResourceTitle,
SkillName = entry.SkillName,
Title = title,
Description = description,
};
}

private static bool IsAdhocId(string planItemId)
=> !string.IsNullOrEmpty(planItemId)
&& planItemId.StartsWith("adhoc-", StringComparison.OrdinalIgnoreCase);
}
112 changes: 112 additions & 0 deletions src/SentenceStudio.Api/Conversation/ConversationScenarioSeeder.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
using Microsoft.EntityFrameworkCore;
using SentenceStudio.Data;
using SentenceStudio.Shared.Models;

namespace SentenceStudio.Api.Conversation;

/// <summary>
/// Seeds predefined <see cref="ConversationScenario"/> rows into the API database on startup.
/// Idempotent — re-running upserts existing rows (matched by Name + IsPredefined=true) and
/// inserts any that are missing. Mirrors the MAUI-side seed in
/// <c>ScenarioService.SeedPredefinedScenariosAsync</c>; both consume the same shared seed list
/// (<see cref="ConversationScenarioSeedData.GetPredefinedScenarios"/>).
/// </summary>
public class ConversationScenarioSeeder
{
private readonly ApplicationDbContext _db;
private readonly ILogger<ConversationScenarioSeeder> _logger;

public ConversationScenarioSeeder(ApplicationDbContext db, ILogger<ConversationScenarioSeeder> logger)
{
_db = db;
_logger = logger;
}

public async Task SeedAsync(CancellationToken ct = default)
{
// Guard against concurrent replicas seeding simultaneously (ACA scale-out, parallel restarts).
// pg_advisory_xact_lock blocks until released at transaction commit; serializes seeders across
// every connection in the cluster. The arbitrary int key (4242_4242) is unique to this seeder.
// No-op on SQLite (tests) — that path is single-threaded by construction.
var isPostgres = _db.Database.IsNpgsql();
if (isPostgres)
{
// Npgsql retry strategy requires user-initiated transactions to run inside
// an IExecutionStrategy.ExecuteAsync block. The strategy may retry the whole
// delegate on transient failure — SeedCoreAsync is idempotent so that's safe.
var strategy = _db.Database.CreateExecutionStrategy();
await strategy.ExecuteAsync(async cancel =>
{
await using var tx = await _db.Database.BeginTransactionAsync(cancel);
await _db.Database.ExecuteSqlRawAsync("SELECT pg_advisory_xact_lock(42424242);", cancel);
await SeedCoreAsync(cancel);
await tx.CommitAsync(cancel);
}, ct);
return;
}

await SeedCoreAsync(ct);
}

private async Task SeedCoreAsync(CancellationToken ct)
{
// Reset the change tracker so this method is safe to invoke multiple times
// (the Npgsql retry strategy may re-execute the delegate on transient failure).
_db.ChangeTracker.Clear();

var seedData = ConversationScenarioSeedData.GetPredefinedScenarios();

// Load existing predefined rows once; match on Name (case-sensitive — seed names are canonical English).
var existingPredefined = await _db.ConversationScenarios
.Where(s => s.IsPredefined)
.ToListAsync(ct);

int inserted = 0;
int updated = 0;

foreach (var seed in seedData)
{
var existing = existingPredefined.FirstOrDefault(s => s.Name == seed.Name);
if (existing is null)
{
seed.CreatedAt = DateTime.UtcNow;
seed.UpdatedAt = DateTime.UtcNow;
_db.ConversationScenarios.Add(seed);
inserted++;
continue;
}

bool changed =
existing.NameKorean != seed.NameKorean ||
existing.PersonaName != seed.PersonaName ||
existing.PersonaDescription != seed.PersonaDescription ||
existing.SituationDescription != seed.SituationDescription ||
existing.ConversationType != seed.ConversationType ||
existing.QuestionBank != seed.QuestionBank;

if (changed)
{
existing.NameKorean = seed.NameKorean;
existing.PersonaName = seed.PersonaName;
existing.PersonaDescription = seed.PersonaDescription;
existing.SituationDescription = seed.SituationDescription;
existing.ConversationType = seed.ConversationType;
existing.QuestionBank = seed.QuestionBank;
existing.UpdatedAt = DateTime.UtcNow;
updated++;
}
}

if (inserted == 0 && updated == 0)
{
_logger.LogDebug("ConversationScenario seed: no changes ({Count} predefined rows already current)", seedData.Count);
return;
}

await _db.SaveChangesAsync(ct);
_logger.LogInformation(
"ConversationScenario seed: inserted {Inserted}, updated {Updated} predefined rows",
inserted,
updated);
}
}
34 changes: 34 additions & 0 deletions src/SentenceStudio.Api/Conversation/IServerConversationService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using SentenceStudio.Contracts.Conversation;
using SentenceStudio.Shared.Models;

namespace SentenceStudio.Api.Conversation;

/// <summary>
/// Stateless server-side analogue of <c>IConversationAgentService</c>.
/// Each request is fully self-contained: the client passes scenario + full
/// history every turn. See spec 004 §Implementation Note 8.
/// </summary>
public interface IServerConversationService
{
/// <summary>
/// Generates the opening assistant message for a (possibly null) scenario.
/// </summary>
Task<string> StartAsync(
ConversationScenario? scenario,
string targetLanguageLabel,
CancellationToken cancellationToken);

/// <summary>
/// Continues the conversation with the user's latest turn. Returns the
/// assistant reply plus grading results (already in wire shape).
/// </summary>
Task<ConversationContinueResponse> ContinueAsync(
string userMessage,
IReadOnlyList<ConversationHistoryItemDto> conversationHistory,
ConversationScenario? scenario,
string targetLanguageLabel,
CancellationToken cancellationToken);
}
Loading
Loading