diff --git a/.gitignore b/.gitignore index b217e1167..0e6cbd96d 100644 --- a/.gitignore +++ b/.gitignore @@ -551,4 +551,4 @@ $RECYCLE.BIN/ src/**/Migrations/* .claude - +plan \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index acac9e0d4..8579e4bab 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,7 +6,7 @@ An intelligent tutoring system for structured learning with knowledge and skill ## Architecture -27 projects organized as 5 domain modules, each with 4 layers, plus shared BuildingBlocks and host. +Projects organized as 6 domain modules, each with 4 layers, plus shared BuildingBlocks and host. **Layer Responsibilities:** - **API** - Public contracts, DTOs, internal service interfaces (what other modules can consume) @@ -37,7 +37,7 @@ An intelligent tutoring system for structured learning with knowledge and skill **Key Entities:** - **Course** - Top-level container (code, name, description, startDate, isArchived) -- **KnowledgeUnit** - Weekly learning unit within a course, contains KCs and Tasks +- **KnowledgeUnit** - Weekly learning unit within a course, contains Reflections, KCs, Tasks - **LearnerGroup** - Groups learners for easier management and monitoring - **WeeklyFeedback** - Instructor's weekly assessment of learner progress (Red/Yellow/Green semaphore + comment) - **Reflection** - Structured questions for learners to reflect on their learning @@ -60,11 +60,9 @@ An intelligent tutoring system for structured learning with knowledge and skill - **KnowledgeComponent** - Atomic learning objective (code, name, expectedDuration) - **AssessmentItem** - Questions to test understanding: MCQ (single choice), MRQ (multiple choice), SAQ (short answer) - **InstructionalItem** - Learning content: Text, Video, or Image with ordering -- **SessionTracker** - Manages a learner's session state for a KC - **Submission** - Learner's answer to an assessment item - **Evaluation** - Feedback on a submission (correct/incorrect, hints, explanations) -- **KCMastery** - Tracks whether a learner has mastered a KC -- **MoveOn Criteria** - Rules for when a KC is considered satisfied (Completed, Passed, CompletedAndPassed, CompletedOrPassed) +- **KcMastery** - Tracks whether a learner has mastered a KC **Use Cases:** - **Authoring**: Instructors create KCs with expected duration, add/reorder assessment items (MCQ/MRQ/SAQ with feedback patterns), add/reorder instructional items (text/video/image), clone KCs for reuse @@ -72,8 +70,6 @@ An intelligent tutoring system for structured learning with knowledge and skill - **Mastery**: System tracks completion (all items seen) and passing (sufficient correct answers), applies move-on criteria to determine if KC is satisfied, records mastery status - **Analytics**: Instructors view KC statistics (submission counts, correctness rates), system detects common misconceptions from wrong answer patterns, tracks most frequent errors per assessment -**Domain Events:** SessionLaunched, KCStarted, KCCompleted, KCPassed, KCSatisfied (used for analytics and cross-module notifications) - **Dependencies:** → Courses.API (for unit context) ### LearningTasks @@ -82,10 +78,7 @@ An intelligent tutoring system for structured learning with knowledge and skill **Key Entities:** - **LearningTask** - A practical exercise (name, description, maxPoints, isTemplate) - **Activity** - A step within a task, contains examples, guidance text, and submission requirements -- **StepProgress** - Tracks learner's progress on a single step (answer, submission time) - **TaskProgress** - Overall progress on a task (started, completed, graded status) -- **StandardEvaluation** - Instructor's grade and comment for a step -- **SubmissionFormat** - Defines how learners should submit (text, file upload, etc.) **Use Cases:** - **Authoring**: Instructors create tasks with multiple steps (activities), define examples with video walkthroughs, write guidance text for each step, specify submission format and point values, clone tasks as templates, move tasks between units @@ -93,8 +86,6 @@ An intelligent tutoring system for structured learning with knowledge and skill - **Progress**: System creates/updates task progress records, tracks which steps are completed, records submission timestamps and content - **Grading**: Instructors view learner submissions, grade individual steps with points and comments, view group summaries showing progress across all learners, bulk retrieve progress for a cohort -**Domain Events:** TaskOpened, TaskCompleted, TaskGraded, StepOpened, StepSubmitted, StepGraded, ExampleOpened, GuidanceOpened, VideoPlayed, VideoPaused, VideoFinished (for learning analytics) - **Dependencies:** → Courses.API (for unit context) ### LearningUtils @@ -105,7 +96,6 @@ An intelligent tutoring system for structured learning with knowledge and skill **Use Cases:** - **Note-taking**: Learners create notes while studying a unit, update note content, reorder notes, delete notes, retrieve all notes for a unit -- **Export**: Learners export their notes to a downloadable file format **Dependencies:** → Stakeholders.API (for learner context) @@ -155,7 +145,6 @@ Generic AI services available for module-specific features. Core defines abstrac - `IAiChatService` - Chat completions with `CompleteAsync` (returns full response) and `StreamAsync` (token streaming). Configure via `CompletionRequest` (messages, system prompt, temperature, max tokens). - `ITextEmbeddingService` - Convert text to vectors via `GenerateEmbeddingAsync` (single) or `GenerateEmbeddingsAsync` (batch). - `IVectorStore` - Store/search embeddings with custom metadata. Supports `UpsertAsync`, `SearchAsync` (cosine similarity with filters), `DeleteAsync`. Each module registers its own instance with `AddVectorStore()`. -- `IInputGuardrail` / `IOutputGuardrail` - Validate user input before LLM calls and LLM output before returning to users. Use `CompositeInputGuardrail` / `CompositeOutputGuardrail` to chain multiple validators. **Registration:** ```csharp @@ -225,7 +214,65 @@ When creating a DTO and matching domain object in a Module.Core project, look fo | `LoggingInterceptor` | Automatic logging of service call results | Cross-cutting logging concern | | `ProxiedServiceExtensions.AddProxiedScoped` | Register service with interceptors (e.g., logging) | Module DI registration | -# Coding Style +# Code generation guidelines + +## 1. Think Before Coding + +**Don't assume. Don't hide confusion. Surface tradeoffs.** + +Before implementing: +- State your assumptions explicitly. If uncertain, ask. +- If multiple interpretations exist, present them - don't pick silently. +- If a simpler approach exists, say so. Push back when warranted. +- If something is unclear, stop. Name what's confusing. Ask. + +## 2. Simplicity First + +**Minimum code that solves the problem. Nothing speculative.** + +- No features beyond what was asked. +- No abstractions for single-use code. +- No "flexibility" or "configurability" that wasn't requested. +- No error handling for impossible scenarios. +- If you write 200 lines and it could be 50, rewrite it. + +Ask yourself: "Would a senior engineer say this is overcomplicated?" If yes, simplify. + +## 3. Surgical Changes + +**Touch only what you must. Clean up only your own mess.** + +When editing existing code: +- Don't "improve" adjacent code, comments, or formatting. +- Don't refactor things that aren't broken. +- Match existing style, even if you'd do it differently. +- If you notice unrelated dead code, mention it - don't delete it. + +When your changes create orphans: +- Remove imports/variables/functions that YOUR changes made unused. +- Don't remove pre-existing dead code unless asked. + +The test: Every changed line should trace directly to the user's request. + +## 4. Goal-Driven Execution + +**Define success criteria. Loop until verified.** + +Transform tasks into verifiable goals: +- "Add validation" → "Write tests for invalid inputs, then make them pass" +- "Fix the bug" → "Write a test that reproduces it, then make it pass" +- "Refactor X" → "Ensure tests pass before and after" + +For multi-step tasks, state a brief plan: +``` +1. [Step] → verify: [check] +2. [Step] → verify: [check] +3. [Step] → verify: [check] +``` + +Strong success criteria let you loop independently. Weak criteria ("make it work") require constant clarification. + +# Style guidelines - Methods with 3 or less parameters should have their headers and invocations fit into one row. -- Methods with more than 3 parameters should have their headers and invocations separate into multiple rows, where each row should contain 2 or 3 parameters. +- Methods with more than 3 parameters should have their headers and invocations separate into multiple rows, where each row should contain 3 parameters. - Do not write method headers and invocations where one row is one parameter. \ No newline at end of file diff --git a/Clean CaDET Tutor.slnx b/Clean CaDET Tutor.slnx index 65c4d1f42..da3b349e0 100644 --- a/Clean CaDET Tutor.slnx +++ b/Clean CaDET Tutor.slnx @@ -17,6 +17,12 @@ + + + + + + diff --git a/src/BuildingBlocks/Tutor.BuildingBlocks.AI.Core/Agents/LlmCaller.cs b/src/BuildingBlocks/Tutor.BuildingBlocks.AI.Core/Agents/LlmCaller.cs new file mode 100644 index 000000000..4b62da3db --- /dev/null +++ b/src/BuildingBlocks/Tutor.BuildingBlocks.AI.Core/Agents/LlmCaller.cs @@ -0,0 +1,187 @@ +using System.Diagnostics; +using System.Runtime.CompilerServices; +using System.Text.Json; +using FluentResults; +using Microsoft.Extensions.Logging; +using Tutor.BuildingBlocks.AI.Core.Conversations; + +namespace Tutor.BuildingBlocks.AI.Core.Agents; + +public abstract class LlmCaller +{ + private const int MaxJsonAttempts = 2; + + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }; + + private readonly IAiChatService _chatService; + private readonly ITurnUsageTracker _usageTracker; + private readonly ILogger _logger; + + protected LlmCaller(IAiChatService chatService, ITurnUsageTracker usageTracker, ILogger logger) + { + _chatService = chatService; + _usageTracker = usageTracker; + _logger = logger; + } + + protected async Task> CompleteJsonAsync( + CompletionRequest request, string label, CancellationToken ct) where TResponse : class + { + var sw = Stopwatch.StartNew(); + var promptTokens = 0; + var completionTokens = 0; + var charCount = 0; + var attempts = 0; + var status = "failure"; + string? failureCategory = "transient"; + + try + { + for (var attempt = 0; attempt < MaxJsonAttempts; attempt++) + { + attempts = attempt + 1; + var completion = await _chatService.CompleteAsync(request, ct); + if (completion.IsFailed) + { + failureCategory = "transient"; + continue; + } + + promptTokens += completion.Value.Usage.PromptTokens; + completionTokens += completion.Value.Usage.CompletionTokens; + charCount += completion.Value.Content.Length; + + if (ShouldSkipRetry(completion.Value.FinishReason)) + { + _logger.LogWarning("{Label} skipping retry due to deterministic finish reason '{FinishReason}'.", + label, completion.Value.FinishReason); + failureCategory = "permanent"; + break; + } + + var parsed = TryDeserialize(completion.Value.Content, label); + if (parsed is null) + { + failureCategory = "parse"; + continue; + } + + status = "ok"; + failureCategory = null; + return parsed; + } + + return Result.Fail($"{label} failed."); + } + finally + { + sw.Stop(); + var level = status == "ok" ? LogLevel.Information : LogLevel.Warning; + _logger.Log(level, + "Agent={Agent} Status={Status} DurationMs={DurationMs} PromptTokens={PromptTokens} " + + "CompletionTokens={CompletionTokens} ResponseChars={ResponseChars} Attempts={Attempts} " + + "FailureCategory={FailureCategory}", + label, status, sw.ElapsedMilliseconds, + promptTokens, completionTokens, charCount, attempts, failureCategory); + } + } + + protected async IAsyncEnumerable StreamAsync( + CompletionRequest request, string label, [EnumeratorCancellation] CancellationToken ct) + { + var sw = Stopwatch.StartNew(); + var usageBefore = _usageTracker.Total; + var charCount = 0; + var status = "ok"; + string? failureCategory = null; + + try + { + var enumerator = _chatService.StreamAsync(request, ct).GetAsyncEnumerator(ct); + try + { + while (true) + { + string? token = null; + string? failure = null; + var moved = false; + + try + { + moved = await enumerator.MoveNextAsync(); + if (moved) token = enumerator.Current; + } + catch (OperationCanceledException) + { + status = "cancelled"; + failureCategory = "cancelled"; + throw; + } + catch (Exception ex) + { + failure = $"Streaming call failed: {ex.Message}"; + } + + if (failure != null) + { + status = "failure"; + failureCategory = "transient"; + yield return new StreamFailure(failure); + yield break; + } + if (!moved) break; + + if (!string.IsNullOrEmpty(token)) + { + charCount += token.Length; + yield return new StreamToken(token); + } + } + } + finally + { + await enumerator.DisposeAsync(); + } + + if (charCount == 0) + { + status = "empty"; + failureCategory = "empty"; + yield return new StreamFailure("Empty response from LLM."); + } + } + finally + { + sw.Stop(); + var delta = _usageTracker.Total.Subtract(usageBefore); + var level = status == "ok" ? LogLevel.Information : LogLevel.Warning; + _logger.Log(level, + "Agent={Agent} Status={Status} DurationMs={DurationMs} PromptTokens={PromptTokens} " + + "CompletionTokens={CompletionTokens} ResponseChars={ResponseChars} Attempts={Attempts} " + + "FailureCategory={FailureCategory}", + label, status, sw.ElapsedMilliseconds, + delta.PromptTokens, delta.CompletionTokens, charCount, 1, failureCategory); + } + } + + private static bool ShouldSkipRetry(string? finishReason) => + string.Equals(finishReason, "length", StringComparison.OrdinalIgnoreCase) + || string.Equals(finishReason, "max_tokens", StringComparison.OrdinalIgnoreCase) + || string.Equals(finishReason, "content_filter", StringComparison.OrdinalIgnoreCase); + + private TResponse? TryDeserialize(string json, string label) where TResponse : class + { + try + { + return JsonSerializer.Deserialize(json, JsonOptions); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "{Label} failed to parse LLM response.", label); + return null; + } + } +} diff --git a/src/BuildingBlocks/Tutor.BuildingBlocks.AI.Core/Agents/StreamOutput.cs b/src/BuildingBlocks/Tutor.BuildingBlocks.AI.Core/Agents/StreamOutput.cs new file mode 100644 index 000000000..bee836b27 --- /dev/null +++ b/src/BuildingBlocks/Tutor.BuildingBlocks.AI.Core/Agents/StreamOutput.cs @@ -0,0 +1,10 @@ +namespace Tutor.BuildingBlocks.AI.Core.Agents; + +/// +/// Output of a streaming agent call. Either a content token or a terminal failure. +/// +public abstract record StreamOutput; + +public sealed record StreamToken(string Content) : StreamOutput; + +public sealed record StreamFailure(string Reason) : StreamOutput; diff --git a/src/BuildingBlocks/Tutor.BuildingBlocks.AI.Core/Conversations/CompletionResponse.cs b/src/BuildingBlocks/Tutor.BuildingBlocks.AI.Core/Conversations/CompletionResponse.cs index 96eeca5e9..211b69e62 100644 --- a/src/BuildingBlocks/Tutor.BuildingBlocks.AI.Core/Conversations/CompletionResponse.cs +++ b/src/BuildingBlocks/Tutor.BuildingBlocks.AI.Core/Conversations/CompletionResponse.cs @@ -17,4 +17,7 @@ public record CompletionResponse public record TokenUsage(int PromptTokens, int CompletionTokens) { public int TotalTokens => PromptTokens + CompletionTokens; + + public TokenUsage Subtract(TokenUsage other) => + new(PromptTokens - other.PromptTokens, CompletionTokens - other.CompletionTokens); } diff --git a/src/BuildingBlocks/Tutor.BuildingBlocks.AI.Core/Conversations/ITurnUsageTracker.cs b/src/BuildingBlocks/Tutor.BuildingBlocks.AI.Core/Conversations/ITurnUsageTracker.cs new file mode 100644 index 000000000..2c7ead715 --- /dev/null +++ b/src/BuildingBlocks/Tutor.BuildingBlocks.AI.Core/Conversations/ITurnUsageTracker.cs @@ -0,0 +1,12 @@ +namespace Tutor.BuildingBlocks.AI.Core.Conversations; + +/// +/// Accumulates across every LLM call within one scope (typically one HTTP request / one conversation turn). +/// Implementations must be thread-safe. +/// +public interface ITurnUsageTracker +{ + void Add(TokenUsage usage); + + TokenUsage Total { get; } +} diff --git a/src/BuildingBlocks/Tutor.BuildingBlocks.AI.Infrastructure/AiServiceExtensions.cs b/src/BuildingBlocks/Tutor.BuildingBlocks.AI.Infrastructure/AiServiceExtensions.cs index ff1cafd2a..34831e344 100644 --- a/src/BuildingBlocks/Tutor.BuildingBlocks.AI.Infrastructure/AiServiceExtensions.cs +++ b/src/BuildingBlocks/Tutor.BuildingBlocks.AI.Infrastructure/AiServiceExtensions.cs @@ -30,7 +30,8 @@ public static IServiceCollection AddAIServices(this IServiceCollection services, var kernel = kernelBuilder.Build(); services.AddSingleton(kernel); - services.AddSingleton(); + services.AddScoped(); + services.AddScoped(); if (!string.IsNullOrWhiteSpace(configuration.EmbeddingModelId)) { diff --git a/src/BuildingBlocks/Tutor.BuildingBlocks.AI.Infrastructure/Conversations/SemanticKernelChatService.cs b/src/BuildingBlocks/Tutor.BuildingBlocks.AI.Infrastructure/Conversations/SemanticKernelChatService.cs index 947f68548..3e2afddf9 100644 --- a/src/BuildingBlocks/Tutor.BuildingBlocks.AI.Infrastructure/Conversations/SemanticKernelChatService.cs +++ b/src/BuildingBlocks/Tutor.BuildingBlocks.AI.Infrastructure/Conversations/SemanticKernelChatService.cs @@ -12,10 +12,12 @@ namespace Tutor.BuildingBlocks.AI.Infrastructure.Conversations; public class SemanticKernelChatService : IAiChatService { private readonly IChatCompletionService _chatCompletionService; + private readonly ITurnUsageTracker _usageTracker; - public SemanticKernelChatService(Kernel kernel) + public SemanticKernelChatService(Kernel kernel, ITurnUsageTracker usageTracker) { _chatCompletionService = kernel.GetRequiredService(); + _usageTracker = usageTracker; } public async Task> CompleteAsync(CompletionRequest request, CancellationToken cancellationToken = default) @@ -23,10 +25,11 @@ public async Task> CompleteAsync(CompletionRequest re try { var chatHistory = BuildChatHistory(request); - var executionSettings = BuildExecutionSettings(request); + var executionSettings = BuildExecutionSettings(request, streaming: false); var result = await _chatCompletionService.GetChatMessageContentAsync(chatHistory, executionSettings, cancellationToken: cancellationToken); - var usage = ExtractTokenUsage(result); + var usage = TryExtractTokenUsage(result.Metadata) ?? new TokenUsage(0, 0); + _usageTracker.Add(usage); return Result.Ok(new CompletionResponse { @@ -44,15 +47,24 @@ public async Task> CompleteAsync(CompletionRequest re public async IAsyncEnumerable StreamAsync(CompletionRequest request, [EnumeratorCancellation] CancellationToken cancellationToken = default) { var chatHistory = BuildChatHistory(request); - var executionSettings = BuildExecutionSettings(request); + var executionSettings = BuildExecutionSettings(request, streaming: true); - await foreach (var chunk in _chatCompletionService.GetStreamingChatMessageContentsAsync(chatHistory, executionSettings, cancellationToken: cancellationToken)) + TokenUsage? capturedUsage = null; + try { - if (!string.IsNullOrEmpty(chunk.Content)) + await foreach (var chunk in _chatCompletionService.GetStreamingChatMessageContentsAsync(chatHistory, executionSettings, cancellationToken: cancellationToken)) { - yield return chunk.Content; + var chunkUsage = TryExtractTokenUsage(chunk.Metadata); + if (chunkUsage is not null) capturedUsage = chunkUsage; + + if (!string.IsNullOrEmpty(chunk.Content)) + yield return chunk.Content; } } + finally + { + if (capturedUsage is not null) _usageTracker.Add(capturedUsage); + } } private static ChatHistory BuildChatHistory(CompletionRequest request) @@ -83,40 +95,36 @@ private static ChatHistory BuildChatHistory(CompletionRequest request) return chatHistory; } - private static PromptExecutionSettings? BuildExecutionSettings(CompletionRequest request) + private static PromptExecutionSettings? BuildExecutionSettings(CompletionRequest request, bool streaming) { - if (request.MaxTokens is null && request.Temperature is null) - { + if (!streaming && request.MaxTokens is null && request.Temperature is null) return null; - } - return new PromptExecutionSettings + var extensionData = new Dictionary { - ExtensionData = new Dictionary - { - ["max_tokens"] = request.MaxTokens ?? 4096, - ["temperature"] = request.Temperature ?? 0.7 - } + ["max_tokens"] = request.MaxTokens ?? 4096, + ["temperature"] = request.Temperature ?? 0.7 }; + + if (streaming) + extensionData["stream_options"] = new Dictionary { ["include_usage"] = true }; + + return new PromptExecutionSettings { ExtensionData = extensionData }; } - private static TokenUsage ExtractTokenUsage(ChatMessageContent result) + private static TokenUsage? TryExtractTokenUsage(IReadOnlyDictionary? metadata) { - var promptTokens = 0; - var completionTokens = 0; + if (metadata is null) return null; + if (!metadata.TryGetValue("Usage", out var usage) || usage is null) return null; - if (result.Metadata?.TryGetValue("Usage", out var usage) == true && usage is not null) - { - var usageType = usage.GetType(); - var inputTokensProperty = usageType.GetProperty("InputTokenCount") ?? usageType.GetProperty("PromptTokens"); - var outputTokensProperty = usageType.GetProperty("OutputTokenCount") ?? usageType.GetProperty("CompletionTokens"); - - if (inputTokensProperty?.GetValue(usage) is int input) - promptTokens = input; - if (outputTokensProperty?.GetValue(usage) is int output) - completionTokens = output; - } + var usageType = usage.GetType(); + var inputTokensProperty = usageType.GetProperty("InputTokenCount") ?? usageType.GetProperty("PromptTokens"); + var outputTokensProperty = usageType.GetProperty("OutputTokenCount") ?? usageType.GetProperty("CompletionTokens"); + + var promptTokens = inputTokensProperty?.GetValue(usage) is int input ? input : 0; + var completionTokens = outputTokensProperty?.GetValue(usage) is int output ? output : 0; + if (promptTokens == 0 && completionTokens == 0) return null; return new TokenUsage(promptTokens, completionTokens); } } diff --git a/src/BuildingBlocks/Tutor.BuildingBlocks.AI.Infrastructure/Conversations/TurnUsageTracker.cs b/src/BuildingBlocks/Tutor.BuildingBlocks.AI.Infrastructure/Conversations/TurnUsageTracker.cs new file mode 100644 index 000000000..eaab05b8a --- /dev/null +++ b/src/BuildingBlocks/Tutor.BuildingBlocks.AI.Infrastructure/Conversations/TurnUsageTracker.cs @@ -0,0 +1,30 @@ +using Tutor.BuildingBlocks.AI.Core.Conversations; + +namespace Tutor.BuildingBlocks.AI.Infrastructure.Conversations; + +public sealed class TurnUsageTracker : ITurnUsageTracker +{ + private readonly object _lock = new(); + private int _promptTokens; + private int _completionTokens; + + public void Add(TokenUsage usage) + { + lock (_lock) + { + _promptTokens += usage.PromptTokens; + _completionTokens += usage.CompletionTokens; + } + } + + public TokenUsage Total + { + get + { + lock (_lock) + { + return new TokenUsage(_promptTokens, _completionTokens); + } + } + } +} diff --git a/src/BuildingBlocks/Tutor.BuildingBlocks.Tests/BaseTestFactory.cs b/src/BuildingBlocks/Tutor.BuildingBlocks.Tests/BaseTestFactory.cs index ad959f956..1570a9aa5 100644 --- a/src/BuildingBlocks/Tutor.BuildingBlocks.Tests/BaseTestFactory.cs +++ b/src/BuildingBlocks/Tutor.BuildingBlocks.Tests/BaseTestFactory.cs @@ -1,4 +1,4 @@ -using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; @@ -12,37 +12,78 @@ namespace Tutor.BuildingBlocks.Tests; public abstract class BaseTestFactory : WebApplicationFactory where TDbContext : DbContext { + private static readonly object _schemaLock = new(); + + // Tracks which schemas have been dropped+recreated in this process to avoid + // redundant work across multiple factory instances (one per test class). + private static readonly HashSet _recreatedSchemas = new(); + protected override void ConfigureWebHost(IWebHostBuilder builder) { builder.ConfigureServices(services => { using var scope = BuildServiceProvider(services).CreateScope(); - var scopedServices = scope.ServiceProvider; + var scopedServices = scope.ServiceProvider; var db = scopedServices.GetRequiredService(); var logger = scopedServices.GetRequiredService>>(); - InitializeDatabase(db, "../../../TestData/", logger); + InitializeDatabase(db, scopedServices, logger); }); } - private static void InitializeDatabase(DbContext context, string scriptFolder, ILogger logger) + private void InitializeDatabase(DbContext context, IServiceProvider services, ILogger logger) { - try - { - context.Database.EnsureCreated(); - var databaseCreator = context.Database.GetService(); - databaseCreator.CreateTables(); - } - catch (Exception) + context.Database.EnsureCreated(); + + // Each factory drop+recreates only its OWN primary schema (TDbContext) to + // handle model changes. Dependency schemas are left alone — they are owned + // and recreated by their respective module's test process. This is important + // because dotnet test runs assemblies in parallel as separate processes + // sharing the same database. + foreach (var contextType in GetRequiredDbContextTypes()) { - // CreateTables throws an exception if the schema already exists. This is a workaround for multiple dbcontexts. + var ctx = (DbContext)services.GetRequiredService(contextType); + var schema = ctx.Model.GetDefaultSchema(); + + if (contextType == typeof(TDbContext)) + { + // Own schema: drop+recreate once per process to pick up model changes. + bool alreadyRecreated; + lock (_schemaLock) + { + alreadyRecreated = !_recreatedSchemas.Add(schema!); + } + + if (!alreadyRecreated && schema != null) + { + ctx.Database.ExecuteSqlRaw($"DROP SCHEMA IF EXISTS \"{schema}\" CASCADE"); + ctx.Database.GetService().CreateTables(); + } + } + else + { + // Dependency schema: create if absent, skip if it already exists. + try + { + ctx.Database.GetService().CreateTables(); + } + catch (Exception) + { + // Schema already exists — created by its owning module's test + // process or persisted from a previous test run. + } + } } try { - var scriptFiles = Directory.GetFiles(scriptFolder); - var script = string.Join('\n', scriptFiles.Select(File.ReadAllText)); - context.Database.ExecuteSqlRaw(script); + foreach (var folder in GetOrderedTestDataFolders()) + { + if (!Directory.Exists(folder)) continue; + var scriptFiles = Directory.GetFiles(folder).Order().ToArray(); + var script = string.Join('\n', scriptFiles.Select(File.ReadAllText)); + context.Database.ExecuteSqlRaw(script); + } } catch (Exception ex) { @@ -51,6 +92,22 @@ private static void InitializeDatabase(DbContext context, string scriptFolder, I } } + /// + /// Returns all DbContext types this factory needs tables for, in creation order. + /// Override in factories with cross-module test data dependencies to include + /// dependency contexts. Each type listed here must also be registered in + /// ReplaceNeededDbContexts so it points to the test database. + /// + protected virtual Type[] GetRequiredDbContextTypes() => [typeof(TDbContext)]; + + /// + /// Returns script folders in dependency order. Override in factories + /// with cross-module dependencies to include dependent modules' TestData + /// folders before the factory's own (e.g., Courses → own). + /// + protected virtual List GetOrderedTestDataFolders() => + ["../../../TestData/"]; + private ServiceProvider BuildServiceProvider(IServiceCollection services) { return ReplaceNeededDbContexts(services).BuildServiceProvider(); @@ -79,4 +136,4 @@ protected static string CreateConnectionString() var connectionString = $"Server={server};Port={port};Database={database};User ID={user};Password={password};Pooling={pooling};Include Error Detail=True"; return connectionString; } -} \ No newline at end of file +} diff --git a/src/Modules/Courses/Tutor.Courses.API/Dtos/TokenWallet/TokenSpendingDtos.cs b/src/Modules/Courses/Tutor.Courses.API/Dtos/TokenWallet/TokenSpendingDtos.cs index 5aef6f5d2..4f76d322d 100644 --- a/src/Modules/Courses/Tutor.Courses.API/Dtos/TokenWallet/TokenSpendingDtos.cs +++ b/src/Modules/Courses/Tutor.Courses.API/Dtos/TokenWallet/TokenSpendingDtos.cs @@ -7,7 +7,7 @@ public class TokenSpendingRequestDto public int UnitId { get; set; } public int PromptTokens { get; set; } public int CompletionTokens { get; set; } - public string FeatureType { get; set; } = string.Empty; // "Kc", "Task", "Reflection" + public string FeatureType { get; set; } = string.Empty; // "Kc", "Task", "Reflection", "Elaboration" public int? EntityId { get; set; } public string? PromptSummary { get; set; } } diff --git a/src/Modules/Courses/Tutor.Courses.API/Internal/ITokenSpendingService.cs b/src/Modules/Courses/Tutor.Courses.API/Internal/ITokenSpendingService.cs index c7615adcc..1ed211c24 100644 --- a/src/Modules/Courses/Tutor.Courses.API/Internal/ITokenSpendingService.cs +++ b/src/Modules/Courses/Tutor.Courses.API/Internal/ITokenSpendingService.cs @@ -17,4 +17,14 @@ public interface ITokenSpendingService /// Records token spending for a completed LLM call. /// Result SpendTokens(TokenSpendingRequestDto request); + + /// + /// Checks balance by resolving courseId from unitId internally. + /// + Result HasSufficientBalanceForUnit(int learnerId, int unitId, int totalCharacterCount); + + /// + /// Records token spending, resolving courseId from the request's UnitId. + /// + Result SpendTokensForUnit(TokenSpendingRequestDto request); } diff --git a/src/Modules/Courses/Tutor.Courses.Core/Domain/TokenWallet/AiFeatureType.cs b/src/Modules/Courses/Tutor.Courses.Core/Domain/TokenWallet/AiFeatureType.cs index 984432608..2617440ed 100644 --- a/src/Modules/Courses/Tutor.Courses.Core/Domain/TokenWallet/AiFeatureType.cs +++ b/src/Modules/Courses/Tutor.Courses.Core/Domain/TokenWallet/AiFeatureType.cs @@ -4,5 +4,6 @@ public enum AiFeatureType { Kc, Task, - Reflection + Reflection, + Elaboration } diff --git a/src/Modules/Courses/Tutor.Courses.Core/UseCases/Management/TokenSpendingService.cs b/src/Modules/Courses/Tutor.Courses.Core/UseCases/Management/TokenSpendingService.cs index 2a6467f79..4a01fcb43 100644 --- a/src/Modules/Courses/Tutor.Courses.Core/UseCases/Management/TokenSpendingService.cs +++ b/src/Modules/Courses/Tutor.Courses.Core/UseCases/Management/TokenSpendingService.cs @@ -3,6 +3,7 @@ using Tutor.BuildingBlocks.Core.UseCases; using Tutor.Courses.API.Dtos.TokenWallet; using Tutor.Courses.API.Internal; +using Tutor.Courses.Core.Domain; using Tutor.Courses.Core.Domain.RepositoryInterfaces; using Tutor.Courses.Core.Domain.TokenWallet; @@ -11,12 +12,16 @@ namespace Tutor.Courses.Core.UseCases.Management; public class TokenSpendingService : ITokenSpendingService { private readonly IWalletRepository _walletRepository; + private readonly ICrudRepository _unitRepository; private readonly ICoursesUnitOfWork _unitOfWork; private readonly IMapper _mapper; - public TokenSpendingService(IWalletRepository walletRepository, ICoursesUnitOfWork unitOfWork, IMapper mapper) + public TokenSpendingService(IWalletRepository walletRepository, + ICrudRepository unitRepository, + ICoursesUnitOfWork unitOfWork, IMapper mapper) { _walletRepository = walletRepository; + _unitRepository = unitRepository; _unitOfWork = unitOfWork; _mapper = mapper; } @@ -60,4 +65,25 @@ public Result SpendTokens(TokenSpendingRequestDto reques RemainingBalance = result.Value.RemainingBalance }; } + + public Result HasSufficientBalanceForUnit(int learnerId, int unitId, int totalCharacterCount) + { + var courseId = ResolveCourseId(unitId); + if (courseId == 0) return Result.Fail(FailureCode.NotFound + ": Unit not found"); + return HasSufficientBalance(learnerId, courseId, totalCharacterCount); + } + + public Result SpendTokensForUnit(TokenSpendingRequestDto request) + { + var courseId = ResolveCourseId(request.UnitId); + if (courseId == 0) return Result.Fail(FailureCode.NotFound + ": Unit not found"); + request.CourseId = courseId; + return SpendTokens(request); + } + + private int ResolveCourseId(int unitId) + { + var unit = _unitRepository.Get(unitId); + return unit?.CourseId ?? 0; + } } diff --git a/src/Modules/Courses/Tutor.Courses.Tests/CoursesTestFactory.cs b/src/Modules/Courses/Tutor.Courses.Tests/CoursesTestFactory.cs index 8f5495e2a..32c65204b 100644 --- a/src/Modules/Courses/Tutor.Courses.Tests/CoursesTestFactory.cs +++ b/src/Modules/Courses/Tutor.Courses.Tests/CoursesTestFactory.cs @@ -10,6 +10,18 @@ namespace Tutor.Courses.Tests; public class CoursesTestFactory : BaseTestFactory { + protected override Type[] GetRequiredDbContextTypes() => + [typeof(StakeholdersContext), typeof(CoursesContext), + typeof(KnowledgeComponentsContext), typeof(LearningTasksContext)]; + + protected override List GetOrderedTestDataFolders() => + [ + "../../../../../Stakeholders/Tutor.Stakeholders.Tests/TestData/", + "../../../../../KnowledgeComponents/Tutor.KnowledgeComponents.Tests/TestData/", + "../../../../../LearningTasks/Tutor.LearningTasks.Tests/TestData/", + "../../../TestData/" + ]; + protected override IServiceCollection ReplaceNeededDbContexts(IServiceCollection services) { var descriptor = services.SingleOrDefault(d => d.ServiceType == typeof(DbContextOptions)); diff --git a/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/ConceptElaborationTasks/CommonMisconceptionDto.cs b/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/ConceptElaborationTasks/CommonMisconceptionDto.cs new file mode 100644 index 000000000..e038daeba --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/ConceptElaborationTasks/CommonMisconceptionDto.cs @@ -0,0 +1,8 @@ +namespace Tutor.Elaborations.API.Dtos.ConceptElaborationTasks; + +public class CommonMisconceptionDto +{ + public string Key { get; set; } = string.Empty; + public string Description { get; set; } = string.Empty; + public string Correction { get; set; } = string.Empty; +} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/ConceptElaborationTasks/ConceptElaborationTaskDto.cs b/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/ConceptElaborationTasks/ConceptElaborationTaskDto.cs new file mode 100644 index 000000000..e5691bfe1 --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/ConceptElaborationTasks/ConceptElaborationTaskDto.cs @@ -0,0 +1,14 @@ +using Tutor.Elaborations.API.Dtos.Conversations; + +namespace Tutor.Elaborations.API.Dtos.ConceptElaborationTasks; + +public class ConceptElaborationTaskDto +{ + public int Id { get; set; } + public int UnitId { get; set; } + public int Order { get; set; } + public string Title { get; set; } = string.Empty; + public string Description { get; set; } = string.Empty; + public ConceptRecordDto? ConceptRecord { get; set; } + public List? Attempts { get; set; } +} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/ConceptElaborationTasks/ConceptRecordDto.cs b/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/ConceptElaborationTasks/ConceptRecordDto.cs new file mode 100644 index 000000000..3a02785e6 --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/ConceptElaborationTasks/ConceptRecordDto.cs @@ -0,0 +1,9 @@ +namespace Tutor.Elaborations.API.Dtos.ConceptElaborationTasks; + +public class ConceptRecordDto +{ + public string CanonicalDefinition { get; set; } = string.Empty; + public List KeyPropositions { get; set; } = new(); + public List CommonMisconceptions { get; set; } = new(); + public List KeyRelations { get; set; } = new(); +} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/ConceptElaborationTasks/KeyPropositionDto.cs b/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/ConceptElaborationTasks/KeyPropositionDto.cs new file mode 100644 index 000000000..bb316f5e1 --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/ConceptElaborationTasks/KeyPropositionDto.cs @@ -0,0 +1,7 @@ +namespace Tutor.Elaborations.API.Dtos.ConceptElaborationTasks; + +public class KeyPropositionDto +{ + public string Key { get; set; } = string.Empty; + public string Statement { get; set; } = string.Empty; +} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/ConceptElaborationTasks/KeyRelationDto.cs b/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/ConceptElaborationTasks/KeyRelationDto.cs new file mode 100644 index 000000000..d71e62265 --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/ConceptElaborationTasks/KeyRelationDto.cs @@ -0,0 +1,9 @@ +namespace Tutor.Elaborations.API.Dtos.ConceptElaborationTasks; + +public class KeyRelationDto +{ + public string Key { get; set; } = string.Empty; + public string SourceKey { get; set; } = string.Empty; + public string TargetKey { get; set; } = string.Empty; + public string Mechanism { get; set; } = string.Empty; +} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/ConceptElaborationTasks/LearnerElaborationSummaryDto.cs b/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/ConceptElaborationTasks/LearnerElaborationSummaryDto.cs new file mode 100644 index 000000000..24e28e8be --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/ConceptElaborationTasks/LearnerElaborationSummaryDto.cs @@ -0,0 +1,10 @@ +namespace Tutor.Elaborations.API.Dtos.ConceptElaborationTasks; + +public class LearnerElaborationSummaryDto +{ + public int Id { get; set; } + public int UnitId { get; set; } + public int Order { get; set; } + public string Title { get; set; } = string.Empty; + public bool HasCompletedAttempt { get; set; } +} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/Conversations/ConversationAttemptDto.cs b/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/Conversations/ConversationAttemptDto.cs new file mode 100644 index 000000000..8b85706ca --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/Conversations/ConversationAttemptDto.cs @@ -0,0 +1,12 @@ +namespace Tutor.Elaborations.API.Dtos.Conversations; + +public class ConversationAttemptDto +{ + public int Id { get; set; } + public int ConceptElaborationTaskId { get; set; } + public string Status { get; set; } = string.Empty; + public DateTime StartedAt { get; set; } + public DateTime? CompletedAt { get; set; } + public double? FinalGrade { get; set; } + public List Rounds { get; set; } = new(); +} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/Conversations/ConversationRoundDto.cs b/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/Conversations/ConversationRoundDto.cs new file mode 100644 index 000000000..28eaa8530 --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/Conversations/ConversationRoundDto.cs @@ -0,0 +1,9 @@ +namespace Tutor.Elaborations.API.Dtos.Conversations; + +public class ConversationRoundDto +{ + public int Order { get; set; } + public string ElaborationContent { get; set; } = string.Empty; + public DateTime SubmittedAt { get; set; } + public string? FeedbackContent { get; set; } +} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/Conversations/SubmitElaborationRequestDto.cs b/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/Conversations/SubmitElaborationRequestDto.cs new file mode 100644 index 000000000..cfed9b457 --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/Conversations/SubmitElaborationRequestDto.cs @@ -0,0 +1,6 @@ +namespace Tutor.Elaborations.API.Dtos.Conversations; + +public class SubmitElaborationRequestDto +{ + public string Elaboration { get; set; } = string.Empty; +} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/Conversations/SubmitElaborationResponseDto.cs b/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/Conversations/SubmitElaborationResponseDto.cs new file mode 100644 index 000000000..762af7bd5 --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.API/Dtos/Conversations/SubmitElaborationResponseDto.cs @@ -0,0 +1,7 @@ +namespace Tutor.Elaborations.API.Dtos.Conversations; + +public class SubmitElaborationResponseDto +{ + public int AttemptId { get; set; } + public string Status { get; set; } = string.Empty; +} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.API/Internal/IConceptElaborationTaskQuerier.cs b/src/Modules/Elaborations/Tutor.Elaborations.API/Internal/IConceptElaborationTaskQuerier.cs new file mode 100644 index 000000000..ed8455719 --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.API/Internal/IConceptElaborationTaskQuerier.cs @@ -0,0 +1,6 @@ +namespace Tutor.Elaborations.API.Internal; + +public interface IConceptElaborationTaskQuerier +{ + int CountByUnit(int unitId); +} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.API/Public/Authoring/IConceptElaborationTaskService.cs b/src/Modules/Elaborations/Tutor.Elaborations.API/Public/Authoring/IConceptElaborationTaskService.cs new file mode 100644 index 000000000..0ea7a197c --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.API/Public/Authoring/IConceptElaborationTaskService.cs @@ -0,0 +1,12 @@ +using FluentResults; +using Tutor.Elaborations.API.Dtos.ConceptElaborationTasks; + +namespace Tutor.Elaborations.API.Public.Authoring; + +public interface IConceptElaborationTaskService +{ + Result> GetByUnit(int unitId, int instructorId); + Result Create(ConceptElaborationTaskDto task, int instructorId); + Result Update(ConceptElaborationTaskDto task, int instructorId); + Result Delete(int id, int unitId, int instructorId); +} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.API/Public/IAccessServices.cs b/src/Modules/Elaborations/Tutor.Elaborations.API/Public/IAccessServices.cs new file mode 100644 index 000000000..5bf5a17d5 --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.API/Public/IAccessServices.cs @@ -0,0 +1,7 @@ +namespace Tutor.Elaborations.API.Public; + +public interface IAccessServices +{ + bool IsUnitOwner(int unitId, int instructorId); + bool IsEnrolledInUnit(int unitId, int learnerId); +} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.API/Public/Learning/IConversationService.cs b/src/Modules/Elaborations/Tutor.Elaborations.API/Public/Learning/IConversationService.cs new file mode 100644 index 000000000..06d6f293f --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.API/Public/Learning/IConversationService.cs @@ -0,0 +1,14 @@ +using FluentResults; +using Tutor.Elaborations.API.Dtos.ConceptElaborationTasks; +using Tutor.Elaborations.API.Dtos.Conversations; + +namespace Tutor.Elaborations.API.Public.Learning; + +public interface IConversationService +{ + Result> GetTasksForUnit(int unitId, int learnerId); + Result GetTaskWithAttempts(int taskId, int learnerId); + IAsyncEnumerable StartConversationAsync(int taskId, string elaboration, int learnerId, CancellationToken ct); + IAsyncEnumerable SubmitElaborationAsync(int attemptId, string elaboration, int learnerId, CancellationToken ct); + Result AbandonAttempt(int attemptId, int learnerId); +} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.API/Tutor.Elaborations.API.csproj b/src/Modules/Elaborations/Tutor.Elaborations.API/Tutor.Elaborations.API.csproj new file mode 100644 index 000000000..05a36137a --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.API/Tutor.Elaborations.API.csproj @@ -0,0 +1,13 @@ + + + + net10.0 + enable + enable + + + + + + + diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptElaborationTasks/CommonMisconception.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptElaborationTasks/CommonMisconception.cs new file mode 100644 index 000000000..442be8e77 --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptElaborationTasks/CommonMisconception.cs @@ -0,0 +1,29 @@ +using System.Text.Json.Serialization; +using Tutor.BuildingBlocks.Core.Domain; + +namespace Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; + +public class CommonMisconception : ValueObject +{ + [JsonPropertyName("key")] + public string Key { get; } + [JsonPropertyName("description")] + public string Description { get; } + [JsonPropertyName("correction")] + public string Correction { get; } + + [JsonConstructor] + public CommonMisconception(string key, string description, string correction) + { + Key = key; + Description = description; + Correction = correction; + } + + protected override IEnumerable GetEqualityComponents() + { + yield return Key; + yield return Description; + yield return Correction; + } +} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptElaborationTasks/ConceptElaborationTask.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptElaborationTasks/ConceptElaborationTask.cs new file mode 100644 index 000000000..d0979344a --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptElaborationTasks/ConceptElaborationTask.cs @@ -0,0 +1,33 @@ +using Tutor.BuildingBlocks.Core.Domain; + +namespace Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; + +public class ConceptElaborationTask : AggregateRoot +{ + public int UnitId { get; internal set; } + public int Order { get; private set; } + public string Title { get; private set; } = string.Empty; + public string Description { get; private set; } = string.Empty; + public ConceptRecord? ConceptRecord { get; private set; } + + private ConceptElaborationTask() { } + + public ConceptElaborationTask(int unitId, int order, string title, string description, ConceptRecord conceptRecord) + { + UnitId = unitId; + Order = order; + Title = title; + Description = description; + ConceptRecord = conceptRecord; + } + + public void Update(ConceptElaborationTask incoming) + { + if (incoming.ConceptRecord == null || ConceptRecord == null) + throw new InvalidOperationException("ConceptRecord cannot be null when updating."); + Title = incoming.Title; + Description = incoming.Description; + Order = incoming.Order; + ConceptRecord.Update(incoming.ConceptRecord); + } +} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptElaborationTasks/ConceptRecord.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptElaborationTasks/ConceptRecord.cs new file mode 100644 index 000000000..c2770e824 --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptElaborationTasks/ConceptRecord.cs @@ -0,0 +1,36 @@ +using Tutor.BuildingBlocks.Core.Domain; + +namespace Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; + +public class ConceptRecord : Entity +{ + public int ConceptElaborationTaskId { get; private set; } + public string CanonicalDefinition { get; private set; } = string.Empty; + public List KeyPropositions { get; private set; } = []; + public List CommonMisconceptions { get; private set; } = []; + public List KeyRelations { get; private set; } = []; + + private ConceptRecord() { } + + public ConceptRecord( + int conceptElaborationTaskId, string canonicalDefinition, + List keyPropositions, List commonMisconceptions, + List keyRelations) + { + ConceptElaborationTaskId = conceptElaborationTaskId; + CanonicalDefinition = canonicalDefinition; + KeyPropositions = keyPropositions; + CommonMisconceptions = commonMisconceptions; + KeyRelations = keyRelations; + } + + public void Update(ConceptRecord incoming) + { + CanonicalDefinition = incoming.CanonicalDefinition; + KeyPropositions = incoming.KeyPropositions; + CommonMisconceptions = incoming.CommonMisconceptions; + KeyRelations = incoming.KeyRelations; + } + + public int CountTargets() => KeyPropositions.Count + KeyRelations.Count; +} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptElaborationTasks/IConceptElaborationTaskRepository.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptElaborationTasks/IConceptElaborationTaskRepository.cs new file mode 100644 index 000000000..468e6244b --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptElaborationTasks/IConceptElaborationTaskRepository.cs @@ -0,0 +1,10 @@ +using Tutor.BuildingBlocks.Core.UseCases; + +namespace Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; + +public interface IConceptElaborationTaskRepository : ICrudRepository +{ + ConceptElaborationTask? GetWithRecord(int id); + List GetByUnit(int unitId); + List GetByUnitWithRecords(int unitId); +} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptElaborationTasks/KeyProposition.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptElaborationTasks/KeyProposition.cs new file mode 100644 index 000000000..a5ef0ccb9 --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptElaborationTasks/KeyProposition.cs @@ -0,0 +1,25 @@ +using System.Text.Json.Serialization; +using Tutor.BuildingBlocks.Core.Domain; + +namespace Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; + +public class KeyProposition : ValueObject +{ + [JsonPropertyName("key")] + public string Key { get; } + [JsonPropertyName("statement")] + public string Statement { get; } + + [JsonConstructor] + public KeyProposition(string key, string statement) + { + Key = key; + Statement = statement; + } + + protected override IEnumerable GetEqualityComponents() + { + yield return Key; + yield return Statement; + } +} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptElaborationTasks/KeyRelation.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptElaborationTasks/KeyRelation.cs new file mode 100644 index 000000000..eab60616d --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/ConceptElaborationTasks/KeyRelation.cs @@ -0,0 +1,33 @@ +using System.Text.Json.Serialization; +using Tutor.BuildingBlocks.Core.Domain; + +namespace Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; + +public class KeyRelation : ValueObject +{ + [JsonPropertyName("key")] + public string Key { get; } + [JsonPropertyName("sourceKey")] + public string SourceKey { get; } + [JsonPropertyName("targetKey")] + public string TargetKey { get; } + [JsonPropertyName("mechanism")] + public string Mechanism { get; } + + [JsonConstructor] + public KeyRelation(string key, string sourceKey, string targetKey, string mechanism) + { + Key = key; + SourceKey = sourceKey; + TargetKey = targetKey; + Mechanism = mechanism; + } + + protected override IEnumerable GetEqualityComponents() + { + yield return Key; + yield return SourceKey; + yield return TargetKey; + yield return Mechanism; + } +} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/AttemptStatus.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/AttemptStatus.cs new file mode 100644 index 000000000..46cd76d69 --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/AttemptStatus.cs @@ -0,0 +1,9 @@ +namespace Tutor.Elaborations.Core.Domain.Conversations; + +public enum AttemptStatus +{ + InProgress, + Completed, + Abandoned, + Expired +} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/ConversationAttempt.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/ConversationAttempt.cs new file mode 100644 index 000000000..3a3fb69cf --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/ConversationAttempt.cs @@ -0,0 +1,156 @@ +using Tutor.BuildingBlocks.Core.Domain; + +namespace Tutor.Elaborations.Core.Domain.Conversations; + +public class ConversationAttempt : AggregateRoot +{ + public int ConceptElaborationTaskId { get; private set; } + public int LearnerId { get; private set; } + public AttemptStatus Status { get; private set; } + public DateTime StartedAt { get; private set; } + public DateTime? CompletedAt { get; private set; } + public double FinalGrade { get; private set; } + public int TotalTargets { get; private set; } + public int MaxRounds { get; private set; } + private readonly List _rounds = new(); + public IReadOnlyList Rounds => _rounds.AsReadOnly(); + + private ConversationAttempt() { } + + public ConversationAttempt(int conceptElaborationTaskId, int learnerId, int totalTargets) + { + ConceptElaborationTaskId = conceptElaborationTaskId; + LearnerId = learnerId; + TotalTargets = totalTargets; + Status = AttemptStatus.InProgress; + StartedAt = DateTime.UtcNow; + MaxRounds = Math.Max(4, (int)Math.Ceiling(totalTargets / 3.0) + 2); + } + + public bool IsHardCapReached() => _rounds.Count >= MaxRounds; + + public bool IsStagnating() + { + var scores = _rounds + .TakeLast(3) + .Select(r => r.Evaluation.ComputeTotalScore()) + .ToList(); + if (scores.Count < 3) return false; + return scores[2] <= scores[1] && scores[1] <= scores[0]; + } + + public void BeginRound(string elaboration, RoundEvaluation evaluation) + { + _rounds.Add(new ConversationRound(_rounds.Count, elaboration, evaluation)); + FinalGrade = evaluation.ComputeGrade(TotalTargets); + } + + public void CompleteRound(string feedbackContent, IReadOnlyList probes) + { + _rounds[^1].Complete(feedbackContent, probes); + } + + public IReadOnlyList SelectProbes(int maxItems = 2) + { + var excludedProbes = GetExcludedProbes(); + var activeProbes = GetRecentActiveProbes(2); + var deficientTargets = _rounds[^1].Evaluation.GetDeficientTargets(excludedProbes); + var probes = new List(); + + probes.AddRange(CreateMomentumProbes(deficientTargets, activeProbes)); + if (probes.Count >= maxItems) return probes.Take(maxItems).ToList(); + + probes.AddRange(CreateStagnantProbes(deficientTargets, activeProbes)); + if (probes.Count >= maxItems) return probes.Take(maxItems).ToList(); + + probes.AddRange(CreateNewProbes(deficientTargets, activeProbes)); + + return probes.Take(maxItems).ToList(); + } + + private static List CreateMomentumProbes(List deficientTargets, List activeProbes) + { + var result = new List(); + foreach (var target in deficientTargets) + { + var related = activeProbes.Find(p => p.ScoredTarget.SameTarget(target)); + if (related?.ScoredTarget.Grade < target.Grade) + result.Add(new Probe(target, 0)); + } + return result; + } + + private static List CreateStagnantProbes(List deficientTargets, List activeProbes) + { + var result = new List(); + foreach (var target in deficientTargets) + { + var related = activeProbes.Find(p => p.ScoredTarget.SameTarget(target)); + if (related == null || related.ScoredTarget.Grade < target.Grade) continue; + result.Add(related.ScoredTarget.Grade == target.Grade + ? new Probe(target, related.StagnantCount + 1) + : new Probe(target, 0)); + } + return result; + } + + private static List CreateNewProbes(List deficientTargets, List activeProbes) + { + var result = new List(); + foreach (var target in deficientTargets) + { + if (activeProbes.Find(p => p.ScoredTarget.SameTarget(target)) == null) + result.Add(new Probe(target, 0)); + } + return result; + } + + private List GetRecentActiveProbes(int lookBack) + { + var result = new List(); + foreach (var round in _rounds.SkipLast(1).Reverse().Take(lookBack)) + { + foreach (var probe in round.Probes) + { + if (probe.IsStalled()) continue; + if (result.Any(p => p.ScoredTarget.SameTarget(probe.ScoredTarget))) continue; + result.Add(probe); + } + } + return result; + } + + private List GetExcludedProbes() + { + var result = new List(); + foreach (var round in _rounds.SkipLast(1).Reverse()) + { + foreach (var probe in round.Probes.Where(p => p.IsStalled())) + { + if (!result.Any(p => p.ScoredTarget.SameTarget(probe.ScoredTarget))) + result.Add(probe); + } + } + return result; + } + + public void Complete() + { + Status = AttemptStatus.Completed; + CompletedAt = DateTime.UtcNow; + } + + public void Abandon() + { + Status = AttemptStatus.Abandoned; + CompletedAt = DateTime.UtcNow; + } + + public void Expire() + { + Status = AttemptStatus.Expired; + CompletedAt = DateTime.UtcNow; + } + + public bool IsGoodEnough() => FinalGrade > 0.9; +} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/ConversationRound.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/ConversationRound.cs new file mode 100644 index 000000000..d94143ff9 --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/ConversationRound.cs @@ -0,0 +1,30 @@ +using Tutor.BuildingBlocks.Core.Domain; + +namespace Tutor.Elaborations.Core.Domain.Conversations; + +public class ConversationRound : Entity +{ + public int ConversationAttemptId { get; private set; } + public int Order { get; private set; } + public string ElaborationContent { get; private set; } = string.Empty; + public DateTime SubmittedAt { get; private set; } + public RoundEvaluation Evaluation { get; private set; } = null!; + public string? FeedbackContent { get; private set; } + public IReadOnlyList Probes { get; private set; } = []; + + private ConversationRound() { } + + internal ConversationRound(int order, string elaborationContent, RoundEvaluation evaluation) + { + Order = order; + ElaborationContent = elaborationContent; + SubmittedAt = DateTime.UtcNow; + Evaluation = evaluation; + } + + internal void Complete(string feedbackContent, IReadOnlyList probes) + { + FeedbackContent = feedbackContent; + Probes = probes; + } +} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/IConversationAttemptRepository.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/IConversationAttemptRepository.cs new file mode 100644 index 000000000..c00576bf0 --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/IConversationAttemptRepository.cs @@ -0,0 +1,11 @@ +using Tutor.BuildingBlocks.Core.UseCases; + +namespace Tutor.Elaborations.Core.Domain.Conversations; + +public interface IConversationAttemptRepository : ICrudRepository +{ + ConversationAttempt? GetActiveAttempt(int conceptElaborationTaskId, int learnerId); + List GetByTaskAndLearner(int conceptElaborationTaskId, int learnerId); + int CountRecentAttempts(int conceptElaborationTaskId, int learnerId, DateTime since); + HashSet GetTaskIdsWithCompletedAttempts(List taskIds, int learnerId); +} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/Probe.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/Probe.cs new file mode 100644 index 000000000..e80da78c4 --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/Probe.cs @@ -0,0 +1,6 @@ +namespace Tutor.Elaborations.Core.Domain.Conversations; + +public record Probe(ScoredTarget ScoredTarget, int StagnantCount) +{ + public bool IsStalled() => StagnantCount >= 2; +} \ No newline at end of file diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/RoundEvaluation.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/RoundEvaluation.cs new file mode 100644 index 000000000..4635c1f23 --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/RoundEvaluation.cs @@ -0,0 +1,36 @@ +using Tutor.BuildingBlocks.Core.Domain; + +namespace Tutor.Elaborations.Core.Domain.Conversations; + +public class RoundEvaluation : Entity +{ + public int ConversationRoundId { get; private set; } + public List Assessments { get; private set; } = []; + public List TriggeredMisconceptions { get; private set; } = []; + + private RoundEvaluation() { } + + public RoundEvaluation(List assessments, List triggeredMisconceptions) + { + Assessments = assessments; + TriggeredMisconceptions = triggeredMisconceptions; + } + + public int ComputeTotalScore() => Assessments.Sum(a => a.Grade); + + public double ComputeGrade(int totalTargets) + { + var normalizedScore = Assessments.Sum(a => a.Grade) / (2.0 * totalTargets); + return Math.Round(Math.Max(0.0, normalizedScore - (0.2 * TriggeredMisconceptions.Count)), 2); + } + + public List GetDeficientTargets(List excludedProbes) + { + var unfinishedTargets = Assessments.Where(a => a.Grade < 2); + + return unfinishedTargets.Concat(TriggeredMisconceptions) + .Where(a => excludedProbes.All(p => !p.ScoredTarget.SameTarget(a))) + .OrderBy(t => t.SeverityRank()) + .ToList(); + } +} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/ScoredTarget.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/ScoredTarget.cs new file mode 100644 index 000000000..3e8fb381f --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/ScoredTarget.cs @@ -0,0 +1,7 @@ +namespace Tutor.Elaborations.Core.Domain.Conversations; + +public record ScoredTarget(string Key, TargetType Type, int Grade, string Evidence = "") +{ + public bool SameTarget(ScoredTarget other) => Key == other.Key && Type == other.Type; + public int SeverityRank() => Grade switch { -2 => 0, -1 => 1, 1 => 2, _ => 3 }; +} \ No newline at end of file diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/TargetType.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/TargetType.cs new file mode 100644 index 000000000..183fcdb3f --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/Domain/Conversations/TargetType.cs @@ -0,0 +1,3 @@ +namespace Tutor.Elaborations.Core.Domain.Conversations; + +public enum TargetType { Proposition, Relation, Misconception } \ No newline at end of file diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/Mappers/ConceptElaborationTaskProfile.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/Mappers/ConceptElaborationTaskProfile.cs new file mode 100644 index 000000000..ae22fde73 --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/Mappers/ConceptElaborationTaskProfile.cs @@ -0,0 +1,25 @@ +using AutoMapper; +using Tutor.Elaborations.API.Dtos.ConceptElaborationTasks; +using Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; + +namespace Tutor.Elaborations.Core.Mappers; + +public class ConceptElaborationTaskProfile : Profile +{ + public ConceptElaborationTaskProfile() + { + CreateMap() + .ForMember(d => d.UnitId, opt => opt.Ignore()) + .ReverseMap() + .ForMember(d => d.Attempts, opt => opt.Ignore()); + + CreateMap() + .ForMember(d => d.ConceptElaborationTaskId, opt => opt.Ignore()) + .ForCtorParam("conceptElaborationTaskId", opt => opt.MapFrom(_ => 0)) + .ReverseMap(); + + CreateMap().ReverseMap(); + CreateMap().ReverseMap(); + CreateMap().ReverseMap(); + } +} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/Mappers/ConversationProfile.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/Mappers/ConversationProfile.cs new file mode 100644 index 000000000..d50f63457 --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/Mappers/ConversationProfile.cs @@ -0,0 +1,15 @@ +using AutoMapper; +using Tutor.Elaborations.API.Dtos.Conversations; +using Tutor.Elaborations.Core.Domain.Conversations; + +namespace Tutor.Elaborations.Core.Mappers; + +public class ConversationProfile : Profile +{ + public ConversationProfile() + { + CreateMap().ReverseMap() + .ForMember(d => d.Status, opt => opt.MapFrom(s => s.Status.ToString())); + CreateMap().ReverseMap(); + } +} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/Tutor.Elaborations.Core.csproj b/src/Modules/Elaborations/Tutor.Elaborations.Core/Tutor.Elaborations.Core.csproj new file mode 100644 index 000000000..67eca5bcd --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/Tutor.Elaborations.Core.csproj @@ -0,0 +1,20 @@ + + + + net10.0 + enable + enable + + + + + + + + + + + + + + diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/AccessServices.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/AccessServices.cs new file mode 100644 index 000000000..6cb35afbb --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/AccessServices.cs @@ -0,0 +1,27 @@ +using Tutor.Courses.API.Internal; +using Tutor.Elaborations.API.Public; + +namespace Tutor.Elaborations.Core.UseCases; + +public class AccessServices : IAccessServices +{ + private readonly IOwnershipValidator _ownershipValidator; + private readonly IEnrollmentValidator _enrollmentValidator; + + public AccessServices(IOwnershipValidator ownershipValidator, + IEnrollmentValidator enrollmentValidator) + { + _ownershipValidator = ownershipValidator; + _enrollmentValidator = enrollmentValidator; + } + + public bool IsUnitOwner(int unitId, int instructorId) + { + return _ownershipValidator.IsUnitOwner(unitId, instructorId); + } + + public bool IsEnrolledInUnit(int unitId, int learnerId) + { + return _enrollmentValidator.HasAccessibleEnrollment(unitId, learnerId); + } +} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Authoring/ConceptElaborationTaskService.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Authoring/ConceptElaborationTaskService.cs new file mode 100644 index 000000000..3863a78e2 --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Authoring/ConceptElaborationTaskService.cs @@ -0,0 +1,82 @@ +using AutoMapper; +using FluentResults; +using Tutor.BuildingBlocks.Core.UseCases; +using Tutor.Elaborations.API.Dtos.ConceptElaborationTasks; +using Tutor.Elaborations.API.Public; +using Tutor.Elaborations.API.Public.Authoring; +using Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; + +namespace Tutor.Elaborations.Core.UseCases.Authoring; + +public class ConceptElaborationTaskService : IConceptElaborationTaskService +{ + private readonly IConceptElaborationTaskRepository _taskRepository; + private readonly IAccessServices _accessServices; + private readonly IElaborationsUnitOfWork _unitOfWork; + private readonly IMapper _mapper; + + public ConceptElaborationTaskService( + IConceptElaborationTaskRepository taskRepository, IAccessServices accessServices, + IElaborationsUnitOfWork unitOfWork, IMapper mapper) + { + _taskRepository = taskRepository; + _accessServices = accessServices; + _unitOfWork = unitOfWork; + _mapper = mapper; + } + + public Result> GetByUnit(int unitId, int instructorId) + { + if (!_accessServices.IsUnitOwner(unitId, instructorId)) + return Result.Fail(FailureCode.Forbidden); + + var tasks = _taskRepository.GetByUnitWithRecords(unitId); + return Result.Ok(tasks.Select(t => _mapper.Map(t)).ToList()); + } + + public Result Create(ConceptElaborationTaskDto task, int instructorId) + { + if (!_accessServices.IsUnitOwner(task.UnitId, instructorId)) + return Result.Fail(FailureCode.Forbidden); + + var newTask = _mapper.Map(task); + newTask.UnitId = task.UnitId; + var created = _taskRepository.Create(newTask); + + var saveResult = _unitOfWork.Save(); + if (saveResult.IsFailed) return saveResult; + + return Result.Ok(_mapper.Map(created)); + } + + public Result Update(ConceptElaborationTaskDto task, int instructorId) + { + if (!_accessServices.IsUnitOwner(task.UnitId, instructorId)) + return Result.Fail(FailureCode.Forbidden); + + var existingTask = _taskRepository.GetWithRecord(task.Id); + if (existingTask == null || existingTask.UnitId != task.UnitId) + return Result.Fail(FailureCode.NotFound); + + existingTask.Update(_mapper.Map(task)); + _taskRepository.Update(existingTask); + + var saveResult = _unitOfWork.Save(); + if (saveResult.IsFailed) return saveResult; + + return Result.Ok(_mapper.Map(existingTask)); + } + + public Result Delete(int id, int unitId, int instructorId) + { + if (!_accessServices.IsUnitOwner(unitId, instructorId)) + return Result.Fail(FailureCode.Forbidden); + + var task = _taskRepository.Get(id); + if (task == null || task.UnitId != unitId) + return Result.Fail(FailureCode.NotFound); + + _taskRepository.Delete(task); + return _unitOfWork.Save(); + } +} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/IElaborationsUnitOfWork.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/IElaborationsUnitOfWork.cs new file mode 100644 index 000000000..0304129d3 --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/IElaborationsUnitOfWork.cs @@ -0,0 +1,5 @@ +using Tutor.BuildingBlocks.Core.UseCases; + +namespace Tutor.Elaborations.Core.UseCases; + +public interface IElaborationsUnitOfWork : IUnitOfWork { } diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/ConversationService.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/ConversationService.cs new file mode 100644 index 000000000..be9972bfe --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/ConversationService.cs @@ -0,0 +1,197 @@ +using System.Runtime.CompilerServices; +using System.Text.Json; +using AutoMapper; +using FluentResults; +using Tutor.BuildingBlocks.Core.UseCases; +using Tutor.Courses.API.Dtos.TokenWallet; +using Tutor.Courses.API.Internal; +using Tutor.Elaborations.API.Dtos.ConceptElaborationTasks; +using Tutor.Elaborations.API.Dtos.Conversations; +using Tutor.Elaborations.API.Public; +using Tutor.Elaborations.API.Public.Learning; +using Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; +using Tutor.Elaborations.Core.Domain.Conversations; +using Tutor.Elaborations.Core.UseCases.Learning.Orchestration; + +namespace Tutor.Elaborations.Core.UseCases.Learning; + +public class ConversationService : IConversationService +{ + private const int MaxAttemptsPerDay = 3; + + private readonly IConversationAttemptRepository _attemptRepo; + private readonly IConceptElaborationTaskRepository _taskRepo; + private readonly IAgentOrchestrator _orchestrator; + private readonly ITokenSpendingService _tokenSpendingService; + private readonly IAccessServices _accessServices; + private readonly IElaborationsUnitOfWork _unitOfWork; + private readonly IMapper _mapper; + + public ConversationService( + IConversationAttemptRepository attemptRepo, IConceptElaborationTaskRepository taskRepo, + IAgentOrchestrator orchestrator, ITokenSpendingService tokenSpendingService, + IAccessServices accessServices, IElaborationsUnitOfWork unitOfWork, IMapper mapper) + { + _attemptRepo = attemptRepo; + _taskRepo = taskRepo; + _orchestrator = orchestrator; + _tokenSpendingService = tokenSpendingService; + _accessServices = accessServices; + _unitOfWork = unitOfWork; + _mapper = mapper; + } + + public Result> GetTasksForUnit(int unitId, int learnerId) + { + if (!_accessServices.IsEnrolledInUnit(unitId, learnerId)) + return Result.Fail(FailureCode.Forbidden); + + var tasks = _taskRepo.GetByUnit(unitId); + var taskIds = tasks.Select(t => t.Id).ToList(); + var completedTaskIds = _attemptRepo.GetTaskIdsWithCompletedAttempts(taskIds, learnerId); + + return Result.Ok(tasks.Select(t => new LearnerElaborationSummaryDto + { + Id = t.Id, + UnitId = t.UnitId, + Order = t.Order, + Title = t.Title, + HasCompletedAttempt = completedTaskIds.Contains(t.Id) + }).ToList()); + } + + public Result GetTaskWithAttempts(int taskId, int learnerId) + { + var task = _taskRepo.Get(taskId); + if (task == null) return Result.Fail(FailureCode.NotFound); + + if (!_accessServices.IsEnrolledInUnit(task.UnitId, learnerId)) + return Result.Fail(FailureCode.Forbidden); + + var attempts = _attemptRepo.GetByTaskAndLearner(taskId, learnerId); + + var dto = _mapper.Map(task); + dto.Attempts = attempts.Select(a => _mapper.Map(a)).ToList(); + return Result.Ok(dto); + } + + public async IAsyncEnumerable StartConversationAsync(int taskId, string elaboration, int learnerId, + [EnumeratorCancellation] CancellationToken ct) + { + var (task, taskError) = ValidateTaskAccess(taskId, learnerId, elaboration); + if (taskError != null) { yield return taskError; yield break; } + + var existing = _attemptRepo.GetActiveAttempt(taskId, learnerId); + if (existing != null) + { + yield return BuildErrorChunk("An active conversation already exists.", 409, existing.Id); + yield break; + } + + var recentCount = _attemptRepo.CountRecentAttempts(taskId, learnerId, DateTime.UtcNow.AddHours(-24)); + if (recentCount >= MaxAttemptsPerDay) + { + yield return BuildErrorChunk("You've practiced this concept recently. Come back tomorrow for another attempt.", 429); + yield break; + } + + var attempt = new ConversationAttempt(taskId, learnerId, task!.ConceptRecord!.CountTargets()); + _attemptRepo.Create(attempt); + _unitOfWork.Save(); + + await foreach (var token in RunSubmissionPipelineAsync(attempt, task, elaboration, ct)) + yield return token; + } + + public async IAsyncEnumerable SubmitElaborationAsync(int attemptId, string elaboration, int learnerId, + [EnumeratorCancellation] CancellationToken ct) + { + var attempt = _attemptRepo.Get(attemptId); + if (attempt == null) { yield return BuildErrorChunk("Attempt not found.", 404); yield break; } + if (attempt.LearnerId != learnerId) { yield return BuildErrorChunk("Access denied.", 403); yield break; } + if (attempt.Status != AttemptStatus.InProgress) + { + yield return BuildErrorChunk("Conversation is no longer active.", 409); + yield break; + } + + var (task, taskError) = ValidateTaskAccess(attempt.ConceptElaborationTaskId, learnerId, elaboration); + if (taskError != null) { yield return taskError; yield break; } + + await foreach (var token in RunSubmissionPipelineAsync(attempt, task!, elaboration, ct)) + yield return token; + } + + public Result AbandonAttempt(int attemptId, int learnerId) + { + var attempt = _attemptRepo.Get(attemptId); + if (attempt == null) return Result.Fail(FailureCode.NotFound); + if (attempt.LearnerId != learnerId) return Result.Fail(FailureCode.Forbidden); + if (attempt.Status != AttemptStatus.InProgress) + return Result.Fail(FailureCode.Conflict); + + attempt.Abandon(); + _attemptRepo.Update(attempt); + _unitOfWork.Save(); + + return Result.Ok(_mapper.Map(attempt)); + } + + private async IAsyncEnumerable RunSubmissionPipelineAsync(ConversationAttempt attempt, + ConceptElaborationTask task, string elaboration, [EnumeratorCancellation] CancellationToken ct) + { + if (task.ConceptRecord == null) { yield return BuildErrorChunk("Concept record missing.", 500); yield break; } + + await foreach (var chunk in _orchestrator.ProcessSubmissionAsync(task.ConceptRecord, attempt, elaboration, ct)) + { + switch (chunk) + { + case TokenChunk token: + yield return token.Token; + break; + + case ErrorChunk error: + yield return BuildErrorChunk(error.Message, error.Code); + yield break; + + case FinalChunk final: + _unitOfWork.Save(); + _tokenSpendingService.SpendTokensForUnit(new TokenSpendingRequestDto + { + LearnerId = attempt.LearnerId, + UnitId = task.UnitId, + PromptTokens = final.Usage.PromptTokens, + CompletionTokens = final.Usage.CompletionTokens, + FeatureType = "Elaboration", + EntityId = task.Id, + PromptSummary = $"Elaboration submission for attempt: {final.AttemptId}" + }); + yield return JsonSerializer.Serialize(new SubmitElaborationResponseDto + { + AttemptId = final.AttemptId, + Status = final.Status.ToString() + }); + yield break; + } + } + } + + private (ConceptElaborationTask? Task, string? Error) ValidateTaskAccess(int taskId, int learnerId, string elaboration) + { + var task = _taskRepo.GetWithRecord(taskId); + if (task == null) return (null, BuildErrorChunk("Task not found.", 404)); + if (!_accessServices.IsEnrolledInUnit(task.UnitId, learnerId)) + return (null, BuildErrorChunk("Not enrolled in unit.", 403)); + var balanceCheck = _tokenSpendingService.HasSufficientBalanceForUnit(learnerId, task.UnitId, elaboration.Length); + if (balanceCheck.IsFailed) + return (null, BuildErrorChunk("Insufficient token balance. Contact your administrator.", 402)); + return (task, null); + } + + private static string BuildErrorChunk(string message, int code, int? attemptId = null) + { + if (attemptId.HasValue) + return JsonSerializer.Serialize(new { error = message, code, attemptId = attemptId.Value }); + return JsonSerializer.Serialize(new { error = message, code }); + } +} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/AgentOrchestrator.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/AgentOrchestrator.cs new file mode 100644 index 000000000..d8e0e8378 --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/AgentOrchestrator.cs @@ -0,0 +1,104 @@ +using System.Runtime.CompilerServices; +using System.Text; +using FluentResults; +using Microsoft.Extensions.Logging; +using Tutor.BuildingBlocks.AI.Core.Agents; +using Tutor.BuildingBlocks.AI.Core.Conversations; +using Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; +using Tutor.Elaborations.Core.Domain.Conversations; +using Tutor.Elaborations.Core.UseCases.Learning.Prompts; + +namespace Tutor.Elaborations.Core.UseCases.Learning.Orchestration; + +public class AgentOrchestrator : LlmCaller, IAgentOrchestrator +{ + private readonly ITurnUsageTracker _usageTracker; + private readonly ILogger _logger; + + public AgentOrchestrator(IAiChatService chatService, ITurnUsageTracker usageTracker, + ILogger logger) : base(chatService, usageTracker, logger) + { + _usageTracker = usageTracker; + _logger = logger; + } + + public async IAsyncEnumerable ProcessSubmissionAsync( + ConceptRecord record, ConversationAttempt attempt, string elaboration, + [EnumeratorCancellation] CancellationToken ct) + { + using var scope = _logger.BeginScope(new Dictionary + { + ["AttemptId"] = attempt.Id, + ["RoundCount"] = attempt.Rounds.Count + }); + + var scoreResult = await ScoreElaborationAsync(record, elaboration, ct); + if (scoreResult.IsFailed) + { + yield return new ErrorChunk("Scoring failed.", 500); + yield break; + } + var evaluation = scoreResult.Value; + + attempt.BeginRound(elaboration, evaluation); + + if (attempt.IsGoodEnough()) + { + attempt.Complete(); + yield return CreateFinalChunk(attempt); + yield break; + } + + if (attempt.IsHardCapReached()) + { + attempt.Expire(); + yield return new TokenChunk(SystemTurnCodes.ExpiredNotice); + yield return CreateFinalChunk(attempt); + yield break; + } + + var probes = attempt.SelectProbes(); + var fullResponse = new StringBuilder(); + await foreach (var chunk in StreamAgentAsync( + LlmRequestFactory.ForEvaluationFeedback(record, elaboration, probes), "EvaluationFeedback", fullResponse, ct)) + { + yield return chunk; + if (chunk is ErrorChunk) yield break; + } + + if (attempt.IsStagnating()) + { + fullResponse.Append(SystemTurnCodes.StagnationRedirect); + yield return new TokenChunk(SystemTurnCodes.StagnationRedirect); + } + + attempt.CompleteRound(fullResponse.ToString(), probes); + yield return CreateFinalChunk(attempt); + } + + private async IAsyncEnumerable StreamAgentAsync(CompletionRequest request, + string label, StringBuilder output, [EnumeratorCancellation] CancellationToken ct) + { + StreamFailure? failure = null; + await foreach (var chunk in StreamAsync(request, label, ct)) + { + if (chunk is StreamFailure f) { failure = f; break; } + var content = ((StreamToken)chunk).Content; + output.Append(content); + yield return new TokenChunk(content); + } + if (failure != null) + yield return new ErrorChunk(failure.Reason, 500); + } + + private async Task> ScoreElaborationAsync(ConceptRecord record, string elaboration, CancellationToken ct) + { + var result = await CompleteJsonAsync( + LlmRequestFactory.ForElaborationScoring(record, elaboration), "ElaborationScoring", ct); + if (result.IsFailed) return Result.Fail(result.Errors); + return result.Value.ToEvaluation(record); + } + + private FinalChunk CreateFinalChunk(ConversationAttempt attempt) + => new(attempt.Id, attempt.Status, attempt.FinalGrade, _usageTracker.Total); +} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/IAgentOrchestrator.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/IAgentOrchestrator.cs new file mode 100644 index 000000000..03d4fa2f4 --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/IAgentOrchestrator.cs @@ -0,0 +1,10 @@ +using Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; +using Tutor.Elaborations.Core.Domain.Conversations; + +namespace Tutor.Elaborations.Core.UseCases.Learning.Orchestration; + +public interface IAgentOrchestrator +{ + IAsyncEnumerable ProcessSubmissionAsync( + ConceptRecord record, ConversationAttempt attempt, string elaboration, CancellationToken ct); +} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/OrchestratorChunk.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/OrchestratorChunk.cs new file mode 100644 index 000000000..98f5fe624 --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/OrchestratorChunk.cs @@ -0,0 +1,16 @@ +using Tutor.BuildingBlocks.AI.Core.Conversations; +using Tutor.Elaborations.Core.Domain.Conversations; + +namespace Tutor.Elaborations.Core.UseCases.Learning.Orchestration; + +public abstract record OrchestratorChunk; + +public sealed record TokenChunk(string Token) : OrchestratorChunk; + +public sealed record FinalChunk( + int AttemptId, + AttemptStatus Status, + double? FinalGrade, + TokenUsage Usage) : OrchestratorChunk; + +public sealed record ErrorChunk(string Message, int Code) : OrchestratorChunk; diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/SystemTurnCodes.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/SystemTurnCodes.cs new file mode 100644 index 000000000..f452cb195 --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Orchestration/SystemTurnCodes.cs @@ -0,0 +1,7 @@ +namespace Tutor.Elaborations.Core.UseCases.Learning.Orchestration; + +public static class SystemTurnCodes +{ + public const string ExpiredNotice = "EXPIRED\n"; + public const string StagnationRedirect = "STAGNATION_REDIRECT\n"; +} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/ConceptRubricSection.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/ConceptRubricSection.cs new file mode 100644 index 000000000..5c19b77cb --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/ConceptRubricSection.cs @@ -0,0 +1,47 @@ +using System.Text; +using Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; + +namespace Tutor.Elaborations.Core.UseCases.Learning.Prompts; + +/// +/// Renders the concept rubric (KPs, CMs, KRs) as a markdown block. +/// Output is byte-stable for a given so the whole block +/// can live at the top of every agent's system prompt and serve as a shared provider-side cache prefix. +/// No per-turn state (coverage markers, soft-cap flags, progress) is rendered here. +/// +public static class ConceptRubricSection +{ + public static string Render(ConceptRecord record) + { + var sb = new StringBuilder(); + + sb.AppendLine("# Concept"); + sb.AppendLine(); + sb.AppendLine("## Key Propositions"); + foreach (var kp in record.KeyPropositions) + sb.AppendLine($"- [{kp.Key}] {kp.Statement}"); + sb.AppendLine(); + + if (record.KeyRelations.Count > 0) + { + sb.AppendLine("## Key Relations"); + var kpByKey = record.KeyPropositions.ToDictionary(kp => kp.Key, kp => kp.Statement); + foreach (var kr in record.KeyRelations) + { + var source = kpByKey.GetValueOrDefault(kr.SourceKey, kr.SourceKey); + var target = kpByKey.GetValueOrDefault(kr.TargetKey, kr.TargetKey); + sb.AppendLine($"- [{kr.Key}] {source} → {target}. Mechanism: {kr.Mechanism}"); + } + } + + if (record.CommonMisconceptions.Count > 0) + { + sb.AppendLine("## Common Misconceptions"); + foreach (var cm in record.CommonMisconceptions) + sb.AppendLine($"- [{cm.Key}] {cm.Description} — correction: {cm.Correction}"); + sb.AppendLine(); + } + + return sb.ToString().TrimEnd() + "\n"; + } +} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/EvaluationFeedbackPrompt.md b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/EvaluationFeedbackPrompt.md new file mode 100644 index 000000000..e69de29bb diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/LlmRequestFactory.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/LlmRequestFactory.cs new file mode 100644 index 000000000..ba62e4050 --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/LlmRequestFactory.cs @@ -0,0 +1,61 @@ +using System.Text; +using Tutor.BuildingBlocks.AI.Core.Conversations; +using Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; +using Tutor.Elaborations.Core.Domain.Conversations; + +namespace Tutor.Elaborations.Core.UseCases.Learning.Prompts; + +public static class LlmRequestFactory +{ + private static readonly string ScoreTemplate = LoadTemplate("ScorePrompt.md"); + private static readonly string EvaluationFeedbackTemplate = LoadTemplate("EvaluationFeedbackPrompt.md"); + + public static CompletionRequest ForElaborationScoring(ConceptRecord record, string elaboration) + { + var messages = new List { ChatMessage.FromUser($"{elaboration}") }; + return CompletionRequest.Create(messages, ScoreTemplate + "\n" + ConceptRubricSection.Render(record), maxTokens: 1024, temperature: 0.0); + } + + public static CompletionRequest ForEvaluationFeedback(ConceptRecord record, string elaboration, + IReadOnlyList probes) + { + var messages = new List { ChatMessage.FromUser(RenderFeedbackInput(elaboration, probes)) }; + return CompletionRequest.Create(messages, EvaluationFeedbackTemplate + "\n" + ConceptRubricSection.Render(record), maxTokens: 512, temperature: 0.7); + } + + private static string RenderFeedbackInput(string elaboration, IReadOnlyList probes) + { + var sb = new StringBuilder(); + sb.AppendLine($"{elaboration}"); + + var misconceptions = probes.Where(p => p.ScoredTarget.Type == TargetType.Misconception).ToList(); + var gaps = probes.Where(p => p.ScoredTarget.Type != TargetType.Misconception).ToList(); + + if (misconceptions.Count > 0) + { + sb.Append(""); + foreach (var p in misconceptions) + sb.Append($""); + sb.Append(""); + } + + if (gaps.Count > 0) + { + sb.Append(""); + foreach (var p in gaps) + sb.Append($""); + sb.Append(""); + } + + return sb.ToString(); + } + + private static string LoadTemplate(string fileName) + { + var assembly = typeof(LlmRequestFactory).Assembly; + using var stream = assembly.GetManifestResourceStream( + $"Tutor.Elaborations.Core.UseCases.Learning.Prompts.{fileName}")!; + using var reader = new StreamReader(stream); + return reader.ReadToEnd(); + } +} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/ScorePrompt.md b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/ScorePrompt.md new file mode 100644 index 000000000..6a24a09a4 --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/ScorePrompt.md @@ -0,0 +1,40 @@ +# Role +You are a scoring agent. Output JSON only, no other text. + +# Scoring task +Score the learner's Elaboration inside against every Key Proposition and Key Relation in the concept rubric. +All KPs and KRs must appear in the output, even if not addressed. + +Use this scale for Key Propositions: + -1 (Incorrect): The opposite of what is true, or so unrelated it signals clear misunderstanding. + 0 (Missing): No segment of the elaboration addresses this proposition. + 1 (Vague): Addressed but imprecise: too broad, omits a critical qualifier, or a reader unfamiliar with the concept could not reconstruct it from this statement alone. Includes statements so unspecific they convey little useful information. + 2 (Adequate): Clearly and correctly stated. Specific enough to distinguish it from adjacent or general concepts. + +Use this scale for Key Relations: + -1 (Incorrect): The opposite of what is true, or so unrelated it signals clear misunderstanding. + 0 (Missing): The causal or conditional link between the two propositions is absent. + 1 (Vague): Both propositions mentioned in proximity, but the mechanism connecting them is not expressed or is vague — the learner lists rather than relates. + 2 (Adequate): The mechanism is explicitly stated: why or under what condition one proposition determines or constrains the other. + +For each item, set "evidence" to exact verbatim quotes from the elaboration that directly support the grade (use | to delimit multiple quotes). +For grades -1, 1, and 2, evidence MUST contain at least one verbatim quote. For grade 0, evidence MUST be "". + +- Credit a KP/KR only when text in the elaboration directly states the claim, even if imprecisely or partially. +- Do not credit ideas merely implied or that a charitable reader could derive but the learner did not write. +- Evaluate concepts, not language. Grammar and style must not reduce scores. +- When an item is borderline between two grades, choose the lower grade. This bias is intentional — under-credit is recoverable through feedback; over-credit ends the round prematurely. +- Resist sycophancy. Default to lower grades when ambiguous. Vague restatement of part of a KP is a 1, not a 2, even when the prose is fluent. + +# Misconception detection +- Flag a misconception only when the learner's text contains a claim or piece of reasoning that directly reflects the flawed thinking in the Description. Vagueness, omission, or failure to mention the correct idea does not trigger a misconception. +- For each misconception you flag, evidence MUST contain a verbatim quote. If you cannot find one, do not flag it. + +# Output Format (JSON only, no other text) +{ + "assessments": [ { "key": "P1", "type": "proposition", "evidence": "exact quotes", "grade": 0 }, … one entry per KP and KR ], + "misconceptions": [ { "key": "M1", "evidence": "exact verbatim quote" }, … (empty array if none) ] +} +"grade" must be an integer in {-1, 0, 1, 2}. + +--- diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/ScoreResponseDto.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/ScoreResponseDto.cs new file mode 100644 index 000000000..7b5e91492 --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Learning/Prompts/ScoreResponseDto.cs @@ -0,0 +1,71 @@ +using FluentResults; +using Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; +using Tutor.Elaborations.Core.Domain.Conversations; + +namespace Tutor.Elaborations.Core.UseCases.Learning.Prompts; + +public class ScoreResponseDto +{ + public List? Assessments { get; set; } + public List? Misconceptions { get; set; } + + public Result ToEvaluation(ConceptRecord record) + { + if (Assessments == null) return Result.Fail("Assessments missing."); + + var kpKeys = record.KeyPropositions.Select(kp => kp.Key).ToHashSet(); + var krKeys = record.KeyRelations.Select(kr => kr.Key).ToHashSet(); + var allKeys = kpKeys.Union(krKeys).ToHashSet(); + + var returnedKeys = Assessments.Select(a => a.Key).ToHashSet(); + if (!returnedKeys.SetEquals(allKeys)) return Result.Fail("Incomplete or unknown keys in assessments."); + if (Assessments.Any(a => a.Grade is < -1 or > 2)) return Result.Fail("Grade out of range."); + + var scoredTargets = CreateScoredTargets(kpKeys, krKeys); + if (scoredTargets.IsFailed) return Result.Fail(scoredTargets.Errors); + + var misconceptions = Misconceptions ?? []; + var validCmKeys = record.CommonMisconceptions.Select(cm => cm.Key).ToHashSet(); + if (misconceptions.Any(m => !validCmKeys.Contains(m.Key))) return Result.Fail("Unknown misconception key."); + + var triggeredMisconceptions = misconceptions + .Select(m => new ScoredTarget(m.Key, TargetType.Misconception, -2, m.Evidence)) + .ToList(); + return new RoundEvaluation(scoredTargets.Value, triggeredMisconceptions); + } + + private Result> CreateScoredTargets(HashSet kpKeys, HashSet krKeys) + { + var scoredTargets = new List(); + foreach (var dto in Assessments!) + { + TargetType? type = dto.Type.ToLowerInvariant() switch + { + "proposition" => TargetType.Proposition, + "relation" => TargetType.Relation, + _ => null + }; + switch (type) + { + case null: + return Result.Fail($"Unknown type '{dto.Type}'."); + case TargetType.Proposition when !kpKeys.Contains(dto.Key): + return Result.Fail($"Key '{dto.Key}' typed as proposition but is a relation."); + case TargetType.Relation when !krKeys.Contains(dto.Key): + return Result.Fail($"Key '{dto.Key}' typed as relation but is a proposition."); + } + scoredTargets.Add(new ScoredTarget(dto.Key, type.Value, dto.Grade, dto.Evidence ?? "")); + } + return scoredTargets; + } +} + +public record MisconceptionDto(string Key, string Evidence); + +public class ScoredTargetDto +{ + public string Key { get; set; } = ""; + public string Type { get; set; } = ""; + public string Evidence { get; set; } = ""; + public int Grade { get; set; } +} \ No newline at end of file diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Monitoring/ConceptElaborationTaskQuerier.cs b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Monitoring/ConceptElaborationTaskQuerier.cs new file mode 100644 index 000000000..93a97f19c --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Core/UseCases/Monitoring/ConceptElaborationTaskQuerier.cs @@ -0,0 +1,19 @@ +using Tutor.Elaborations.API.Internal; +using Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; + +namespace Tutor.Elaborations.Core.UseCases.Monitoring; + +public class ConceptElaborationTaskQuerier : IConceptElaborationTaskQuerier +{ + private readonly IConceptElaborationTaskRepository _taskRepository; + + public ConceptElaborationTaskQuerier(IConceptElaborationTaskRepository taskRepository) + { + _taskRepository = taskRepository; + } + + public int CountByUnit(int unitId) + { + return _taskRepository.GetByUnit(unitId).Count; + } +} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Database/ElaborationsContext.cs b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Database/ElaborationsContext.cs new file mode 100644 index 000000000..9f473e979 --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Database/ElaborationsContext.cs @@ -0,0 +1,79 @@ +using Microsoft.EntityFrameworkCore; +using Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; +using Tutor.Elaborations.Core.Domain.Conversations; + +namespace Tutor.Elaborations.Infrastructure.Database; + +public class ElaborationsContext : DbContext +{ + public DbSet ConceptElaborationTasks { get; set; } + public DbSet ConceptRecords { get; set; } + public DbSet ConversationAttempts { get; set; } + public DbSet ConversationRounds { get; set; } + public DbSet RoundEvaluations { get; set; } + + public ElaborationsContext(DbContextOptions options) : base(options) { } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.HasDefaultSchema("elaborations"); + + ConfigureConceptElaborationTasks(modelBuilder); + ConfigureConceptRecords(modelBuilder); + ConfigureConversations(modelBuilder); + } + + private static void ConfigureConceptElaborationTasks(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasIndex(cet => new { cet.UnitId, cet.Order }); + entity.HasOne(cet => cet.ConceptRecord) + .WithOne() + .HasForeignKey(r => r.ConceptElaborationTaskId) + .OnDelete(DeleteBehavior.Cascade); + }); + } + + private static void ConfigureConceptRecords(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.Property(r => r.KeyPropositions).HasColumnType("jsonb"); + entity.Property(r => r.CommonMisconceptions).HasColumnType("jsonb"); + entity.Property(r => r.KeyRelations).HasColumnType("jsonb"); + }); + } + + private static void ConfigureConversations(ModelBuilder modelBuilder) + { + modelBuilder.Entity() + .HasMany(ca => ca.Rounds) + .WithOne() + .HasForeignKey(r => r.ConversationAttemptId); + + modelBuilder.Entity() + .Navigation(ca => ca.Rounds) + .HasField("_rounds"); + + modelBuilder.Entity() + .HasIndex(ca => new { ca.ConceptElaborationTaskId, ca.LearnerId }); + + modelBuilder.Entity() + .HasOne(r => r.Evaluation) + .WithOne() + .HasForeignKey(te => te.ConversationRoundId); + + modelBuilder.Entity() + .HasIndex(r => new { r.ConversationAttemptId, r.Order }); + + modelBuilder.Entity() + .Property(r => r.Probes).HasColumnType("jsonb"); + + modelBuilder.Entity(entity => + { + entity.Property(te => te.Assessments).HasColumnType("jsonb"); + entity.Property(te => te.TriggeredMisconceptions).HasColumnType("jsonb"); + }); + } +} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Database/ElaborationsUnitOfWork.cs b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Database/ElaborationsUnitOfWork.cs new file mode 100644 index 000000000..5f5379448 --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Database/ElaborationsUnitOfWork.cs @@ -0,0 +1,9 @@ +using Tutor.BuildingBlocks.Infrastructure.Database; +using Tutor.Elaborations.Core.UseCases; + +namespace Tutor.Elaborations.Infrastructure.Database; + +public class ElaborationsUnitOfWork : UnitOfWork, IElaborationsUnitOfWork +{ + public ElaborationsUnitOfWork(ElaborationsContext dbContext) : base(dbContext) { } +} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Database/Repositories/ConceptElaborationTaskDatabaseRepository.cs b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Database/Repositories/ConceptElaborationTaskDatabaseRepository.cs new file mode 100644 index 000000000..b3653e759 --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Database/Repositories/ConceptElaborationTaskDatabaseRepository.cs @@ -0,0 +1,35 @@ +using Microsoft.EntityFrameworkCore; +using Tutor.BuildingBlocks.Infrastructure.Database; +using Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; + +namespace Tutor.Elaborations.Infrastructure.Database.Repositories; + +public class ConceptElaborationTaskDatabaseRepository : + CrudDatabaseRepository, IConceptElaborationTaskRepository +{ + public ConceptElaborationTaskDatabaseRepository(ElaborationsContext dbContext) : base(dbContext) { } + + public ConceptElaborationTask? GetWithRecord(int id) + { + return DbContext.ConceptElaborationTasks + .Include(cet => cet.ConceptRecord) + .FirstOrDefault(cet => cet.Id == id); + } + + public List GetByUnit(int unitId) + { + return DbContext.ConceptElaborationTasks + .Where(cet => cet.UnitId == unitId) + .OrderBy(cet => cet.Order) + .ToList(); + } + + public List GetByUnitWithRecords(int unitId) + { + return DbContext.ConceptElaborationTasks + .Include(cet => cet.ConceptRecord) + .Where(cet => cet.UnitId == unitId) + .OrderBy(cet => cet.Order) + .ToList(); + } +} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Database/Repositories/ConversationAttemptDatabaseRepository.cs b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Database/Repositories/ConversationAttemptDatabaseRepository.cs new file mode 100644 index 000000000..21e985e21 --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Database/Repositories/ConversationAttemptDatabaseRepository.cs @@ -0,0 +1,57 @@ +using Microsoft.EntityFrameworkCore; +using Tutor.BuildingBlocks.Infrastructure.Database; +using Tutor.Elaborations.Core.Domain.Conversations; + +namespace Tutor.Elaborations.Infrastructure.Database.Repositories; + +public class ConversationAttemptDatabaseRepository : + CrudDatabaseRepository, IConversationAttemptRepository +{ + public ConversationAttemptDatabaseRepository(ElaborationsContext dbContext) : base(dbContext) { } + + public new ConversationAttempt? Get(int id) + { + return DbContext.ConversationAttempts + .Include(ca => ca.Rounds.OrderBy(r => r.Order)) + .ThenInclude(r => r.Evaluation) + .FirstOrDefault(ca => ca.Id == id); + } + + public ConversationAttempt? GetActiveAttempt(int conceptElaborationTaskId, int learnerId) + { + return DbContext.ConversationAttempts + .Include(ca => ca.Rounds.OrderBy(r => r.Order)) + .ThenInclude(r => r.Evaluation) + .FirstOrDefault(ca => ca.ConceptElaborationTaskId == conceptElaborationTaskId + && ca.LearnerId == learnerId + && ca.Status == AttemptStatus.InProgress); + } + + public List GetByTaskAndLearner(int conceptElaborationTaskId, int learnerId) + { + return DbContext.ConversationAttempts + .Include(ca => ca.Rounds.OrderBy(r => r.Order)) + .Where(ca => ca.ConceptElaborationTaskId == conceptElaborationTaskId && ca.LearnerId == learnerId) + .OrderByDescending(ca => ca.StartedAt) + .ToList(); + } + + public int CountRecentAttempts(int conceptElaborationTaskId, int learnerId, DateTime since) + { + return DbContext.ConversationAttempts + .Count(ca => ca.ConceptElaborationTaskId == conceptElaborationTaskId + && ca.LearnerId == learnerId + && ca.StartedAt >= since); + } + + public HashSet GetTaskIdsWithCompletedAttempts(List taskIds, int learnerId) + { + return DbContext.ConversationAttempts + .Where(ca => taskIds.Contains(ca.ConceptElaborationTaskId) + && ca.LearnerId == learnerId + && ca.Status == AttemptStatus.Completed) + .Select(ca => ca.ConceptElaborationTaskId) + .Distinct() + .ToHashSet(); + } +} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/ElaborationsStartup.cs b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/ElaborationsStartup.cs new file mode 100644 index 000000000..3101dffb5 --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/ElaborationsStartup.cs @@ -0,0 +1,63 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Npgsql; +using Tutor.BuildingBlocks.Infrastructure.Database; +using Tutor.BuildingBlocks.Infrastructure.Interceptors; +using Tutor.Elaborations.API.Internal; +using Tutor.Elaborations.API.Public; +using Tutor.Elaborations.API.Public.Authoring; +using Tutor.Elaborations.API.Public.Learning; +using Tutor.Elaborations.Core.Domain.ConceptElaborationTasks; +using Tutor.Elaborations.Core.Domain.Conversations; +using Tutor.Elaborations.Core.Mappers; +using Tutor.Elaborations.Core.UseCases; +using Tutor.Elaborations.Core.UseCases.Authoring; +using Tutor.Elaborations.Core.UseCases.Learning; +using Tutor.Elaborations.Core.UseCases.Learning.Orchestration; +using Tutor.Elaborations.Core.UseCases.Monitoring; +using Tutor.Elaborations.Infrastructure.Database; +using Tutor.Elaborations.Infrastructure.Database.Repositories; + +namespace Tutor.Elaborations.Infrastructure; + +public static class ElaborationsStartup +{ + public static IServiceCollection ConfigureElaborationsModule(this IServiceCollection services) + { + SetupAutoMapper(services); + SetupCore(services); + SetupInfrastructure(services); + return services; + } + + private static void SetupAutoMapper(IServiceCollection services) + { + services.AddAutoMapper(typeof(ConceptElaborationTaskProfile).Assembly); + } + + private static void SetupCore(IServiceCollection services) + { + services.AddProxiedScoped(); + services.AddProxiedScoped(); + services.AddProxiedScoped(); + services.AddProxiedScoped(); + } + + private static void SetupInfrastructure(IServiceCollection services) + { + services.AddScoped(); + services.AddScoped(); + + services.AddScoped(); + + services.AddScoped(); + + var dataSourceBuilder = new NpgsqlDataSourceBuilder(DbConnectionStringBuilder.Build("elaborations")); + dataSourceBuilder.EnableDynamicJson(); + var dataSource = dataSourceBuilder.Build(); + + services.AddDbContext(opt => + opt.UseNpgsql(dataSource, + x => x.MigrationsHistoryTable("__EFMigrationsHistory", "elaborations"))); + } +} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Tutor.Elaborations.Infrastructure.csproj b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Tutor.Elaborations.Infrastructure.csproj new file mode 100644 index 000000000..252f75bfc --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Infrastructure/Tutor.Elaborations.Infrastructure.csproj @@ -0,0 +1,15 @@ + + + + net10.0 + enable + enable + + + + + + + + + diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Tests/BaseElaborationsIntegrationTest.cs b/src/Modules/Elaborations/Tutor.Elaborations.Tests/BaseElaborationsIntegrationTest.cs new file mode 100644 index 000000000..0a61ecdde --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Tests/BaseElaborationsIntegrationTest.cs @@ -0,0 +1,8 @@ +using Tutor.BuildingBlocks.Tests; + +namespace Tutor.Elaborations.Tests; + +public class BaseElaborationsIntegrationTest : BaseWebIntegrationTest +{ + public BaseElaborationsIntegrationTest(ElaborationsTestFactory factory) : base(factory) { } +} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Tests/ElaborationsTestFactory.cs b/src/Modules/Elaborations/Tutor.Elaborations.Tests/ElaborationsTestFactory.cs new file mode 100644 index 000000000..7acae0e3a --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Tests/ElaborationsTestFactory.cs @@ -0,0 +1,92 @@ +using System.Runtime.CompilerServices; +using System.Text; +using FluentResults; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.TestHost; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Moq; +using Tutor.BuildingBlocks.AI.Core.Conversations; +using Tutor.BuildingBlocks.Tests; +using Tutor.Courses.Infrastructure.Database; +using Tutor.Elaborations.Infrastructure.Database; + +namespace Tutor.Elaborations.Tests; + +public class ElaborationsTestFactory : BaseTestFactory +{ + public Mock MockChatService { get; } = new(); + + protected override Type[] GetRequiredDbContextTypes() => + [typeof(CoursesContext), typeof(ElaborationsContext)]; + + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + base.ConfigureWebHost(builder); + builder.ConfigureTestServices(services => + { + var descriptors = services.Where(d => d.ServiceType == typeof(IAiChatService)).ToList(); + foreach (var descriptor in descriptors) services.Remove(descriptor); + services.AddSingleton(MockChatService.Object); + }); + } + + protected override IServiceCollection ReplaceNeededDbContexts(IServiceCollection services) + { + var descriptor = services.SingleOrDefault(d => d.ServiceType == typeof(DbContextOptions)); + services.Remove(descriptor!); + services.AddDbContext(SetupTestContext()); + + descriptor = services.SingleOrDefault(d => d.ServiceType == typeof(DbContextOptions)); + services.Remove(descriptor!); + services.AddDbContext(SetupTestContext()); + + return services; + } + + public void SetupEvaluationMock( + List<(string key, string type, int grade)> assessments, + List? misconceptionsTriggeredKeys = null) + { + var scorerJson = BuildScorerJson(assessments, misconceptionsTriggeredKeys ?? []); + MockChatService.Setup(x => x.CompleteAsync( + It.Is(r => r.MaxTokens == 1024), It.IsAny())) + .ReturnsAsync(Result.Ok(new CompletionResponse + { + Content = scorerJson, + Usage = new TokenUsage(100, 50) + })); + } + + private static string BuildScorerJson( + List<(string key, string type, int grade)> assessments, + List misconceptions) + { + var sb = new StringBuilder(); + sb.Append("{ \"assessments\": ["); + sb.Append(string.Join(", ", assessments.Select(a => + $"{{ \"key\": \"{a.key}\", \"type\": \"{a.type}\", \"grade\": {a.grade} }}"))); + sb.Append("], \"misconceptionsTriggeredKeys\": ["); + sb.Append(string.Join(", ", misconceptions.Select(m => $"\"{m}\""))); + sb.Append("]}"); + return sb.ToString(); + } + + public void SetupDialogueMock(params string[] tokens) + { + var mockTokens = tokens.Length > 0 ? tokens : ["Mock ", "feedback."]; + MockChatService.Setup(x => x.StreamAsync( + It.IsAny(), It.IsAny())) + .Returns(MockStream(mockTokens)); + } + + private static async IAsyncEnumerable MockStream( + string[] tokens, [EnumeratorCancellation] CancellationToken ct = default) + { + foreach (var token in tokens) + { + await Task.Yield(); + yield return token; + } + } +} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Authoring/ConceptElaborationTaskCommandTests.cs b/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Authoring/ConceptElaborationTaskCommandTests.cs new file mode 100644 index 000000000..dbb01a834 --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Authoring/ConceptElaborationTaskCommandTests.cs @@ -0,0 +1,347 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.DependencyInjection; +using Shouldly; +using Tutor.API.Controllers.Instructor.Authoring.Elaboration; +using Tutor.Elaborations.API.Dtos.ConceptElaborationTasks; +using Tutor.Elaborations.API.Public.Authoring; +using Tutor.Elaborations.Infrastructure.Database; + +namespace Tutor.Elaborations.Tests.Integration.Authoring; + +[Collection("Sequential")] +public class ConceptElaborationTaskCommandTests : BaseElaborationsIntegrationTest +{ + public ConceptElaborationTaskCommandTests(ElaborationsTestFactory factory) : base(factory) { } + + [Fact] + public void Creates() + { + using var scope = Factory.Services.CreateScope(); + var controller = CreateController(scope); + var dbContext = scope.ServiceProvider.GetRequiredService(); + var newEntity = new ConceptElaborationTaskDto + { + UnitId = -1, + Order = 10, + Title = "New Concept", + Description = "A new concept for testing.", + ConceptRecord = new ConceptRecordDto + { + CanonicalDefinition = "A new concept definition.", + KeyPropositions = new List + { + new() { Key = "P1", Statement = "First proposition" }, + new() { Key = "P2", Statement = "Second proposition" } + }, + CommonMisconceptions = new List + { + new() { Key = "M1", Description = "A misconception", Correction = "The correction" } + }, + KeyRelations = new List() + } + }; + dbContext.Database.BeginTransaction(); + + var actionResult = controller.Create(-1, newEntity).Result; + var result = (actionResult as OkObjectResult)?.Value as ConceptElaborationTaskDto; + + dbContext.ChangeTracker.Clear(); + result.ShouldNotBeNull(); + result.Title.ShouldBe(newEntity.Title); + result.UnitId.ShouldBe(-1); + result.Order.ShouldBe(10); + result.ConceptRecord.ShouldNotBeNull(); + result.ConceptRecord.KeyPropositions.Count.ShouldBe(2); + result.ConceptRecord.CommonMisconceptions.Count.ShouldBe(1); + result.ConceptRecord.KeyRelations.Count.ShouldBe(0); + } + + [Fact] + public void Creates_with_relations() + { + using var scope = Factory.Services.CreateScope(); + var controller = CreateController(scope); + var dbContext = scope.ServiceProvider.GetRequiredService(); + var newEntity = new ConceptElaborationTaskDto + { + UnitId = -1, + Order = 11, + Title = "Concept With Relations", + Description = "A concept created with KPs and KRs.", + ConceptRecord = new ConceptRecordDto + { + CanonicalDefinition = "A concept created with KPs and KRs in one request.", + KeyPropositions = new List + { + new() { Key = "P1", Statement = "First proposition" }, + new() { Key = "P2", Statement = "Second proposition" } + }, + + CommonMisconceptions = new List(), + KeyRelations = new List + { + new() + { + Key = "R1", SourceKey = "P1", TargetKey = "P2", + Mechanism = "First enables second" + } + } + } + }; + dbContext.Database.BeginTransaction(); + + var actionResult = controller.Create(-1, newEntity).Result; + var result = (actionResult as OkObjectResult)?.Value as ConceptElaborationTaskDto; + + dbContext.ChangeTracker.Clear(); + result.ShouldNotBeNull(); + result.ConceptRecord.ShouldNotBeNull(); + result.ConceptRecord.KeyPropositions.Count.ShouldBe(2); + result.ConceptRecord.KeyRelations.Count.ShouldBe(1); + result.ConceptRecord.KeyRelations[0].Mechanism.ShouldBe("First enables second"); + result.ConceptRecord.KeyRelations[0].SourceKey.ShouldBe("P1"); + result.ConceptRecord.KeyRelations[0].TargetKey.ShouldBe("P2"); + } + + [Fact] + public void Updates() + { + using var scope = Factory.Services.CreateScope(); + var controller = CreateController(scope); + var dbContext = scope.ServiceProvider.GetRequiredService(); + var updatedEntity = new ConceptElaborationTaskDto + { + Id = -1, + UnitId = -1, + Order = 1, + Title = "Updated Encapsulation", + Description = "Updated description.", + ConceptRecord = new ConceptRecordDto + { + CanonicalDefinition = "Updated definition.", + KeyPropositions = new List + { + new() { Key = "P1", Statement = "Updated proposition" } + }, + + CommonMisconceptions = new List(), + KeyRelations = new List() + } + }; + dbContext.Database.BeginTransaction(); + + var actionResult = controller.Update(-1, -1, updatedEntity).Result; + var result = (actionResult as OkObjectResult)?.Value as ConceptElaborationTaskDto; + + dbContext.ChangeTracker.Clear(); + result.ShouldNotBeNull(); + result.Id.ShouldBe(-1); + result.Title.ShouldBe("Updated Encapsulation"); + result.ConceptRecord.ShouldNotBeNull(); + result.ConceptRecord.KeyPropositions.Count.ShouldBe(1); + result.ConceptRecord.KeyPropositions[0].Statement.ShouldBe("Updated proposition"); + } + + [Fact] + public void Updates_relations_with_natural_keys() + { + using var scope = Factory.Services.CreateScope(); + var controller = CreateController(scope); + var dbContext = scope.ServiceProvider.GetRequiredService(); + // CET -7 has KPs P1, P2 and KR R1 (source=P1, target=P2). + var updatedEntity = new ConceptElaborationTaskDto + { + Id = -7, + UnitId = -2, + Order = 4, + Title = "Polymorphism Mechanics", + Description = "Runtime method dispatch and virtual call mechanics.", + ConceptRecord = new ConceptRecordDto + { + CanonicalDefinition = "Polymorphism resolves method calls at runtime via dynamic dispatch.", + KeyPropositions = new List + { + new() { Key = "P1", Statement = "A subclass can override a parent method" }, + new() { Key = "P2", Statement = "The runtime selects the implementation by the actual type" }, + new() { Key = "P3", Statement = "Dispatch table resolves virtual calls" } + }, + + CommonMisconceptions = new List(), + KeyRelations = new List + { + new() + { + Key = "R1", SourceKey = "P1", TargetKey = "P2", + Mechanism = "Override matters because dispatch happens at runtime" + }, + new() + { + Key = "R2", SourceKey = "P2", TargetKey = "P3", + Mechanism = "Runtime dispatch uses vtable lookup" + } + } + } + }; + dbContext.Database.BeginTransaction(); + + var actionResult = controller.Update(-2, -7, updatedEntity).Result; + var result = (actionResult as OkObjectResult)?.Value as ConceptElaborationTaskDto; + + dbContext.ChangeTracker.Clear(); + result.ShouldNotBeNull(); + result.ConceptRecord.ShouldNotBeNull(); + result.ConceptRecord.KeyPropositions.Count.ShouldBe(3); + result.ConceptRecord.KeyRelations.Count.ShouldBe(2); + result.ConceptRecord.KeyRelations.ShouldContain(kr => kr.Mechanism.Contains("dispatch happens at runtime")); + result.ConceptRecord.KeyRelations.ShouldContain(kr => kr.Mechanism.Contains("vtable lookup")); + } + + [Fact] + public void Removes_relation_and_referenced_kp() + { + using var scope = Factory.Services.CreateScope(); + var controller = CreateController(scope); + var dbContext = scope.ServiceProvider.GetRequiredService(); + // CET -7 has KPs P1, P2 and KR R1. Remove KR and KP P2, keeping only P1. + var updatedEntity = new ConceptElaborationTaskDto + { + Id = -7, + UnitId = -2, + Order = 4, + Title = "Polymorphism Mechanics", + Description = "Runtime method dispatch and virtual call mechanics.", + ConceptRecord = new ConceptRecordDto + { + CanonicalDefinition = "Polymorphism resolves method calls at runtime via dynamic dispatch.", + KeyPropositions = new List + { + new() { Key = "P1", Statement = "A subclass can override a parent method" } + }, + + CommonMisconceptions = new List(), + KeyRelations = new List() + } + }; + dbContext.Database.BeginTransaction(); + + var actionResult = controller.Update(-2, -7, updatedEntity).Result; + var result = (actionResult as OkObjectResult)?.Value as ConceptElaborationTaskDto; + + dbContext.ChangeTracker.Clear(); + result.ShouldNotBeNull(); + result.ConceptRecord.ShouldNotBeNull(); + result.ConceptRecord.KeyPropositions.Count.ShouldBe(1); + result.ConceptRecord.KeyRelations.Count.ShouldBe(0); + } + + [Fact] + public void Deletes() + { + using var scope = Factory.Services.CreateScope(); + var controller = CreateController(scope); + var dbContext = scope.ServiceProvider.GetRequiredService(); + dbContext.Database.BeginTransaction(); + + var result = (OkResult)controller.Delete(-2, -7); + + dbContext.ChangeTracker.Clear(); + result.ShouldNotBeNull(); + result.StatusCode.ShouldBe(200); + var stored = dbContext.ConceptElaborationTasks.FirstOrDefault(cet => cet.Id == -7); + stored.ShouldBeNull(); + var storedRecord = dbContext.ConceptRecords.FirstOrDefault(r => r.ConceptElaborationTaskId == -7); + storedRecord.ShouldBeNull(); + } + + [Fact] + public void Fails_to_delete_nonexistent() + { + using var scope = Factory.Services.CreateScope(); + var controller = CreateController(scope); + + var actionResult = controller.Delete(-1, -999); + var objectResult = actionResult as ObjectResult; + + objectResult.ShouldNotBeNull(); + objectResult.StatusCode.ShouldBe(404); + } + + [Fact] + public void Non_owner_fails_to_create() + { + using var scope = Factory.Services.CreateScope(); + var controller = CreateController(scope); + var newEntity = new ConceptElaborationTaskDto + { + UnitId = -3, + Order = 99, + Title = "Should Fail", + Description = "Should not be created.", + ConceptRecord = new ConceptRecordDto + { + CanonicalDefinition = "Fail", + KeyPropositions = new List(), + + CommonMisconceptions = new List(), + KeyRelations = new List() + } + }; + + var actionResult = controller.Create(-3, newEntity).Result; + var objectResult = actionResult as ObjectResult; + + objectResult.ShouldNotBeNull(); + objectResult.StatusCode.ShouldBe(403); + } + + [Fact] + public void Non_owner_fails_to_update() + { + using var scope = Factory.Services.CreateScope(); + var controller = CreateController(scope); + var updatedEntity = new ConceptElaborationTaskDto + { + Id = -4, + UnitId = -3, + Order = 1, + Title = "Should Fail", + Description = "Should not be updated.", + ConceptRecord = new ConceptRecordDto + { + CanonicalDefinition = "Fail", + KeyPropositions = new List(), + + CommonMisconceptions = new List(), + KeyRelations = new List() + } + }; + + var actionResult = controller.Update(-3, -4, updatedEntity).Result; + var objectResult = actionResult as ObjectResult; + + objectResult.ShouldNotBeNull(); + objectResult.StatusCode.ShouldBe(403); + } + + [Fact] + public void Non_owner_fails_to_delete() + { + using var scope = Factory.Services.CreateScope(); + var controller = CreateController(scope); + + var actionResult = controller.Delete(-3, -4); + var objectResult = actionResult as ObjectResult; + + objectResult.ShouldNotBeNull(); + objectResult.StatusCode.ShouldBe(403); + } + + private static ConceptElaborationTaskController CreateController(IServiceScope scope) + { + return new ConceptElaborationTaskController( + scope.ServiceProvider.GetRequiredService()) + { + ControllerContext = BuildContext("-51", "instructor") + }; + } +} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Authoring/ConceptElaborationTaskQueryTests.cs b/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Authoring/ConceptElaborationTaskQueryTests.cs new file mode 100644 index 000000000..c41c04edc --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Authoring/ConceptElaborationTaskQueryTests.cs @@ -0,0 +1,52 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.DependencyInjection; +using Shouldly; +using Tutor.API.Controllers.Instructor.Authoring.Elaboration; +using Tutor.Elaborations.API.Dtos.ConceptElaborationTasks; +using Tutor.Elaborations.API.Public.Authoring; + +namespace Tutor.Elaborations.Tests.Integration.Authoring; + +[Collection("Sequential")] +public class ConceptElaborationTaskQueryTests : BaseElaborationsIntegrationTest +{ + public ConceptElaborationTaskQueryTests(ElaborationsTestFactory factory) : base(factory) { } + + [Fact] + public void Gets_by_unit() + { + using var scope = Factory.Services.CreateScope(); + var controller = CreateController(scope); + + var actionResult = controller.GetByUnit(-1).Result; + var result = (actionResult as OkObjectResult)?.Value as List; + + result.ShouldNotBeNull(); + result.Count.ShouldBe(2); + result[0].Order.ShouldBeLessThanOrEqualTo(result[1].Order); + result.ShouldContain(s => s.Id == -1 && s.Title == "Encapsulation (Basics)"); + result.ShouldContain(s => s.Id == -2 && s.Title == "Encapsulation (Members)"); + } + + [Fact] + public void Non_owner_fails_to_get_by_unit() + { + using var scope = Factory.Services.CreateScope(); + var controller = CreateController(scope); + + var actionResult = controller.GetByUnit(-3).Result; + var objectResult = actionResult as ObjectResult; + + objectResult.ShouldNotBeNull(); + objectResult.StatusCode.ShouldBe(403); + } + + private static ConceptElaborationTaskController CreateController(IServiceScope scope) + { + return new ConceptElaborationTaskController( + scope.ServiceProvider.GetRequiredService()) + { + ControllerContext = BuildContext("-51", "instructor") + }; + } +} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Learning/ConversationAttemptTests.cs b/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Learning/ConversationAttemptTests.cs new file mode 100644 index 000000000..399431d3a --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Learning/ConversationAttemptTests.cs @@ -0,0 +1,78 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.DependencyInjection; +using Shouldly; +using Tutor.API.Controllers.Learner.Learning.Elaboration; +using Tutor.Elaborations.API.Dtos.Conversations; +using Tutor.Elaborations.API.Public.Learning; +using Tutor.Elaborations.Infrastructure.Database; + +namespace Tutor.Elaborations.Tests.Integration.Learning; + +[Collection("Sequential")] +public class ConversationAttemptTests : BaseElaborationsIntegrationTest +{ + public ConversationAttemptTests(ElaborationsTestFactory factory) : base(factory) { } + + [Fact] + public void Abandons_in_progress() + { + using var scope = Factory.Services.CreateScope(); + var controller = CreateController(scope, "-3"); + var dbContext = scope.ServiceProvider.GetRequiredService(); + + var actionResult = controller.AbandonAttempt(-7).Result; + var result = (actionResult as OkObjectResult)?.Value as ConversationAttemptDto; + + dbContext.ChangeTracker.Clear(); + result.ShouldNotBeNull(); + result.Status.ShouldBe("Abandoned"); + result.CompletedAt.ShouldNotBeNull(); + } + + [Fact] + public void Cannot_abandon_completed() + { + using var scope = Factory.Services.CreateScope(); + var controller = CreateController(scope, "-2"); + + var actionResult = controller.AbandonAttempt(-1).Result; + var objectResult = actionResult as ObjectResult; + + objectResult.ShouldNotBeNull(); + objectResult.StatusCode.ShouldBe(409); + } + + [Fact] + public void Wrong_learner_cannot_abandon() + { + using var scope = Factory.Services.CreateScope(); + var controller = CreateController(scope, "-2"); + + var actionResult = controller.AbandonAttempt(-3).Result; + var objectResult = actionResult as ObjectResult; + + objectResult.ShouldNotBeNull(); + objectResult.StatusCode.ShouldBe(403); + } + + [Fact] + public void Fails_to_abandon_nonexistent() + { + using var scope = Factory.Services.CreateScope(); + var controller = CreateController(scope, "-2"); + + var actionResult = controller.AbandonAttempt(-999).Result; + var objectResult = actionResult as ObjectResult; + + objectResult.ShouldNotBeNull(); + objectResult.StatusCode.ShouldBe(404); + } + + private static ConversationController CreateController(IServiceScope scope, string learnerId) + { + return new ConversationController(scope.ServiceProvider.GetRequiredService()) + { + ControllerContext = BuildContext(learnerId, "learner") + }; + } +} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Learning/ConversationQueryTests.cs b/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Learning/ConversationQueryTests.cs new file mode 100644 index 000000000..9e9efa4e2 --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Learning/ConversationQueryTests.cs @@ -0,0 +1,109 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.DependencyInjection; +using Shouldly; +using Tutor.API.Controllers.Learner.Learning.Elaboration; +using Tutor.Elaborations.API.Dtos.ConceptElaborationTasks; +using Tutor.Elaborations.API.Public.Learning; + +namespace Tutor.Elaborations.Tests.Integration.Learning; + +[Collection("Sequential")] +public class ConversationQueryTests : BaseElaborationsIntegrationTest +{ + public ConversationQueryTests(ElaborationsTestFactory factory) : base(factory) { } + + [Fact] + public void Gets_tasks_for_enrolled_unit() + { + using var scope = Factory.Services.CreateScope(); + var controller = CreateController(scope, "-2"); + + var actionResult = controller.GetTasksForUnit(-1).Result; + var result = (actionResult as OkObjectResult)?.Value as List; + + result.ShouldNotBeNull(); + result.Count.ShouldBe(2); + result.First(t => t.Id == -1).HasCompletedAttempt.ShouldBeTrue(); + result.First(t => t.Id == -2).HasCompletedAttempt.ShouldBeFalse(); + } + + [Fact] + public void Unenrolled_fails_to_get_tasks() + { + using var scope = Factory.Services.CreateScope(); + var controller = CreateController(scope, "-1"); + + var actionResult = controller.GetTasksForUnit(-1).Result; + var objectResult = actionResult as ObjectResult; + + objectResult.ShouldNotBeNull(); + objectResult.StatusCode.ShouldBe(403); + } + + [Fact] + public void Gets_task_detail() + { + using var scope = Factory.Services.CreateScope(); + var controller = CreateController(scope, "-2"); + + var actionResult = controller.GetTaskWithAttempts(-1).Result; + var result = (actionResult as OkObjectResult)?.Value as ConceptElaborationTaskDto; + + result.ShouldNotBeNull(); + result.Id.ShouldBe(-1); + result.Title.ShouldNotBeNullOrEmpty(); + result.ConceptRecord.ShouldBeNull(); + result.Attempts.ShouldNotBeNull(); + result.Attempts.Count.ShouldBe(2); + result.Attempts.Any(a => a.Status == "Completed").ShouldBeTrue(); + result.Attempts.Any(a => a.Status == "Abandoned").ShouldBeTrue(); + } + + [Fact] + public void Gets_task_detail_with_active_attempt() + { + using var scope = Factory.Services.CreateScope(); + var controller = CreateController(scope, "-3"); + + var actionResult = controller.GetTaskWithAttempts(-2).Result; + var result = (actionResult as OkObjectResult)?.Value as ConceptElaborationTaskDto; + + result.ShouldNotBeNull(); + result.Attempts.ShouldNotBeNull(); + result.Attempts.Any(a => a.Status == "InProgress").ShouldBeTrue(); + } + + [Fact] + public void Gets_task_detail_unenrolled_fails() + { + using var scope = Factory.Services.CreateScope(); + var controller = CreateController(scope, "-1"); + + var actionResult = controller.GetTaskWithAttempts(-1).Result; + var objectResult = actionResult as ObjectResult; + + objectResult.ShouldNotBeNull(); + objectResult.StatusCode.ShouldBe(403); + } + + [Fact] + public void Gets_task_detail_nonexistent_fails() + { + using var scope = Factory.Services.CreateScope(); + var controller = CreateController(scope, "-2"); + + var actionResult = controller.GetTaskWithAttempts(-999).Result; + var objectResult = actionResult as ObjectResult; + + objectResult.ShouldNotBeNull(); + objectResult.StatusCode.ShouldBe(404); + } + + private static ConversationController CreateController(IServiceScope scope, string learnerId) + { + return new ConversationController(scope.ServiceProvider.GetRequiredService()) + { + ControllerContext = BuildContext(learnerId, "learner") + }; + } +} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Learning/ConversationTurnTests.cs b/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Learning/ConversationTurnTests.cs new file mode 100644 index 000000000..dafcc862d --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Tests/Integration/Learning/ConversationTurnTests.cs @@ -0,0 +1,297 @@ +using System.Text.Json; +using FluentResults; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Moq; +using Shouldly; +using Tutor.API.Controllers.Learner.Learning.Elaboration; +using Tutor.BuildingBlocks.AI.Core.Conversations; +using Tutor.Elaborations.API.Dtos.Conversations; +using Tutor.Elaborations.API.Public.Learning; +using Tutor.Elaborations.Infrastructure.Database; + +namespace Tutor.Elaborations.Tests.Integration.Learning; + +// Test data layout: +// CET -1: Encapsulation (Basics), Unit -1 — KPs: P1 | CMs: M1 +// CET -2: Encapsulation (Members), Unit -1 — KPs: P1, P2 | CMs: M1, M2 +// CET -3: Encapsulation (Basics — Unit 2), -2 — KPs: P1 +// CET -5: Encapsulation (Members — Unit 2), -2 — KPs: P1, P2 — isolated for StartConversation +// CET -6: Encapsulation (Invariants), Unit -2 — KPs: P1, P2, P3 — isolated for Start+Submit flow +// CET -7: Polymorphism Mechanics, Unit -2 — KPs: P1, P2 | KRs: R1 +// Learner -2: enrolled in Units -1, -2 | Learner -3: enrolled in Units -1, -2 +// Learner -1: NOT enrolled | Learner -4: exhausted wallet +// Attempt -1: Learner -2, CET -1, Completed (for conflict / cannot-submit tests) +// Attempt -3: Learner -3, CET -1, InProgress, RoundCount=1 (conflict + eval failure tests) +// Attempt -4: Learner -3, CET -2, InProgress, RoundCount=1 (completion test) +// Attempt -5: Learner -2, CET -2, InProgress, RoundCount=3 (hard cap test — MaxRounds=4) +// Attempt -7: Learner -3, CET -5, InProgress (isolated for abandon test) +[Collection("Sequential")] +public class ConversationTurnTests : BaseElaborationsIntegrationTest +{ + public ConversationTurnTests(ElaborationsTestFactory factory) : base(factory) { } + + [Fact] + public async Task Starts_conversation_with_first_turn() + { + Factory.MockChatService.Reset(); + Factory.SetupEvaluationMock([("P1", "proposition", 0), ("P2", "proposition", 0)]); + Factory.SetupDialogueMock(); + using var scope = Factory.Services.CreateScope(); + var controller = CreateController(scope, "-2"); + var dto = new SubmitElaborationRequestDto { Elaboration = "Encapsulation bundles data and methods." }; + + var tokens = await CollectStreamAsync(controller.StartConversation(-5, dto, CancellationToken.None)); + + tokens.Count.ShouldBeGreaterThan(1); + var metadata = JsonSerializer.Deserialize(tokens.Last()); + metadata.ShouldNotBeNull(); + metadata.Status.ShouldBe("InProgress"); + metadata.AttemptId.ShouldBeGreaterThan(0); + Factory.MockChatService.Verify(x => x.CompleteAsync( + It.Is(r => r.MaxTokens == 1024), It.IsAny()), Times.Once); + } + + [Fact] + public async Task Submission_completes_when_all_targets_adequate() + { + // CET -2 has P1, P2. Attempt -4 has RoundCount=1. Submit with all grade 2 → Completed. + Factory.MockChatService.Reset(); + Factory.SetupEvaluationMock([("P1", "proposition", 2), ("P2", "proposition", 2)]); + using var scope = Factory.Services.CreateScope(); + var controller = CreateController(scope, "-3"); + var dto = new SubmitElaborationRequestDto { Elaboration = "Covers both propositions adequately." }; + + var tokens = await CollectStreamAsync(controller.SubmitElaboration(-4, dto, CancellationToken.None)); + + var metadata = JsonSerializer.Deserialize(tokens.Last()); + metadata.ShouldNotBeNull(); + metadata.Status.ShouldBe("Completed"); + } + + [Fact] + public async Task Hard_cap_expires_attempt() + { + // Attempt -5: CET -2, RoundCount=3, MaxRounds=4. Next submission hits cap → Expired. + Factory.MockChatService.Reset(); + Factory.SetupEvaluationMock([("P1", "proposition", 0), ("P2", "proposition", 0)]); + using var scope = Factory.Services.CreateScope(); + var controller = CreateController(scope, "-2"); + var dto = new SubmitElaborationRequestDto { Elaboration = "Attempt at the hard cap boundary." }; + + var tokens = await CollectStreamAsync(controller.SubmitElaboration(-5, dto, CancellationToken.None)); + + var metadata = JsonSerializer.Deserialize(tokens.Last()); + metadata.ShouldNotBeNull(); + metadata.Status.ShouldBe("Expired"); + } + + [Fact] + public async Task Submission_with_KPs_and_KR_completes_when_all_adequate() + { + // CET -7 (KPs P1, P2 + KR R1 = 3 targets). All grade 2 → Completed immediately. + Factory.MockChatService.Reset(); + Factory.SetupEvaluationMock( + [("P1", "proposition", 2), ("P2", "proposition", 2), ("R1", "relation", 2)]); + using var scope = Factory.Services.CreateScope(); + var controller = CreateController(scope, "-3"); + var dto = new SubmitElaborationRequestDto + { + Elaboration = "Override works because the runtime dispatches on the actual type, linking polymorphism to dynamic dispatch." + }; + + var tokens = await CollectStreamAsync(controller.StartConversation(-7, dto, CancellationToken.None)); + + var metadata = JsonSerializer.Deserialize(tokens.Last()); + metadata.ShouldNotBeNull(); + metadata.Status.ShouldBe("Completed"); + } + + [Fact] + public async Task Start_then_submit_adds_turns_to_same_attempt() + { + // CET -6 (P1, P2, P3). Start creates attempt; submit reuses it. + Factory.MockChatService.Reset(); + Factory.SetupEvaluationMock( + [("P1", "proposition", 0), ("P2", "proposition", 0), ("P3", "proposition", 0)]); + Factory.SetupDialogueMock(); + using var scope = Factory.Services.CreateScope(); + var controller = CreateController(scope, "-2"); + var dbContext = scope.ServiceProvider.GetRequiredService(); + var firstDto = new SubmitElaborationRequestDto { Elaboration = "First elaboration." }; + + var firstTokens = await CollectStreamAsync(controller.StartConversation(-6, firstDto, CancellationToken.None)); + var firstMetadata = JsonSerializer.Deserialize(firstTokens.Last()); + firstMetadata.ShouldNotBeNull(); + var attemptId = firstMetadata.AttemptId; + + dbContext.ChangeTracker.Clear(); + var roundCountAfterFirst = dbContext.ConversationAttempts + .Include(a => a.Rounds).First(a => a.Id == attemptId).Rounds.Count; + + Factory.MockChatService.Reset(); + Factory.SetupEvaluationMock( + [("P1", "proposition", 0), ("P2", "proposition", 0), ("P3", "proposition", 0)]); + Factory.SetupDialogueMock(); + var secondDto = new SubmitElaborationRequestDto { Elaboration = "Revised elaboration." }; + + var tokens = await CollectStreamAsync(controller.SubmitElaboration(attemptId, secondDto, CancellationToken.None)); + + dbContext.ChangeTracker.Clear(); + var metadata = JsonSerializer.Deserialize(tokens.Last()); + metadata.ShouldNotBeNull(); + metadata.Status.ShouldBe("InProgress"); + metadata.AttemptId.ShouldBe(attemptId); + var reusedAttempt = dbContext.ConversationAttempts.Include(a => a.Rounds).First(a => a.Id == attemptId); + reusedAttempt.Rounds.Count.ShouldBe(roundCountAfterFirst + 1); + } + + [Fact] + public async Task Start_unenrolled_fails() + { + using var scope = Factory.Services.CreateScope(); + var controller = CreateController(scope, "-1"); + var dto = new SubmitElaborationRequestDto { Elaboration = "Should fail." }; + + var tokens = await CollectStreamAsync(controller.StartConversation(-1, dto, CancellationToken.None)); + + tokens.Count.ShouldBe(1); + var error = JsonSerializer.Deserialize(tokens[0]); + error.GetProperty("code").GetInt32().ShouldBe(403); + } + + [Fact] + public async Task Start_insufficient_tokens_fails() + { + Factory.MockChatService.Reset(); + using var scope = Factory.Services.CreateScope(); + var controller = CreateController(scope, "-4"); + var dto = new SubmitElaborationRequestDto { Elaboration = "Should fail due to exhausted wallet." }; + + var tokens = await CollectStreamAsync(controller.StartConversation(-1, dto, CancellationToken.None)); + + tokens.Count.ShouldBe(1); + var error = JsonSerializer.Deserialize(tokens[0]); + error.GetProperty("code").GetInt32().ShouldBe(402); + } + + [Fact] + public async Task Start_max_daily_attempts_fails() + { + Factory.MockChatService.Reset(); + using var scope = Factory.Services.CreateScope(); + var controller = CreateController(scope, "-2"); + var dto = new SubmitElaborationRequestDto { Elaboration = "Should fail due to daily limit." }; + + var tokens = await CollectStreamAsync(controller.StartConversation(-3, dto, CancellationToken.None)); + + tokens.Count.ShouldBe(1); + var error = JsonSerializer.Deserialize(tokens[0]); + error.GetProperty("code").GetInt32().ShouldBe(429); + } + + [Fact] + public async Task Submit_evaluation_failure_returns_error() + { + Factory.MockChatService.Reset(); + Factory.MockChatService.Setup(x => x.CompleteAsync( + It.IsAny(), It.IsAny())) + .ReturnsAsync(Result.Fail("LLM unavailable")); + using var scope = Factory.Services.CreateScope(); + var controller = CreateController(scope, "-3"); + var dto = new SubmitElaborationRequestDto { Elaboration = "Should trigger eval failure." }; + + var tokens = await CollectStreamAsync(controller.SubmitElaboration(-3, dto, CancellationToken.None)); + + tokens.Count.ShouldBe(1, $"Got: [{string.Join("|", tokens)}]"); + var error = JsonSerializer.Deserialize(tokens[0]); + error.GetProperty("code").GetInt32().ShouldBe(500); + } + + [Fact] + public async Task Start_nonexistent_task_fails() + { + using var scope = Factory.Services.CreateScope(); + var controller = CreateController(scope, "-2"); + var dto = new SubmitElaborationRequestDto { Elaboration = "Task does not exist." }; + + var tokens = await CollectStreamAsync(controller.StartConversation(-999, dto, CancellationToken.None)); + + tokens.Count.ShouldBe(1); + var error = JsonSerializer.Deserialize(tokens[0]); + error.GetProperty("code").GetInt32().ShouldBe(404); + } + + [Fact] + public async Task Start_with_active_attempt_returns_conflict() + { + using var scope = Factory.Services.CreateScope(); + var controller = CreateController(scope, "-3"); + var dto = new SubmitElaborationRequestDto { Elaboration = "Should conflict." }; + + var tokens = await CollectStreamAsync(controller.StartConversation(-1, dto, CancellationToken.None)); + + tokens.Count.ShouldBe(1); + var error = JsonSerializer.Deserialize(tokens[0]); + error.GetProperty("code").GetInt32().ShouldBe(409); + error.GetProperty("attemptId").GetInt32().ShouldBe(-3); + } + + [Fact] + public async Task Submit_nonexistent_attempt_fails() + { + using var scope = Factory.Services.CreateScope(); + var controller = CreateController(scope, "-2"); + var dto = new SubmitElaborationRequestDto { Elaboration = "Attempt does not exist." }; + + var tokens = await CollectStreamAsync(controller.SubmitElaboration(-999, dto, CancellationToken.None)); + + tokens.Count.ShouldBe(1); + var error = JsonSerializer.Deserialize(tokens[0]); + error.GetProperty("code").GetInt32().ShouldBe(404); + } + + [Fact] + public async Task Submit_wrong_learner_fails() + { + using var scope = Factory.Services.CreateScope(); + var controller = CreateController(scope, "-2"); + var dto = new SubmitElaborationRequestDto { Elaboration = "Not my attempt." }; + + var tokens = await CollectStreamAsync(controller.SubmitElaboration(-4, dto, CancellationToken.None)); + + tokens.Count.ShouldBe(1); + var error = JsonSerializer.Deserialize(tokens[0]); + error.GetProperty("code").GetInt32().ShouldBe(403); + } + + [Fact] + public async Task Submit_completed_attempt_fails() + { + using var scope = Factory.Services.CreateScope(); + var controller = CreateController(scope, "-2"); + var dto = new SubmitElaborationRequestDto { Elaboration = "Attempt already done." }; + + var tokens = await CollectStreamAsync(controller.SubmitElaboration(-1, dto, CancellationToken.None)); + + tokens.Count.ShouldBe(1); + var error = JsonSerializer.Deserialize(tokens[0]); + error.GetProperty("code").GetInt32().ShouldBe(409); + } + + private static ConversationController CreateController(IServiceScope scope, string learnerId) + { + return new ConversationController(scope.ServiceProvider.GetRequiredService()) + { + ControllerContext = BuildContext(learnerId, "learner") + }; + } + + private static async Task> CollectStreamAsync(IAsyncEnumerable stream) + { + var tokens = new List(); + await foreach (var token in stream) + tokens.Add(token); + return tokens; + } +} diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Tests/TestData/a-delete.sql b/src/Modules/Elaborations/Tutor.Elaborations.Tests/TestData/a-delete.sql new file mode 100644 index 000000000..deeec1aaf --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Tests/TestData/a-delete.sql @@ -0,0 +1,13 @@ +DELETE FROM elaborations."RoundEvaluations"; +DELETE FROM elaborations."ConversationRounds"; +DELETE FROM elaborations."ConversationAttempts"; +DELETE FROM elaborations."ConceptRecords"; +DELETE FROM elaborations."ConceptElaborationTasks"; + +DELETE FROM courses."CourseOwnerships"; +DELETE FROM courses."UnitEnrollments"; +DELETE FROM courses."LearnerGroups"; +DELETE FROM courses."WalletEvents"; +DELETE FROM courses."TokenWallets"; +DELETE FROM courses."KnowledgeUnits"; +DELETE FROM courses."Courses"; diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Tests/TestData/b-courses.sql b/src/Modules/Elaborations/Tutor.Elaborations.Tests/TestData/b-courses.sql new file mode 100644 index 000000000..3c80360cb --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Tests/TestData/b-courses.sql @@ -0,0 +1,53 @@ +-- Courses +INSERT INTO courses."Courses"("Id", "Code", "Name", "Description", "IsArchived", "StartDate") +VALUES (-1, 'T-1', 'TestCourse1', '', false, '2022-09-11 12:00:01'); +INSERT INTO courses."Courses"("Id", "Code", "Name", "Description", "IsArchived", "StartDate") +VALUES (-2, 'T-2', 'TestCourse2', '', false, '2022-09-11 12:00:01'); + +-- Knowledge Units: -1, -2 in Course -1; -3 in Course -2 +INSERT INTO courses."KnowledgeUnits"("Id", "Name", "Code", "Goals", "CourseId", "Order") +VALUES (-1, 'T-1', 'T-1', 'T-1', -1, 1); +INSERT INTO courses."KnowledgeUnits"("Id", "Name", "Code", "Goals", "CourseId", "Order") +VALUES (-2, 'T-2', 'T-2', 'T-2', -1, 2); +INSERT INTO courses."KnowledgeUnits"("Id", "Name", "Code", "Goals", "CourseId", "Order") +VALUES (-3, 'T-3', 'T-3', 'T-3', -2, 3); + +-- Course Ownerships: Instructor -51 owns Course -1; Instructor -52 owns Courses -1 and -2 +INSERT INTO courses."CourseOwnerships"("Id", "CourseId", "InstructorId") VALUES (-1, -1, -51); +INSERT INTO courses."CourseOwnerships"("Id", "CourseId", "InstructorId") VALUES (-2, -1, -52); +INSERT INTO courses."CourseOwnerships"("Id", "CourseId", "InstructorId") VALUES (-3, -2, -52); + +-- Enrollments (BestBefore in future so they remain accessible) +INSERT INTO courses."UnitEnrollments"("Id", "LearnerId", "KnowledgeUnitId", "Start", "BestBefore", "Status") +VALUES (-1, -2, -1, '2021-12-19 21:29:50+01', '2027-12-25 21:29:50+01', 1); +INSERT INTO courses."UnitEnrollments"("Id", "LearnerId", "KnowledgeUnitId", "Start", "BestBefore", "Status") +VALUES (-2, -2, -2, '2021-12-19 21:29:50+01', '2027-12-25 21:29:50+01', 1); +INSERT INTO courses."UnitEnrollments"("Id", "LearnerId", "KnowledgeUnitId", "Start", "BestBefore", "Status") +VALUES (-3, -3, -1, '2021-12-19 21:29:50+01', '2027-12-25 21:29:50+01', 1); +INSERT INTO courses."UnitEnrollments"("Id", "LearnerId", "KnowledgeUnitId", "Start", "BestBefore", "Status") +VALUES (-4, -3, -2, '2021-12-19 21:29:50+01', '2027-12-25 21:29:50+01', 1); +INSERT INTO courses."UnitEnrollments"("Id", "LearnerId", "KnowledgeUnitId", "Start", "BestBefore", "Status") +VALUES (-5, -4, -1, '2021-12-19 21:29:50+01', '2027-12-25 21:29:50+01', 1); +INSERT INTO courses."UnitEnrollments"("Id", "LearnerId", "KnowledgeUnitId", "Start", "BestBefore", "Status") +VALUES (-7, -2, -3, '2021-12-19 21:29:50+01', '2027-12-25 21:29:50+01', 1); + +-- Token Wallets (IDs -101+ to avoid collision with other modules) +INSERT INTO courses."WalletEvents"("Id", "AggregateType", "AggregateId", "TimeStamp", "LearnerId", "CourseId", "DomainEvent") +VALUES (-101, 'Wallet', -101, '2024-01-01 10:00:00+00', -2, -1, + '{{"$type":"WalletInitialized","InitialAllowance":2000000,"LearnerId":-2,"CourseId":-1,"TimeStamp":"2024-01-01T10:00:00Z"}}'::jsonb); +INSERT INTO courses."WalletEvents"("Id", "AggregateType", "AggregateId", "TimeStamp", "LearnerId", "CourseId", "DomainEvent") +VALUES (-102, 'Wallet', -102, '2024-01-01 10:00:00+00', -3, -1, + '{{"$type":"WalletInitialized","InitialAllowance":2500000,"LearnerId":-3,"CourseId":-1,"TimeStamp":"2024-01-01T10:00:00Z"}}'::jsonb); +INSERT INTO courses."WalletEvents"("Id", "AggregateType", "AggregateId", "TimeStamp", "LearnerId", "CourseId", "DomainEvent") +VALUES (-103, 'Wallet', -103, '2024-01-01 10:00:00+00', -4, -1, + '{{"$type":"WalletInitialized","InitialAllowance":100,"LearnerId":-4,"CourseId":-1,"TimeStamp":"2024-01-01T10:00:00Z"}}'::jsonb); +INSERT INTO courses."WalletEvents"("Id", "AggregateType", "AggregateId", "TimeStamp", "LearnerId", "CourseId", "DomainEvent") +VALUES (-104, 'Wallet', -103, '2024-01-02 10:00:00+00', -4, -1, + '{{"$type":"TokensSpent","UnitId":-1,"PromptTokens":25,"CompletionTokens":25,"FeatureType":"Elaboration","EntityId":1,"PromptSummary":"Exhaust balance","LearnerId":-4,"CourseId":-1,"TimeStamp":"2024-01-02T10:00:00Z"}}'::jsonb); + +INSERT INTO courses."TokenWallets"("Id", "LearnerId", "CourseId", "TotalAllowance", "TotalSpent") +VALUES (-101, -2, -1, 2000000, 150); +INSERT INTO courses."TokenWallets"("Id", "LearnerId", "CourseId", "TotalAllowance", "TotalSpent") +VALUES (-102, -3, -1, 2500000, 0); +INSERT INTO courses."TokenWallets"("Id", "LearnerId", "CourseId", "TotalAllowance", "TotalSpent") +VALUES (-103, -4, -1, 100, 100); diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Tests/TestData/c-concept-elaboration-tasks.sql b/src/Modules/Elaborations/Tutor.Elaborations.Tests/TestData/c-concept-elaboration-tasks.sql new file mode 100644 index 000000000..a2d131c47 --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Tests/TestData/c-concept-elaboration-tasks.sql @@ -0,0 +1,93 @@ +-- Braces in JSON literals are doubled ({{ }}) because the test harness +-- loads this SQL via ExecuteSqlRaw, which runs it through string.Format first. + +-- CET -1: Encapsulation (Basics), Unit -1, Order 1 (owned by Instructor -51 via Course -1) +INSERT INTO elaborations."ConceptElaborationTasks"("Id", "UnitId", "Order", "Title", "Description") +VALUES (-1, -1, 1, 'Encapsulation (Basics)', 'Introduction to encapsulation and data hiding.'); + +INSERT INTO elaborations."ConceptRecords"( + "Id", "ConceptElaborationTaskId", "CanonicalDefinition", + "KeyPropositions", "CommonMisconceptions", "KeyRelations") +VALUES (-1, -1, + 'Encapsulation is the bundling of data and methods within a class, restricting direct access to internal state.', + '[{{"key":"P1","statement":"Data and methods are bundled in a class"}}]'::jsonb, + '[{{"key":"M1","description":"Encapsulation means making everything private","correction":"Encapsulation is about controlled access, not total hiding"}}]'::jsonb, + '[]'::jsonb); + +-- CET -2: Encapsulation (Members), Unit -1, Order 2 +INSERT INTO elaborations."ConceptElaborationTasks"("Id", "UnitId", "Order", "Title", "Description") +VALUES (-2, -1, 2, 'Encapsulation (Members)', 'Encapsulation applied to class members and access modifiers.'); + +INSERT INTO elaborations."ConceptRecords"( + "Id", "ConceptElaborationTaskId", "CanonicalDefinition", + "KeyPropositions", "CommonMisconceptions", "KeyRelations") +VALUES (-2, -2, + 'Encapsulation is the bundling of data and methods within a class, restricting direct access to internal state.', + '[{{"key":"P1","statement":"Data and methods are bundled in a class"}},{{"key":"P2","statement":"Access modifiers control visibility of members"}}]'::jsonb, + '[{{"key":"M1","description":"Encapsulation means making everything private","correction":"Encapsulation is about controlled access, not total hiding"}},{{"key":"M2","description":"Getters and setters are always good encapsulation","correction":"Blind getters/setters can break encapsulation by exposing internals"}}]'::jsonb, + '[]'::jsonb); + +-- CET -3: Encapsulation (Basics — Unit 2), Unit -2, Order 1 (owned by Instructor -51) +INSERT INTO elaborations."ConceptElaborationTasks"("Id", "UnitId", "Order", "Title", "Description") +VALUES (-3, -2, 1, 'Encapsulation (Basics — Unit 2)', 'Introduction to encapsulation and data hiding.'); + +INSERT INTO elaborations."ConceptRecords"( + "Id", "ConceptElaborationTaskId", "CanonicalDefinition", + "KeyPropositions", "CommonMisconceptions", "KeyRelations") +VALUES (-3, -3, + 'Encapsulation is the bundling of data and methods within a class, restricting direct access to internal state.', + '[{{"key":"P1","statement":"Data and methods are bundled in a class"}}]'::jsonb, + '[]'::jsonb, + '[]'::jsonb); + +-- CET -4: Inheritance, Unit -3, Order 1 (owned ONLY by Instructor -52, NOT -51) +INSERT INTO elaborations."ConceptElaborationTasks"("Id", "UnitId", "Order", "Title", "Description") +VALUES (-4, -3, 1, 'Inheritance', 'Class inheritance and behavior reuse.'); + +INSERT INTO elaborations."ConceptRecords"( + "Id", "ConceptElaborationTaskId", "CanonicalDefinition", + "KeyPropositions", "CommonMisconceptions", "KeyRelations") +VALUES (-4, -4, + 'Inheritance allows a class to derive behavior from another class.', + '[{{"key":"P1","statement":"Child class inherits parent behavior"}}]'::jsonb, + '[]'::jsonb, + '[]'::jsonb); + +-- CET -5: Encapsulation (Members — Unit 2), Unit -2, Order 2 (isolated for StartConversation tests) +INSERT INTO elaborations."ConceptElaborationTasks"("Id", "UnitId", "Order", "Title", "Description") +VALUES (-5, -2, 2, 'Encapsulation (Members — Unit 2)', 'Encapsulation applied to class members and access modifiers.'); + +INSERT INTO elaborations."ConceptRecords"( + "Id", "ConceptElaborationTaskId", "CanonicalDefinition", + "KeyPropositions", "CommonMisconceptions", "KeyRelations") +VALUES (-5, -5, + 'Encapsulation is the bundling of data and methods within a class, restricting direct access to internal state.', + '[{{"key":"P1","statement":"Data and methods are bundled in a class"}},{{"key":"P2","statement":"Access modifiers control visibility of members"}}]'::jsonb, + '[]'::jsonb, + '[]'::jsonb); + +-- CET -6: Encapsulation (Invariants), Unit -2, Order 3 (isolated for Start+Submit flow test) +INSERT INTO elaborations."ConceptElaborationTasks"("Id", "UnitId", "Order", "Title", "Description") +VALUES (-6, -2, 3, 'Encapsulation (Invariants)', 'Protecting internal invariants through encapsulation.'); + +INSERT INTO elaborations."ConceptRecords"( + "Id", "ConceptElaborationTaskId", "CanonicalDefinition", + "KeyPropositions", "CommonMisconceptions", "KeyRelations") +VALUES (-6, -6, + 'Encapsulation is the bundling of data and methods within a class, restricting direct access to internal state.', + '[{{"key":"P1","statement":"Data and methods are bundled in a class"}},{{"key":"P2","statement":"Access modifiers control visibility of members"}},{{"key":"P3","statement":"Internal invariants are protected from external corruption"}}]'::jsonb, + '[]'::jsonb, + '[]'::jsonb); + +-- CET -7: Polymorphism Mechanics, Unit -2, Order 4 (isolated, has KeyRelation) +INSERT INTO elaborations."ConceptElaborationTasks"("Id", "UnitId", "Order", "Title", "Description") +VALUES (-7, -2, 4, 'Polymorphism Mechanics', 'Runtime method dispatch and virtual call mechanics.'); + +INSERT INTO elaborations."ConceptRecords"( + "Id", "ConceptElaborationTaskId", "CanonicalDefinition", + "KeyPropositions", "CommonMisconceptions", "KeyRelations") +VALUES (-7, -7, + 'Polymorphism resolves method calls at runtime via dynamic dispatch.', + '[{{"key":"P1","statement":"A subclass can override a parent method"}},{{"key":"P2","statement":"The runtime selects the implementation by the actual type"}}]'::jsonb, + '[]'::jsonb, + '[{{"key":"R1","sourceKey":"P1","targetKey":"P2","mechanism":"Override matters because dispatch happens at runtime, not compile time"}}]'::jsonb); diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Tests/TestData/e-conversation-attempts.sql b/src/Modules/Elaborations/Tutor.Elaborations.Tests/TestData/e-conversation-attempts.sql new file mode 100644 index 000000000..9901acde8 --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Tests/TestData/e-conversation-attempts.sql @@ -0,0 +1,59 @@ +-- Attempt -1: Learner -2, CET -1, Completed (for query tests and cannot-submit tests) +INSERT INTO elaborations."ConversationAttempts"("Id", "ConceptElaborationTaskId", "LearnerId", "Status", "StartedAt", "CompletedAt", "FinalGrade", "TotalTargets", "MaxRounds") +VALUES (-1, -1, -2, 1, '2024-06-01 10:00:00+00', '2024-06-01 10:15:00+00', 1.0, 1, 4); + +INSERT INTO elaborations."ConversationRounds"("Id", "ConversationAttemptId", "Order", "ElaborationContent", "SubmittedAt", "FeedbackContent", "Probes") +VALUES (-1, -1, 0, 'Encapsulation bundles data and methods in a class, hiding implementation details.', '2024-06-01 10:01:00+00', NULL, '[]'::jsonb); + +INSERT INTO elaborations."RoundEvaluations"("Id", "ConversationRoundId", "Assessments", "TriggeredMisconceptions") +VALUES (-1, -1, '[{{"Key":"P1","Type":0,"Grade":2}}]'::jsonb, '[]'::jsonb); + +-- Attempt -2: Learner -2, CET -1, Abandoned (for query tests) +INSERT INTO elaborations."ConversationAttempts"("Id", "ConceptElaborationTaskId", "LearnerId", "Status", "StartedAt", "CompletedAt", "FinalGrade", "TotalTargets", "MaxRounds") +VALUES (-2, -1, -2, 2, '2024-06-02 10:00:00+00', '2024-06-02 10:05:00+00', 0.0, 1, 4); + +-- Attempt -3: Learner -3, CET -1, InProgress, 1 round (conflict + eval failure tests) +INSERT INTO elaborations."ConversationAttempts"("Id", "ConceptElaborationTaskId", "LearnerId", "Status", "StartedAt", "CompletedAt", "FinalGrade", "TotalTargets", "MaxRounds") +VALUES (-3, -1, -3, 0, '2024-06-03 10:00:00+00', null, 0.0, 1, 4); + +INSERT INTO elaborations."ConversationRounds"("Id", "ConversationAttemptId", "Order", "ElaborationContent", "SubmittedAt", "FeedbackContent", "Probes") +VALUES (-3, -3, 0, 'Encapsulation is about data hiding.', '2024-06-03 10:01:00+00', 'What else can you tell me about encapsulation?', '[{{"ScoredTarget":{{"Key":"P1","Type":0,"Grade":0}},"StagnantCount":0}}]'::jsonb); + +INSERT INTO elaborations."RoundEvaluations"("Id", "ConversationRoundId", "Assessments", "TriggeredMisconceptions") +VALUES (-3, -3, '[{{"Key":"P1","Type":0,"Grade":0}}]'::jsonb, '[{{"Key":"M1","Type":2,"Grade":-2,"Evidence":""}}]'::jsonb); + +-- Attempt -4: Learner -3, CET -2, InProgress, 1 round (completion test: submit all grade 2 → Completed) +INSERT INTO elaborations."ConversationAttempts"("Id", "ConceptElaborationTaskId", "LearnerId", "Status", "StartedAt", "CompletedAt", "FinalGrade", "TotalTargets", "MaxRounds") +VALUES (-4, -2, -3, 0, '2024-06-04 10:00:00+00', null, 0.0, 2, 4); + +INSERT INTO elaborations."ConversationRounds"("Id", "ConversationAttemptId", "Order", "ElaborationContent", "SubmittedAt", "FeedbackContent", "Probes") +VALUES (-4, -4, 0, 'Encapsulation bundles data and methods, but access control is unclear.', '2024-06-04 10:01:00+00', 'Consider elaborating on how access modifiers enforce encapsulation.', '[{{"ScoredTarget":{{"Key":"P2","Type":0,"Grade":0}},"StagnantCount":0}}]'::jsonb); + +INSERT INTO elaborations."RoundEvaluations"("Id", "ConversationRoundId", "Assessments", "TriggeredMisconceptions") +VALUES (-4, -4, '[{{"Key":"P1","Type":0,"Grade":1}},{{"Key":"P2","Type":0,"Grade":0}}]'::jsonb, '[]'::jsonb); + +-- Attempt -5: Learner -2, CET -2, InProgress, 3 rounds, MaxRounds=4 (hard cap test: next submission expires) +INSERT INTO elaborations."ConversationAttempts"("Id", "ConceptElaborationTaskId", "LearnerId", "Status", "StartedAt", "CompletedAt", "FinalGrade", "TotalTargets", "MaxRounds") +VALUES (-5, -2, -2, 0, '2024-06-05 10:00:00+00', null, 0.0, 2, 4); + +INSERT INTO elaborations."ConversationRounds"("Id", "ConversationAttemptId", "Order", "ElaborationContent", "SubmittedAt", "FeedbackContent", "Probes") +VALUES (-50, -5, 0, 'Round 1 elaboration.', '2024-06-05 10:01:00+00', 'Feedback 1.', '[{{"ScoredTarget":{{"Key":"P1","Type":0,"Grade":0}},"StagnantCount":0}},{{"ScoredTarget":{{"Key":"P2","Type":0,"Grade":0}},"StagnantCount":0}}]'::jsonb); +INSERT INTO elaborations."ConversationRounds"("Id", "ConversationAttemptId", "Order", "ElaborationContent", "SubmittedAt", "FeedbackContent", "Probes") +VALUES (-52, -5, 1, 'Round 2 elaboration.', '2024-06-05 10:02:00+00', 'Feedback 2.', '[{{"ScoredTarget":{{"Key":"P1","Type":0,"Grade":0}},"StagnantCount":1}},{{"ScoredTarget":{{"Key":"P2","Type":0,"Grade":0}},"StagnantCount":1}}]'::jsonb); +INSERT INTO elaborations."ConversationRounds"("Id", "ConversationAttemptId", "Order", "ElaborationContent", "SubmittedAt", "FeedbackContent", "Probes") +VALUES (-54, -5, 2, 'Round 3 elaboration.', '2024-06-05 10:03:00+00', 'Feedback 3.', '[{{"ScoredTarget":{{"Key":"P1","Type":0,"Grade":0}},"StagnantCount":2}},{{"ScoredTarget":{{"Key":"P2","Type":0,"Grade":0}},"StagnantCount":2}}]'::jsonb); + +INSERT INTO elaborations."RoundEvaluations"("Id", "ConversationRoundId", "Assessments", "TriggeredMisconceptions") +VALUES (-50, -50, '[{{"Key":"P1","Type":0,"Grade":0}},{{"Key":"P2","Type":0,"Grade":0}}]'::jsonb, '[]'::jsonb); +INSERT INTO elaborations."RoundEvaluations"("Id", "ConversationRoundId", "Assessments", "TriggeredMisconceptions") +VALUES (-52, -52, '[{{"Key":"P1","Type":0,"Grade":0}},{{"Key":"P2","Type":0,"Grade":0}}]'::jsonb, '[]'::jsonb); +INSERT INTO elaborations."RoundEvaluations"("Id", "ConversationRoundId", "Assessments", "TriggeredMisconceptions") +VALUES (-54, -54, '[{{"Key":"P1","Type":0,"Grade":0}},{{"Key":"P2","Type":0,"Grade":0}}]'::jsonb, '[]'::jsonb); + +-- Attempt -6: Learner -3, CET -3, InProgress, 0 rounds +INSERT INTO elaborations."ConversationAttempts"("Id", "ConceptElaborationTaskId", "LearnerId", "Status", "StartedAt", "CompletedAt", "FinalGrade", "TotalTargets", "MaxRounds") +VALUES (-6, -3, -3, 0, '2024-06-06 10:00:00+00', null, 0.0, 1, 4); + +-- Attempt -7: Learner -3, CET -5, InProgress, 0 rounds (isolated for abandon test) +INSERT INTO elaborations."ConversationAttempts"("Id", "ConceptElaborationTaskId", "LearnerId", "Status", "StartedAt", "CompletedAt", "FinalGrade", "TotalTargets", "MaxRounds") +VALUES (-7, -5, -3, 0, '2024-06-07 10:00:00+00', null, 0.0, 2, 4); diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Tests/TestData/f-daily-limit-attempts.sql b/src/Modules/Elaborations/Tutor.Elaborations.Tests/TestData/f-daily-limit-attempts.sql new file mode 100644 index 000000000..705d0dfd2 --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Tests/TestData/f-daily-limit-attempts.sql @@ -0,0 +1,7 @@ +-- 3 recent attempts for Learner -2 on CET -3 (triggers MaxAttemptsPerDay=3 limit) +INSERT INTO elaborations."ConversationAttempts"("Id", "ConceptElaborationTaskId", "LearnerId", "Status", "StartedAt", "CompletedAt", "FinalGrade", "TotalTargets", "MaxRounds") +VALUES (-10, -3, -2, 1, NOW() - INTERVAL '3 hours', NOW() - INTERVAL '2 hours', 1.0, 1, 4); +INSERT INTO elaborations."ConversationAttempts"("Id", "ConceptElaborationTaskId", "LearnerId", "Status", "StartedAt", "CompletedAt", "FinalGrade", "TotalTargets", "MaxRounds") +VALUES (-11, -3, -2, 1, NOW() - INTERVAL '2 hours', NOW() - INTERVAL '1 hour', 1.0, 1, 4); +INSERT INTO elaborations."ConversationAttempts"("Id", "ConceptElaborationTaskId", "LearnerId", "Status", "StartedAt", "CompletedAt", "FinalGrade", "TotalTargets", "MaxRounds") +VALUES (-12, -3, -2, 2, NOW() - INTERVAL '1 hour', NOW() - INTERVAL '30 minutes', 0.0, 1, 4); diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Tests/Tutor.Elaborations.Tests.csproj b/src/Modules/Elaborations/Tutor.Elaborations.Tests/Tutor.Elaborations.Tests.csproj new file mode 100644 index 000000000..20ed7a9d4 --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Tests/Tutor.Elaborations.Tests.csproj @@ -0,0 +1,34 @@ + + + + net10.0 + enable + enable + + false + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + + + diff --git a/src/Modules/Elaborations/Tutor.Elaborations.Tests/Usings.cs b/src/Modules/Elaborations/Tutor.Elaborations.Tests/Usings.cs new file mode 100644 index 000000000..c802f4480 --- /dev/null +++ b/src/Modules/Elaborations/Tutor.Elaborations.Tests/Usings.cs @@ -0,0 +1 @@ +global using Xunit; diff --git a/src/Modules/KnowledgeComponents/Tutor.KnowledgeComponents.Tests/KnowledgeComponentsTestFactory.cs b/src/Modules/KnowledgeComponents/Tutor.KnowledgeComponents.Tests/KnowledgeComponentsTestFactory.cs index 75fb054a4..cdc032af8 100644 --- a/src/Modules/KnowledgeComponents/Tutor.KnowledgeComponents.Tests/KnowledgeComponentsTestFactory.cs +++ b/src/Modules/KnowledgeComponents/Tutor.KnowledgeComponents.Tests/KnowledgeComponentsTestFactory.cs @@ -8,6 +8,15 @@ namespace Tutor.KnowledgeComponents.Tests; public class KnowledgeComponentsTestFactory : BaseTestFactory { + protected override Type[] GetRequiredDbContextTypes() => + [typeof(CoursesContext), typeof(KnowledgeComponentsContext)]; + + protected override List GetOrderedTestDataFolders() => + [ + "../../../../../Courses/Tutor.Courses.Tests/TestData/", + "../../../TestData/" + ]; + protected override IServiceCollection ReplaceNeededDbContexts(IServiceCollection services) { var descriptor = services.SingleOrDefault(d => d.ServiceType == typeof(DbContextOptions)); diff --git a/src/Modules/LearningTasks/Tutor.LearningTasks.Tests/LearningTasksTestFactory.cs b/src/Modules/LearningTasks/Tutor.LearningTasks.Tests/LearningTasksTestFactory.cs index f76e991df..9ff026d7b 100644 --- a/src/Modules/LearningTasks/Tutor.LearningTasks.Tests/LearningTasksTestFactory.cs +++ b/src/Modules/LearningTasks/Tutor.LearningTasks.Tests/LearningTasksTestFactory.cs @@ -8,6 +8,15 @@ namespace Tutor.LearningTasks.Tests; public class LearningTasksTestFactory : BaseTestFactory { + protected override Type[] GetRequiredDbContextTypes() => + [typeof(CoursesContext), typeof(LearningTasksContext)]; + + protected override List GetOrderedTestDataFolders() => + [ + "../../../../../Courses/Tutor.Courses.Tests/TestData/", + "../../../TestData/" + ]; + protected override IServiceCollection ReplaceNeededDbContexts(IServiceCollection services) { var descriptor = services.SingleOrDefault(d => d.ServiceType == typeof(DbContextOptions)); diff --git a/src/Modules/Stakeholders/Tutor.Stakeholders.Core/UseCases/Management/StakeholderService.cs b/src/Modules/Stakeholders/Tutor.Stakeholders.Core/UseCases/Management/StakeholderService.cs index 3becc9d38..efe5fc111 100644 --- a/src/Modules/Stakeholders/Tutor.Stakeholders.Core/UseCases/Management/StakeholderService.cs +++ b/src/Modules/Stakeholders/Tutor.Stakeholders.Core/UseCases/Management/StakeholderService.cs @@ -33,10 +33,10 @@ public Result Register(StakeholderAccountDto entity, stri } entity.UserId = user.Id; var registerResult = Create(entity); - if (result.IsFailed) + if (registerResult.IsFailed) { UnitOfWork.Rollback(); - return result; + return registerResult; } UnitOfWork.Commit(); diff --git a/src/Modules/Stakeholders/Tutor.Stakeholders.Infrastructure/Database/StakeholdersContext.cs b/src/Modules/Stakeholders/Tutor.Stakeholders.Infrastructure/Database/StakeholdersContext.cs index d69d8a147..7478ed8bd 100644 --- a/src/Modules/Stakeholders/Tutor.Stakeholders.Infrastructure/Database/StakeholdersContext.cs +++ b/src/Modules/Stakeholders/Tutor.Stakeholders.Infrastructure/Database/StakeholdersContext.cs @@ -31,5 +31,6 @@ private static void ConfigureStakeholder(ModelBuilder modelBuilder) .HasForeignKey(s => s.UserId); modelBuilder.Entity().Property(l => l.LearnerType).HasDefaultValue(LearnerType.Regular); + modelBuilder.Entity().HasIndex(l => l.UserId).IsUnique(); } } \ No newline at end of file diff --git a/src/Tutor.API/Controllers/BaseApiController.cs b/src/Tutor.API/Controllers/BaseApiController.cs index 51dfe9fb0..a345f5bb2 100644 --- a/src/Tutor.API/Controllers/BaseApiController.cs +++ b/src/Tutor.API/Controllers/BaseApiController.cs @@ -11,6 +11,7 @@ protected ActionResult CreateErrorResponse(IReadOnlyList errors) { var code = 500; if (ContainsErrorCode(errors, 400)) code = 400; + if (ContainsErrorCode(errors, 402)) code = 402; if (ContainsErrorCode(errors, 403)) code = 403; if (ContainsErrorCode(errors, 404)) code = 404; if (ContainsErrorCode(errors, 409)) code = 409; diff --git a/src/Tutor.API/Controllers/Instructor/Authoring/Elaboration/ConceptElaborationTaskController.cs b/src/Tutor.API/Controllers/Instructor/Authoring/Elaboration/ConceptElaborationTaskController.cs new file mode 100644 index 000000000..fd038c23e --- /dev/null +++ b/src/Tutor.API/Controllers/Instructor/Authoring/Elaboration/ConceptElaborationTaskController.cs @@ -0,0 +1,50 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Tutor.Elaborations.API.Dtos.ConceptElaborationTasks; +using Tutor.Elaborations.API.Public.Authoring; +using Tutor.Stakeholders.Infrastructure.Authentication; + +namespace Tutor.API.Controllers.Instructor.Authoring.Elaboration; + +[Authorize(Policy = "instructorPolicy")] +[Route("api/authoring/units/{unitId:int}/concept-elaborations")] +public class ConceptElaborationTaskController : BaseApiController +{ + private readonly IConceptElaborationTaskService _service; + + public ConceptElaborationTaskController(IConceptElaborationTaskService service) + { + _service = service; + } + + [HttpGet] + public ActionResult> GetByUnit(int unitId) + { + var result = _service.GetByUnit(unitId, User.InstructorId()); + return CreateResponse(result); + } + + [HttpPost] + public ActionResult Create(int unitId, [FromBody] ConceptElaborationTaskDto dto) + { + dto.UnitId = unitId; + var result = _service.Create(dto, User.InstructorId()); + return CreateResponse(result); + } + + [HttpPut("{id:int}")] + public ActionResult Update(int unitId, int id, [FromBody] ConceptElaborationTaskDto dto) + { + dto.Id = id; + dto.UnitId = unitId; + var result = _service.Update(dto, User.InstructorId()); + return CreateResponse(result); + } + + [HttpDelete("{id:int}")] + public ActionResult Delete(int unitId, int id) + { + var result = _service.Delete(id, unitId, User.InstructorId()); + return CreateResponse(result); + } +} diff --git a/src/Tutor.API/Controllers/Learner/Learning/Elaboration/ConversationController.cs b/src/Tutor.API/Controllers/Learner/Learning/Elaboration/ConversationController.cs new file mode 100644 index 000000000..1583b542a --- /dev/null +++ b/src/Tutor.API/Controllers/Learner/Learning/Elaboration/ConversationController.cs @@ -0,0 +1,66 @@ +using System.Runtime.CompilerServices; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Tutor.Elaborations.API.Dtos.ConceptElaborationTasks; +using Tutor.Elaborations.API.Dtos.Conversations; +using Tutor.Elaborations.API.Public.Learning; +using Tutor.Stakeholders.Infrastructure.Authentication; + +namespace Tutor.API.Controllers.Learner.Learning.Elaboration; + +[Authorize(Policy = "learnerPolicy")] +[Route("api/learning")] +public class ConversationController : BaseApiController +{ + private readonly IConversationService _conversationService; + + public ConversationController(IConversationService conversationService) + { + _conversationService = conversationService; + } + + [HttpGet("units/{unitId:int}/concept-elaborations")] + public ActionResult> GetTasksForUnit(int unitId) + { + var result = _conversationService.GetTasksForUnit(unitId, User.LearnerId()); + return CreateResponse(result); + } + + [HttpGet("concept-elaborations/{taskId:int}")] + public ActionResult GetTaskWithAttempts(int taskId) + { + var result = _conversationService.GetTaskWithAttempts(taskId, User.LearnerId()); + return CreateResponse(result); + } + + [HttpPost("concept-elaborations/{taskId:int}/conversations")] + public async IAsyncEnumerable StartConversation(int taskId, + [FromBody] SubmitElaborationRequestDto dto, + [EnumeratorCancellation] CancellationToken ct) + { + await foreach (var token in _conversationService.StartConversationAsync( + taskId, dto.Elaboration, User.LearnerId(), ct)) + { + yield return token; + } + } + + [HttpPost("concept-elaborations/attempts/{attemptId:int}/elaborations")] + public async IAsyncEnumerable SubmitElaboration(int attemptId, + [FromBody] SubmitElaborationRequestDto dto, + [EnumeratorCancellation] CancellationToken ct) + { + await foreach (var token in _conversationService.SubmitElaborationAsync( + attemptId, dto.Elaboration, User.LearnerId(), ct)) + { + yield return token; + } + } + + [HttpPost("concept-elaborations/attempts/{attemptId:int}/abandon")] + public ActionResult AbandonAttempt(int attemptId) + { + var result = _conversationService.AbandonAttempt(attemptId, User.LearnerId()); + return CreateResponse(result); + } +} diff --git a/src/Tutor.API/Startup/ModulesConfiguration.cs b/src/Tutor.API/Startup/ModulesConfiguration.cs index ff457ca8c..3cbc6b7e6 100644 --- a/src/Tutor.API/Startup/ModulesConfiguration.cs +++ b/src/Tutor.API/Startup/ModulesConfiguration.cs @@ -1,6 +1,7 @@ using Tutor.BuildingBlocks.AI.Infrastructure; using Tutor.BuildingBlocks.Infrastructure.Security; using Tutor.Courses.Infrastructure; +using Tutor.Elaborations.Infrastructure; using Tutor.KnowledgeComponents.Infrastructure; using Tutor.LearningTasks.Infrastructure; using Tutor.LearningUtils.Infrastructure; @@ -14,7 +15,7 @@ public static IServiceCollection RegisterModules(this IServiceCollection service { services.AddAIServices(new AiServiceConfiguration { - ApiKey = EnvironmentConnection.GetSecret("OPENAI_API_KEY") ?? "", + ApiKey = EnvironmentConnection.GetSecret("OPENAI_API_KEY") ?? "TODO", ChatModelId = Environment.GetEnvironmentVariable("AI_CHAT_MODEL") ?? "gpt-4.1-mini", EmbeddingModelId = Environment.GetEnvironmentVariable("AI_EMBEDDING_MODEL") ?? "text-embedding-3-small" }); @@ -24,6 +25,7 @@ public static IServiceCollection RegisterModules(this IServiceCollection service services.ConfigureLearningUtilitiesModule(); services.ConfigureKnowledgeComponentsModule(); services.ConfigureLearningTasksModule(); + services.ConfigureElaborationsModule(); return services; } diff --git a/src/Tutor.API/Tutor.API.csproj b/src/Tutor.API/Tutor.API.csproj index 426c70a8a..f73fa684f 100644 --- a/src/Tutor.API/Tutor.API.csproj +++ b/src/Tutor.API/Tutor.API.csproj @@ -31,6 +31,8 @@ + + diff --git a/utils/.gitignore b/utils/.gitignore new file mode 100644 index 000000000..8854d14ad --- /dev/null +++ b/utils/.gitignore @@ -0,0 +1 @@ +conversations \ No newline at end of file diff --git a/utils/conversation-rounds.sql b/utils/conversation-rounds.sql new file mode 100644 index 000000000..8c571465b --- /dev/null +++ b/utils/conversation-rounds.sql @@ -0,0 +1,18 @@ +SELECT + cr."Id" AS "ConversationRoundId", + cr."ConversationAttemptId", + cr."Order", + cr."ElaborationContent", + cr."SubmittedAt", + cr."FeedbackContent", + cr."Probes", + + re."Id" AS "RoundEvaluationId", + re."Assessments", + re."TriggeredMisconceptions" +FROM elaborations."ConversationRounds" cr +LEFT JOIN elaborations."RoundEvaluations" re + ON re."ConversationRoundId" = cr."Id" + +WHERE cr."ConversationAttemptId"=? +ORDER BY cr."Order"; \ No newline at end of file diff --git a/utils/fetch_elaborations.py b/utils/fetch_elaborations.py new file mode 100644 index 000000000..224cd4691 --- /dev/null +++ b/utils/fetch_elaborations.py @@ -0,0 +1,69 @@ +import sys +import json +import re +import psycopg2 +import psycopg2.extras +from pathlib import Path + +DSN = "host=localhost port=5432 dbname=tutor-v9 user=postgres password=admin options='-c search_path=elaborations,public'" + +SQL_PATH = Path(__file__).parent / "conversation-rounds.sql" +OUTPUT_DIR = Path(__file__).parent / "conversations" + + +def to_json(data): + raw = json.dumps(data, indent=2, default=str, ensure_ascii=False) + # Collapse flat objects (no nested {} or []) onto a single line + return re.sub(r'\{[^{}\[\]]*\}', lambda m: re.sub(r'\s+', ' ', m.group()), raw, flags=re.DOTALL) + + +def load_sql(): + return SQL_PATH.read_text().replace("?", "%s") + + +def fetch_rounds(cursor, attempt_id): + cursor.execute(load_sql(), (attempt_id,)) + results = [] + for row in cursor.fetchall(): + evaluation = None + if row["RoundEvaluationId"] is not None: + evaluation = { + "RoundEvaluationId": row["RoundEvaluationId"], + "Assessments": row["Assessments"], + "TriggeredMisconceptions": row["TriggeredMisconceptions"], + } + results.append({ + "ConversationRoundId": row["ConversationRoundId"], + "ConversationAttemptId": row["ConversationAttemptId"], + "Order": row["Order"], + "ElaborationContent": row["ElaborationContent"], + "SubmittedAt": str(row["SubmittedAt"]), + "FeedbackContent": row["FeedbackContent"], + "Probes": row["Probes"], + "Evaluation": evaluation, + }) + return results + + +def main(): + if len(sys.argv) < 2: + print("Usage: python fetch_conversations.py [id2] ...") + sys.exit(1) + + ids = [int(arg) for arg in sys.argv[1:]] + OUTPUT_DIR.mkdir(exist_ok=True) + + conn = psycopg2.connect(DSN) + try: + with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur: + for attempt_id in ids: + rounds = fetch_rounds(cur, attempt_id) + out = OUTPUT_DIR / f"{attempt_id}.json" + out.write_text(to_json(rounds), encoding="utf-8") + print(f"{attempt_id}: {len(rounds)} rounds -> {out}") + finally: + conn.close() + + +if __name__ == "__main__": + main()