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); + } +}