diff --git a/src/SentenceStudio.Api/Activity/ActivityLogEndpoints.cs b/src/SentenceStudio.Api/Activity/ActivityLogEndpoints.cs
new file mode 100644
index 00000000..2580b7fe
--- /dev/null
+++ b/src/SentenceStudio.Api/Activity/ActivityLogEndpoints.cs
@@ -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;
+
+///
+/// GET /api/v1/activity-log — read-only Activity Log endpoint serving
+/// the Flutter client. Wraps and maps the
+/// rich internal week/day/plan/entry records to the wire DTOs documented in
+/// specs/activity-log-api-spec.md. No new business logic — clustering,
+/// week rollup, and category mapping all happen in the shared service.
+///
+public static class ActivityLogEndpoints
+{
+ /// 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.
+ 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 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(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(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);
+}
diff --git a/src/SentenceStudio.Api/Conversation/ConversationScenarioSeeder.cs b/src/SentenceStudio.Api/Conversation/ConversationScenarioSeeder.cs
new file mode 100644
index 00000000..9d025a54
--- /dev/null
+++ b/src/SentenceStudio.Api/Conversation/ConversationScenarioSeeder.cs
@@ -0,0 +1,112 @@
+using Microsoft.EntityFrameworkCore;
+using SentenceStudio.Data;
+using SentenceStudio.Shared.Models;
+
+namespace SentenceStudio.Api.Conversation;
+
+///
+/// Seeds predefined 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
+/// ScenarioService.SeedPredefinedScenariosAsync; both consume the same shared seed list
+/// ().
+///
+public class ConversationScenarioSeeder
+{
+ private readonly ApplicationDbContext _db;
+ private readonly ILogger _logger;
+
+ public ConversationScenarioSeeder(ApplicationDbContext db, ILogger 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);
+ }
+}
diff --git a/src/SentenceStudio.Api/Conversation/IServerConversationService.cs b/src/SentenceStudio.Api/Conversation/IServerConversationService.cs
new file mode 100644
index 00000000..39470c94
--- /dev/null
+++ b/src/SentenceStudio.Api/Conversation/IServerConversationService.cs
@@ -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;
+
+///
+/// Stateless server-side analogue of IConversationAgentService.
+/// Each request is fully self-contained: the client passes scenario + full
+/// history every turn. See spec 004 §Implementation Note 8.
+///
+public interface IServerConversationService
+{
+ ///
+ /// Generates the opening assistant message for a (possibly null) scenario.
+ ///
+ Task StartAsync(
+ ConversationScenario? scenario,
+ string targetLanguageLabel,
+ CancellationToken cancellationToken);
+
+ ///
+ /// Continues the conversation with the user's latest turn. Returns the
+ /// assistant reply plus grading results (already in wire shape).
+ ///
+ Task ContinueAsync(
+ string userMessage,
+ IReadOnlyList conversationHistory,
+ ConversationScenario? scenario,
+ string targetLanguageLabel,
+ CancellationToken cancellationToken);
+}
diff --git a/src/SentenceStudio.Api/Conversation/ServerConversationService.cs b/src/SentenceStudio.Api/Conversation/ServerConversationService.cs
new file mode 100644
index 00000000..a104e290
--- /dev/null
+++ b/src/SentenceStudio.Api/Conversation/ServerConversationService.cs
@@ -0,0 +1,261 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.AI;
+using Microsoft.Extensions.Logging;
+using SentenceStudio.Contracts.Conversation;
+using SentenceStudio.Shared.Models;
+
+namespace SentenceStudio.Api.Conversation;
+
+///
+/// HTTP-shaped, stateless implementation of .
+/// Builds the conversation-partner system prompt and grading instructions
+/// inline (port of the four scriban templates the MAUI agent service loads
+/// from Resources/Raw) so the API does not need a MAUI file-system
+/// package source.
+///
+public sealed class ServerConversationService : IServerConversationService
+{
+ private readonly IChatClient _chatClient;
+ private readonly ILogger _logger;
+
+ public ServerConversationService(
+ IChatClient chatClient,
+ ILogger logger)
+ {
+ _chatClient = chatClient;
+ _logger = logger;
+ }
+
+ public async Task StartAsync(
+ ConversationScenario? scenario,
+ string targetLanguageLabel,
+ CancellationToken cancellationToken)
+ {
+ var systemPrompt = BuildConversationPartnerPrompt(scenario, targetLanguageLabel);
+ var openingPrompt = BuildOpeningPrompt(scenario, targetLanguageLabel);
+
+ _logger.LogInformation(
+ "ConversationStart: scenario={ScenarioName} language={Language}",
+ scenario?.Name ?? "(default)", targetLanguageLabel);
+
+ var messages = new List
+ {
+ new(ChatRole.User, openingPrompt),
+ };
+
+ var response = await _chatClient.GetResponseAsync(
+ messages,
+ new ChatOptions { Instructions = systemPrompt },
+ cancellationToken);
+
+ return (response.Text ?? string.Empty).Trim();
+ }
+
+ public async Task ContinueAsync(
+ string userMessage,
+ IReadOnlyList conversationHistory,
+ ConversationScenario? scenario,
+ string targetLanguageLabel,
+ CancellationToken cancellationToken)
+ {
+ var systemPrompt = BuildConversationPartnerPrompt(scenario, targetLanguageLabel);
+ var gradingInstructions = BuildGradingInstructions(targetLanguageLabel);
+
+ // Build partner context: replay prior history (oldest -> newest), then the user's new turn.
+ var partnerMessages = new List(conversationHistory.Count + 1);
+ foreach (var item in conversationHistory)
+ {
+ var role = string.Equals(item.Role, "assistant", StringComparison.OrdinalIgnoreCase)
+ ? ChatRole.Assistant
+ : ChatRole.User;
+ partnerMessages.Add(new ChatMessage(role, item.Text ?? string.Empty));
+ }
+
+ partnerMessages.Add(new ChatMessage(ChatRole.User, userMessage));
+
+ var gradingPrompt = BuildGradingPrompt(userMessage, conversationHistory);
+
+ // Run partner + grader in parallel — they don't share state.
+ var partnerTask = _chatClient.GetResponseAsync(
+ partnerMessages,
+ new ChatOptions { Instructions = systemPrompt },
+ cancellationToken);
+
+ var gradingTask = _chatClient.GetResponseAsync(
+ gradingPrompt,
+ new ChatOptions { Instructions = gradingInstructions },
+ cancellationToken: cancellationToken);
+
+ await Task.WhenAll(partnerTask, gradingTask);
+
+ var partnerText = ((await partnerTask).Text ?? string.Empty).Trim();
+ var grade = (await gradingTask)?.Result ?? new GradeResult { ComprehensionScore = 0.5 };
+
+ return new ConversationContinueResponse
+ {
+ AssistantMessage = partnerText,
+ ComprehensionScore = NormalizeComprehensionScore(grade.ComprehensionScore),
+ ComprehensionNotes = grade.ComprehensionNotes,
+ GrammarCorrections = (grade.GrammarCorrections ?? new())
+ .Select(g => new ConversationGrammarCorrectionDto
+ {
+ Original = g.Original ?? string.Empty,
+ Corrected = g.Corrected ?? string.Empty,
+ Explanation = g.Explanation ?? string.Empty,
+ })
+ .ToList(),
+ // v1: vocabulary analysis is not produced by the grader prompt yet.
+ // Spec defers vocabulary scoring write-back; emit an empty array
+ // so the Flutter parser sees a stable shape.
+ VocabularyAnalysis = new List(),
+ // v1: isComplete derivation is out of scope (spec §Behavior). The
+ // Flutter UI handles `false` gracefully.
+ IsComplete = false,
+ };
+ }
+
+ ///
+ /// Normalizes a grader score into [0, 100]. The MAUI service treats the
+ /// score as 0.0-1.0, but at least one scenario template prompts the model
+ /// for 0-100; we accept either by detecting the magnitude.
+ ///
+ public static int NormalizeComprehensionScore(double raw)
+ {
+ if (double.IsNaN(raw) || double.IsInfinity(raw)) return 0;
+ var asPercent = raw <= 1.0 ? raw * 100.0 : raw;
+ var rounded = (int)Math.Round(asPercent, MidpointRounding.AwayFromZero);
+ if (rounded < 0) return 0;
+ if (rounded > 100) return 100;
+ return rounded;
+ }
+
+ private static string BuildConversationPartnerPrompt(
+ ConversationScenario? scenario,
+ string targetLanguage)
+ {
+ if (scenario is null)
+ {
+ return $@"You are playing the role of a friendly {targetLanguage} native speaker.
+
+## Your Role
+You are meeting me for conversation practice. Make up your backstory as needed to answer my questions naturally.
+
+## Conversation Style
+This is an open-ended conversation. Keep exploring topics with follow-up questions.
+Never end abruptly - always leave room for continuation.
+
+## Rules:
+- Speak naturally in {targetLanguage} as a native speaker would
+- Keep responses conversational and friendly
+- If the user's message is hard to understand or could be phrased more clearly, ask for clarification or confirm your understanding
+- If the user asks a question, respond to it before asking a new question
+- In your response, only include your newest reply in {targetLanguage} - do not repeat your character name";
+ }
+
+ var sb = new StringBuilder();
+ sb.AppendLine($"You are playing the role of {scenario.PersonaName}, {scenario.PersonaDescription}.");
+ sb.AppendLine();
+ sb.AppendLine("## Situation");
+ sb.AppendLine(scenario.SituationDescription);
+ sb.AppendLine();
+ sb.AppendLine("## Conversation Style");
+ if (scenario.ConversationType == ConversationType.Finite)
+ {
+ sb.AppendLine("This is a transactional conversation. Complete the interaction naturally when the task is done.");
+ sb.AppendLine("When the conversation reaches its natural conclusion (e.g., payment complete, directions given, order placed), wrap up gracefully.");
+ }
+ else
+ {
+ sb.AppendLine("This is an open-ended conversation. Keep exploring topics with follow-up questions.");
+ sb.AppendLine("Never end abruptly - always leave room for continuation.");
+ }
+ sb.AppendLine();
+ sb.AppendLine("## Rules:");
+ sb.AppendLine($"- Speak naturally in {targetLanguage} as a native speaker would");
+ sb.AppendLine($"- Stay in character as {scenario.PersonaName}");
+ sb.AppendLine("- Keep responses conversational and friendly");
+ sb.AppendLine("- If the user's message is hard to understand or could be phrased more clearly, ask for clarification or confirm your understanding");
+ sb.AppendLine("- If the user asks a question, respond to it before asking a new question");
+ sb.AppendLine($"- In your response, only include your newest reply in {targetLanguage} - do not repeat your character name");
+
+ if (!string.IsNullOrWhiteSpace(scenario.QuestionBank))
+ {
+ sb.AppendLine();
+ sb.AppendLine("## Suggested topics/phrases (work these into the conversation naturally):");
+ sb.AppendLine(scenario.QuestionBank);
+ }
+
+ return sb.ToString();
+ }
+
+ private static string BuildOpeningPrompt(
+ ConversationScenario? scenario,
+ string targetLanguage)
+ {
+ if (scenario is null)
+ {
+ return $"You will play the role of a friendly {targetLanguage} native speaker who I am meeting for conversation practice. " +
+ "Make up your backstory as needed to answer my questions naturally. " +
+ $"Let's have a conversation in {targetLanguage}. Start by greeting me and asking for my name in a natural, friendly way.";
+ }
+
+ var styleLine = scenario.ConversationType == ConversationType.Finite
+ ? $"This is a transactional conversation, so ask something relevant to completing the transaction in {targetLanguage}."
+ : $"This is an open-ended conversation, so ask something to get to know them or explore the topic in {targetLanguage}.";
+
+ return
+ "Start the conversation with an appropriate greeting for this scenario.\n\n" +
+ $"As {scenario.PersonaName}, {scenario.PersonaDescription}, begin by greeting the customer/person naturally in {targetLanguage} and asking an appropriate opening question for this situation:\n\n" +
+ $"Situation: {scenario.SituationDescription}\n\n" +
+ styleLine + "\n\n" +
+ $"Only respond with your greeting and question in {targetLanguage} - nothing else.";
+ }
+
+ private static string BuildGradingInstructions(string targetLanguage)
+ {
+ return $@"You are a {targetLanguage} language grading assistant. Your job is to:
+
+1. Evaluate the user's {targetLanguage} message for comprehension (0.0 to 1.0 scale):
+ - 1.0: Perfect, native-like communication
+ - 0.8-0.9: Minor errors but message is clear
+ - 0.6-0.7: Understandable with some effort
+ - 0.4-0.5: Partially understandable
+ - 0.2-0.3: Difficult to understand
+ - 0.0-0.1: Not understandable
+
+2. Provide brief comprehension notes explaining what worked well or what was unclear.
+
+3. Identify grammar corrections:
+ - Find grammar mistakes in the user's {targetLanguage}
+ - Provide the corrected version
+ - Explain the grammar rule simply
+
+Focus on being helpful and encouraging while providing accurate corrections.
+Only identify significant errors that affect meaning or are common learner mistakes.
+Do not nitpick minor stylistic preferences.";
+ }
+
+ private static string BuildGradingPrompt(
+ string userMessage,
+ IReadOnlyList history)
+ {
+ var sb = new StringBuilder();
+ sb.AppendLine("Conversation context:");
+ foreach (var chunk in history.TakeLast(6))
+ {
+ var speaker = string.Equals(chunk.Role, "user", StringComparison.OrdinalIgnoreCase)
+ ? "User"
+ : "Partner";
+ sb.AppendLine($"{speaker}: {chunk.Text}");
+ }
+
+ sb.AppendLine();
+ sb.AppendLine($"User's latest message to grade: {userMessage}");
+ return sb.ToString();
+ }
+}
diff --git a/src/SentenceStudio.Api/ConversationEndpoints.cs b/src/SentenceStudio.Api/ConversationEndpoints.cs
new file mode 100644
index 00000000..99fcdeba
--- /dev/null
+++ b/src/SentenceStudio.Api/ConversationEndpoints.cs
@@ -0,0 +1,190 @@
+using System.Security.Claims;
+using Microsoft.AspNetCore.Mvc;
+using SentenceStudio.Api.Conversation;
+using SentenceStudio.Contracts;
+using SentenceStudio.Contracts.Conversation;
+using SentenceStudio.Data;
+using SentenceStudio.Shared.Models;
+
+namespace SentenceStudio.Api;
+
+///
+/// HTTP endpoints for the Conversation activity. Thin wrapper over
+/// and .
+/// Mirrors the wire contract documented in spec 004.
+///
+public static class ConversationEndpoints
+{
+ ///
+ /// Subset of BCP-47 tags we recognize; falls through to the original
+ /// value when no mapping matches. Mirrors SpeechEndpoints.Bcp47ToLabel
+ /// to keep agent prompts addressable by language name.
+ ///
+ private static readonly Dictionary Bcp47ToLabel = new(StringComparer.OrdinalIgnoreCase)
+ {
+ { "en", "English" },
+ { "fr", "French" },
+ { "de", "German" },
+ { "ko", "Korean" },
+ { "es", "Spanish" },
+ };
+
+ public static WebApplication MapConversationEndpoints(this WebApplication app)
+ {
+ var group = app.MapGroup("/api/v1/conversation").RequireAuthorization();
+
+ group.MapGet("/scenarios", GetScenarios);
+ group.MapPost("/start", StartConversation);
+ group.MapPost("/continue", ContinueConversation);
+
+ return app;
+ }
+
+ private static async Task GetScenarios(
+ ClaimsPrincipal user,
+ [FromServices] ScenarioRepository scenarioRepository,
+ [FromServices] ILoggerFactory loggerFactory,
+ CancellationToken cancellationToken)
+ {
+ var logger = loggerFactory.CreateLogger("ConversationEndpoints");
+
+ var userProfileId = user.FindFirstValue(AuthClaimTypes.UserProfileId);
+ if (string.IsNullOrEmpty(userProfileId)) return Results.Unauthorized();
+
+ var scenarios = await scenarioRepository.GetAllAsync();
+ var dtos = scenarios.Select(MapScenario).ToList();
+
+ logger.LogInformation("GetScenarios: returning {Count} scenarios for user {UserProfileId}",
+ dtos.Count, userProfileId);
+
+ return Results.Ok(dtos);
+ }
+
+ private static async Task StartConversation(
+ ConversationStartRequest request,
+ ClaimsPrincipal user,
+ [FromServices] IServerConversationService? conversationService,
+ [FromServices] ScenarioRepository scenarioRepository,
+ [FromServices] ILoggerFactory loggerFactory,
+ CancellationToken cancellationToken)
+ {
+ var logger = loggerFactory.CreateLogger("ConversationEndpoints");
+
+ var userProfileId = user.FindFirstValue(AuthClaimTypes.UserProfileId);
+ if (string.IsNullOrEmpty(userProfileId)) return Results.Unauthorized();
+
+ if (conversationService is null)
+ {
+ logger.LogWarning("ConversationStart: IServerConversationService not registered (no AI:OpenAI:ApiKey)");
+ return Results.StatusCode(StatusCodes.Status503ServiceUnavailable);
+ }
+
+ ConversationScenario? scenario = null;
+ if (request.ScenarioId is int id)
+ {
+ scenario = await scenarioRepository.GetByIdAsync(id);
+ if (scenario is null)
+ {
+ logger.LogWarning("ConversationStart: scenario {ScenarioId} not found", id);
+ return Results.NotFound(new { error = $"Scenario {id} not found." });
+ }
+ }
+
+ var languageLabel = ResolveLanguageLabel(request.TargetLanguage) ?? "Korean";
+
+ var opening = await conversationService.StartAsync(scenario, languageLabel, cancellationToken);
+
+ return Results.Ok(new ConversationStartResponse
+ {
+ FirstAssistantMessage = opening,
+ PersonaName = scenario?.PersonaName,
+ ConversationType = (scenario?.ConversationType ?? ConversationType.OpenEnded).ToString(),
+ });
+ }
+
+ private static async Task ContinueConversation(
+ ConversationContinueRequest request,
+ ClaimsPrincipal user,
+ [FromServices] IServerConversationService? conversationService,
+ [FromServices] ScenarioRepository scenarioRepository,
+ [FromServices] ILoggerFactory loggerFactory,
+ CancellationToken cancellationToken)
+ {
+ var logger = loggerFactory.CreateLogger("ConversationEndpoints");
+
+ var userProfileId = user.FindFirstValue(AuthClaimTypes.UserProfileId);
+ if (string.IsNullOrEmpty(userProfileId)) return Results.Unauthorized();
+
+ if (conversationService is null)
+ {
+ logger.LogWarning("ConversationContinue: IServerConversationService not registered (no AI:OpenAI:ApiKey)");
+ return Results.StatusCode(StatusCodes.Status503ServiceUnavailable);
+ }
+
+ if (request.History is null || request.History.Count == 0)
+ {
+ return Results.ValidationProblem(new Dictionary
+ {
+ ["history"] = new[] { "history must contain at least one turn (the user's new message)." },
+ });
+ }
+
+ var lastTurn = request.History[^1];
+ if (!string.Equals(lastTurn.Role, "user", StringComparison.OrdinalIgnoreCase) ||
+ string.IsNullOrWhiteSpace(lastTurn.Text))
+ {
+ return Results.ValidationProblem(new Dictionary
+ {
+ ["history"] = new[] { "the last history entry must be a non-empty user turn." },
+ });
+ }
+
+ ConversationScenario? scenario = null;
+ if (request.ScenarioId is int id)
+ {
+ scenario = await scenarioRepository.GetByIdAsync(id);
+ if (scenario is null)
+ {
+ logger.LogWarning("ConversationContinue: scenario {ScenarioId} not found", id);
+ return Results.NotFound(new { error = $"Scenario {id} not found." });
+ }
+ }
+
+ var languageLabel = ResolveLanguageLabel(request.TargetLanguage) ?? "Korean";
+
+ var userMessage = lastTurn.Text;
+ var priorHistory = request.History.Take(request.History.Count - 1).ToList();
+
+ var result = await conversationService.ContinueAsync(
+ userMessage,
+ priorHistory,
+ scenario,
+ languageLabel,
+ cancellationToken);
+
+ return Results.Ok(result);
+ }
+
+ private static ConversationScenarioDto MapScenario(ConversationScenario s) => new()
+ {
+ Id = s.Id,
+ Name = s.Name,
+ NameKorean = s.NameKorean,
+ PersonaName = s.PersonaName,
+ PersonaDescription = s.PersonaDescription,
+ SituationDescription = s.SituationDescription,
+ ConversationType = s.ConversationType.ToString(),
+ QuestionBank = s.QuestionBank,
+ IsPredefined = s.IsPredefined,
+ };
+
+ private static string? ResolveLanguageLabel(string? value)
+ {
+ if (string.IsNullOrWhiteSpace(value)) return null;
+ var trimmed = value.Trim();
+ if (Bcp47ToLabel.Values.Contains(trimmed, StringComparer.OrdinalIgnoreCase))
+ return trimmed;
+ var primary = trimmed.Split('-')[0];
+ return Bcp47ToLabel.TryGetValue(primary, out var label) ? label : trimmed;
+ }
+}
diff --git a/src/SentenceStudio.Api/Plans/AdhocPlanEndpoints.cs b/src/SentenceStudio.Api/Plans/AdhocPlanEndpoints.cs
new file mode 100644
index 00000000..8c9220bd
--- /dev/null
+++ b/src/SentenceStudio.Api/Plans/AdhocPlanEndpoints.cs
@@ -0,0 +1,204 @@
+using System.Globalization;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Routing;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using SentenceStudio.Contracts.Plans;
+using SentenceStudio.Data;
+using SentenceStudio.Services.Plans;
+using SentenceStudio.Services.Progress;
+using SentenceStudio.Shared.Models;
+
+namespace SentenceStudio.Api.Plans;
+
+///
+/// POST /api/v1/plans/adhoc/start — starts (or idempotently resumes) an
+/// ad-hoc activity-tracking session. The synthetic planItemId is
+/// deterministic from clientSessionId (adhoc-{clientSessionId}),
+/// which gives durable idempotency without a schema change: the existing
+/// (UserProfileId, Date, PlanItemId) uniqueness contract on
+/// DailyPlanCompletion covers replay protection.
+///
+/// MAUI's ProgressService.StartAdHocSessionAsync continues to generate
+/// adhoc-{Guid.NewGuid()} for local-only flows and is unaffected.
+///
+public static class AdhocPlanEndpoints
+{
+ /// Default when the client omits estimatedMinutes. Matches
+ /// the MAUI default.
+ public const int DefaultEstimatedMinutes = 10;
+
+ /// Upper clamp to mirror plan endpoint validation. Aligns with
+ /// PlanService.MaxMinutesSpent so an ad-hoc row can't blow past the
+ /// progress endpoint's clamp anyway.
+ public const int MaxEstimatedMinutes = 24 * 60;
+
+ public static IEndpointRouteBuilder MapAdhocPlan(this IEndpointRouteBuilder app)
+ {
+ app.MapPost("/api/v1/plans/adhoc/start", StartAdhocAsync)
+ .WithName("StartAdhocSession")
+ .RequireAuthorization();
+ return app;
+ }
+
+ private static async Task StartAdhocAsync(
+ AdhocStartRequest? request,
+ IUserScopeProvider userScope,
+ ApplicationDbContext db,
+ ILoggerFactory loggerFactory,
+ CancellationToken ct)
+ {
+ var logger = loggerFactory.CreateLogger("AdhocPlan");
+
+ if (request is null)
+ {
+ return Results.Problem(
+ title: "Missing request body",
+ detail: "Request body is required.",
+ statusCode: StatusCodes.Status400BadRequest);
+ }
+
+ if (string.IsNullOrWhiteSpace(request.ClientSessionId) ||
+ !Guid.TryParse(request.ClientSessionId, out var clientSessionGuid))
+ {
+ return Results.Problem(
+ title: "Invalid clientSessionId",
+ detail: "'clientSessionId' must be a non-empty UUID generated by the client.",
+ statusCode: StatusCodes.Status400BadRequest);
+ }
+
+ if (string.IsNullOrWhiteSpace(request.ActivityType) ||
+ !Enum.TryParse(request.ActivityType, ignoreCase: false, out var activityType) ||
+ !Enum.IsDefined(typeof(PlanActivityType), activityType))
+ {
+ return Results.Problem(
+ title: "Invalid activityType",
+ detail: "'activityType' must be a valid PlanActivityType (e.g. Reading, Writing, Translation, ...).",
+ statusCode: StatusCodes.Status400BadRequest);
+ }
+
+ var estimatedMinutes = request.EstimatedMinutes ?? DefaultEstimatedMinutes;
+ if (estimatedMinutes <= 0)
+ {
+ return Results.Problem(
+ title: "Invalid estimatedMinutes",
+ detail: "'estimatedMinutes' must be a positive integer.",
+ statusCode: StatusCodes.Status400BadRequest);
+ }
+ if (estimatedMinutes > MaxEstimatedMinutes)
+ {
+ // Clamp on the upper bound rather than rejecting — matches the
+ // progress endpoint's clamp-rather-than-reject behaviour.
+ estimatedMinutes = MaxEstimatedMinutes;
+ }
+
+ if (!userScope.TryGetUserProfileId(out var userProfileId) || string.IsNullOrEmpty(userProfileId))
+ {
+ return Results.Unauthorized();
+ }
+
+ // Deterministic id makes the operation idempotent end-to-end: the
+ // primary uniqueness check is the lookup below, and even a race
+ // between two replays would only collide on the same row.
+ var planItemId = $"adhoc-{clientSessionGuid:D}";
+ var today = DateTime.UtcNow.Date;
+
+ try
+ {
+ var existing = await db.DailyPlanCompletions
+ .FirstOrDefaultAsync(c => c.UserProfileId == userProfileId
+ && c.Date == today
+ && c.PlanItemId == planItemId, ct);
+
+ if (existing is not null)
+ {
+ logger.LogInformation("🔁 Ad-hoc replay for client session {ClientSessionId} → {PlanItemId}",
+ clientSessionGuid, planItemId);
+ return Results.Ok(BuildResponse(existing, activityType, estimatedMinutes));
+ }
+
+ var completion = new DailyPlanCompletion
+ {
+ Id = Guid.NewGuid().ToString(),
+ UserProfileId = userProfileId,
+ Date = today,
+ PlanItemId = planItemId,
+ ActivityType = activityType.ToString(),
+ ResourceId = request.ResourceId,
+ SkillId = request.SkillId,
+ IsCompleted = false,
+ CompletedAt = null,
+ MinutesSpent = 0,
+ EstimatedMinutes = estimatedMinutes,
+ Priority = 999, // sort after planned items, mirrors ProgressService.StartAdHocSessionAsync
+ TitleKey = $"Activity_{activityType}",
+ DescriptionKey = string.Empty,
+ Rationale = string.Empty,
+ CreatedAt = DateTime.UtcNow,
+ UpdatedAt = DateTime.UtcNow,
+ };
+
+ db.DailyPlanCompletions.Add(completion);
+ try
+ {
+ await db.SaveChangesAsync(ct);
+ }
+ catch (DbUpdateException ex) when (IsUniqueViolation(ex))
+ {
+ // Lost a race with a concurrent replay — fetch the winner and
+ // return 200 as if we were the duplicate caller.
+ db.Entry(completion).State = EntityState.Detached;
+ var winner = await db.DailyPlanCompletions
+ .FirstAsync(c => c.UserProfileId == userProfileId
+ && c.Date == today
+ && c.PlanItemId == planItemId, ct);
+ logger.LogInformation("🔁 Ad-hoc replay (race) for client session {ClientSessionId} → {PlanItemId}",
+ clientSessionGuid, planItemId);
+ return Results.Ok(BuildResponse(winner, activityType, estimatedMinutes));
+ }
+
+ logger.LogInformation("🎯 Started ad-hoc session {PlanItemId} for {ActivityType} (user={UserProfileId})",
+ planItemId, activityType, userProfileId);
+
+ return Results.Created($"/api/v1/plans/{today:yyyy-MM-dd}/items/{planItemId}/progress",
+ BuildResponse(completion, activityType, estimatedMinutes));
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "POST /api/v1/plans/adhoc/start failed");
+ return Results.Problem(
+ detail: "Failed to start ad-hoc session.",
+ statusCode: StatusCodes.Status500InternalServerError);
+ }
+ }
+
+ private static AdhocStartResponse BuildResponse(
+ DailyPlanCompletion row,
+ PlanActivityType activityType,
+ int requestedEstimatedMinutes)
+ {
+ // On replay, prefer the row's stored EstimatedMinutes (the first call
+ // is the source of truth for the session). On insert these are equal.
+ return new AdhocStartResponse
+ {
+ PlanItemId = row.PlanItemId,
+ ActivityType = activityType.ToString(),
+ Date = row.Date.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture),
+ EstimatedMinutes = row.EstimatedMinutes > 0 ? row.EstimatedMinutes : requestedEstimatedMinutes,
+ };
+ }
+
+ private static bool IsUniqueViolation(DbUpdateException ex)
+ {
+ // SQLite: "UNIQUE constraint failed". Postgres: SqlState 23505.
+ // We treat any inner-exception message containing "unique" as a
+ // best-effort hint — sufficient because we only get here if the
+ // primary-key Id collides (very unlikely) OR a unique index is added
+ // on (UserProfileId, Date, PlanItemId) later.
+ var message = ex.InnerException?.Message ?? ex.Message;
+ return message.Contains("UNIQUE", StringComparison.OrdinalIgnoreCase)
+ || message.Contains("23505", StringComparison.Ordinal);
+ }
+}
diff --git a/src/SentenceStudio.Api/Platform/ApiFileSystemService.cs b/src/SentenceStudio.Api/Platform/ApiFileSystemService.cs
index 11e148ee..728eb4b3 100644
--- a/src/SentenceStudio.Api/Platform/ApiFileSystemService.cs
+++ b/src/SentenceStudio.Api/Platform/ApiFileSystemService.cs
@@ -7,7 +7,21 @@ public sealed class ApiFileSystemService : IFileSystemService
public ApiFileSystemService(string appDataDirectory)
{
AppDataDirectory = appDataDirectory;
- Directory.CreateDirectory(AppDataDirectory);
+ try
+ {
+ Directory.CreateDirectory(AppDataDirectory);
+ }
+ catch (UnauthorizedAccessException)
+ {
+ // Read-only filesystem (e.g. ACA /app). The API doesn't actually write to this
+ // path — the dependency is structural via shared services. Swallow so the DI
+ // graph can resolve; downstream writes will surface their own errors if they
+ // ever happen, but in practice the API never writes here.
+ }
+ catch (IOException)
+ {
+ // Same rationale — permission/EROFS wrapped as IOException on some platforms.
+ }
}
public string AppDataDirectory { get; }
diff --git a/src/SentenceStudio.Api/Program.cs b/src/SentenceStudio.Api/Program.cs
index ffd91f10..9d6674f9 100644
--- a/src/SentenceStudio.Api/Program.cs
+++ b/src/SentenceStudio.Api/Program.cs
@@ -23,6 +23,7 @@
using SentenceStudio;
using SentenceStudio.Abstractions;
using SentenceStudio.Api;
+using SentenceStudio.Api.Activity;
using SentenceStudio.Api.Auth;
using SentenceStudio.Api.Diagnostics;
using SentenceStudio.Api.Platform;
@@ -38,6 +39,7 @@
using SentenceStudio.Infrastructure;
using SentenceStudio.Services;
using SentenceStudio.Services.LanguageSegmentation;
+using SentenceStudio.Services.Progress;
using SentenceStudio.Shared.Models;
var builder = WebApplication.CreateBuilder(args);
@@ -144,8 +146,14 @@
builder.Services.AddScoped();
-// Platform abstractions for API server
-var appDataDirectory = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "sentencestudio", "api");
+// Platform abstractions for API server.
+// AppDataDirectory: ACA's /app filesystem is read-only for the runtime user, so resolving
+// Environment.SpecialFolder.LocalApplicationData (→ /app/sentencestudio) throws on
+// Directory.CreateDirectory. The API doesn't persist anything here in practice (the
+// dependency is structural — pulled in transitively by MAUI-oriented services). Default
+// to a writable temp path, allow override via the AppDataDirectory config key.
+var appDataDirectory = builder.Configuration["AppDataDirectory"]
+ ?? Path.Combine(Path.GetTempPath(), "sentencestudio", "api");
builder.Services.AddSingleton();
builder.Services.AddSingleton(_ => new ApiFileSystemService(appDataDirectory));
@@ -167,11 +175,10 @@
// YouTube channel monitoring services
builder.Services.AddSingleton();
-builder.Services.AddSingleton();
builder.Services.AddSingleton();
builder.Services.AddSingleton();
-builder.Services.AddSingleton();
-builder.Services.AddSingleton();
+// VideoImportPipelineService, TranscriptFormattingService, AiService all
+// transitively depend on IChatClient — registered conditionally below.
// Release notes service
builder.Services.AddSingleton();
@@ -222,6 +229,7 @@
// NumberDrill content seeder — populates NumberContext / NumberSubMode / NumberCounter
// from lib/content/numbers/{language}.json (idempotent upsert by natural key).
builder.Services.AddScoped();
+builder.Services.AddScoped();
// Multi-worktree footgun: if the API binds to a fresh Postgres volume (different worktree
// or freshly-provisioned Aspire environment), AspNetUsers will be empty and login will return
@@ -287,8 +295,23 @@
SentenceStudio.Services.Plans.PlanService>();
-// Voice discovery (ElevenLabs) — registered here for the same reason as above.
-builder.Services.AddSingleton();
+// Voice discovery (ElevenLabs) — depends on ElevenLabsClient which is
+// registered conditionally below.
+
+// Conversation activity — server-side stateless analogue of the MAUI agent
+// service. ScenarioRepository is reused from SentenceStudio.Shared (no MAUI
+// deps); the server service is a thin IChatClient wrapper. See
+// specs/004-conversation-http-endpoints/spec.md. The IServerConversationService
+// registration is conditional on IChatClient being available; endpoints return
+// 503 when it isn't (parity with /api/v1/ai/chat).
+builder.Services.AddSingleton();
+
+// Activity Log — Flutter client read endpoint (activity-log-api-spec.md).
+// Extracted from ProgressService so the API doesn't need the full progress
+// graph (LLM plan generation, vocabulary services, sync). MAUI continues to
+// resolve IProgressService which delegates Activity Log work here.
+// Scoped because it consumes the request-scoped IUserScopeProvider.
+builder.Services.AddScoped();
// Vocabulary progress services
builder.Services.AddSingleton();
@@ -314,6 +337,14 @@
return new OpenAIClient(new System.ClientModel.ApiKeyCredential(openAiApiKey), clientOptions)
.GetChatClient(chatModel).AsIChatClient();
});
+
+ // Services that depend on IChatClient (directly or transitively via AiService).
+ builder.Services.AddSingleton();
+ builder.Services.AddSingleton();
+ builder.Services.AddSingleton();
+
+ builder.Services.AddScoped();
}
// GitHub API client for feedback issue creation
@@ -332,6 +363,8 @@
// timeout (100s) trips on /synthesize-timestamped with long input. Bump to 5 min.
var elevenLabsHttp = new HttpClient { Timeout = TimeSpan.FromMinutes(5) };
builder.Services.AddSingleton(new ElevenLabsClient(elevenLabsKey, httpClient: elevenLabsHttp));
+ builder.Services.AddSingleton();
}
var app = builder.Build();
@@ -355,6 +388,11 @@
// Seed Korean number content (NumberDrill activity — idempotent upsert by natural key)
var numberSeeder = scope.ServiceProvider.GetRequiredService();
await numberSeeder.SeedAsync("ko");
+
+ // Seed predefined Conversation scenarios (idempotent upsert keyed on Name + IsPredefined=true).
+ // Shared seed data with MAUI lives in SentenceStudio.Data.ConversationScenarioSeedData.
+ var scenarioSeeder = scope.ServiceProvider.GetRequiredService();
+ await scenarioSeeder.SeedAsync();
}
// Apply CoreSync provisioning (creates change-tracking tables if missing)
@@ -491,6 +529,8 @@ await context.Response.WriteAsync("""
// plan.md. Replaces the legacy /api/v1/plans/generate stub below (kept
// for backward compat during the MAUI Blazor v2 flip).
app.MapPlans();
+app.MapAdhocPlan();
+app.MapActivityLog();
// YouTube channel monitoring endpoints
app.MapChannelEndpoints();
@@ -508,6 +548,9 @@ await context.Response.WriteAsync("""
// Speech / voice discovery
app.MapSpeechEndpoints();
+// Conversation activity (Flutter client)
+app.MapConversationEndpoints();
+
app.MapGet("/api/v1/auth/bootstrap", (ClaimsPrincipal user, ITenantContext tenantContext) =>
Results.Ok(new BootstrapResponse
{
diff --git a/src/SentenceStudio.AppLib/Services/CoreServiceExtensions.cs b/src/SentenceStudio.AppLib/Services/CoreServiceExtensions.cs
index abb15fb4..fd64ad30 100644
--- a/src/SentenceStudio.AppLib/Services/CoreServiceExtensions.cs
+++ b/src/SentenceStudio.AppLib/Services/CoreServiceExtensions.cs
@@ -105,6 +105,7 @@ public static IServiceCollection AddSentenceStudioCoreServices(this IServiceColl
// Progress & timing
services.AddSingleton();
+ services.AddSingleton();
services.AddSingleton();
// Plan generation — use local DeterministicPlanBuilder for rich narratives
diff --git a/src/SentenceStudio.AppLib/Services/ScenarioService.cs b/src/SentenceStudio.AppLib/Services/ScenarioService.cs
index 125bc865..d6c5cb57 100644
--- a/src/SentenceStudio.AppLib/Services/ScenarioService.cs
+++ b/src/SentenceStudio.AppLib/Services/ScenarioService.cs
@@ -303,71 +303,16 @@ public async Task SeedPredefinedScenariosAsync()
_logger.LogInformation("Seeding predefined conversation scenarios");
- var predefinedScenarios = new[]
- {
- new ConversationScenario
- {
- Name = "First Meeting",
- NameKorean = "첫 만남",
- PersonaName = "김철수",
- PersonaDescription = "a 25-year-old drama writer from Seoul",
- SituationDescription = "Getting acquainted with a new person",
- ConversationType = Shared.Models.ConversationType.OpenEnded,
- QuestionBank = "몇 살이에요? 성함이 어떻게 되세요? 생일이 언제예요? 나이가 어떻게 되세요? 무슨 일해요? 어디에 살아요? 어릴 때 뭐가 되고 싶었어요? 취미가 뭐예요? 뭐 좋아해요? 취미가 어떻게 되세요? 왜 한국어 배워요? 오늘 뭐 먹었어요? 지난 주말에 뭐 했어요? 내일 뭐 할 거예요? 어느 나라 여행하고 싶어요? 한 주 동안 뭐 했어요? 한국에 가 봤어요? 한국에 가면 뭐 해 보고 싶어요?",
- IsPredefined = true
- },
- new ConversationScenario
- {
- Name = "Ordering Coffee",
- NameKorean = "커피 주문",
- PersonaName = "박지영",
- PersonaDescription = "a friendly barista at a local cafe",
- SituationDescription = "Ordering coffee and snacks at a Korean cafe",
- ConversationType = Shared.Models.ConversationType.Finite,
- QuestionBank = "",
- IsPredefined = true
- },
- new ConversationScenario
- {
- Name = "Ordering Dinner",
- NameKorean = "저녁 식사 주문",
- PersonaName = "이민호",
- PersonaDescription = "a waiter at a Korean BBQ restaurant",
- SituationDescription = "Ordering food at a Korean BBQ restaurant",
- ConversationType = Shared.Models.ConversationType.Finite,
- QuestionBank = "몇 분이세요? 뭐 드시겠어요? 고기는 어떤 거로 하시겠어요? 반찬 더 필요하세요? 음료는요? 디저트는요? 계산은 어떻게 하시겠어요?",
- IsPredefined = true
- },
- new ConversationScenario
- {
- Name = "Asking for Directions",
- NameKorean = "길 찾기",
- PersonaName = "최수진",
- PersonaDescription = "a helpful stranger on the street",
- SituationDescription = "Asking for directions to a destination",
- ConversationType = Shared.Models.ConversationType.Finite,
- QuestionBank = "어디 가세요? 이 근처 아세요? 지하철역이 어디예요? 버스 정류장이 어디예요? 얼마나 걸려요? 걸어서 갈 수 있어요?",
- IsPredefined = true
- },
- new ConversationScenario
- {
- Name = "Weekend Plans",
- NameKorean = "주말 계획",
- PersonaName = "김하나",
- PersonaDescription = "a curious friend asking about your plans",
- SituationDescription = "Discussing weekend activities and plans with a friend",
- ConversationType = Shared.Models.ConversationType.OpenEnded,
- QuestionBank = "주말에 뭐 해요? 어디 가요? 누구랑 가요? 뭐 먹을 거예요? 영화 볼 거예요? 쇼핑할 거예요? 집에서 쉴 거예요?",
- IsPredefined = true
- }
- };
+ // Source of truth: SentenceStudio.Data.ConversationScenarioSeedData.GetPredefinedScenarios()
+ // Shared with the API server-side seeder so the two never drift.
+ var predefinedScenarios = ConversationScenarioSeedData.GetPredefinedScenarios();
foreach (var scenario in predefinedScenarios)
{
await _repository.SaveAsync(scenario);
}
- _logger.LogInformation("Seeded {Count} predefined scenarios", predefinedScenarios.Length);
+ _logger.LogInformation("Seeded {Count} predefined scenarios", predefinedScenarios.Count);
}
#endregion
diff --git a/src/SentenceStudio.Contracts/Activity/ActivityLogDto.cs b/src/SentenceStudio.Contracts/Activity/ActivityLogDto.cs
new file mode 100644
index 00000000..6ab27ed9
--- /dev/null
+++ b/src/SentenceStudio.Contracts/Activity/ActivityLogDto.cs
@@ -0,0 +1,141 @@
+using System.Text.Json.Serialization;
+
+namespace SentenceStudio.Contracts.Activity;
+
+///
+/// Wire contract for GET /api/v1/activity-log. Mirrors the shape the
+/// Flutter client builds its UI around (see activity-log-api-spec.md). The
+/// server maps from SentenceStudio.Services.Progress.ActivityLogWeek
+/// to this DTO so the wire contract stays explicit and stable even as the
+/// internal model evolves.
+///
+public sealed record ActivityLogWeekDto
+{
+ [JsonPropertyName("weekStart")]
+ public DateTime WeekStart { get; init; }
+
+ [JsonPropertyName("weekEnd")]
+ public DateTime WeekEnd { get; init; }
+
+ [JsonPropertyName("totalMinutes")]
+ public int TotalMinutes { get; init; }
+
+ [JsonPropertyName("inputMinutes")]
+ public int InputMinutes { get; init; }
+
+ [JsonPropertyName("outputMinutes")]
+ public int OutputMinutes { get; init; }
+
+ [JsonPropertyName("activityCount")]
+ public int ActivityCount { get; init; }
+
+ [JsonPropertyName("plansCompleted")]
+ public int PlansCompleted { get; init; }
+
+ [JsonPropertyName("plansTotal")]
+ public int PlansTotal { get; init; }
+
+ [JsonPropertyName("days")]
+ public required IReadOnlyList Days { get; init; }
+}
+
+public sealed record ActivityLogDayDto
+{
+ /// Calendar date as yyyy-MM-dd. No time component — Flutter
+ /// treats this as the user's local day.
+ [JsonPropertyName("date")]
+ public required string Date { get; init; }
+
+ [JsonPropertyName("hasActivity")]
+ public bool HasActivity { get; init; }
+
+ [JsonPropertyName("inputMinutes")]
+ public int InputMinutes { get; init; }
+
+ [JsonPropertyName("outputMinutes")]
+ public int OutputMinutes { get; init; }
+
+ [JsonPropertyName("totalMinutes")]
+ public int TotalMinutes { get; init; }
+
+ [JsonPropertyName("allPlansCompleted")]
+ public bool AllPlansCompleted { get; init; }
+
+ [JsonPropertyName("plans")]
+ public required IReadOnlyList Plans { get; init; }
+}
+
+public sealed record ActivityLogPlanDto
+{
+ [JsonPropertyName("isAdhoc")]
+ public bool IsAdhoc { get; init; }
+
+ /// Stable identifier for the plan grouping. For ad-hoc plans this
+ /// is the first entry's PlanItemId (already prefixed adhoc-). For
+ /// generated plans it is the first entry's PlanItemId in the cluster.
+ [JsonPropertyName("planItemId")]
+ public string PlanItemId { get; init; } = string.Empty;
+
+ /// Human-readable label for the plan grouping. Generated plans are
+ /// labeled Plan N ordered chronologically within the day. Ad-hoc
+ /// plans are labeled Ad-hoc.
+ [JsonPropertyName("displayName")]
+ public required string DisplayName { get; init; }
+
+ [JsonPropertyName("totalMinutes")]
+ public int TotalMinutes { get; init; }
+
+ [JsonPropertyName("completed")]
+ public bool Completed { get; init; }
+
+ [JsonPropertyName("entries")]
+ public required IReadOnlyList Entries { get; init; }
+}
+
+public sealed record ActivityLogEntryDto
+{
+ [JsonPropertyName("planItemId")]
+ public required string PlanItemId { get; init; }
+
+ /// PascalCase enum name — e.g. Reading, Writing,
+ /// VocabularyReview. Matches the PlanActivityType enum.
+ [JsonPropertyName("activityType")]
+ public required string ActivityType { get; init; }
+
+ /// Either "Input" or "Output" — PascalCase, matching
+ /// ActivityCategory.
+ [JsonPropertyName("category")]
+ public required string Category { get; init; }
+
+ [JsonPropertyName("minutesSpent")]
+ public int MinutesSpent { get; init; }
+
+ [JsonPropertyName("estimatedMinutes")]
+ public int EstimatedMinutes { get; init; }
+
+ [JsonPropertyName("isCompleted")]
+ public bool IsCompleted { get; init; }
+
+ /// ISO 8601 UTC timestamp with Z suffix. Null when the
+ /// activity is in-progress (i.e. IsCompleted=false).
+ [JsonPropertyName("completedAtUtc")]
+ public DateTime? CompletedAtUtc { get; init; }
+
+ [JsonPropertyName("resourceTitle")]
+ public string? ResourceTitle { get; init; }
+
+ [JsonPropertyName("skillName")]
+ public string? SkillName { get; init; }
+
+ /// Display title. TEMPORARY: currently returns the TitleKey
+ /// resource identifier (e.g. "PlanItemReadingTitle") until the API
+ /// gains server-side localization. The MAUI client renders these via
+ /// LocalizationManager; Flutter currently does the same via its own
+ /// localization assets. See activity-log-api-spec.md.
+ [JsonPropertyName("title")]
+ public required string Title { get; init; }
+
+ /// Display description. TEMPORARY — see .
+ [JsonPropertyName("description")]
+ public required string Description { get; init; }
+}
diff --git a/src/SentenceStudio.Contracts/Conversation/ConversationContinueRequest.cs b/src/SentenceStudio.Contracts/Conversation/ConversationContinueRequest.cs
new file mode 100644
index 00000000..c0268005
--- /dev/null
+++ b/src/SentenceStudio.Contracts/Conversation/ConversationContinueRequest.cs
@@ -0,0 +1,14 @@
+using System.Collections.Generic;
+
+namespace SentenceStudio.Contracts.Conversation;
+
+///
+/// Body for POST /api/v1/conversation/continue.
+/// History is oldest -> newest; the last entry must be the user's new turn.
+///
+public sealed class ConversationContinueRequest
+{
+ public int? ScenarioId { get; set; }
+ public string? TargetLanguage { get; set; }
+ public List History { get; set; } = new();
+}
diff --git a/src/SentenceStudio.Contracts/Conversation/ConversationContinueResponse.cs b/src/SentenceStudio.Contracts/Conversation/ConversationContinueResponse.cs
new file mode 100644
index 00000000..577cb36f
--- /dev/null
+++ b/src/SentenceStudio.Contracts/Conversation/ConversationContinueResponse.cs
@@ -0,0 +1,46 @@
+using System.Collections.Generic;
+
+namespace SentenceStudio.Contracts.Conversation;
+
+///
+/// Body for POST /api/v1/conversation/continue.
+/// Keys serialize as camelCase via the minimal-API web defaults.
+///
+public sealed class ConversationContinueResponse
+{
+ public string AssistantMessage { get; set; } = string.Empty;
+
+ ///
+ /// Integer 0-100. The underlying agent grades on a 0.0-1.0 scale; the
+ /// endpoint multiplies, clamps, and rounds before serializing.
+ ///
+ public int ComprehensionScore { get; set; }
+
+ public string? ComprehensionNotes { get; set; }
+
+ public List GrammarCorrections { get; set; } = new();
+
+ public List VocabularyAnalysis { get; set; } = new();
+
+ ///
+ /// True when a finite scenario reaches its natural end. v1 always emits
+ /// false; the Flutter UI handles false gracefully. See spec 004 §Behavior.
+ ///
+ public bool IsComplete { get; set; }
+}
+
+public sealed class ConversationGrammarCorrectionDto
+{
+ public string Original { get; set; } = string.Empty;
+ public string Corrected { get; set; } = string.Empty;
+ public string Explanation { get; set; } = string.Empty;
+}
+
+public sealed class ConversationVocabularyAnalysisDto
+{
+ public string UsedForm { get; set; } = string.Empty;
+ public string DictionaryForm { get; set; } = string.Empty;
+ public string Meaning { get; set; } = string.Empty;
+ public bool UsageCorrect { get; set; }
+ public string? UsageExplanation { get; set; }
+}
diff --git a/src/SentenceStudio.Contracts/Conversation/ConversationHistoryItemDto.cs b/src/SentenceStudio.Contracts/Conversation/ConversationHistoryItemDto.cs
new file mode 100644
index 00000000..f7e66b4b
--- /dev/null
+++ b/src/SentenceStudio.Contracts/Conversation/ConversationHistoryItemDto.cs
@@ -0,0 +1,19 @@
+namespace SentenceStudio.Contracts.Conversation;
+
+///
+/// One turn in the conversation history. The client sends oldest -> newest.
+///
+public sealed class ConversationHistoryItemDto
+{
+ ///
+ /// Lowercase wire value: "user" or "assistant".
+ ///
+ public string Role { get; set; } = "user";
+
+ ///
+ /// Optional display name of the speaker (persona name for assistant turns).
+ ///
+ public string? Author { get; set; }
+
+ public string Text { get; set; } = string.Empty;
+}
diff --git a/src/SentenceStudio.Contracts/Conversation/ConversationScenarioDto.cs b/src/SentenceStudio.Contracts/Conversation/ConversationScenarioDto.cs
new file mode 100644
index 00000000..a9580f77
--- /dev/null
+++ b/src/SentenceStudio.Contracts/Conversation/ConversationScenarioDto.cs
@@ -0,0 +1,44 @@
+using System.Text.Json.Serialization;
+
+namespace SentenceStudio.Contracts.Conversation;
+
+///
+/// Wire shape for a conversation scenario.
+/// Uses explicit PascalCase JSON property names to match the
+/// existing `ConversationScenario` EF entity shape the MAUI app
+/// already consumes (the Flutter client's conversation_dtos.dart
+/// is the source of truth — see spec 004).
+///
+public sealed class ConversationScenarioDto
+{
+ [JsonPropertyName("Id")]
+ public int Id { get; set; }
+
+ [JsonPropertyName("Name")]
+ public string Name { get; set; } = string.Empty;
+
+ [JsonPropertyName("NameKorean")]
+ public string? NameKorean { get; set; }
+
+ [JsonPropertyName("PersonaName")]
+ public string PersonaName { get; set; } = string.Empty;
+
+ [JsonPropertyName("PersonaDescription")]
+ public string PersonaDescription { get; set; } = string.Empty;
+
+ [JsonPropertyName("SituationDescription")]
+ public string SituationDescription { get; set; } = string.Empty;
+
+ ///
+ /// Wire values are the strings "OpenEnded" or "Finite" (PascalCase).
+ /// Unknown values fall back to OpenEnded on the Flutter side.
+ ///
+ [JsonPropertyName("ConversationType")]
+ public string ConversationType { get; set; } = "OpenEnded";
+
+ [JsonPropertyName("QuestionBank")]
+ public string? QuestionBank { get; set; }
+
+ [JsonPropertyName("IsPredefined")]
+ public bool IsPredefined { get; set; }
+}
diff --git a/src/SentenceStudio.Contracts/Conversation/ConversationStartRequest.cs b/src/SentenceStudio.Contracts/Conversation/ConversationStartRequest.cs
new file mode 100644
index 00000000..66417154
--- /dev/null
+++ b/src/SentenceStudio.Contracts/Conversation/ConversationStartRequest.cs
@@ -0,0 +1,18 @@
+namespace SentenceStudio.Contracts.Conversation;
+
+///
+/// Body for POST /api/v1/conversation/start.
+/// All JSON keys serialize as camelCase via the minimal-API web defaults.
+///
+public sealed class ConversationStartRequest
+{
+ ///
+ /// Optional scenario id; when omitted, a free-chat opening is used.
+ ///
+ public int? ScenarioId { get; set; }
+
+ ///
+ /// BCP-47 tag (e.g. "ko", "ko-KR") or the human label (e.g. "Korean").
+ ///
+ public string? TargetLanguage { get; set; }
+}
diff --git a/src/SentenceStudio.Contracts/Conversation/ConversationStartResponse.cs b/src/SentenceStudio.Contracts/Conversation/ConversationStartResponse.cs
new file mode 100644
index 00000000..e0c9e14b
--- /dev/null
+++ b/src/SentenceStudio.Contracts/Conversation/ConversationStartResponse.cs
@@ -0,0 +1,12 @@
+namespace SentenceStudio.Contracts.Conversation;
+
+///
+/// Body for POST /api/v1/conversation/start.
+/// Keys serialize as camelCase via the minimal-API web defaults.
+///
+public sealed class ConversationStartResponse
+{
+ public string FirstAssistantMessage { get; set; } = string.Empty;
+ public string? PersonaName { get; set; }
+ public string ConversationType { get; set; } = "OpenEnded";
+}
diff --git a/src/SentenceStudio.Contracts/Plans/AdhocStartDto.cs b/src/SentenceStudio.Contracts/Plans/AdhocStartDto.cs
new file mode 100644
index 00000000..5026c449
--- /dev/null
+++ b/src/SentenceStudio.Contracts/Plans/AdhocStartDto.cs
@@ -0,0 +1,51 @@
+using System.Text.Json.Serialization;
+
+namespace SentenceStudio.Contracts.Plans;
+
+///
+/// Wire request for POST /api/v1/plans/adhoc/start. The ClientSessionId
+/// is the idempotency key — repeat calls with the same id resolve to the same
+/// PlanItemId and never create duplicate DailyPlanCompletion rows.
+///
+public sealed record AdhocStartRequest
+{
+ /// Client-generated UUID. Used as the idempotency key per user.
+ [JsonPropertyName("clientSessionId")]
+ public string? ClientSessionId { get; init; }
+
+ /// PascalCase enum string — see .
+ [JsonPropertyName("activityType")]
+ public string? ActivityType { get; init; }
+
+ [JsonPropertyName("resourceId")]
+ public string? ResourceId { get; init; }
+
+ [JsonPropertyName("skillId")]
+ public string? SkillId { get; init; }
+
+ /// Optional. Defaults to 10 when omitted.
+ [JsonPropertyName("estimatedMinutes")]
+ public int? EstimatedMinutes { get; init; }
+}
+
+///
+/// Wire response for POST /api/v1/plans/adhoc/start.
+///
+public sealed record AdhocStartResponse
+{
+ /// Synthetic id prefixed with adhoc-. Accepted by the existing
+ /// POST /api/v1/plans/{date}/items/{id}/progress and
+ /// POST /api/v1/plans/{date}/items/{id}/complete endpoints.
+ [JsonPropertyName("planItemId")]
+ public required string PlanItemId { get; init; }
+
+ [JsonPropertyName("activityType")]
+ public required string ActivityType { get; init; }
+
+ /// Calendar date the completion row is attached to, yyyy-MM-dd UTC.
+ [JsonPropertyName("date")]
+ public required string Date { get; init; }
+
+ [JsonPropertyName("estimatedMinutes")]
+ public int EstimatedMinutes { get; init; }
+}
diff --git a/src/SentenceStudio.Shared/Data/ConversationScenarioSeedData.cs b/src/SentenceStudio.Shared/Data/ConversationScenarioSeedData.cs
new file mode 100644
index 00000000..ed58eebf
--- /dev/null
+++ b/src/SentenceStudio.Shared/Data/ConversationScenarioSeedData.cs
@@ -0,0 +1,79 @@
+using SentenceStudio.Shared.Models;
+
+namespace SentenceStudio.Data;
+
+///
+/// Single source of truth for the predefined set.
+/// Consumed by both the API (server-side seeding to Postgres on startup) and the
+/// MAUI app (local SQLite seeding via ScenarioService.SeedPredefinedScenariosAsync).
+/// Keep this list in sync — both seeders are idempotent and re-run safely.
+///
+public static class ConversationScenarioSeedData
+{
+ ///
+ /// Returns fresh instances of every predefined scenario.
+ /// Returns new objects on each call so callers may mutate (e.g. set CreatedAt/UpdatedAt)
+ /// without cross-talk between seeders.
+ ///
+ public static IReadOnlyList GetPredefinedScenarios()
+ {
+ return new[]
+ {
+ new ConversationScenario
+ {
+ Name = "First Meeting",
+ NameKorean = "첫 만남",
+ PersonaName = "김철수",
+ PersonaDescription = "a 25-year-old drama writer from Seoul",
+ SituationDescription = "Getting acquainted with a new person",
+ ConversationType = ConversationType.OpenEnded,
+ QuestionBank = "몇 살이에요? 성함이 어떻게 되세요? 생일이 언제예요? 나이가 어떻게 되세요? 무슨 일해요? 어디에 살아요? 어릴 때 뭐가 되고 싶었어요? 취미가 뭐예요? 뭐 좋아해요? 취미가 어떻게 되세요? 왜 한국어 배워요? 오늘 뭐 먹었어요? 지난 주말에 뭐 했어요? 내일 뭐 할 거예요? 어느 나라 여행하고 싶어요? 한 주 동안 뭐 했어요? 한국에 가 봤어요? 한국에 가면 뭐 해 보고 싶어요?",
+ IsPredefined = true
+ },
+ new ConversationScenario
+ {
+ Name = "Ordering Coffee",
+ NameKorean = "커피 주문",
+ PersonaName = "박지영",
+ PersonaDescription = "a friendly barista at a local cafe",
+ SituationDescription = "Ordering coffee and snacks at a Korean cafe",
+ ConversationType = ConversationType.Finite,
+ QuestionBank = "",
+ IsPredefined = true
+ },
+ new ConversationScenario
+ {
+ Name = "Ordering Dinner",
+ NameKorean = "저녁 식사 주문",
+ PersonaName = "이민호",
+ PersonaDescription = "a waiter at a Korean BBQ restaurant",
+ SituationDescription = "Ordering food at a Korean BBQ restaurant",
+ ConversationType = ConversationType.Finite,
+ QuestionBank = "몇 분이세요? 뭐 드시겠어요? 고기는 어떤 거로 하시겠어요? 반찬 더 필요하세요? 음료는요? 디저트는요? 계산은 어떻게 하시겠어요?",
+ IsPredefined = true
+ },
+ new ConversationScenario
+ {
+ Name = "Asking for Directions",
+ NameKorean = "길 찾기",
+ PersonaName = "최수진",
+ PersonaDescription = "a helpful stranger on the street",
+ SituationDescription = "Asking for directions to a destination",
+ ConversationType = ConversationType.Finite,
+ QuestionBank = "어디 가세요? 이 근처 아세요? 지하철역이 어디예요? 버스 정류장이 어디예요? 얼마나 걸려요? 걸어서 갈 수 있어요?",
+ IsPredefined = true
+ },
+ new ConversationScenario
+ {
+ Name = "Weekend Plans",
+ NameKorean = "주말 계획",
+ PersonaName = "김하나",
+ PersonaDescription = "a curious friend asking about your plans",
+ SituationDescription = "Discussing weekend activities and plans with a friend",
+ ConversationType = ConversationType.OpenEnded,
+ QuestionBank = "주말에 뭐 해요? 어디 가요? 누구랑 가요? 뭐 먹을 거예요? 영화 볼 거예요? 쇼핑할 거예요? 집에서 쉴 거예요?",
+ IsPredefined = true
+ }
+ };
+ }
+}
diff --git a/src/SentenceStudio.Shared/Services/Progress/ActivityLogService.cs b/src/SentenceStudio.Shared/Services/Progress/ActivityLogService.cs
new file mode 100644
index 00000000..09c5c7e4
--- /dev/null
+++ b/src/SentenceStudio.Shared/Services/Progress/ActivityLogService.cs
@@ -0,0 +1,254 @@
+using SentenceStudio.Data;
+using SentenceStudio.Services.Plans;
+using SentenceStudio.Shared.Models;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+
+namespace SentenceStudio.Services.Progress;
+
+///
+/// Computes the per-week Activity Log rollup from DailyPlanCompletions.
+/// Extracted from so the API can serve it
+/// without depending on the full progress graph (LLM plan generator,
+/// vocabulary services, sync, etc.). MAUI's
+/// delegates here to keep a single source of truth for the rollup logic.
+///
+public interface IActivityLogService
+{
+ Task> GetActivityLogAsync(
+ DateTime fromUtc,
+ DateTime toUtc,
+ ActivityCategory? filter = null,
+ CancellationToken ct = default);
+}
+
+public sealed class ActivityLogService : IActivityLogService
+{
+ private readonly LearningResourceRepository _resourceRepo;
+ private readonly SkillProfileRepository _skillRepo;
+ private readonly IUserScopeProvider _userScope;
+ private readonly IServiceProvider _serviceProvider;
+ private readonly ILogger _logger;
+
+ public ActivityLogService(
+ LearningResourceRepository resourceRepo,
+ SkillProfileRepository skillRepo,
+ IUserScopeProvider userScope,
+ IServiceProvider serviceProvider,
+ ILogger logger)
+ {
+ _resourceRepo = resourceRepo;
+ _skillRepo = skillRepo;
+ _userScope = userScope;
+ _serviceProvider = serviceProvider;
+ _logger = logger;
+ }
+
+ public async Task> GetActivityLogAsync(
+ DateTime fromUtc,
+ DateTime toUtc,
+ ActivityCategory? filter = null,
+ CancellationToken ct = default)
+ {
+ _logger.LogDebug("📅 GetActivityLogAsync — from={From:yyyy-MM-dd}, to={To:yyyy-MM-dd}, filter={Filter}", fromUtc, toUtc, filter);
+
+ if (!_userScope.TryGetUserProfileId(out var userProfileId) || string.IsNullOrEmpty(userProfileId))
+ {
+ _logger.LogWarning("❌ No active user profile — cannot get activity log");
+ return new List();
+ }
+
+ using var scope = _serviceProvider.CreateScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+
+ var completions = await db.DailyPlanCompletions
+ .Where(c => c.UserProfileId == userProfileId && c.Date >= fromUtc.Date && c.Date <= toUtc.Date)
+ .OrderBy(c => c.Date)
+ .ThenBy(c => c.CreatedAt)
+ .ToListAsync(ct);
+
+ if (!completions.Any())
+ {
+ _logger.LogDebug("ℹ️ No completions found in date range");
+ return new List();
+ }
+
+ var resources = await _resourceRepo.GetAllResourcesLightweightAsync();
+ var resourceLookup = resources
+ .GroupBy(r => r.Id)
+ .ToDictionary(g => g.Key, g => g.First().Title ?? string.Empty);
+
+ var skills = await _skillRepo.ListAsync();
+ var skillLookup = skills
+ .GroupBy(s => s.Id)
+ .ToDictionary(g => g.Key, g => g.First().Title ?? string.Empty);
+
+ var dayGroups = completions
+ .GroupBy(c => c.Date.Date)
+ .OrderBy(g => g.Key)
+ .ToList();
+
+ var activityLogDays = new List();
+
+ foreach (var dayGroup in dayGroups)
+ {
+ var date = dayGroup.Key;
+ var dayCompletions = dayGroup.OrderBy(c => c.CreatedAt).ToList();
+
+ // Sub-group by plan generation (cluster items within 60 seconds of each other)
+ var plans = new List();
+ if (dayCompletions.Any())
+ {
+ var currentPlan = new List { dayCompletions[0] };
+ for (int i = 1; i < dayCompletions.Count; i++)
+ {
+ if ((dayCompletions[i].CreatedAt - dayCompletions[i - 1].CreatedAt).TotalSeconds <= 60)
+ currentPlan.Add(dayCompletions[i]);
+ else
+ {
+ plans.Add(BuildActivityLogPlan(currentPlan, resourceLookup, skillLookup, filter));
+ currentPlan = new List { dayCompletions[i] };
+ }
+ }
+ plans.Add(BuildActivityLogPlan(currentPlan, resourceLookup, skillLookup, filter));
+ }
+
+ // Drop plans whose entries were entirely filtered out
+ plans = plans.Where(p => p.Items.Any()).ToList();
+ if (!plans.Any())
+ continue;
+
+ var totalMinutes = plans.Sum(p => p.TotalMinutesSpent);
+ var hasInput = plans.Any(p => p.Items.Any(i => i.Category == ActivityCategory.Input));
+ var hasOutput = plans.Any(p => p.Items.Any(i => i.Category == ActivityCategory.Output));
+ var allPlansCompleted = plans.All(p => p.IsFullyCompleted);
+
+ activityLogDays.Add(new ActivityLogDay(
+ Date: date,
+ Plans: plans,
+ TotalMinutes: totalMinutes,
+ HasInput: hasInput,
+ HasOutput: hasOutput,
+ AllPlansCompleted: allPlansCompleted));
+ }
+
+ var weeks = BuildWeeks(fromUtc.Date, toUtc.Date, activityLogDays);
+
+ _logger.LogDebug("✅ Built {WeekCount} weeks with {DayCount} active days", weeks.Count, activityLogDays.Count);
+
+ return weeks;
+ }
+
+ private ActivityLogPlan BuildActivityLogPlan(
+ List completions,
+ Dictionary resourceLookup,
+ Dictionary skillLookup,
+ ActivityCategory? filter)
+ {
+ var entries = new List();
+
+ foreach (var completion in completions)
+ {
+ if (!Enum.TryParse(completion.ActivityType, out var actType))
+ {
+ _logger.LogWarning("⚠️ Failed to parse ActivityType '{ActivityType}' for completion {Id}", completion.ActivityType, completion.Id);
+ continue;
+ }
+
+ var category = ActivityCategoryMapper.Categorize(actType);
+ if (filter.HasValue && category != filter.Value)
+ continue;
+
+ var resourceTitle = !string.IsNullOrEmpty(completion.ResourceId) && resourceLookup.TryGetValue(completion.ResourceId, out var title)
+ ? title : null;
+ var skillName = !string.IsNullOrEmpty(completion.SkillId) && skillLookup.TryGetValue(completion.SkillId, out var skill)
+ ? skill : null;
+
+ entries.Add(new ActivityLogEntry(
+ PlanItemId: completion.PlanItemId,
+ ActivityType: actType,
+ Category: category,
+ MinutesSpent: completion.MinutesSpent,
+ EstimatedMinutes: completion.EstimatedMinutes,
+ IsCompleted: completion.IsCompleted,
+ CompletedAt: completion.CompletedAt,
+ ResourceTitle: resourceTitle,
+ SkillName: skillName,
+ TitleKey: completion.TitleKey,
+ DescriptionKey: completion.DescriptionKey));
+ }
+
+ var generatedAt = completions.FirstOrDefault()?.CreatedAt ?? DateTime.UtcNow;
+#pragma warning disable CS0618 // Rationale/NarrativeJson on completion are deprecated but still readable.
+ var rationale = completions.FirstOrDefault()?.Rationale;
+ var narrativeJson = completions.FirstOrDefault()?.NarrativeJson;
+#pragma warning restore CS0618
+
+ return new ActivityLogPlan(
+ GeneratedAt: generatedAt,
+ Items: entries,
+ CompletedCount: entries.Count(e => e.IsCompleted),
+ TotalCount: entries.Count,
+ IsFullyCompleted: entries.Any() && entries.All(e => e.IsCompleted),
+ TotalMinutesSpent: entries.Sum(e => e.MinutesSpent),
+ TotalEstimatedMinutes: entries.Sum(e => e.EstimatedMinutes),
+ Rationale: rationale,
+ NarrativeJson: narrativeJson);
+ }
+
+ private static List BuildWeeks(DateTime fromDate, DateTime toDate, List activityLogDays)
+ {
+ var dayLookup = activityLogDays.ToDictionary(d => d.Date.Date, d => d);
+ var weeks = new List();
+ var current = fromDate.Date;
+
+ // Anchor to Monday
+ while (current.DayOfWeek != DayOfWeek.Monday && current <= toDate)
+ current = current.AddDays(-1);
+
+ while (current <= toDate)
+ {
+ var weekStart = current;
+ var weekEnd = weekStart.AddDays(6);
+
+ var days = new ActivityLogDay[7];
+ for (int i = 0; i < 7; i++)
+ {
+ var date = weekStart.AddDays(i);
+ days[i] = dayLookup.TryGetValue(date, out var day)
+ ? day
+ : new ActivityLogDay(
+ Date: date,
+ Plans: new List(),
+ TotalMinutes: 0,
+ HasInput: false,
+ HasOutput: false,
+ AllPlansCompleted: false);
+ }
+
+ var totalMinutes = days.Sum(d => d.TotalMinutes);
+ var inputMinutes = days.Sum(d => d.Plans.Sum(p => p.Items.Where(i => i.Category == ActivityCategory.Input).Sum(i => i.MinutesSpent)));
+ var outputMinutes = days.Sum(d => d.Plans.Sum(p => p.Items.Where(i => i.Category == ActivityCategory.Output).Sum(i => i.MinutesSpent)));
+ var activityCount = days.Sum(d => d.Plans.Sum(p => p.Items.Count));
+ var plansCompleted = days.Sum(d => d.Plans.Count(p => p.IsFullyCompleted));
+ var plansTotal = days.Sum(d => d.Plans.Count);
+
+ weeks.Add(new ActivityLogWeek(
+ WeekStart: weekStart,
+ WeekEnd: weekEnd,
+ Days: days,
+ TotalMinutes: totalMinutes,
+ InputMinutes: inputMinutes,
+ OutputMinutes: outputMinutes,
+ ActivityCount: activityCount,
+ PlansCompleted: plansCompleted,
+ PlansTotal: plansTotal));
+
+ current = weekEnd.AddDays(1);
+ }
+
+ weeks.Reverse(); // newest first
+ return weeks;
+ }
+}
diff --git a/src/SentenceStudio.Shared/Services/Progress/ProgressService.cs b/src/SentenceStudio.Shared/Services/Progress/ProgressService.cs
index 74e6ea72..cadf32b6 100644
--- a/src/SentenceStudio.Shared/Services/Progress/ProgressService.cs
+++ b/src/SentenceStudio.Shared/Services/Progress/ProgressService.cs
@@ -18,6 +18,7 @@ public class ProgressService : IProgressService
private readonly ILogger _logger;
private readonly UserProfileRepository _userProfileRepo;
private readonly ISyncService? _syncService;
+ private readonly IActivityLogService _activityLogService;
public ProgressService(
LearningResourceRepository resourceRepo,
@@ -30,6 +31,7 @@ public ProgressService(
ILlmPlanGenerationService llmPlanService,
ILogger logger,
UserProfileRepository userProfileRepo,
+ IActivityLogService activityLogService,
ISyncService? syncService = null)
{
_resourceRepo = resourceRepo;
@@ -42,6 +44,7 @@ public ProgressService(
_llmPlanService = llmPlanService;
_logger = logger;
_userProfileRepo = userProfileRepo;
+ _activityLogService = activityLogService;
_syncService = syncService;
}
@@ -1284,236 +1287,6 @@ private string GeneratePlanItemId(DateTime date, PlanActivityType activityType,
return guid.ToString();
}
- public async Task> GetActivityLogAsync(DateTime fromUtc, DateTime toUtc, ActivityCategory? filter = null, CancellationToken ct = default)
- {
- _logger.LogDebug("📅 GetActivityLogAsync - from={From:yyyy-MM-dd}, to={To:yyyy-MM-dd}, filter={Filter}", fromUtc, toUtc, filter);
-
- // Get user profile
- var userProfile = await _userProfileRepo.GetAsync();
- if (userProfile == null)
- {
- _logger.LogWarning("❌ No user profile found - cannot get activity log");
- return new List();
- }
-
- // Query all completions within the date range
- using var scope = _serviceProvider.CreateScope();
- var db = scope.ServiceProvider.GetRequiredService();
-
- var completions = await db.DailyPlanCompletions
- .Where(c => c.UserProfileId == userProfile.Id && c.Date >= fromUtc.Date && c.Date <= toUtc.Date)
- .OrderBy(c => c.Date)
- .ThenBy(c => c.CreatedAt)
- .ToListAsync(ct);
-
- if (!completions.Any())
- {
- _logger.LogDebug("ℹ️ No completions found in date range");
- return new List();
- }
-
- // Lookup resource and skill titles for enrichment (handle duplicates gracefully)
- var resources = await _resourceRepo.GetAllResourcesLightweightAsync();
- var resourceLookup = resources
- .GroupBy(r => r.Id)
- .ToDictionary(g => g.Key, g => g.First().Title ?? string.Empty);
-
- var skills = await _skillRepo.ListAsync();
- var skillLookup = skills
- .GroupBy(s => s.Id)
- .ToDictionary(g => g.Key, g => g.First().Title ?? string.Empty);
-
- // Group by date
- var dayGroups = completions
- .GroupBy(c => c.Date.Date)
- .OrderBy(g => g.Key)
- .ToList();
-
- var activityLogDays = new List();
-
- foreach (var dayGroup in dayGroups)
- {
- var date = dayGroup.Key;
- var dayCompletions = dayGroup.OrderBy(c => c.CreatedAt).ToList();
-
- // Sub-group by plan generation (cluster items within 60 seconds)
- var plans = new List();
- if (dayCompletions.Any())
- {
- var currentPlan = new List { dayCompletions[0] };
-
- for (int i = 1; i < dayCompletions.Count; i++)
- {
- if ((dayCompletions[i].CreatedAt - dayCompletions[i - 1].CreatedAt).TotalSeconds <= 60)
- {
- currentPlan.Add(dayCompletions[i]);
- }
- else
- {
- plans.Add(BuildActivityLogPlan(currentPlan, resourceLookup, skillLookup, filter));
- currentPlan = new List { dayCompletions[i] };
- }
- }
- plans.Add(BuildActivityLogPlan(currentPlan, resourceLookup, skillLookup, filter));
- }
-
- // Remove plans with no entries (all filtered out)
- plans = plans.Where(p => p.Items.Any()).ToList();
-
- if (!plans.Any())
- continue; // Skip days with no matching activities after filtering
-
- var totalMinutes = plans.Sum(p => p.TotalMinutesSpent);
- var hasInput = plans.Any(p => p.Items.Any(i => i.Category == ActivityCategory.Input));
- var hasOutput = plans.Any(p => p.Items.Any(i => i.Category == ActivityCategory.Output));
- var allPlansCompleted = plans.All(p => p.IsFullyCompleted);
-
- activityLogDays.Add(new ActivityLogDay(
- Date: date,
- Plans: plans,
- TotalMinutes: totalMinutes,
- HasInput: hasInput,
- HasOutput: hasOutput,
- AllPlansCompleted: allPlansCompleted
- ));
- }
-
- // Build weeks (Monday-anchored)
- var weeks = BuildWeeks(fromUtc.Date, toUtc.Date, activityLogDays);
-
- _logger.LogDebug("✅ Built {WeekCount} weeks with {DayCount} active days", weeks.Count, activityLogDays.Count);
-
- return weeks;
- }
-
- private ActivityLogPlan BuildActivityLogPlan(
- List completions,
- Dictionary resourceLookup,
- Dictionary skillLookup,
- ActivityCategory? filter)
- {
- var entries = new List();
-
- foreach (var completion in completions)
- {
- // Parse ActivityType
- if (!Enum.TryParse(completion.ActivityType, out var actType))
- {
- _logger.LogWarning("⚠️ Failed to parse ActivityType '{ActivityType}' for completion {Id}", completion.ActivityType, completion.Id);
- continue;
- }
-
- var category = ActivityCategoryMapper.Categorize(actType);
-
- // Apply filter
- if (filter.HasValue && category != filter.Value)
- continue;
-
- var resourceTitle = !string.IsNullOrEmpty(completion.ResourceId) && resourceLookup.TryGetValue(completion.ResourceId, out var title)
- ? title
- : null;
-
- var skillName = !string.IsNullOrEmpty(completion.SkillId) && skillLookup.TryGetValue(completion.SkillId, out var skill)
- ? skill
- : null;
-
- entries.Add(new ActivityLogEntry(
- PlanItemId: completion.PlanItemId,
- ActivityType: actType,
- Category: category,
- MinutesSpent: completion.MinutesSpent,
- EstimatedMinutes: completion.EstimatedMinutes,
- IsCompleted: completion.IsCompleted,
- CompletedAt: completion.CompletedAt,
- ResourceTitle: resourceTitle,
- SkillName: skillName,
- TitleKey: completion.TitleKey,
- DescriptionKey: completion.DescriptionKey
- ));
- }
-
- var generatedAt = completions.FirstOrDefault()?.CreatedAt ?? DateTime.UtcNow;
- var rationale = completions.FirstOrDefault()?.Rationale;
- var narrativeJson = completions.FirstOrDefault()?.NarrativeJson;
-
- return new ActivityLogPlan(
- GeneratedAt: generatedAt,
- Items: entries,
- CompletedCount: entries.Count(e => e.IsCompleted),
- TotalCount: entries.Count,
- IsFullyCompleted: entries.Any() && entries.All(e => e.IsCompleted),
- TotalMinutesSpent: entries.Sum(e => e.MinutesSpent),
- TotalEstimatedMinutes: entries.Sum(e => e.EstimatedMinutes),
- Rationale: rationale,
- NarrativeJson: narrativeJson
- );
- }
-
- private List BuildWeeks(DateTime fromDate, DateTime toDate, List activityLogDays)
- {
- // Create a dictionary for fast lookup
- var dayLookup = activityLogDays.ToDictionary(d => d.Date.Date, d => d);
-
- // Find Monday-anchored weeks
- var weeks = new List();
- var current = fromDate.Date;
-
- // Align to Monday
- while (current.DayOfWeek != DayOfWeek.Monday && current <= toDate)
- current = current.AddDays(-1);
-
- while (current <= toDate)
- {
- var weekStart = current;
- var weekEnd = weekStart.AddDays(6);
-
- var days = new ActivityLogDay[7];
- for (int i = 0; i < 7; i++)
- {
- var date = weekStart.AddDays(i);
- if (dayLookup.TryGetValue(date, out var day))
- {
- days[i] = day;
- }
- else
- {
- // Empty day
- days[i] = new ActivityLogDay(
- Date: date,
- Plans: new List(),
- TotalMinutes: 0,
- HasInput: false,
- HasOutput: false,
- AllPlansCompleted: false
- );
- }
- }
-
- var totalMinutes = days.Sum(d => d.TotalMinutes);
- var inputMinutes = days.Sum(d => d.Plans.Sum(p => p.Items.Where(i => i.Category == ActivityCategory.Input).Sum(i => i.MinutesSpent)));
- var outputMinutes = days.Sum(d => d.Plans.Sum(p => p.Items.Where(i => i.Category == ActivityCategory.Output).Sum(i => i.MinutesSpent)));
- var activityCount = days.Sum(d => d.Plans.Sum(p => p.Items.Count));
- var plansCompleted = days.Sum(d => d.Plans.Count(p => p.IsFullyCompleted));
- var plansTotal = days.Sum(d => d.Plans.Count);
-
- weeks.Add(new ActivityLogWeek(
- WeekStart: weekStart,
- WeekEnd: weekEnd,
- Days: days,
- TotalMinutes: totalMinutes,
- InputMinutes: inputMinutes,
- OutputMinutes: outputMinutes,
- ActivityCount: activityCount,
- PlansCompleted: plansCompleted,
- PlansTotal: plansTotal
- ));
-
- current = weekEnd.AddDays(1);
- }
-
- // Return most recent week first
- weeks.Reverse();
-
- return weeks;
- }
+ public Task> GetActivityLogAsync(DateTime fromUtc, DateTime toUtc, ActivityCategory? filter = null, CancellationToken ct = default)
+ => _activityLogService.GetActivityLogAsync(fromUtc, toUtc, filter, ct);
}
diff --git a/tests/SentenceStudio.Api.Tests/ActivityLogEndpointsTests.cs b/tests/SentenceStudio.Api.Tests/ActivityLogEndpointsTests.cs
new file mode 100644
index 00000000..c79c239e
--- /dev/null
+++ b/tests/SentenceStudio.Api.Tests/ActivityLogEndpointsTests.cs
@@ -0,0 +1,276 @@
+using System.Net;
+using System.Net.Http.Headers;
+using System.Net.Http.Json;
+using System.Text.Json;
+using Microsoft.Extensions.DependencyInjection;
+using SentenceStudio.Api.Tests.Infrastructure;
+using SentenceStudio.Contracts.Activity;
+using SentenceStudio.Data;
+using SentenceStudio.Shared.Models;
+
+namespace SentenceStudio.Api.Tests;
+
+///
+/// Integration tests for GET /api/v1/activity-log. Pins the wire shape
+/// the Flutter client builds against (see activity-log-api-spec.md): camelCase
+/// keys, yyyy-MM-dd day dates, 7-day weeks newest-first, PascalCase
+/// enum values for category and activityType.
+///
+public sealed class ActivityLogEndpointsTests : IClassFixture
+{
+ private readonly ActivityLogApiFactory _factory;
+ private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
+
+ public ActivityLogEndpointsTests(ActivityLogApiFactory factory)
+ {
+ _factory = factory;
+ }
+
+ private HttpClient ClientWithJwt(string userProfileId)
+ {
+ var token = TestJwtGenerator.GenerateToken(userProfileId: userProfileId);
+ var client = _factory.CreateClient();
+ client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
+ return client;
+ }
+
+ private async Task SeedCompletionAsync(
+ string userProfileId,
+ DateTime date,
+ string planItemId,
+ string activityType,
+ int minutesSpent,
+ DateTime createdAt)
+ {
+ using var scope = _factory.Services.CreateScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+ db.DailyPlanCompletions.Add(new DailyPlanCompletion
+ {
+ Id = Guid.NewGuid().ToString(),
+ UserProfileId = userProfileId,
+ Date = date.Date,
+ PlanItemId = planItemId,
+ ActivityType = activityType,
+ IsCompleted = true,
+ CompletedAt = createdAt,
+ MinutesSpent = minutesSpent,
+ EstimatedMinutes = minutesSpent,
+ TitleKey = "PlanItem.Title",
+ DescriptionKey = "PlanItem.Description",
+ CreatedAt = createdAt,
+ UpdatedAt = createdAt,
+ });
+ await db.SaveChangesAsync();
+ }
+
+ [Fact]
+ public async Task GetActivityLog_NoJwt_Returns401()
+ {
+ var client = _factory.CreateClient();
+ var response = await client.GetAsync("/api/v1/activity-log?fromUtc=2026-01-01T00:00:00Z&toUtc=2026-01-07T23:59:59Z");
+ response.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
+ }
+
+ [Fact]
+ public async Task GetActivityLog_MissingDates_Returns400()
+ {
+ var client = ClientWithJwt($"actlog-bad-{Guid.NewGuid():N}");
+ var response = await client.GetAsync("/api/v1/activity-log");
+ response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
+ }
+
+ [Fact]
+ public async Task GetActivityLog_InvertedRange_Returns400()
+ {
+ var client = ClientWithJwt($"actlog-bad-{Guid.NewGuid():N}");
+ var response = await client.GetAsync(
+ "/api/v1/activity-log?fromUtc=2026-01-08T00:00:00Z&toUtc=2026-01-01T00:00:00Z");
+ response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
+ }
+
+ [Fact]
+ public async Task GetActivityLog_RangeTooLarge_Returns400()
+ {
+ var client = ClientWithJwt($"actlog-bad-{Guid.NewGuid():N}");
+ var response = await client.GetAsync(
+ "/api/v1/activity-log?fromUtc=2025-01-01T00:00:00Z&toUtc=2026-01-01T00:00:00Z");
+ response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
+ }
+
+ [Fact]
+ public async Task GetActivityLog_InvalidFilter_Returns400()
+ {
+ var client = ClientWithJwt($"actlog-bad-{Guid.NewGuid():N}");
+ var response = await client.GetAsync(
+ "/api/v1/activity-log?fromUtc=2026-01-01T00:00:00Z&toUtc=2026-01-07T23:59:59Z&filter=Bogus");
+ response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
+ }
+
+ [Fact]
+ public async Task GetActivityLog_NoActivity_ReturnsEmptyArray()
+ {
+ var client = ClientWithJwt($"actlog-empty-{Guid.NewGuid():N}");
+ var response = await client.GetAsync(
+ "/api/v1/activity-log?fromUtc=2026-01-01T00:00:00Z&toUtc=2026-01-07T23:59:59Z");
+ response.StatusCode.Should().Be(HttpStatusCode.OK);
+
+ var weeks = await response.Content.ReadFromJsonAsync>(JsonOptions);
+ weeks.Should().NotBeNull();
+ weeks!.Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task GetActivityLog_WithCompletions_RollsUpWeekWithCamelCaseShape()
+ {
+ var userProfileId = $"actlog-rollup-{Guid.NewGuid():N}";
+
+ // Two completions on a Tuesday — clustered (within 60s) into one Plan.
+ // One Input (Reading) + one Output (Writing) so we exercise the day's
+ // input/output minute totals.
+ var tuesday = new DateTime(2026, 3, 31, 0, 0, 0, DateTimeKind.Utc); // 2026-03-31 is a Tuesday
+ var firstCompletion = tuesday.AddHours(13).AddMinutes(42);
+ var secondCompletion = firstCompletion.AddSeconds(30);
+
+ await SeedCompletionAsync(userProfileId, tuesday, "item-1", "Reading", 12, firstCompletion);
+ await SeedCompletionAsync(userProfileId, tuesday, "item-2", "Writing", 8, secondCompletion);
+
+ var client = ClientWithJwt(userProfileId);
+ var response = await client.GetAsync(
+ "/api/v1/activity-log?fromUtc=2026-03-30T00:00:00Z&toUtc=2026-04-05T23:59:59Z");
+ response.StatusCode.Should().Be(HttpStatusCode.OK);
+
+ var raw = await response.Content.ReadAsStringAsync();
+ // Wire contract: camelCase keys, PascalCase enum values, yyyy-MM-dd dates.
+ raw.Should().Contain("\"weekStart\"");
+ raw.Should().Contain("\"weekEnd\"");
+ raw.Should().Contain("\"activityCount\"");
+ raw.Should().Contain("\"plansCompleted\"");
+ raw.Should().Contain("\"plansTotal\"");
+ raw.Should().Contain("\"hasActivity\"");
+ raw.Should().Contain("\"inputMinutes\"");
+ raw.Should().Contain("\"outputMinutes\"");
+ raw.Should().Contain("\"estimatedMinutes\"");
+ raw.Should().Contain("\"isCompleted\"");
+ raw.Should().Contain("\"completedAtUtc\"");
+ raw.Should().Contain("\"resourceTitle\"");
+ raw.Should().Contain("\"skillName\"");
+ raw.Should().Contain("\"title\"");
+ raw.Should().Contain("\"description\"");
+ raw.Should().Contain("\"planItemId\"");
+ raw.Should().Contain("\"category\":\"Input\"");
+ raw.Should().Contain("\"category\":\"Output\"");
+ raw.Should().Contain("\"activityType\":\"Reading\"");
+ raw.Should().Contain("\"activityType\":\"Writing\"");
+ raw.Should().Contain("\"date\":\"2026-03-31\"");
+
+ var weeks = await response.Content.ReadFromJsonAsync>(JsonOptions);
+ weeks.Should().NotBeNull().And.HaveCount(1);
+
+ var week = weeks![0];
+ week.Days.Should().HaveCount(7, "weeks always have 7 day entries Mon–Sun");
+ week.WeekStart.DayOfWeek.Should().Be(DayOfWeek.Monday);
+ week.TotalMinutes.Should().Be(20);
+ week.InputMinutes.Should().Be(12);
+ week.OutputMinutes.Should().Be(8);
+ week.ActivityCount.Should().Be(2);
+ week.PlansCompleted.Should().Be(1);
+ week.PlansTotal.Should().Be(1);
+
+ var tuesdayDay = week.Days.Single(d => d.Date == "2026-03-31");
+ tuesdayDay.HasActivity.Should().BeTrue();
+ tuesdayDay.InputMinutes.Should().Be(12);
+ tuesdayDay.OutputMinutes.Should().Be(8);
+ tuesdayDay.TotalMinutes.Should().Be(20);
+ tuesdayDay.AllPlansCompleted.Should().BeTrue();
+ tuesdayDay.Plans.Should().HaveCount(1, "the two completions are within 60s of each other");
+
+ var plan = tuesdayDay.Plans[0];
+ plan.IsAdhoc.Should().BeFalse();
+ plan.DisplayName.Should().Be("Plan 1");
+ plan.TotalMinutes.Should().Be(20);
+ plan.Completed.Should().BeTrue();
+ plan.Entries.Should().HaveCount(2);
+
+ var reading = plan.Entries.Single(e => e.ActivityType == "Reading");
+ reading.Category.Should().Be("Input");
+ reading.PlanItemId.Should().Be("item-1");
+ reading.MinutesSpent.Should().Be(12);
+ reading.EstimatedMinutes.Should().Be(12);
+ reading.IsCompleted.Should().BeTrue();
+ reading.CompletedAtUtc.Should().NotBeNull();
+ reading.Title.Should().Be("PlanItem.Title", "passthrough TitleKey while server-side localization pending");
+ reading.Description.Should().Be("PlanItem.Description");
+
+ var writing = plan.Entries.Single(e => e.ActivityType == "Writing");
+ writing.Category.Should().Be("Output");
+
+ // Empty days still present with hasActivity=false and zero totals.
+ var monday = week.Days.Single(d => d.Date == "2026-03-30");
+ monday.HasActivity.Should().BeFalse();
+ monday.Plans.Should().BeEmpty();
+ monday.TotalMinutes.Should().Be(0);
+ }
+
+ [Fact]
+ public async Task GetActivityLog_AdhocCompletion_LabeledAsAdhoc()
+ {
+ var userProfileId = $"actlog-adhoc-{Guid.NewGuid():N}";
+ var wednesday = new DateTime(2026, 4, 1, 14, 0, 0, DateTimeKind.Utc); // Wednesday
+ await SeedCompletionAsync(userProfileId, wednesday, "adhoc-001", "Reading", 5, wednesday);
+
+ var client = ClientWithJwt(userProfileId);
+ var response = await client.GetAsync(
+ "/api/v1/activity-log?fromUtc=2026-03-30T00:00:00Z&toUtc=2026-04-05T23:59:59Z");
+ response.StatusCode.Should().Be(HttpStatusCode.OK);
+
+ var weeks = await response.Content.ReadFromJsonAsync>(JsonOptions);
+ var day = weeks!.SelectMany(w => w.Days).Single(d => d.Date == "2026-04-01");
+ day.Plans.Should().HaveCount(1);
+ day.Plans[0].IsAdhoc.Should().BeTrue();
+ day.Plans[0].DisplayName.Should().Be("Ad-hoc");
+ day.Plans[0].PlanItemId.Should().Be("adhoc-001");
+ }
+
+ [Fact]
+ public async Task GetActivityLog_InputFilter_DropsOutputEntries()
+ {
+ var userProfileId = $"actlog-filter-{Guid.NewGuid():N}";
+ var thursday = new DateTime(2026, 4, 2, 10, 0, 0, DateTimeKind.Utc);
+ await SeedCompletionAsync(userProfileId, thursday, "i-1", "Reading", 10, thursday);
+ await SeedCompletionAsync(userProfileId, thursday, "o-1", "Writing", 7, thursday.AddSeconds(5));
+
+ var client = ClientWithJwt(userProfileId);
+ var response = await client.GetAsync(
+ "/api/v1/activity-log?fromUtc=2026-03-30T00:00:00Z&toUtc=2026-04-05T23:59:59Z&filter=Input");
+ response.StatusCode.Should().Be(HttpStatusCode.OK);
+
+ var weeks = await response.Content.ReadFromJsonAsync>(JsonOptions);
+ var day = weeks!.SelectMany(w => w.Days).Single(d => d.Date == "2026-04-02");
+ day.Plans.Should().HaveCount(1);
+ day.Plans[0].Entries.Should().OnlyContain(e => e.Category == "Input");
+ day.Plans[0].Entries.Should().HaveCount(1);
+ }
+
+ [Fact]
+ public async Task GetActivityLog_PerUserIsolation()
+ {
+ var meId = $"actlog-me-{Guid.NewGuid():N}";
+ var otherId = $"actlog-other-{Guid.NewGuid():N}";
+ var friday = new DateTime(2026, 4, 3, 9, 0, 0, DateTimeKind.Utc);
+ await SeedCompletionAsync(meId, friday, "me-1", "Reading", 9, friday);
+ await SeedCompletionAsync(otherId, friday, "you-1", "Writing", 15, friday);
+
+ var client = ClientWithJwt(meId);
+ var response = await client.GetAsync(
+ "/api/v1/activity-log?fromUtc=2026-03-30T00:00:00Z&toUtc=2026-04-05T23:59:59Z");
+ response.StatusCode.Should().Be(HttpStatusCode.OK);
+
+ var weeks = await response.Content.ReadFromJsonAsync>(JsonOptions);
+ weeks!.SelectMany(w => w.Days)
+ .Where(d => d.HasActivity)
+ .SelectMany(d => d.Plans)
+ .SelectMany(p => p.Entries)
+ .Should().OnlyContain(e => e.ActivityType == "Reading",
+ "the other user's Writing entry must not appear");
+ }
+}
diff --git a/tests/SentenceStudio.Api.Tests/AdhocPlanEndpointsTests.cs b/tests/SentenceStudio.Api.Tests/AdhocPlanEndpointsTests.cs
new file mode 100644
index 00000000..f8c1e8c7
--- /dev/null
+++ b/tests/SentenceStudio.Api.Tests/AdhocPlanEndpointsTests.cs
@@ -0,0 +1,247 @@
+using System.Net;
+using System.Net.Http.Headers;
+using System.Net.Http.Json;
+using System.Text.Json;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+using SentenceStudio.Api.Tests.Infrastructure;
+using SentenceStudio.Contracts.Plans;
+using SentenceStudio.Data;
+
+namespace SentenceStudio.Api.Tests;
+
+///
+/// Integration tests for POST /api/v1/plans/adhoc/start. Reuses the
+/// Activity Log factory (same SQLite + JWT setup); the endpoint depends only
+/// on IUserScopeProvider and ApplicationDbContext.
+///
+public sealed class AdhocPlanEndpointsTests : IClassFixture
+{
+ private readonly ActivityLogApiFactory _factory;
+ private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
+
+ public AdhocPlanEndpointsTests(ActivityLogApiFactory factory)
+ {
+ _factory = factory;
+ }
+
+ private HttpClient ClientWithJwt(string userProfileId)
+ {
+ var token = TestJwtGenerator.GenerateToken(userProfileId: userProfileId);
+ var client = _factory.CreateClient();
+ client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
+ return client;
+ }
+
+ [Fact]
+ public async Task Start_NoJwt_Returns401()
+ {
+ var client = _factory.CreateClient();
+ var response = await client.PostAsJsonAsync("/api/v1/plans/adhoc/start", new AdhocStartRequest
+ {
+ ClientSessionId = Guid.NewGuid().ToString(),
+ ActivityType = "Translation",
+ });
+ response.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
+ }
+
+ [Fact]
+ public async Task Start_MissingClientSessionId_Returns400()
+ {
+ var client = ClientWithJwt($"adhoc-{Guid.NewGuid():N}");
+ var response = await client.PostAsJsonAsync("/api/v1/plans/adhoc/start", new AdhocStartRequest
+ {
+ ClientSessionId = null,
+ ActivityType = "Translation",
+ });
+ response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
+ }
+
+ [Fact]
+ public async Task Start_InvalidClientSessionId_Returns400()
+ {
+ var client = ClientWithJwt($"adhoc-{Guid.NewGuid():N}");
+ var response = await client.PostAsJsonAsync("/api/v1/plans/adhoc/start", new AdhocStartRequest
+ {
+ ClientSessionId = "not-a-uuid",
+ ActivityType = "Translation",
+ });
+ response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
+ }
+
+ [Fact]
+ public async Task Start_InvalidActivityType_Returns400()
+ {
+ var client = ClientWithJwt($"adhoc-{Guid.NewGuid():N}");
+ var response = await client.PostAsJsonAsync("/api/v1/plans/adhoc/start", new AdhocStartRequest
+ {
+ ClientSessionId = Guid.NewGuid().ToString(),
+ ActivityType = "NotARealActivity",
+ });
+ response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
+ }
+
+ [Fact]
+ public async Task Start_NonPositiveEstimatedMinutes_Returns400()
+ {
+ var client = ClientWithJwt($"adhoc-{Guid.NewGuid():N}");
+ var response = await client.PostAsJsonAsync("/api/v1/plans/adhoc/start", new AdhocStartRequest
+ {
+ ClientSessionId = Guid.NewGuid().ToString(),
+ ActivityType = "Translation",
+ EstimatedMinutes = 0,
+ });
+ response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
+ }
+
+ [Fact]
+ public async Task Start_ValidRequest_Returns201AndCreatesCompletion()
+ {
+ var userProfileId = $"adhoc-create-{Guid.NewGuid():N}";
+ var clientSessionId = Guid.NewGuid().ToString();
+ var client = ClientWithJwt(userProfileId);
+
+ var response = await client.PostAsJsonAsync("/api/v1/plans/adhoc/start", new AdhocStartRequest
+ {
+ ClientSessionId = clientSessionId,
+ ActivityType = "Translation",
+ EstimatedMinutes = 15,
+ });
+
+ response.StatusCode.Should().Be(HttpStatusCode.Created);
+ var body = await response.Content.ReadFromJsonAsync(JsonOptions);
+ body.Should().NotBeNull();
+ body!.PlanItemId.Should().StartWith("adhoc-");
+ body.PlanItemId.Should().Contain(clientSessionId);
+ body.ActivityType.Should().Be("Translation");
+ body.EstimatedMinutes.Should().Be(15);
+ body.Date.Should().MatchRegex(@"^\d{4}-\d{2}-\d{2}$");
+
+ // Row actually exists in the DB.
+ using var scope = _factory.Services.CreateScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+ var row = await db.DailyPlanCompletions
+ .SingleAsync(c => c.UserProfileId == userProfileId && c.PlanItemId == body.PlanItemId);
+ row.ActivityType.Should().Be("Translation");
+ row.EstimatedMinutes.Should().Be(15);
+ row.IsCompleted.Should().BeFalse();
+ row.MinutesSpent.Should().Be(0);
+ }
+
+ [Fact]
+ public async Task Start_OmittedEstimatedMinutes_DefaultsTo10()
+ {
+ var userProfileId = $"adhoc-default-{Guid.NewGuid():N}";
+ var client = ClientWithJwt(userProfileId);
+ var response = await client.PostAsJsonAsync("/api/v1/plans/adhoc/start", new AdhocStartRequest
+ {
+ ClientSessionId = Guid.NewGuid().ToString(),
+ ActivityType = "Reading",
+ });
+ response.StatusCode.Should().Be(HttpStatusCode.Created);
+ var body = await response.Content.ReadFromJsonAsync(JsonOptions);
+ body!.EstimatedMinutes.Should().Be(10);
+ }
+
+ [Fact]
+ public async Task Start_ReplayWithSameClientSessionId_Returns200AndSamePlanItemId()
+ {
+ var userProfileId = $"adhoc-replay-{Guid.NewGuid():N}";
+ var clientSessionId = Guid.NewGuid().ToString();
+ var client = ClientWithJwt(userProfileId);
+
+ var first = await client.PostAsJsonAsync("/api/v1/plans/adhoc/start", new AdhocStartRequest
+ {
+ ClientSessionId = clientSessionId,
+ ActivityType = "Writing",
+ EstimatedMinutes = 20,
+ });
+ first.StatusCode.Should().Be(HttpStatusCode.Created);
+ var firstBody = await first.Content.ReadFromJsonAsync(JsonOptions);
+
+ // Replay with same id (and even different EstimatedMinutes) — must
+ // not create a duplicate row, must return the same planItemId, and
+ // must return 200 OK (not 201).
+ var second = await client.PostAsJsonAsync("/api/v1/plans/adhoc/start", new AdhocStartRequest
+ {
+ ClientSessionId = clientSessionId,
+ ActivityType = "Writing",
+ EstimatedMinutes = 99,
+ });
+ second.StatusCode.Should().Be(HttpStatusCode.OK);
+ var secondBody = await second.Content.ReadFromJsonAsync(JsonOptions);
+ secondBody!.PlanItemId.Should().Be(firstBody!.PlanItemId);
+ // The first call's EstimatedMinutes wins (stored on the row).
+ secondBody.EstimatedMinutes.Should().Be(20);
+
+ using var scope = _factory.Services.CreateScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+ var rows = await db.DailyPlanCompletions
+ .Where(c => c.UserProfileId == userProfileId && c.PlanItemId == firstBody.PlanItemId)
+ .ToListAsync();
+ rows.Should().HaveCount(1, "idempotent replay must not insert a duplicate row");
+ }
+
+ [Fact]
+ public async Task Start_PlanItemIdUsableByProgressEndpoint()
+ {
+ var userProfileId = $"adhoc-progress-{Guid.NewGuid():N}";
+ var client = ClientWithJwt(userProfileId);
+ var response = await client.PostAsJsonAsync("/api/v1/plans/adhoc/start", new AdhocStartRequest
+ {
+ ClientSessionId = Guid.NewGuid().ToString(),
+ ActivityType = "Translation",
+ EstimatedMinutes = 10,
+ });
+ response.StatusCode.Should().Be(HttpStatusCode.Created);
+ var body = await response.Content.ReadFromJsonAsync(JsonOptions);
+
+ // POST progress against the existing /api/v1/plans/{date}/items/{id}/progress
+ // endpoint must find the synthetic ad-hoc row. PlanService.UpdateProgressAsync
+ // returns NoContent on success, NotFound when no row matches.
+ var progress = await client.PostAsJsonAsync(
+ $"/api/v1/plans/{body!.Date}/items/{body.PlanItemId}/progress",
+ new { minutesSpent = 5 });
+
+ progress.StatusCode.Should().BeOneOf(HttpStatusCode.NoContent, HttpStatusCode.OK);
+
+ using var scope = _factory.Services.CreateScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+ var row = await db.DailyPlanCompletions
+ .SingleAsync(c => c.UserProfileId == userProfileId && c.PlanItemId == body.PlanItemId);
+ row.MinutesSpent.Should().Be(5);
+ }
+
+ [Fact]
+ public async Task Start_PerUserIsolation_DifferentUsersSameClientSessionIdGetDistinctRows()
+ {
+ var sharedSessionId = Guid.NewGuid().ToString();
+ var userA = $"adhoc-userA-{Guid.NewGuid():N}";
+ var userB = $"adhoc-userB-{Guid.NewGuid():N}";
+
+ var clientA = ClientWithJwt(userA);
+ var clientB = ClientWithJwt(userB);
+
+ var aResp = await clientA.PostAsJsonAsync("/api/v1/plans/adhoc/start", new AdhocStartRequest
+ {
+ ClientSessionId = sharedSessionId,
+ ActivityType = "Reading",
+ });
+ var bResp = await clientB.PostAsJsonAsync("/api/v1/plans/adhoc/start", new AdhocStartRequest
+ {
+ ClientSessionId = sharedSessionId,
+ ActivityType = "Reading",
+ });
+
+ aResp.StatusCode.Should().Be(HttpStatusCode.Created);
+ bResp.StatusCode.Should().Be(HttpStatusCode.Created,
+ "user B has never used this clientSessionId; idempotency is scoped per user");
+
+ using var scope = _factory.Services.CreateScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+ var aRow = await db.DailyPlanCompletions.SingleAsync(c => c.UserProfileId == userA);
+ var bRow = await db.DailyPlanCompletions.SingleAsync(c => c.UserProfileId == userB);
+ aRow.PlanItemId.Should().Be(bRow.PlanItemId,
+ "deterministic id from same clientSessionId — collision is fine because UserProfileId differs");
+ }
+}
diff --git a/tests/SentenceStudio.Api.Tests/ConversationEndpointsTests.cs b/tests/SentenceStudio.Api.Tests/ConversationEndpointsTests.cs
new file mode 100644
index 00000000..494bb1f4
--- /dev/null
+++ b/tests/SentenceStudio.Api.Tests/ConversationEndpointsTests.cs
@@ -0,0 +1,273 @@
+using System.Net;
+using System.Net.Http.Headers;
+using System.Net.Http.Json;
+using System.Text.Json;
+using Microsoft.Extensions.DependencyInjection;
+using SentenceStudio.Api.Conversation;
+using SentenceStudio.Api.Tests.Infrastructure;
+using SentenceStudio.Contracts.Conversation;
+using SentenceStudio.Data;
+using SentenceStudio.Shared.Models;
+
+namespace SentenceStudio.Api.Tests;
+
+///
+/// Integration tests for the Conversation HTTP endpoints (spec 004).
+/// The Flutter client treats conversation_dtos.dart as the source of
+/// truth for the wire shape; these tests pin the contract.
+///
+public sealed class ConversationEndpointsTests : IClassFixture
+{
+ private readonly ConversationApiFactory _factory;
+ private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
+
+ public ConversationEndpointsTests(ConversationApiFactory factory)
+ {
+ _factory = factory;
+ }
+
+ private HttpClient ClientWithJwt(string userProfileId)
+ {
+ var token = TestJwtGenerator.GenerateToken(userProfileId: userProfileId);
+ var client = _factory.CreateClient();
+ client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
+ return client;
+ }
+
+ private async Task SeedScenarioAsync(
+ string name = "First Meeting",
+ ConversationType type = ConversationType.OpenEnded,
+ bool isPredefined = true)
+ {
+ using var scope = _factory.Services.CreateScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+ var scenario = new ConversationScenario
+ {
+ Name = name,
+ NameKorean = "첫 만남",
+ PersonaName = "Mr. Kim",
+ PersonaDescription = "a friendly stranger",
+ SituationDescription = "Meeting for the first time at a coffee shop.",
+ ConversationType = type,
+ QuestionBank = null,
+ IsPredefined = isPredefined,
+ };
+ db.ConversationScenarios.Add(scenario);
+ await db.SaveChangesAsync();
+ return scenario;
+ }
+
+ [Fact]
+ public async Task GetScenarios_NoJwt_Returns401()
+ {
+ var client = _factory.CreateClient();
+ var response = await client.GetAsync("/api/v1/conversation/scenarios");
+ response.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
+ }
+
+ [Fact]
+ public async Task GetScenarios_Authed_ReturnsSeededScenariosWithPascalCaseKeys()
+ {
+ await SeedScenarioAsync(name: $"PascalCase Smoke {Guid.NewGuid():N}");
+ var client = ClientWithJwt($"conv-scenarios-{Guid.NewGuid():N}");
+
+ var response = await client.GetAsync("/api/v1/conversation/scenarios");
+ response.StatusCode.Should().Be(HttpStatusCode.OK);
+
+ var raw = await response.Content.ReadAsStringAsync();
+ // Wire contract: scenario keys are PascalCase. A camelCase key like
+ // "personaName" indicates JSON serialization defaults leaked through.
+ raw.Should().Contain("\"PersonaName\"");
+ raw.Should().Contain("\"ConversationType\"");
+ raw.Should().Contain("\"IsPredefined\"");
+ raw.Should().NotContain("\"personaName\"");
+ raw.Should().NotContain("\"conversationType\"");
+
+ var scenarios = await response.Content.ReadFromJsonAsync>(JsonOptions);
+ scenarios.Should().NotBeNullOrEmpty();
+ scenarios!.Should().Contain(s => s.PersonaName == "Mr. Kim");
+ scenarios.Should().OnlyContain(s => s.ConversationType == "OpenEnded" || s.ConversationType == "Finite");
+ }
+
+ [Fact]
+ public async Task PostStart_NoJwt_Returns401()
+ {
+ var client = _factory.CreateClient();
+ var response = await client.PostAsJsonAsync(
+ "/api/v1/conversation/start",
+ new ConversationStartRequest { TargetLanguage = "ko" });
+ response.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
+ }
+
+ [Fact]
+ public async Task PostStart_KnownScenario_ReturnsOpeningMessage()
+ {
+ var scenario = await SeedScenarioAsync(name: $"Start OK {Guid.NewGuid():N}");
+ _factory.ConversationService.OpeningMessage = "안녕하세요! 반갑습니다.";
+
+ var client = ClientWithJwt($"conv-start-{Guid.NewGuid():N}");
+ var response = await client.PostAsJsonAsync("/api/v1/conversation/start",
+ new ConversationStartRequest { ScenarioId = scenario.Id, TargetLanguage = "ko" });
+
+ response.StatusCode.Should().Be(HttpStatusCode.OK);
+ var payload = await response.Content.ReadFromJsonAsync(JsonOptions);
+ payload.Should().NotBeNull();
+ payload!.FirstAssistantMessage.Should().Be("안녕하세요! 반갑습니다.");
+ payload.PersonaName.Should().Be("Mr. Kim");
+ payload.ConversationType.Should().Be("OpenEnded");
+
+ // Language normalization happens at the endpoint boundary.
+ _factory.ConversationService.LastTargetLanguageLabel.Should().Be("Korean");
+ _factory.ConversationService.LastScenario.Should().NotBeNull();
+ _factory.ConversationService.LastScenario!.Id.Should().Be(scenario.Id);
+ }
+
+ [Fact]
+ public async Task PostStart_UnknownScenario_Returns404()
+ {
+ var client = ClientWithJwt($"conv-start-404-{Guid.NewGuid():N}");
+ var response = await client.PostAsJsonAsync("/api/v1/conversation/start",
+ new ConversationStartRequest { ScenarioId = 999999, TargetLanguage = "ko" });
+
+ response.StatusCode.Should().Be(HttpStatusCode.NotFound);
+ }
+
+ [Fact]
+ public async Task PostContinue_OneTurnHistory_ReturnsExpectedWireShape()
+ {
+ var scenario = await SeedScenarioAsync(name: $"Continue OK {Guid.NewGuid():N}");
+ _factory.ConversationService.ContinueResponse = new ConversationContinueResponse
+ {
+ AssistantMessage = "네, 좋아요.",
+ ComprehensionScore = 92,
+ ComprehensionNotes = "Great natural phrasing.",
+ GrammarCorrections = new()
+ {
+ new ConversationGrammarCorrectionDto
+ {
+ Original = "나는 학생이에요",
+ Corrected = "저는 학생이에요",
+ Explanation = "Use 저 in polite speech.",
+ },
+ },
+ VocabularyAnalysis = new(),
+ IsComplete = false,
+ };
+
+ var client = ClientWithJwt($"conv-continue-{Guid.NewGuid():N}");
+ var response = await client.PostAsJsonAsync("/api/v1/conversation/continue",
+ new ConversationContinueRequest
+ {
+ ScenarioId = scenario.Id,
+ TargetLanguage = "ko",
+ History = new()
+ {
+ new ConversationHistoryItemDto
+ {
+ Role = "assistant", Author = "Mr. Kim", Text = "안녕하세요.",
+ },
+ new ConversationHistoryItemDto
+ {
+ Role = "user", Text = "안녕하세요, 저는 학생이에요.",
+ },
+ },
+ });
+
+ response.StatusCode.Should().Be(HttpStatusCode.OK);
+
+ var raw = await response.Content.ReadAsStringAsync();
+ // Wire contract: response keys are camelCase. Assert key presence to
+ // catch JSON serialization regressions early.
+ raw.Should().Contain("\"assistantMessage\"");
+ raw.Should().Contain("\"comprehensionScore\"");
+ raw.Should().Contain("\"comprehensionNotes\"");
+ raw.Should().Contain("\"grammarCorrections\"");
+ raw.Should().Contain("\"vocabularyAnalysis\"");
+ raw.Should().Contain("\"isComplete\"");
+ raw.Should().NotContain("\"AssistantMessage\"");
+ raw.Should().NotContain("\"ComprehensionScore\"");
+
+ var payload = await response.Content.ReadFromJsonAsync(JsonOptions);
+ payload.Should().NotBeNull();
+ payload!.AssistantMessage.Should().Be("네, 좋아요.");
+ payload.ComprehensionScore.Should().BeInRange(0, 100);
+ payload.ComprehensionScore.Should().Be(92);
+ payload.GrammarCorrections.Should().ContainSingle()
+ .Which.Corrected.Should().Be("저는 학생이에요");
+ payload.IsComplete.Should().BeFalse();
+
+ // Endpoint extracts the last entry as the user's new message; prior
+ // turns become conversation history for the service call.
+ _factory.ConversationService.LastUserMessage.Should().Be("안녕하세요, 저는 학생이에요.");
+ _factory.ConversationService.LastHistory.Should().ContainSingle()
+ .Which.Role.Should().Be("assistant");
+ }
+
+ [Fact]
+ public async Task PostContinue_EmptyHistory_Returns400()
+ {
+ var client = ClientWithJwt($"conv-continue-empty-{Guid.NewGuid():N}");
+ var response = await client.PostAsJsonAsync("/api/v1/conversation/continue",
+ new ConversationContinueRequest
+ {
+ ScenarioId = null,
+ TargetLanguage = "ko",
+ History = new(),
+ });
+
+ response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
+ }
+
+ [Fact]
+ public async Task PostContinue_LastTurnNotUser_Returns400()
+ {
+ var client = ClientWithJwt($"conv-continue-badlast-{Guid.NewGuid():N}");
+ var response = await client.PostAsJsonAsync("/api/v1/conversation/continue",
+ new ConversationContinueRequest
+ {
+ ScenarioId = null,
+ TargetLanguage = "ko",
+ History = new()
+ {
+ new ConversationHistoryItemDto
+ {
+ Role = "assistant", Text = "안녕하세요.",
+ },
+ },
+ });
+
+ response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
+ }
+
+ [Fact]
+ public async Task PostContinue_UnknownScenario_Returns404()
+ {
+ var client = ClientWithJwt($"conv-continue-404-{Guid.NewGuid():N}");
+ var response = await client.PostAsJsonAsync("/api/v1/conversation/continue",
+ new ConversationContinueRequest
+ {
+ ScenarioId = 999999,
+ TargetLanguage = "ko",
+ History = new()
+ {
+ new ConversationHistoryItemDto { Role = "user", Text = "안녕." },
+ },
+ });
+
+ response.StatusCode.Should().Be(HttpStatusCode.NotFound);
+ }
+
+ [Theory]
+ [InlineData(0.0, 0)]
+ [InlineData(0.5, 50)]
+ [InlineData(0.85, 85)]
+ [InlineData(1.0, 100)]
+ [InlineData(85.0, 85)] // grader returned a 0-100 magnitude — normalize, don't multiply.
+ [InlineData(100.0, 100)]
+ [InlineData(-0.5, 0)] // clamp below
+ [InlineData(150.0, 100)] // clamp above
+ public void NormalizeComprehensionScore_HandlesBothScales(double raw, int expected)
+ {
+ ServerConversationService.NormalizeComprehensionScore(raw).Should().Be(expected);
+ }
+}
diff --git a/tests/SentenceStudio.Api.Tests/ConversationScenarioSeederTests.cs b/tests/SentenceStudio.Api.Tests/ConversationScenarioSeederTests.cs
new file mode 100644
index 00000000..122b1849
--- /dev/null
+++ b/tests/SentenceStudio.Api.Tests/ConversationScenarioSeederTests.cs
@@ -0,0 +1,159 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging.Abstractions;
+using SentenceStudio.Api.Conversation;
+using SentenceStudio.Data;
+using SentenceStudio.Shared.Models;
+
+namespace SentenceStudio.Api.Tests;
+
+public class ConversationScenarioSeederTests : IDisposable
+{
+ private readonly ApplicationDbContext _db;
+
+ public ConversationScenarioSeederTests()
+ {
+ var options = new DbContextOptionsBuilder()
+ .UseSqlite("DataSource=:memory:")
+ .Options;
+ _db = new ApplicationDbContext(options);
+ _db.Database.OpenConnection();
+ _db.Database.EnsureCreated();
+ }
+
+ public void Dispose()
+ {
+ _db.Database.CloseConnection();
+ _db.Dispose();
+ }
+
+ private ConversationScenarioSeeder CreateSeeder() =>
+ new(_db, NullLogger.Instance);
+
+ [Fact]
+ public async Task SeedAsync_EmptyDatabase_InsertsAllPredefinedScenarios()
+ {
+ var expected = ConversationScenarioSeedData.GetPredefinedScenarios();
+
+ await CreateSeeder().SeedAsync();
+
+ var stored = await _db.ConversationScenarios.AsNoTracking().ToListAsync();
+ Assert.Equal(expected.Count, stored.Count);
+ Assert.All(stored, s => Assert.True(s.IsPredefined));
+ foreach (var seed in expected)
+ {
+ Assert.Contains(stored, s => s.Name == seed.Name && s.PersonaName == seed.PersonaName);
+ }
+ }
+
+ [Fact]
+ public async Task SeedAsync_RunTwice_IsIdempotent_NoDuplicates()
+ {
+ await CreateSeeder().SeedAsync();
+ var firstCount = await _db.ConversationScenarios.CountAsync();
+
+ // Detach so the second seeder reads fresh from DB
+ foreach (var entry in _db.ChangeTracker.Entries().ToList())
+ entry.State = EntityState.Detached;
+
+ await CreateSeeder().SeedAsync();
+
+ var secondCount = await _db.ConversationScenarios.CountAsync();
+ Assert.Equal(firstCount, secondCount);
+ Assert.Equal(ConversationScenarioSeedData.GetPredefinedScenarios().Count, secondCount);
+ }
+
+ [Fact]
+ public async Task SeedAsync_ExistingPredefinedWithStaleData_UpdatesInPlace()
+ {
+ var first = ConversationScenarioSeedData.GetPredefinedScenarios().First();
+
+ var stale = new ConversationScenario
+ {
+ Name = first.Name,
+ NameKorean = first.NameKorean,
+ PersonaName = "STALE",
+ PersonaDescription = "stale description",
+ SituationDescription = "stale situation",
+ ConversationType = first.ConversationType,
+ QuestionBank = "stale",
+ IsPredefined = true,
+ CreatedAt = new DateTime(2020, 1, 1, 0, 0, 0, DateTimeKind.Utc),
+ UpdatedAt = new DateTime(2020, 1, 1, 0, 0, 0, DateTimeKind.Utc)
+ };
+ _db.ConversationScenarios.Add(stale);
+ await _db.SaveChangesAsync();
+ var staleId = stale.Id;
+
+ foreach (var entry in _db.ChangeTracker.Entries().ToList())
+ entry.State = EntityState.Detached;
+
+ await CreateSeeder().SeedAsync();
+
+ var refreshed = await _db.ConversationScenarios.AsNoTracking().FirstAsync(s => s.Id == staleId);
+ Assert.Equal(first.PersonaName, refreshed.PersonaName);
+ Assert.Equal(first.PersonaDescription, refreshed.PersonaDescription);
+ Assert.Equal(first.SituationDescription, refreshed.SituationDescription);
+ Assert.Equal(first.QuestionBank, refreshed.QuestionBank);
+ Assert.True(refreshed.UpdatedAt > new DateTime(2020, 1, 1, 0, 0, 0, DateTimeKind.Utc));
+
+ var total = await _db.ConversationScenarios.CountAsync();
+ Assert.Equal(ConversationScenarioSeedData.GetPredefinedScenarios().Count, total);
+ }
+
+ [Fact]
+ public async Task SeedAsync_PartialState_FillsInMissingScenarios()
+ {
+ var seedList = ConversationScenarioSeedData.GetPredefinedScenarios();
+ var existing = seedList.First();
+ _db.ConversationScenarios.Add(new ConversationScenario
+ {
+ Name = existing.Name,
+ NameKorean = existing.NameKorean,
+ PersonaName = existing.PersonaName,
+ PersonaDescription = existing.PersonaDescription,
+ SituationDescription = existing.SituationDescription,
+ ConversationType = existing.ConversationType,
+ QuestionBank = existing.QuestionBank,
+ IsPredefined = true
+ });
+ await _db.SaveChangesAsync();
+
+ foreach (var entry in _db.ChangeTracker.Entries().ToList())
+ entry.State = EntityState.Detached;
+
+ await CreateSeeder().SeedAsync();
+
+ var total = await _db.ConversationScenarios.CountAsync();
+ Assert.Equal(seedList.Count, total);
+ }
+
+ [Fact]
+ public async Task SeedAsync_DoesNotTouchUserCreatedScenarios()
+ {
+ _db.ConversationScenarios.Add(new ConversationScenario
+ {
+ Name = "My Custom Scenario",
+ PersonaName = "Custom",
+ PersonaDescription = "custom",
+ SituationDescription = "custom",
+ ConversationType = ConversationType.OpenEnded,
+ IsPredefined = false
+ });
+ await _db.SaveChangesAsync();
+
+ foreach (var entry in _db.ChangeTracker.Entries().ToList())
+ entry.State = EntityState.Detached;
+
+ await CreateSeeder().SeedAsync();
+
+ var userCreated = await _db.ConversationScenarios
+ .AsNoTracking()
+ .Where(s => !s.IsPredefined)
+ .ToListAsync();
+ Assert.Single(userCreated);
+ Assert.Equal("My Custom Scenario", userCreated[0].Name);
+
+ var predefined = await _db.ConversationScenarios.CountAsync(s => s.IsPredefined);
+ Assert.Equal(ConversationScenarioSeedData.GetPredefinedScenarios().Count, predefined);
+ }
+}
diff --git a/tests/SentenceStudio.Api.Tests/Infrastructure/ActivityLogApiFactory.cs b/tests/SentenceStudio.Api.Tests/Infrastructure/ActivityLogApiFactory.cs
new file mode 100644
index 00000000..eff99f58
--- /dev/null
+++ b/tests/SentenceStudio.Api.Tests/Infrastructure/ActivityLogApiFactory.cs
@@ -0,0 +1,56 @@
+using Microsoft.AspNetCore.Authentication;
+using Microsoft.AspNetCore.Authentication.JwtBearer;
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.AspNetCore.Mvc.Testing;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+
+namespace SentenceStudio.Api.Tests.Infrastructure;
+
+///
+/// Test host for Activity Log endpoint tests. Uses the real
+/// so the
+/// test exercises the actual rollup logic against seeded SQLite data.
+///
+public class ActivityLogApiFactory : WebApplicationFactory
+{
+ private readonly string _dbPath = Path.Combine(Path.GetTempPath(),
+ $"sentencestudio_activitylog_{Guid.NewGuid():N}.db");
+
+ protected override void ConfigureWebHost(IWebHostBuilder builder)
+ {
+ builder.UseEnvironment("Development");
+ builder.UseSetting("ConnectionStrings:sentencestudio",
+ TestApiHostConfigurator.DummyPostgresConnectionString);
+ builder.UseSetting("Database:SkipMigrateOnStartup", "true");
+ builder.UseSetting("Auth:EnableDevAuthFallback", "false");
+ builder.UseSetting("Jwt:SigningKey", TestJwtGenerator.TestSigningKeyValue);
+ builder.UseSetting("Jwt:Issuer", TestJwtGenerator.TestIssuer);
+ builder.UseSetting("Jwt:Audience", TestJwtGenerator.TestAudience);
+
+ builder.ConfigureServices(services =>
+ {
+ TestApiHostConfigurator.ConfigureSqliteDatabaseAndSync(services, _dbPath);
+
+ services.Configure(options =>
+ {
+ options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
+ options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
+ });
+ });
+ }
+
+ protected override IHost CreateHost(IHostBuilder builder)
+ {
+ var host = base.CreateHost(builder);
+ TestApiHostConfigurator.InitializeSqliteDatabaseAndSync(host.Services);
+ return host;
+ }
+
+ protected override void Dispose(bool disposing)
+ {
+ base.Dispose(disposing);
+ if (File.Exists(_dbPath))
+ File.Delete(_dbPath);
+ }
+}
diff --git a/tests/SentenceStudio.Api.Tests/Infrastructure/ConversationApiFactory.cs b/tests/SentenceStudio.Api.Tests/Infrastructure/ConversationApiFactory.cs
new file mode 100644
index 00000000..96bd858e
--- /dev/null
+++ b/tests/SentenceStudio.Api.Tests/Infrastructure/ConversationApiFactory.cs
@@ -0,0 +1,64 @@
+using Microsoft.AspNetCore.Authentication;
+using Microsoft.AspNetCore.Authentication.JwtBearer;
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.AspNetCore.Mvc.Testing;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.DependencyInjection.Extensions;
+using Microsoft.Extensions.Hosting;
+using SentenceStudio.Api.Conversation;
+
+namespace SentenceStudio.Api.Tests.Infrastructure;
+
+///
+/// Test host for Conversation endpoint tests. Replaces the real
+/// with
+/// so tests never call OpenAI
+/// and can deterministically assert wire shape and routing behavior.
+///
+public class ConversationApiFactory : WebApplicationFactory
+{
+ private readonly string _dbPath = Path.Combine(Path.GetTempPath(),
+ $"sentencestudio_conversation_{Guid.NewGuid():N}.db");
+
+ public StubServerConversationService ConversationService { get; } = new();
+
+ protected override void ConfigureWebHost(IWebHostBuilder builder)
+ {
+ builder.UseEnvironment("Development");
+ builder.UseSetting("ConnectionStrings:sentencestudio",
+ TestApiHostConfigurator.DummyPostgresConnectionString);
+ builder.UseSetting("Database:SkipMigrateOnStartup", "true");
+ builder.UseSetting("Auth:EnableDevAuthFallback", "false");
+ builder.UseSetting("Jwt:SigningKey", TestJwtGenerator.TestSigningKeyValue);
+ builder.UseSetting("Jwt:Issuer", TestJwtGenerator.TestIssuer);
+ builder.UseSetting("Jwt:Audience", TestJwtGenerator.TestAudience);
+
+ builder.ConfigureServices(services =>
+ {
+ TestApiHostConfigurator.ConfigureSqliteDatabaseAndSync(services, _dbPath);
+
+ services.Configure(options =>
+ {
+ options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
+ options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
+ });
+
+ services.RemoveAll();
+ services.AddSingleton(ConversationService);
+ });
+ }
+
+ protected override IHost CreateHost(IHostBuilder builder)
+ {
+ var host = base.CreateHost(builder);
+ TestApiHostConfigurator.InitializeSqliteDatabaseAndSync(host.Services);
+ return host;
+ }
+
+ protected override void Dispose(bool disposing)
+ {
+ base.Dispose(disposing);
+ if (File.Exists(_dbPath))
+ File.Delete(_dbPath);
+ }
+}
diff --git a/tests/SentenceStudio.Api.Tests/Infrastructure/StubServerConversationService.cs b/tests/SentenceStudio.Api.Tests/Infrastructure/StubServerConversationService.cs
new file mode 100644
index 00000000..ce3dbbd8
--- /dev/null
+++ b/tests/SentenceStudio.Api.Tests/Infrastructure/StubServerConversationService.cs
@@ -0,0 +1,57 @@
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using SentenceStudio.Api.Conversation;
+using SentenceStudio.Contracts.Conversation;
+using SentenceStudio.Shared.Models;
+
+namespace SentenceStudio.Api.Tests.Infrastructure;
+
+///
+/// Stub implementation of used by the
+/// conversation endpoint tests so they never reach an LLM. Tests can inspect
+/// the last call's arguments and set canned responses.
+///
+public sealed class StubServerConversationService : IServerConversationService
+{
+ public ConversationScenario? LastScenario { get; private set; }
+ public string? LastUserMessage { get; private set; }
+ public List LastHistory { get; private set; } = new();
+ public string? LastTargetLanguageLabel { get; private set; }
+
+ public string OpeningMessage { get; set; } = "안녕하세요. 만나서 반갑습니다.";
+
+ public ConversationContinueResponse ContinueResponse { get; set; } = new()
+ {
+ AssistantMessage = "네, 그렇군요.",
+ ComprehensionScore = 85,
+ ComprehensionNotes = "Clear and natural.",
+ GrammarCorrections = new(),
+ VocabularyAnalysis = new(),
+ IsComplete = false,
+ };
+
+ public Task StartAsync(
+ ConversationScenario? scenario,
+ string targetLanguageLabel,
+ CancellationToken cancellationToken)
+ {
+ LastScenario = scenario;
+ LastTargetLanguageLabel = targetLanguageLabel;
+ return Task.FromResult(OpeningMessage);
+ }
+
+ public Task ContinueAsync(
+ string userMessage,
+ IReadOnlyList conversationHistory,
+ ConversationScenario? scenario,
+ string targetLanguageLabel,
+ CancellationToken cancellationToken)
+ {
+ LastUserMessage = userMessage;
+ LastHistory = new List(conversationHistory);
+ LastScenario = scenario;
+ LastTargetLanguageLabel = targetLanguageLabel;
+ return Task.FromResult(ContinueResponse);
+ }
+}